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(&self, agent: AgentId, mcp_config: &Path, startup: &str) -> String {
56        match self {
57            Self::Claude(cfg) => claude::build_command(agent, cfg, mcp_config, startup),
58            Self::Codex(cfg) => codex::build_command(agent, cfg, mcp_config, startup),
59        }
60    }
61
62    /// Post-spawn hook: runs once after the tmux session exists. Claude
63    /// is a no-op — startup already landed via the CLI positional arg
64    /// assembled in [`Self::build_command`]. Codex has no CLI slot for
65    /// a second prompt, so the hook pastes startup into the pane as a
66    /// follow-on user turn.
67    pub fn post_spawn(&self, session: &str, startup: &str) -> Result<()> {
68        match self {
69            Self::Claude(_) => Ok(()),
70            Self::Codex(_) => codex::post_spawn(session, startup),
71        }
72    }
73
74    /// Default runtime for `agent`. Claude unless `AGENT_RUNTIME=codex`
75    /// picks the codex flavor. Explicit `netsky agent N --type <flavor>`
76    /// overrides this and constructs the runtime directly.
77    pub fn defaults_for(agent: AgentId) -> Self {
78        match std::env::var("AGENT_RUNTIME").ok().as_deref() {
79            Some("codex") => Self::Codex(CodexConfig::defaults_for()),
80            _ => Self::Claude(ClaudeConfig::defaults_for(agent)),
81        }
82    }
83
84    /// Human-readable description for log lines. Stable-ish formatting —
85    /// tests grep this, but callers are internal.
86    pub fn describe(&self) -> String {
87        match self {
88            Self::Claude(cfg) => format!("claude model={} effort={}", cfg.model, cfg.effort),
89            Self::Codex(cfg) => format!(
90                "codex model={} sandbox={} approval={}",
91                cfg.model, cfg.sandbox, cfg.approval
92            ),
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn claude_post_spawn_is_noop_without_tmux() {
103        // Passing a non-existent session must succeed — claude's
104        // post_spawn must not shell out to tmux at all. If it ever did,
105        // this call would fail because the session doesn't exist.
106        let rt = Runtime::Claude(ClaudeConfig {
107            model: "opus".to_string(),
108            effort: "high".to_string(),
109        });
110        rt.post_spawn("nonexistent-session-xyz", "/up")
111            .expect("claude post_spawn must be a no-op");
112    }
113}