Skip to main content

netsky_core/runtime/
mod.rs

1//! Agent runtime: the underlying model + CLI a spawned agent executes.
2//!
3//! netsky is runtime-agnostic; the agent lifecycle, prompt model, and
4//! bus transport stay the same regardless of which flavor drives the
5//! pane. Each runtime encapsulates:
6//!
7//! - its PATH dependencies ([`Runtime::required_deps`])
8//! - how to build the shell command a tmux session will run
9//!   ([`Runtime::build_command`])
10//! - its own per-agent defaults (model, effort, etc.)
11//!
12//! `Claude` is the default flavor. `Codex` is selected per-invocation
13//! via `netsky agent N --type codex` (resident) or via the sidecar
14//! path in `cmd/codex_agent.rs` (single-turn).
15
16use std::path::Path;
17
18use crate::agent::AgentId;
19use crate::error::Result;
20
21pub mod claude;
22pub mod codex;
23
24pub use claude::ClaudeConfig;
25pub use codex::CodexConfig;
26
27/// The runtime flavor driving an agent pane.
28#[derive(Debug, Clone)]
29pub enum Runtime {
30    Claude(ClaudeConfig),
31    Codex(CodexConfig),
32}
33
34impl Runtime {
35    /// Stable identifier for logs + config. `"claude"`, `"codex"`.
36    pub fn name(&self) -> &'static str {
37        match self {
38            Self::Claude(_) => "claude",
39            Self::Codex(_) => "codex",
40        }
41    }
42
43    /// Executables the runtime expects on PATH. Checked at spawn time
44    /// before any destructive step (teardown, tmux creation).
45    pub fn required_deps(&self) -> Vec<&'static str> {
46        match self {
47            Self::Claude(_) => claude::required_deps(),
48            Self::Codex(_) => codex::required_deps(),
49        }
50    }
51
52    /// Assemble the shell command string a tmux session will run to
53    /// start this agent. Runtime owns all per-flavor CLI decoration;
54    /// callers supply only the runtime-agnostic inputs.
55    pub fn build_command(
56        &self,
57        agent: AgentId,
58        mcp_config: &Path,
59        claude_settings: Option<&Path>,
60        startup: &str,
61    ) -> String {
62        match self {
63            Self::Claude(cfg) => {
64                claude::build_command(agent, cfg, mcp_config, claude_settings, startup)
65            }
66            Self::Codex(cfg) => codex::build_command(agent, cfg, mcp_config, startup),
67        }
68    }
69
70    /// Post-spawn hook: runs once after the tmux session exists. Claude
71    /// is a no-op — startup already landed via the CLI positional arg
72    /// assembled in [`Self::build_command`]. Codex has no CLI slot for
73    /// a second prompt, so the hook pastes startup into the pane as a
74    /// follow-on user turn.
75    pub fn post_spawn(&self, session: &str, startup: &str) -> Result<()> {
76        match self {
77            Self::Claude(_) => Ok(()),
78            Self::Codex(_) => codex::post_spawn(session, startup),
79        }
80    }
81
82    /// Default runtime for `agent`. Claude unless `AGENT_RUNTIME=codex`
83    /// picks the codex flavor. Explicit `netsky agent N --type <flavor>`
84    /// overrides this and constructs the runtime directly.
85    pub fn defaults_for(agent: AgentId) -> Self {
86        match std::env::var("AGENT_RUNTIME").ok().as_deref() {
87            Some("codex") => Self::Codex(CodexConfig::defaults_for()),
88            _ => Self::Claude(ClaudeConfig::defaults_for(agent)),
89        }
90    }
91
92    /// Human-readable description for log lines. Stable-ish formatting —
93    /// tests grep this, but callers are internal.
94    pub fn describe(&self) -> String {
95        match self {
96            Self::Claude(cfg) => format!("claude model={} effort={}", cfg.model, cfg.effort),
97            Self::Codex(cfg) => format!(
98                "codex model={} sandbox={} approval={}",
99                cfg.model, cfg.sandbox, cfg.approval
100            ),
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn claude_post_spawn_is_noop_without_tmux() {
111        // Passing a non-existent session must succeed — claude's
112        // post_spawn must not shell out to tmux at all. If it ever did,
113        // this call would fail because the session doesn't exist.
114        let rt = Runtime::Claude(ClaudeConfig {
115            model: "opus".to_string(),
116            effort: "high".to_string(),
117        });
118        rt.post_spawn("nonexistent-session-xyz", "/up")
119            .expect("claude post_spawn must be a no-op");
120    }
121}