1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
//! `netsky agent <N> [--type claude|codex] [--fresh]` — spawn a single agent.
//!
//! N=0 spawns agent0, N>0 spawns a clone. Idempotent by default: a
//! live session is left alone. With `--fresh` the session is killed
//! first, forcing a clean context. Used by agent0 to reset a clone
//! whose context is polluted with unrelated prior work.
//!
//! Runtime selection:
//! - `--type claude` (default): Claude Code in a tmux-resident session
//! (the original shape).
//! - `--type codex`: codex REPL in a tmux-resident session
//! (resident-codex parity v0). Inbox delivery uses
//! `netsky channel drain <agent>` from inside the REPL; outbound
//! replies use `netsky channel send <target> <text>`.
use std::time::Duration;
use chrono::Utc;
use netsky_core::agent::AgentId;
use netsky_core::consts::RESTART_AGENT0_TOS_WAIT_S;
use netsky_core::runtime::CodexConfig;
use netsky_core::runtime::Runtime;
use netsky_core::spawn::{self, SpawnOptions, SpawnOutcome};
use netsky_db::CloneDispatchRecord;
use crate::observability;
/// Spawn a resident agent (claude default, codex on explicit opt-in).
pub fn run(n: u32, fresh: bool) -> netsky_core::Result<()> {
spawn_resident(n, fresh, RuntimeKind::Claude)
}
/// Spawn a tmux-resident codex agent. Called by cli::dispatch when
/// `--type codex` is passed without `--prompt` or `--drain`.
pub fn run_codex_resident(n: u32, fresh: bool) -> netsky_core::Result<()> {
spawn_resident(n, fresh, RuntimeKind::Codex)
}
#[derive(Clone, Copy)]
enum RuntimeKind {
Claude,
Codex,
}
fn spawn_resident(n: u32, fresh: bool, kind: RuntimeKind) -> netsky_core::Result<()> {
let agent = AgentId::from_number(n);
// Pin spawn cwd to the resolved netsky root, NOT whatever shell
// cwd agent0 happened to be in. Closes briefs/clone-cwd-pin.md
// (agent5): a clone spawned from `workspaces/foo/repo` previously
// inherited that cwd silently and broke /up.
let cwd = netsky_core::paths::netsky_root_or_cwd()?;
let runtime = match kind {
RuntimeKind::Claude => Runtime::defaults_for(agent),
RuntimeKind::Codex => Runtime::Codex(CodexConfig::defaults_for()),
};
let opts = SpawnOptions {
runtime,
cwd: cwd.clone(),
};
if fresh && spawn::is_up(agent) {
spawn::kill(agent)?;
println!("[agent] killed existing session '{agent}' for fresh spawn");
}
match spawn::spawn(agent, &opts)? {
SpawnOutcome::AlreadyUp => {
println!("[agent] session '{agent}' already up; skipping spawn");
}
SpawnOutcome::Spawned => {
let tag = if fresh { "fresh" } else { "spawned" };
println!("[agent] {tag} '{agent}' ({})", opts.runtime.describe());
// Record clone-dispatch start. Lifetime end is populated
// either by the clone's own /down skill or by a /gc sweep.
// Clone lifetime analytics derive from clone_dispatches;
// see the clone_lifetimes view in netsky-db.
let runtime_label = match kind {
RuntimeKind::Claude => "claude",
RuntimeKind::Codex => "codex",
};
let agent_name = agent.name();
observability::record_clone_dispatch(CloneDispatchRecord {
ts_utc_start: Utc::now(),
ts_utc_end: None,
agent_id: &agent_name,
runtime: Some(runtime_label),
brief_path: None,
brief: None,
workspace: None,
branch: None,
status: Some("spawned"),
exit_code: None,
detail_json: None,
});
// Auto-dismiss the dev-channels TOS dialog for claude. Codex
// has no equivalent one-shot consent dialog on spawn, so
// skip the probe there (it would just burn up to the
// RESTART_AGENT0_TOS_WAIT_S wait waiting for a string that
// never appears).
if matches!(kind, RuntimeKind::Claude)
&& spawn::dismiss_tos(
&agent.name(),
Duration::from_secs(RESTART_AGENT0_TOS_WAIT_S),
)
{
println!("[agent] {agent}: TOS dismissed");
}
}
}
Ok(())
}