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}