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}