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