Skip to main content

netsky_core/
consts.rs

1//! Canonical string constants. If a string literal appears twice in the
2//! code base and carries semantic meaning, it belongs here.
3
4// ---- identity ---------------------------------------------------------------
5
6/// Name of the tmux session and agent id for the root orchestrator.
7pub const AGENT0_NAME: &str = "agent0";
8/// Name of the tmux session and agent id for the watchdog.
9pub const AGENTINFINITY_NAME: &str = "agentinfinity";
10/// Prefix applied to clone sessions: `agent<N>` where N > 0.
11pub const CLONE_PREFIX: &str = "agent";
12
13// ---- env vars ---------------------------------------------------------------
14
15/// Env var carrying the agent number. Read by skills to route notes + sessions.
16pub const ENV_AGENT_N: &str = "AGENT_N";
17/// Env var carrying the PATH to a file holding the fully-rendered system
18/// prompt. The shell tmux runs expands `$(cat "$NETSKY_PROMPT_FILE")` at
19/// exec time. We pass a path (~100 bytes) instead of the 20KB+ prompt
20/// content through `tmux new-session -e` because tmux's internal command
21/// parser rejects oversized argv elements with "command too long" — the
22/// bug that took the constellation down in session-11.
23pub const ENV_NETSKY_PROMPT_FILE: &str = "NETSKY_PROMPT_FILE";
24pub const ENV_CODEX_CHANNEL_DIR: &str = "CODEX_CHANNEL_DIR";
25
26// ---- MCP servers (names used in per-agent mcp-config.json) ------------------
27
28pub const MCP_SERVER_AGENT: &str = "agent";
29pub const MCP_SERVER_IMESSAGE: &str = "imessage";
30
31// ---- paths (relative to $HOME unless absolute) ------------------------------
32
33/// State directory outside the macOS /tmp reaper window.
34pub const STATE_DIR: &str = ".netsky/state";
35/// Durable logs directory. Backend-agnostic JSONL event streams land
36/// here (watchdog events, future meta-db error spool) so forensics
37/// survive meta.db outages and macOS /tmp reaping.
38pub const LOGS_SUBDIR: &str = ".netsky/logs";
39/// Subdirectory under state-dir holding per-agent system-prompt files.
40/// One file per agent, atomically overwritten on each spawn.
41pub const PROMPTS_SUBDIR: &str = ".netsky/state/prompts";
42/// Subdirectory under state-dir holding crash-handoff drafts written by
43/// the watchdog on crash-recovery. Kept under the durable state dir so
44/// the macOS /tmp reaper does not eat the forensic trail after ~3 days
45/// of a handoff never being consumed.
46pub const CRASH_HANDOFFS_SUBDIR: &str = ".netsky/state/crash-handoffs";
47/// Filename prefix for crash-handoff drafts. Full name is
48/// `<prefix><pid><suffix>` under [`CRASH_HANDOFFS_SUBDIR`]. The `$TMPDIR`
49/// version of the same prefix is swept by the one-time migration at
50/// watchdog startup.
51pub const CRASH_HANDOFF_FILENAME_PREFIX: &str = "netsky-crash-handoff.";
52pub const CRASH_HANDOFF_FILENAME_SUFFIX: &str = ".txt";
53/// Readiness marker written by agentinfinity as its final startup step.
54pub const AGENTINFINITY_READY_MARKER: &str = ".netsky/state/agentinfinity-ready";
55/// Marker file written when agentinit fails repeatedly.
56pub const AGENTINIT_ESCALATION_MARKER: &str = ".netsky/state/agentinit-escalation";
57/// Per-session resume file refreshed by agent0 before a planned restart.
58pub const LOOP_RESUME_FILE: &str = ".netsky/state/netsky-loop-resume.txt";
59/// Watchdog-driven tmux ticker session name.
60pub const TICKER_SESSION: &str = "netsky-ticker";
61/// Watchdog gap threshold. If the watchdog log has not advanced in
62/// this long, the next tick records a durable ticker-stopped event.
63pub const WATCHDOG_TICK_GAP_WARN_S: u64 = 300;
64/// Watchdog escalation threshold for a stalled tick driver.
65pub const WATCHDOG_TICK_GAP_ESCALATE_S: u64 = 600;
66/// How long a detached restart may remain unverified before the
67/// watchdog marks it failed and pages the owner.
68pub const WATCHDOG_RESTART_VERIFY_WINDOW_S: u64 = 180;
69/// Handoff archive directory written by `netsky restart` alongside the
70/// inbox delivery. Durable record of every handoff to agent0.
71pub const HANDOFF_ARCHIVE_SUBDIR: &str = "Library/Logs/netsky-handoffs";
72/// Planned-restart request file claimed by the watchdog.
73pub const RESTART_REQUEST_FILE: &str = "/tmp/netsky-restart-request.txt";
74/// In-flight restart sentinel.
75pub const RESTART_PROCESSING_FILE: &str = "/tmp/netsky-restart-processing.txt";
76
77// ---- claude CLI flags (passed verbatim) -------------------------------------
78
79pub const CLAUDE: &str = "claude";
80pub const CLAUDE_FLAG_MODEL: &str = "--model";
81pub const CLAUDE_FLAG_EFFORT: &str = "--effort";
82pub const CLAUDE_FLAG_ALLOWED_TOOLS: &str = "--allowed-tools";
83pub const CLAUDE_FLAG_DISALLOWED_TOOLS: &str = "--disallowed-tools";
84pub const CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS: &str = "--dangerously-skip-permissions";
85pub const CLAUDE_FLAG_PERMISSION_MODE: &str = "--permission-mode";
86pub const CLAUDE_FLAG_MCP_CONFIG: &str = "--mcp-config";
87pub const CLAUDE_FLAG_STRICT_MCP_CONFIG: &str = "--strict-mcp-config";
88pub const CLAUDE_FLAG_APPEND_SYSTEM_PROMPT: &str = "--append-system-prompt";
89pub const CLAUDE_FLAG_LOAD_DEV_CHANNELS: &str = "--dangerously-load-development-channels";
90
91/// Default model for spawned agents. Overridable via `AGENT_MODEL` env.
92pub const DEFAULT_MODEL: &str = "opus[1m]";
93/// Default effort level for clones + agent0. agentinfinity overrides to "medium".
94pub const DEFAULT_EFFORT: &str = "high";
95pub const AGENTINFINITY_EFFORT: &str = "medium";
96/// Default clone count for `netsky up` (agent0 + this many clones).
97/// 0 means "agent0 + agentinfinity only" — clones spawn on-demand via
98/// `netsky agent <N>`. Pre-warming a constellation stays explicit
99/// (`netsky up 8`). Idle clones were burning tokens on /up + /down +
100/// /notes without ever executing a brief; lazy spawn keeps the bus
101/// cheap and matches the "use clones heavily, not always-on" policy.
102pub const DEFAULT_CLONE_COUNT: u32 = 0;
103
104// ---- cwd addendum filenames (relative to invocation cwd) --------------------
105
106/// cwd addendum loaded for agent0 on top of the baked base prompt.
107pub const CWD_ADDENDUM_AGENT0: &str = "0.md";
108/// cwd addendum loaded for agentinfinity on top of the baked base prompt.
109pub const CWD_ADDENDUM_AGENTINFINITY: &str = "agentinfinity.md";
110/// cwd addendum template for clone N: `N.md` where N > 0.
111pub const CWD_ADDENDUM_CLONE_EXT: &str = ".md";
112
113// ---- model + effort overrides ----------------------------------------------
114
115pub const ENV_AGENT_MODEL_OVERRIDE: &str = "AGENT_MODEL";
116pub const ENV_AGENT_EFFORT_OVERRIDE: &str = "AGENT_EFFORT";
117
118// ---- dependencies on PATH --------------------------------------------------
119
120pub const NETSKY_IO_BIN: &str = "netsky";
121pub const TMUX_BIN: &str = "tmux";
122
123// ---- claude tool + channel lists -------------------------------------------
124
125/// Tools agent0 + clones expose. Clone-specific injection guards live in
126/// the clone stanza + `.agents/skills/spawn/SKILL.md`. All MCP reply /
127/// query / mutation tools across every netsky-io source are allowlisted
128/// unconditionally here; per-agent gating (who actually sees which
129/// channel's inbound events) is enforced by the dev-channel flag set in
130/// `runtime::claude::build_command`, not by this list. The parity test
131/// `allowed_tools_agent_subset_of_agentinfinity` pins this set as a
132/// subset of [`ALLOWED_TOOLS_AGENTINFINITY`] so the watchdog never
133/// regresses to a narrower allowlist than the primary agent.
134pub const ALLOWED_TOOLS_AGENT: &str = "Bash,CronCreate,CronDelete,CronList,Edit,Glob,Grep,Monitor,Read,Skill,TaskCreate,TaskGet,TaskList,TaskStop,TaskUpdate,Write,mcp__imessage__reply,mcp__agent__reply,mcp__email__reply,mcp__email__list_messages,mcp__email__read_message,mcp__email__create_draft,mcp__email__send_draft,mcp__email__list_drafts,mcp__email__archive_message,mcp__email__trash_message,mcp__calendar__list_calendars,mcp__calendar__list_events,mcp__calendar__get_event,mcp__calendar__create_event,mcp__calendar__delete_event,mcp__drive__list_files,mcp__drive__get_file,mcp__drive__download_file,mcp__drive__upload_file,mcp__drive__create_folder,mcp__drive__delete_file,mcp__drive__list_trash,mcp__drive__move_file,mcp__drive__rename_file,mcp__drive__copy_file,mcp__drive__share_file,mcp__drive__list_permissions,mcp__iroh__iroh_send,mcp__tasks__list_tasks,mcp__tasks__create_task,mcp__tasks__complete_task,mcp__tasks__delete_task";
135/// Tools the watchdog exposes. Must be a superset of
136/// [`ALLOWED_TOOLS_AGENT`] — agentinfinity acts as a backstop for any
137/// tool the primary agents can invoke. No task/cron tools: agentinfinity
138/// does not orchestrate. WebFetch + WebSearch are agentinfinity-only
139/// (needed for meta-docs + repair research).
140pub const ALLOWED_TOOLS_AGENTINFINITY: &str = "Bash,CronCreate,CronDelete,CronList,Edit,Glob,Grep,Monitor,Read,Skill,TaskCreate,TaskGet,TaskList,TaskStop,TaskUpdate,WebFetch,WebSearch,Write,mcp__imessage__reply,mcp__agent__reply,mcp__email__reply,mcp__email__list_messages,mcp__email__read_message,mcp__email__create_draft,mcp__email__send_draft,mcp__email__list_drafts,mcp__email__archive_message,mcp__email__trash_message,mcp__calendar__list_calendars,mcp__calendar__list_events,mcp__calendar__get_event,mcp__calendar__create_event,mcp__calendar__delete_event,mcp__drive__list_files,mcp__drive__get_file,mcp__drive__download_file,mcp__drive__upload_file,mcp__drive__create_folder,mcp__drive__delete_file,mcp__drive__list_trash,mcp__drive__move_file,mcp__drive__rename_file,mcp__drive__copy_file,mcp__drive__share_file,mcp__drive__list_permissions,mcp__iroh__iroh_send,mcp__tasks__list_tasks,mcp__tasks__create_task,mcp__tasks__complete_task,mcp__tasks__delete_task";
141
142/// Tools explicitly denied for agent0, clones, and agentinfinity. The
143/// `Agent` tool is reserved for bounded subsystems spawned via `/spawn`;
144/// top-level agents delegate concurrent work to clones over the bus,
145/// never by spinning up anonymous subagents inside their own context.
146/// Passed via `--disallowed-tools` for defense-in-depth (bypass mode
147/// may otherwise open tools absent from the allowlist).
148pub const DISALLOWED_TOOLS: &str = "Agent";
149
150/// `--permission-mode` value used across the board.
151pub const PERMISSION_MODE_BYPASS: &str = "bypassPermissions";
152
153/// Dev-channel identifiers passed to `--dangerously-load-development-channels`.
154pub const DEV_CHANNEL_AGENT: &str = "server:agent";
155pub const DEV_CHANNEL_IMESSAGE: &str = "server:imessage";
156
157// ---- per-agent MCP config layout -------------------------------------------
158
159/// Subdirectory of $HOME holding per-agent mcp-config.json files.
160/// Claude reads `~/.claude/channels/agent/<agent-name>/mcp-config.json`
161/// when launched with `--mcp-config` pointing into it.
162pub const MCP_CHANNEL_DIR_PREFIX: &str = ".claude/channels/agent";
163pub const MCP_CONFIG_FILENAME: &str = "mcp-config.json";
164
165// ---- agentinit (bootstrap helper) ------------------------------------------
166
167/// Haiku pin for agentinit. Fast cold-start, cheap, no orchestration needs.
168/// If deprecated, this pin breaks loudly at the next tick — intentional.
169pub const AGENTINIT_MODEL: &str = "claude-haiku-4-5-20251001";
170pub const AGENTINIT_EFFORT: &str = "low";
171pub const AGENTINIT_ALLOWED_TOOLS: &str = "Bash,Read";
172/// `-p` flag for non-interactive claude output.
173pub const CLAUDE_FLAG_PRINT: &str = "-p";
174/// Ceiling on a single `agentinit` claude-haiku invocation. Held under
175/// the watchdog's D1 lock, so unbounded waits cascade the same way
176/// escalate does. 90s accommodates a cold start + a slow turn; if we
177/// exceed it the agentinit-failure counter handles it.
178pub const AGENTINIT_TIMEOUT_S: u64 = 90;
179
180// ---- netsky binary name (PATH lookup) -------------------------------------
181
182pub const NETSKY_BIN: &str = "netsky";
183
184// ---- canonical source-checkout root (NETSKY_DIR resolution) ---------------
185
186/// Env var that pins the netsky source-checkout root. When set, takes
187/// precedence over the `$HOME/netsky` default; lets the owner relocate
188/// the checkout (e.g. `~/code/netsky`) without forking the binary. Read
189/// by [`paths::resolve_netsky_dir`] and passed through to launchd-spawned
190/// subprocesses so the watchdog tick agrees with the interactive shell.
191pub const ENV_NETSKY_DIR: &str = "NETSKY_DIR";
192
193/// Default location of the netsky source checkout, relative to `$HOME`.
194/// `$HOME/netsky` is the canonical convention referenced from
195/// `ONBOARDING.md`, `bin/onboard`, the launchd plist baker, and every
196/// skill that assumes a stable cwd.
197pub const NETSKY_DIR_DEFAULT_SUBDIR: &str = "netsky";
198/// Binary-mode state root, relative to `$HOME`.
199///
200/// When no checkout is found, the CLI falls back to `~/.netsky` and
201/// stores prompts, addenda, notes, and state there.
202pub const NETSKY_STATE_DIR: &str = ".netsky";
203
204// ---- launchd -----------------------------------------------------------------
205
206pub const LAUNCHD_LABEL: &str = "dev.dkdc.netsky-watchdog";
207pub const LAUNCHD_PLIST_SUBDIR: &str = "Library/LaunchAgents";
208pub const LAUNCHD_STDOUT_LOG: &str = "/tmp/netsky-watchdog.out.log";
209pub const LAUNCHD_STDERR_LOG: &str = "/tmp/netsky-watchdog.err.log";
210pub const LAUNCHD_BOOTSTRAP_ERR: &str = "/tmp/netsky-launchd-bootstrap.err";
211/// Watchdog cadence in seconds. macOS pauses StartInterval during sleep.
212pub const LAUNCHD_INTERVAL_S: u32 = 120;
213/// PATH baked into the LaunchAgent env. Includes `$HOME/.local/bin`
214/// substitution marker `<<HOME>>` replaced at install time.
215pub const LAUNCHD_JOB_PATH_TEMPLATE: &str =
216    "<<HOME>>/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
217
218// ---- tick driver -----------------------------------------------------------
219
220/// Default ticker interval. Override via [`ENV_TICKER_INTERVAL`].
221pub const TICKER_INTERVAL_DEFAULT_S: u64 = 60;
222pub const ENV_TICKER_INTERVAL: &str = "NETSKY_TICKER_INTERVAL_S";
223pub const TICKER_LOG_PATH: &str = "/tmp/netsky-watchdog.out.log";
224/// Rotate the watchdog log when its size exceeds this. The rotated
225/// file is renamed to `TICKER_LOG_PATH.1`, overwriting any prior
226/// rotation. One generation is enough: doctor/morning only read the
227/// live file, and `.1` is available for forensics.
228pub const TICKER_LOG_ROTATE_BYTES: u64 = 5 * 1024 * 1024;
229/// Config file holding the agent0 status-tick interval, written by
230/// `netsky tick enable <secs>`. Absence = ticks disabled.
231pub const TICK_INTERVAL_CONFIG: &str = "/tmp/netsky-tick-interval-s";
232/// Marker file touched on each successful tick-request drop; used to
233/// gate interval enforcement.
234pub const TICK_LAST_MARKER: &str = "/tmp/netsky-last-tick";
235/// Floor on status-tick interval. Below this = spam, rejected.
236pub const TICK_MIN_INTERVAL_S: u64 = 60;
237
238/// agent0 channel inbox, relative to `$HOME`. Envelopes written here
239/// are surfaced by netsky-io's agent poll loop.
240pub const AGENT0_INBOX_SUBDIR: &str = ".claude/channels/agent/agent0/inbox";
241
242// ---- watchdog-tick tunables -----------------------------------------------
243
244/// Watchdog D1 lock dir. `mkdir` is atomic on posix. The holder writes
245/// its PID to `WATCHDOG_LOCK_DIR/pid`; the next tick checks that PID
246/// with `kill -0` and force-releases only if the holder is dead. The
247/// stale-age threshold is a last-resort fallback for legacy locks with
248/// no PID file, sized to exceed the worst-case restart time.
249pub const WATCHDOG_LOCK_DIR: &str = "/tmp/netsky-watchdog.lock";
250pub const WATCHDOG_LOCK_PID_FILE: &str = "pid";
251/// Upper bound on a legitimate tick. Covers a restart with 8 clones at
252/// 120s /up-wait each (~1100s) plus margin. Legacy locks older than
253/// this with no PID file are force-removed.
254pub const WATCHDOG_LOCK_STALE_S: u64 = 1500;
255/// Archive stale `.processing` files older than this (D2).
256pub const RESTART_PROCESSING_STALE_S: u64 = 600;
257/// Warn if /tmp partition has less than this many MB free (C5).
258pub const DISK_MIN_MB_DEFAULT: u64 = 500;
259pub const ENV_DISK_MIN_MB: &str = "NETSKY_DISK_MIN_MB";
260
261/// agentinit failure sliding-window state file (E2).
262pub const AGENTINIT_FAILURES_FILE: &str = ".netsky/state/agentinit-failures";
263pub const AGENTINIT_WINDOW_S_DEFAULT: u64 = 600;
264pub const AGENTINIT_THRESHOLD_DEFAULT: u64 = 3;
265pub const ENV_AGENTINIT_WINDOW_S: &str = "NETSKY_AGENTINIT_WINDOW_S";
266pub const ENV_AGENTINIT_THRESHOLD: &str = "NETSKY_AGENTINIT_THRESHOLD";
267
268/// B3 hang-detection state.
269pub const AGENT0_PANE_HASH_FILE: &str = ".netsky/state/agent0-pane-hash";
270pub const AGENT0_HANG_MARKER: &str = ".netsky/state/agent0-hang-suspected";
271pub const AGENT0_HANG_PAGED_MARKER: &str = ".netsky/state/agent0-hang-paged";
272
273/// P0-1 crashloop-detection state. Newline-delimited unix ts of restart
274/// attempts, pruned to a 600s sliding window. Paired with the crashloop
275/// marker (written once N attempts accumulate) + the restart-status
276/// subdir (P0-2) which captures the last-known restart error for the
277/// marker body + escalation page.
278pub const AGENT0_RESTART_ATTEMPTS_FILE: &str = ".netsky/state/agent0-restart-attempts";
279pub const AGENT0_CRASHLOOP_MARKER: &str = ".netsky/state/agent0-crashloop-suspected";
280pub const AGENT0_CRASHLOOP_WINDOW_S_DEFAULT: u64 = 600;
281pub const AGENT0_CRASHLOOP_THRESHOLD_DEFAULT: u64 = 3;
282pub const ENV_AGENT0_CRASHLOOP_WINDOW_S: &str = "NETSKY_AGENT0_CRASHLOOP_WINDOW_S";
283pub const ENV_AGENT0_CRASHLOOP_THRESHOLD: &str = "NETSKY_AGENT0_CRASHLOOP_THRESHOLD";
284
285/// P0-2 restart-child status subdir. The detached `netsky restart`
286/// subprocess writes one status file per invocation at known phase
287/// transitions (spawned / up-detected / errored). The next watchdog
288/// tick reads the most-recent file to feed the crashloop detector's
289/// marker body + escalation page with the actual failure cause.
290pub const RESTART_STATUS_SUBDIR: &str = ".netsky/state/restart-status";
291/// Max status files retained after each write. Older entries pruned by
292/// mtime. Mirrors the handoff-archive prune pattern in restart.rs.
293pub const RESTART_STATUS_KEEP: usize = 20;
294pub const AGENT0_HANG_S_DEFAULT: u64 = 1800;
295pub const AGENT0_HANG_REPAGE_S_DEFAULT: u64 = 21600;
296pub const ENV_AGENT0_HANG_S: &str = "NETSKY_AGENT0_HANG_S";
297pub const ENV_AGENT0_HANG_REPAGE_S: &str = "NETSKY_AGENT0_HANG_REPAGE_S";
298pub const ENV_HANG_DETECT: &str = "NETSKY_HANG_DETECT";
299
300/// Quiet-sentinel prefix. A file `agent0-quiet-until-<epoch>` in the
301/// state dir suppresses hang detection while `<epoch>` is in the future.
302/// Written by `netsky quiet <seconds>` before a legit long nap or a
303/// /loop stop; read by the watchdog tick. Past-epoch files are reaped
304/// by the reader so they self-clean.
305pub const AGENT0_QUIET_UNTIL_PREFIX: &str = "agent0-quiet-until-";
306
307/// Archived `/tmp/netsky-restart-processing.txt` forensic records land in
308/// [`RESTART_ARCHIVE_SUBDIR`] under `<prefix><stamp><suffix>`. Filenames
309/// only — the directory comes from the paths helper.
310pub const RESTART_PROCESSING_ARCHIVE_FILENAME_PREFIX: &str = "netsky-restart-processing.";
311pub const RESTART_PROCESSING_ARCHIVE_FILENAME_SUFFIX: &str = ".archived";
312
313/// Durable home for restart-related forensic artifacts: the detached
314/// restart log + archived stale-processing files. Out of the macOS /tmp
315/// reaper window so post-mortem traces survive reboots.
316pub const RESTART_ARCHIVE_SUBDIR: &str = ".netsky/state/restart-archive";
317
318/// Default TTL for entries in [`RESTART_ARCHIVE_SUBDIR`]. The sweep
319/// preflight deletes files older than this on every tick. 30 days
320/// matches the `find -mtime +30` guidance in the audit brief.
321pub const RESTART_ARCHIVE_TTL_S_DEFAULT: u64 = 30 * 24 * 60 * 60;
322pub const ENV_RESTART_ARCHIVE_TTL_S: &str = "NETSKY_RESTART_ARCHIVE_TTL_S";
323
324/// In-flight marker for a detached `netsky restart` subprocess. The
325/// watchdog tick writes `<pid>\n<iso-ts>\n` here after spawning the
326/// detached restart, then releases its own lock. Subsequent ticks read
327/// this file and, if the pid is still alive, skip their own
328/// mode-switch body — the restart is already in hand, and running it
329/// again would race with clone teardown.
330pub const RESTART_INFLIGHT_FILE: &str = "/tmp/netsky-restart-inflight";
331/// Consecutive-miss counter for the ticker tmux session. When the
332/// ticker disappears, the watchdog increments this state so the second
333/// consecutive miss can self-heal instead of requiring manual start.
334pub const TICKER_MISSING_COUNT_FILE: &str = ".netsky/state/netsky-ticker-missing-count";
335/// Hard ceiling on a detached restart's runtime before the in-flight
336/// marker is treated as stale and removed. A legitimate restart should
337/// finish in <20min even with 8 pathologically slow clones; anything
338/// beyond is a stuck subprocess and the next tick should take over.
339pub const RESTART_INFLIGHT_STALE_S: u64 = 1800;
340/// Filename of the detached restart subprocess stdout+stderr log under
341/// [`RESTART_ARCHIVE_SUBDIR`]. Captures what used to print directly to
342/// the tick's stdout so post-mortem debugging still has it. Resolved to
343/// a full path via `paths::restart_detached_log_path()`.
344pub const RESTART_DETACHED_LOG_FILENAME: &str = "netsky-restart-detached.log";
345
346/// Default clone-count fed into `netsky restart` by the watchdog.
347/// Aliased to [`DEFAULT_CLONE_COUNT`] so tuning one tunes the other —
348/// the two carry the same contract and drifted silently before.
349pub const WATCHDOG_RESTART_CLONE_COUNT: u32 = DEFAULT_CLONE_COUNT;
350
351// ---- owner identity (template substitutions + escalate) -------------------
352
353/// Display name for the owner, substituted into prompt templates that
354/// address the owner by name (currently `prompts/tick-request.md`).
355/// Defaults to a system-neutral phrase so a fresh deployment works
356/// without any env wiring; set `NETSKY_OWNER_NAME` in the per-deployment
357/// environment to personalize.
358pub const OWNER_NAME_DEFAULT: &str = "the owner";
359pub const ENV_OWNER_NAME: &str = "NETSKY_OWNER_NAME";
360
361// ---- escalate (iMessage floor page) ---------------------------------------
362
363pub const ENV_OWNER_IMESSAGE: &str = "NETSKY_OWNER_IMESSAGE";
364pub const ESCALATE_ERR_FILE: &str = "/tmp/netsky-escalate.err";
365pub const OSASCRIPT_BIN: &str = "osascript";
366/// Ceiling on osascript execution. Messages.app can hang on modal
367/// dialogs or a stuck iMessage sync; escalate runs under the watchdog's
368/// D1 lock, so an unbounded wait cascades into concurrent watchdog
369/// ticks. 15s is generous for a one-shot AppleScript send.
370pub const ESCALATE_TIMEOUT_S: u64 = 15;
371/// Backoff between the first osascript attempt and the retry. One-shot
372/// AppleScript sends hit transient Messages.app sync stalls; a 1s pause
373/// lets the sync settle without pushing total wall-time past the
374/// watchdog's D1 lock budget (2 * timeout + backoff < mode-switch).
375pub const ESCALATE_RETRY_BACKOFF_MS: u64 = 1000;
376/// Filename prefix for the durable failure marker written when both
377/// escalate attempts fall over. Full name is `escalate-failed-<ts>`
378/// under `state_dir()`. Surfaced by `netsky doctor` in a later pass.
379pub const ESCALATE_FAILED_MARKER_PREFIX: &str = "escalate-failed-";
380
381// ---- restart (constellation respawn) --------------------------------------
382
383pub const RESTART_AGENT0_TOS_WAIT_S: u64 = 30;
384pub const RESTART_AGENT0_UP_WAIT_S: u64 = 90;
385pub const RESTART_TEARDOWN_SETTLE_MS: u64 = 2000;
386pub const RESTART_TOS_PROBE: &str = "I am using this for local development";
387pub const RESTART_UP_DONE_REGEX: &str = r"session \d+";
388pub const HANDOFF_FROM: &str = "agentinfinity";
389pub const ENV_HANDOFF_KEEP: &str = "NETSKY_HANDOFF_KEEP";
390pub const HANDOFF_KEEP_DEFAULT: usize = 100;
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use std::collections::BTreeSet;
396
397    fn tool_set(raw: &str) -> BTreeSet<&str> {
398        raw.split(',')
399            .map(str::trim)
400            .filter(|s| !s.is_empty())
401            .collect()
402    }
403
404    /// Discover every MCP tool a netsky-io source registers by scanning
405    /// the source file for `Tool::new("<name>", ...)` literals and for
406    /// the `.on_reply(` handler (which registers an implicit "reply"
407    /// tool on sources that don't use `Tool::new` — agent + imessage).
408    /// Parsing is deliberately string-based (no regex, no syn): current
409    /// formatting across all sources is `Tool::new(\n    "<name>",`,
410    /// and inline `Tool::new("<name>", ...)` is equally matched. A new
411    /// source that spreads `Tool::new` across files other than its
412    /// entrypoint would miss detections here — the failure mode is
413    /// under-counting (test passes when it shouldn't), caught at the
414    /// next allowlist-drift incident.
415    fn discover_source_tools(src: &str) -> Vec<String> {
416        let mut tools: Vec<String> = Vec::new();
417        let mut seen: BTreeSet<String> = BTreeSet::new();
418        for segment in src.split("Tool::new(").skip(1) {
419            let Some(open) = segment.find('"') else {
420                continue;
421            };
422            let rest = &segment[open + 1..];
423            let Some(close) = rest.find('"') else {
424                continue;
425            };
426            let name = &rest[..close];
427            if !name.is_empty()
428                && name
429                    .chars()
430                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
431                && seen.insert(name.to_string())
432            {
433                tools.push(name.to_string());
434            }
435        }
436        if src.contains(".on_reply(") && seen.insert("reply".to_string()) {
437            tools.push("reply".to_string());
438        }
439        tools
440    }
441
442    #[test]
443    fn allowed_tools_agent_subset_of_agentinfinity() {
444        // Invariant: agentinfinity is a strict superset of the primary
445        // agent toolset. The watchdog acts as a backstop — it must never
446        // lack a tool the main agents can invoke. Regressions here have
447        // shipped silently before (session 5: ALLOWED_TOOLS_AGENT omitted
448        // both reply tools while AGENTINFINITY had them, and nobody
449        // noticed until bus traffic dried up).
450        let agent: BTreeSet<_> = tool_set(ALLOWED_TOOLS_AGENT);
451        let watchdog: BTreeSet<_> = tool_set(ALLOWED_TOOLS_AGENTINFINITY);
452        let missing: Vec<_> = agent.difference(&watchdog).copied().collect();
453        assert!(
454            missing.is_empty(),
455            "ALLOWED_TOOLS_AGENTINFINITY must be a superset of ALLOWED_TOOLS_AGENT; \
456             missing from watchdog: {missing:?}"
457        );
458    }
459
460    #[test]
461    fn allowed_tools_have_no_duplicates() {
462        for (name, raw) in [
463            ("ALLOWED_TOOLS_AGENT", ALLOWED_TOOLS_AGENT),
464            ("ALLOWED_TOOLS_AGENTINFINITY", ALLOWED_TOOLS_AGENTINFINITY),
465        ] {
466            let parts: Vec<_> = raw.split(',').map(str::trim).collect();
467            let uniq: BTreeSet<_> = parts.iter().copied().collect();
468            assert_eq!(
469                parts.len(),
470                uniq.len(),
471                "{name} contains duplicate entries: {parts:?}"
472            );
473        }
474    }
475
476    #[test]
477    fn allowed_tools_include_every_netsky_io_source_reply() {
478        // Every netsky-io source that exposes a reply/mutation tool must
479        // appear in ALLOWED_TOOLS_AGENT — otherwise the tool is silently
480        // blocked at the claude CLI boundary (the session-5 regression).
481        for tool in [
482            "mcp__agent__reply",
483            "mcp__imessage__reply",
484            "mcp__email__reply",
485            "mcp__calendar__create_event",
486        ] {
487            assert!(
488                tool_set(ALLOWED_TOOLS_AGENT).contains(tool),
489                "ALLOWED_TOOLS_AGENT missing `{tool}`"
490            );
491        }
492    }
493
494    #[test]
495    fn netsky_io_sources_have_3_place_sync() {
496        // Closes failure-mode FM-2 from briefs/failure-mode-codification.md:
497        // Every production netsky-io source must stay registered in
498        // `.mcp.json`, and the repo-default `.agents/settings.json`
499        // allowlist must stay narrow. External channels are runtime
500        // gated by `channels.toml`, so fresh clones should auto-enable
501        // only the agent bus.
502        //
503        // Source list is discovered from netsky-io's sources/mod.rs to
504        // avoid hardcoding (which is exactly the drift this test prevents).
505        // `demo` is excluded — it is a dev scaffold, not a registered
506        // production source.
507
508        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
509            .ancestors()
510            .nth(3)
511            .expect("repo root sits 3 levels above netsky-core's manifest dir");
512
513        // 1. Discover sources from sources/mod.rs (`pub mod <name>;`).
514        let sources_mod = repo_root.join("src/crates/netsky-io/src/sources/mod.rs");
515        let mod_src = std::fs::read_to_string(&sources_mod)
516            .unwrap_or_else(|e| panic!("read {}: {e}", sources_mod.display()));
517        let sources: Vec<&str> = mod_src
518            .lines()
519            .filter_map(|l| {
520                l.trim()
521                    .strip_prefix("pub mod ")
522                    .and_then(|s| s.strip_suffix(';'))
523            })
524            .filter(|s| *s != "demo")
525            .collect();
526        assert!(
527            !sources.is_empty(),
528            "no production sources discovered from {}",
529            sources_mod.display()
530        );
531
532        // 2. Parse .mcp.json mcpServers.
533        let mcp_path = repo_root.join(".mcp.json");
534        let mcp_v: serde_json::Value = serde_json::from_str(
535            &std::fs::read_to_string(&mcp_path)
536                .unwrap_or_else(|e| panic!("read {}: {e}", mcp_path.display())),
537        )
538        .unwrap_or_else(|e| panic!("parse {}: {e}", mcp_path.display()));
539        let mcp_servers = mcp_v
540            .get("mcpServers")
541            .and_then(|v| v.as_object())
542            .expect(".mcp.json missing top-level `mcpServers` object");
543
544        // 3. Parse .agents/settings.json enabledMcpjsonServers.
545        let settings_path = repo_root.join(".agents/settings.json");
546        let settings_v: serde_json::Value = serde_json::from_str(
547            &std::fs::read_to_string(&settings_path)
548                .unwrap_or_else(|e| panic!("read {}: {e}", settings_path.display())),
549        )
550        .unwrap_or_else(|e| panic!("parse {}: {e}", settings_path.display()));
551        let enabled: Vec<String> = settings_v
552            .get("enabledMcpjsonServers")
553            .and_then(|v| v.as_array())
554            .expect(".agents/settings.json missing `enabledMcpjsonServers` array")
555            .iter()
556            .filter_map(|v| v.as_str().map(str::to_owned))
557            .collect();
558
559        // 4. ALLOWED_TOOLS_AGENT tokens.
560        let allowed = tool_set(ALLOWED_TOOLS_AGENT);
561
562        // 5. Per-source registration check plus default allowlist
563        //    shape. Collect all failures so a single test run names
564        //    every drift, not just the first.
565        let mut failures: Vec<String> = Vec::new();
566        for src in &sources {
567            if !mcp_servers.contains_key(*src) {
568                failures.push(format!(
569                    "`{src}` missing from .mcp.json `mcpServers` (add an entry that runs `netsky io serve -s {src}`)"
570                ));
571            }
572            // Per-tool allowlist check: every Tool::new / on_reply in
573            // the source's entrypoint must have its matching
574            // `mcp__<source>__<tool>` token allowlisted. Tightens the
575            // prior "at least one tool allowlisted" heuristic, which
576            // missed mcp__calendar__delete_event after the calendar
577            // source added delete_event without backfilling the
578            // allowlist.
579            let source_path = [
580                repo_root.join(format!("src/crates/netsky-io/src/sources/{src}/mod.rs")),
581                repo_root.join(format!("src/crates/netsky-io/src/sources/{src}.rs")),
582            ]
583            .into_iter()
584            .find(|p| p.exists());
585            let Some(source_path) = source_path else {
586                failures.push(format!(
587                    "`{src}` declared in sources/mod.rs but neither `sources/{src}/mod.rs` nor `sources/{src}.rs` exists"
588                ));
589                continue;
590            };
591            let src_contents = std::fs::read_to_string(&source_path)
592                .unwrap_or_else(|e| panic!("read {}: {e}", source_path.display()));
593            let tools = discover_source_tools(&src_contents);
594            if tools.is_empty() {
595                failures.push(format!(
596                    "`{src}` at {} exposes no tools (no `Tool::new(` call and no `.on_reply(` handler found)",
597                    source_path.display()
598                ));
599            }
600            for tool in &tools {
601                let token = format!("mcp__{src}__{tool}");
602                if !allowed.contains(token.as_str()) {
603                    failures.push(format!(
604                        "ALLOWED_TOOLS_AGENT missing `{token}` (source declares it at {}; allowlist in src/crates/netsky-core/src/consts.rs ALLOWED_TOOLS_AGENT and ALLOWED_TOOLS_AGENTINFINITY)",
605                        source_path.display()
606                    ));
607                }
608            }
609        }
610        if enabled != vec!["agent".to_string()] {
611            failures.push(format!(
612                ".agents/settings.json `enabledMcpjsonServers` must default to [\"agent\"], got {:?}",
613                enabled
614            ));
615        }
616
617        assert!(
618            failures.is_empty(),
619            "netsky-io 3-place sync drift detected (sources: {sources:?}):\n  - {}",
620            failures.join("\n  - ")
621        );
622    }
623}