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
197// ---- launchd -----------------------------------------------------------------
198
199pub const LAUNCHD_LABEL: &str = "dev.dkdc.netsky-watchdog";
200pub const LAUNCHD_PLIST_SUBDIR: &str = "Library/LaunchAgents";
201pub const LAUNCHD_STDOUT_LOG: &str = "/tmp/netsky-watchdog.out.log";
202pub const LAUNCHD_STDERR_LOG: &str = "/tmp/netsky-watchdog.err.log";
203pub const LAUNCHD_BOOTSTRAP_ERR: &str = "/tmp/netsky-launchd-bootstrap.err";
204/// Watchdog cadence in seconds. macOS pauses StartInterval during sleep.
205pub const LAUNCHD_INTERVAL_S: u32 = 120;
206/// PATH baked into the LaunchAgent env. Includes `$HOME/.local/bin`
207/// substitution marker `<<HOME>>` replaced at install time.
208pub const LAUNCHD_JOB_PATH_TEMPLATE: &str =
209    "<<HOME>>/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
210
211// ---- tick driver -----------------------------------------------------------
212
213/// Default ticker interval. Override via [`ENV_TICKER_INTERVAL`].
214pub const TICKER_INTERVAL_DEFAULT_S: u64 = 60;
215pub const ENV_TICKER_INTERVAL: &str = "NETSKY_TICKER_INTERVAL_S";
216pub const TICKER_LOG_PATH: &str = "/tmp/netsky-watchdog.out.log";
217/// Rotate the watchdog log when its size exceeds this. The rotated
218/// file is renamed to `TICKER_LOG_PATH.1`, overwriting any prior
219/// rotation. One generation is enough: doctor/morning only read the
220/// live file, and `.1` is available for forensics.
221pub const TICKER_LOG_ROTATE_BYTES: u64 = 5 * 1024 * 1024;
222/// Config file holding the agent0 status-tick interval, written by
223/// `netsky tick enable <secs>`. Absence = ticks disabled.
224pub const TICK_INTERVAL_CONFIG: &str = "/tmp/netsky-tick-interval-s";
225/// Marker file touched on each successful tick-request drop; used to
226/// gate interval enforcement.
227pub const TICK_LAST_MARKER: &str = "/tmp/netsky-last-tick";
228/// Floor on status-tick interval. Below this = spam, rejected.
229pub const TICK_MIN_INTERVAL_S: u64 = 60;
230
231/// agent0 channel inbox, relative to `$HOME`. Envelopes written here
232/// are surfaced by netsky-io's agent poll loop.
233pub const AGENT0_INBOX_SUBDIR: &str = ".claude/channels/agent/agent0/inbox";
234
235// ---- watchdog-tick tunables -----------------------------------------------
236
237/// Watchdog D1 lock dir. `mkdir` is atomic on posix. The holder writes
238/// its PID to `WATCHDOG_LOCK_DIR/pid`; the next tick checks that PID
239/// with `kill -0` and force-releases only if the holder is dead. The
240/// stale-age threshold is a last-resort fallback for legacy locks with
241/// no PID file, sized to exceed the worst-case restart time.
242pub const WATCHDOG_LOCK_DIR: &str = "/tmp/netsky-watchdog.lock";
243pub const WATCHDOG_LOCK_PID_FILE: &str = "pid";
244/// Upper bound on a legitimate tick. Covers a restart with 8 clones at
245/// 120s /up-wait each (~1100s) plus margin. Legacy locks older than
246/// this with no PID file are force-removed.
247pub const WATCHDOG_LOCK_STALE_S: u64 = 1500;
248/// Archive stale `.processing` files older than this (D2).
249pub const RESTART_PROCESSING_STALE_S: u64 = 600;
250/// Warn if /tmp partition has less than this many MB free (C5).
251pub const DISK_MIN_MB_DEFAULT: u64 = 500;
252pub const ENV_DISK_MIN_MB: &str = "NETSKY_DISK_MIN_MB";
253
254/// agentinit failure sliding-window state file (E2).
255pub const AGENTINIT_FAILURES_FILE: &str = ".netsky/state/agentinit-failures";
256pub const AGENTINIT_WINDOW_S_DEFAULT: u64 = 600;
257pub const AGENTINIT_THRESHOLD_DEFAULT: u64 = 3;
258pub const ENV_AGENTINIT_WINDOW_S: &str = "NETSKY_AGENTINIT_WINDOW_S";
259pub const ENV_AGENTINIT_THRESHOLD: &str = "NETSKY_AGENTINIT_THRESHOLD";
260
261/// B3 hang-detection state.
262pub const AGENT0_PANE_HASH_FILE: &str = ".netsky/state/agent0-pane-hash";
263pub const AGENT0_HANG_MARKER: &str = ".netsky/state/agent0-hang-suspected";
264pub const AGENT0_HANG_PAGED_MARKER: &str = ".netsky/state/agent0-hang-paged";
265
266/// P0-1 crashloop-detection state. Newline-delimited unix ts of restart
267/// attempts, pruned to a 600s sliding window. Paired with the crashloop
268/// marker (written once N attempts accumulate) + the restart-status
269/// subdir (P0-2) which captures the last-known restart error for the
270/// marker body + escalation page.
271pub const AGENT0_RESTART_ATTEMPTS_FILE: &str = ".netsky/state/agent0-restart-attempts";
272pub const AGENT0_CRASHLOOP_MARKER: &str = ".netsky/state/agent0-crashloop-suspected";
273pub const AGENT0_CRASHLOOP_WINDOW_S_DEFAULT: u64 = 600;
274pub const AGENT0_CRASHLOOP_THRESHOLD_DEFAULT: u64 = 3;
275pub const ENV_AGENT0_CRASHLOOP_WINDOW_S: &str = "NETSKY_AGENT0_CRASHLOOP_WINDOW_S";
276pub const ENV_AGENT0_CRASHLOOP_THRESHOLD: &str = "NETSKY_AGENT0_CRASHLOOP_THRESHOLD";
277
278/// P0-2 restart-child status subdir. The detached `netsky restart`
279/// subprocess writes one status file per invocation at known phase
280/// transitions (spawned / up-detected / errored). The next watchdog
281/// tick reads the most-recent file to feed the crashloop detector's
282/// marker body + escalation page with the actual failure cause.
283pub const RESTART_STATUS_SUBDIR: &str = ".netsky/state/restart-status";
284/// Max status files retained after each write. Older entries pruned by
285/// mtime. Mirrors the handoff-archive prune pattern in restart.rs.
286pub const RESTART_STATUS_KEEP: usize = 20;
287pub const AGENT0_HANG_S_DEFAULT: u64 = 1800;
288pub const AGENT0_HANG_REPAGE_S_DEFAULT: u64 = 21600;
289pub const ENV_AGENT0_HANG_S: &str = "NETSKY_AGENT0_HANG_S";
290pub const ENV_AGENT0_HANG_REPAGE_S: &str = "NETSKY_AGENT0_HANG_REPAGE_S";
291pub const ENV_HANG_DETECT: &str = "NETSKY_HANG_DETECT";
292
293/// Quiet-sentinel prefix. A file `agent0-quiet-until-<epoch>` in the
294/// state dir suppresses hang detection while `<epoch>` is in the future.
295/// Written by `netsky quiet <seconds>` before a legit long nap or a
296/// /loop stop; read by the watchdog tick. Past-epoch files are reaped
297/// by the reader so they self-clean.
298pub const AGENT0_QUIET_UNTIL_PREFIX: &str = "agent0-quiet-until-";
299
300/// Archived `/tmp/netsky-restart-processing.txt` forensic records land in
301/// [`RESTART_ARCHIVE_SUBDIR`] under `<prefix><stamp><suffix>`. Filenames
302/// only — the directory comes from the paths helper.
303pub const RESTART_PROCESSING_ARCHIVE_FILENAME_PREFIX: &str = "netsky-restart-processing.";
304pub const RESTART_PROCESSING_ARCHIVE_FILENAME_SUFFIX: &str = ".archived";
305
306/// Durable home for restart-related forensic artifacts: the detached
307/// restart log + archived stale-processing files. Out of the macOS /tmp
308/// reaper window so post-mortem traces survive reboots.
309pub const RESTART_ARCHIVE_SUBDIR: &str = ".netsky/state/restart-archive";
310
311/// Default TTL for entries in [`RESTART_ARCHIVE_SUBDIR`]. The sweep
312/// preflight deletes files older than this on every tick. 30 days
313/// matches the `find -mtime +30` guidance in the audit brief.
314pub const RESTART_ARCHIVE_TTL_S_DEFAULT: u64 = 30 * 24 * 60 * 60;
315pub const ENV_RESTART_ARCHIVE_TTL_S: &str = "NETSKY_RESTART_ARCHIVE_TTL_S";
316
317/// In-flight marker for a detached `netsky restart` subprocess. The
318/// watchdog tick writes `<pid>\n<iso-ts>\n` here after spawning the
319/// detached restart, then releases its own lock. Subsequent ticks read
320/// this file and, if the pid is still alive, skip their own
321/// mode-switch body — the restart is already in hand, and running it
322/// again would race with clone teardown.
323pub const RESTART_INFLIGHT_FILE: &str = "/tmp/netsky-restart-inflight";
324/// Consecutive-miss counter for the ticker tmux session. When the
325/// ticker disappears, the watchdog increments this state so the second
326/// consecutive miss can self-heal instead of requiring manual start.
327pub const TICKER_MISSING_COUNT_FILE: &str = ".netsky/state/netsky-ticker-missing-count";
328/// Hard ceiling on a detached restart's runtime before the in-flight
329/// marker is treated as stale and removed. A legitimate restart should
330/// finish in <20min even with 8 pathologically slow clones; anything
331/// beyond is a stuck subprocess and the next tick should take over.
332pub const RESTART_INFLIGHT_STALE_S: u64 = 1800;
333/// Filename of the detached restart subprocess stdout+stderr log under
334/// [`RESTART_ARCHIVE_SUBDIR`]. Captures what used to print directly to
335/// the tick's stdout so post-mortem debugging still has it. Resolved to
336/// a full path via `paths::restart_detached_log_path()`.
337pub const RESTART_DETACHED_LOG_FILENAME: &str = "netsky-restart-detached.log";
338
339/// Default clone-count fed into `netsky restart` by the watchdog.
340/// Aliased to [`DEFAULT_CLONE_COUNT`] so tuning one tunes the other —
341/// the two carry the same contract and drifted silently before.
342pub const WATCHDOG_RESTART_CLONE_COUNT: u32 = DEFAULT_CLONE_COUNT;
343
344// ---- owner identity (template substitutions + escalate) -------------------
345
346/// Display name for the owner, substituted into prompt templates that
347/// address the owner by name (currently `prompts/tick-request.md`).
348/// Defaults to a system-neutral phrase so a fresh deployment works
349/// without any env wiring; set `NETSKY_OWNER_NAME` in the per-deployment
350/// environment to personalize.
351pub const OWNER_NAME_DEFAULT: &str = "the owner";
352pub const ENV_OWNER_NAME: &str = "NETSKY_OWNER_NAME";
353
354// ---- escalate (iMessage floor page) ---------------------------------------
355
356pub const OWNER_IMESSAGE_DEFAULT: &str = "+13527271145";
357pub const ENV_OWNER_IMESSAGE: &str = "NETSKY_OWNER_IMESSAGE";
358pub const ESCALATE_ERR_FILE: &str = "/tmp/netsky-escalate.err";
359pub const OSASCRIPT_BIN: &str = "osascript";
360/// Ceiling on osascript execution. Messages.app can hang on modal
361/// dialogs or a stuck iMessage sync; escalate runs under the watchdog's
362/// D1 lock, so an unbounded wait cascades into concurrent watchdog
363/// ticks. 15s is generous for a one-shot AppleScript send.
364pub const ESCALATE_TIMEOUT_S: u64 = 15;
365
366// ---- restart (constellation respawn) --------------------------------------
367
368pub const RESTART_AGENT0_TOS_WAIT_S: u64 = 30;
369pub const RESTART_AGENT0_UP_WAIT_S: u64 = 90;
370pub const RESTART_TEARDOWN_SETTLE_MS: u64 = 2000;
371pub const RESTART_TOS_PROBE: &str = "I am using this for local development";
372pub const RESTART_UP_DONE_REGEX: &str = r"session \d+";
373pub const HANDOFF_FROM: &str = "agentinfinity";
374pub const ENV_HANDOFF_KEEP: &str = "NETSKY_HANDOFF_KEEP";
375pub const HANDOFF_KEEP_DEFAULT: usize = 100;
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use std::collections::BTreeSet;
381
382    fn tool_set(raw: &str) -> BTreeSet<&str> {
383        raw.split(',')
384            .map(str::trim)
385            .filter(|s| !s.is_empty())
386            .collect()
387    }
388
389    /// Discover every MCP tool a netsky-io source registers by scanning
390    /// the source file for `Tool::new("<name>", ...)` literals and for
391    /// the `.on_reply(` handler (which registers an implicit "reply"
392    /// tool on sources that don't use `Tool::new` — agent + imessage).
393    /// Parsing is deliberately string-based (no regex, no syn): current
394    /// formatting across all sources is `Tool::new(\n    "<name>",`,
395    /// and inline `Tool::new("<name>", ...)` is equally matched. A new
396    /// source that spreads `Tool::new` across files other than its
397    /// entrypoint would miss detections here — the failure mode is
398    /// under-counting (test passes when it shouldn't), caught at the
399    /// next allowlist-drift incident.
400    fn discover_source_tools(src: &str) -> Vec<String> {
401        let mut tools: Vec<String> = Vec::new();
402        let mut seen: BTreeSet<String> = BTreeSet::new();
403        for segment in src.split("Tool::new(").skip(1) {
404            let Some(open) = segment.find('"') else {
405                continue;
406            };
407            let rest = &segment[open + 1..];
408            let Some(close) = rest.find('"') else {
409                continue;
410            };
411            let name = &rest[..close];
412            if !name.is_empty()
413                && name
414                    .chars()
415                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
416                && seen.insert(name.to_string())
417            {
418                tools.push(name.to_string());
419            }
420        }
421        if src.contains(".on_reply(") && seen.insert("reply".to_string()) {
422            tools.push("reply".to_string());
423        }
424        tools
425    }
426
427    #[test]
428    fn allowed_tools_agent_subset_of_agentinfinity() {
429        // Invariant: agentinfinity is a strict superset of the primary
430        // agent toolset. The watchdog acts as a backstop — it must never
431        // lack a tool the main agents can invoke. Regressions here have
432        // shipped silently before (session 5: ALLOWED_TOOLS_AGENT omitted
433        // both reply tools while AGENTINFINITY had them, and nobody
434        // noticed until bus traffic dried up).
435        let agent: BTreeSet<_> = tool_set(ALLOWED_TOOLS_AGENT);
436        let watchdog: BTreeSet<_> = tool_set(ALLOWED_TOOLS_AGENTINFINITY);
437        let missing: Vec<_> = agent.difference(&watchdog).copied().collect();
438        assert!(
439            missing.is_empty(),
440            "ALLOWED_TOOLS_AGENTINFINITY must be a superset of ALLOWED_TOOLS_AGENT; \
441             missing from watchdog: {missing:?}"
442        );
443    }
444
445    #[test]
446    fn allowed_tools_have_no_duplicates() {
447        for (name, raw) in [
448            ("ALLOWED_TOOLS_AGENT", ALLOWED_TOOLS_AGENT),
449            ("ALLOWED_TOOLS_AGENTINFINITY", ALLOWED_TOOLS_AGENTINFINITY),
450        ] {
451            let parts: Vec<_> = raw.split(',').map(str::trim).collect();
452            let uniq: BTreeSet<_> = parts.iter().copied().collect();
453            assert_eq!(
454                parts.len(),
455                uniq.len(),
456                "{name} contains duplicate entries: {parts:?}"
457            );
458        }
459    }
460
461    #[test]
462    fn allowed_tools_include_every_netsky_io_source_reply() {
463        // Every netsky-io source that exposes a reply/mutation tool must
464        // appear in ALLOWED_TOOLS_AGENT — otherwise the tool is silently
465        // blocked at the claude CLI boundary (the session-5 regression).
466        for tool in [
467            "mcp__agent__reply",
468            "mcp__imessage__reply",
469            "mcp__email__reply",
470            "mcp__calendar__create_event",
471        ] {
472            assert!(
473                tool_set(ALLOWED_TOOLS_AGENT).contains(tool),
474                "ALLOWED_TOOLS_AGENT missing `{tool}`"
475            );
476        }
477    }
478
479    #[test]
480    fn netsky_io_sources_have_3_place_sync() {
481        // Closes failure-mode FM-2 from briefs/failure-mode-codification.md:
482        // every production netsky-io source must be registered in all three
483        // surfaces Claude Code reads at session start, or comms drift in
484        // silently. Five distinct hits across 04-14 / 04-15 (consts.rs P0
485        // comms blackout, .mcp.json missing tasks, settings.json missing
486        // drive+tasks, fresh-clone allowlist gap, allowlist-parity-only
487        // PR #18). One mechanical gate kills the class.
488        //
489        // Source list is discovered from netsky-io's sources/mod.rs to
490        // avoid hardcoding (which is exactly the drift this test prevents).
491        // `demo` is excluded — it is a dev scaffold, not a registered
492        // production source.
493
494        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
495            .ancestors()
496            .nth(3)
497            .expect("repo root sits 3 levels above netsky-core's manifest dir");
498
499        // 1. Discover sources from sources/mod.rs (`pub mod <name>;`).
500        let sources_mod = repo_root.join("src/crates/netsky-io/src/sources/mod.rs");
501        let mod_src = std::fs::read_to_string(&sources_mod)
502            .unwrap_or_else(|e| panic!("read {}: {e}", sources_mod.display()));
503        let sources: Vec<&str> = mod_src
504            .lines()
505            .filter_map(|l| {
506                l.trim()
507                    .strip_prefix("pub mod ")
508                    .and_then(|s| s.strip_suffix(';'))
509            })
510            .filter(|s| *s != "demo")
511            .collect();
512        assert!(
513            !sources.is_empty(),
514            "no production sources discovered from {}",
515            sources_mod.display()
516        );
517
518        // 2. Parse .mcp.json mcpServers.
519        let mcp_path = repo_root.join(".mcp.json");
520        let mcp_v: serde_json::Value = serde_json::from_str(
521            &std::fs::read_to_string(&mcp_path)
522                .unwrap_or_else(|e| panic!("read {}: {e}", mcp_path.display())),
523        )
524        .unwrap_or_else(|e| panic!("parse {}: {e}", mcp_path.display()));
525        let mcp_servers = mcp_v
526            .get("mcpServers")
527            .and_then(|v| v.as_object())
528            .expect(".mcp.json missing top-level `mcpServers` object");
529
530        // 3. Parse .agents/settings.json enabledMcpjsonServers.
531        let settings_path = repo_root.join(".agents/settings.json");
532        let settings_v: serde_json::Value = serde_json::from_str(
533            &std::fs::read_to_string(&settings_path)
534                .unwrap_or_else(|e| panic!("read {}: {e}", settings_path.display())),
535        )
536        .unwrap_or_else(|e| panic!("parse {}: {e}", settings_path.display()));
537        let enabled: Vec<String> = settings_v
538            .get("enabledMcpjsonServers")
539            .and_then(|v| v.as_array())
540            .expect(".agents/settings.json missing `enabledMcpjsonServers` array")
541            .iter()
542            .filter_map(|v| v.as_str().map(str::to_owned))
543            .collect();
544
545        // 4. ALLOWED_TOOLS_AGENT tokens.
546        let allowed = tool_set(ALLOWED_TOOLS_AGENT);
547
548        // 5. Per-source 3-place check. Collect all failures so a single
549        //    test run names every drift, not just the first.
550        let mut failures: Vec<String> = Vec::new();
551        for src in &sources {
552            if !mcp_servers.contains_key(*src) {
553                failures.push(format!(
554                    "`{src}` missing from .mcp.json `mcpServers` (add an entry that runs `netsky io serve -s {src}`)"
555                ));
556            }
557            if !enabled.iter().any(|s| s == src) {
558                failures.push(format!(
559                    "`{src}` missing from .agents/settings.json `enabledMcpjsonServers` (append \"{src}\" to the array)"
560                ));
561            }
562            // Per-tool allowlist check: every Tool::new / on_reply in
563            // the source's entrypoint must have its matching
564            // `mcp__<source>__<tool>` token allowlisted. Tightens the
565            // prior "at least one tool allowlisted" heuristic, which
566            // missed mcp__calendar__delete_event after the calendar
567            // source added delete_event without backfilling the
568            // allowlist.
569            let source_path = [
570                repo_root.join(format!("src/crates/netsky-io/src/sources/{src}/mod.rs")),
571                repo_root.join(format!("src/crates/netsky-io/src/sources/{src}.rs")),
572            ]
573            .into_iter()
574            .find(|p| p.exists());
575            let Some(source_path) = source_path else {
576                failures.push(format!(
577                    "`{src}` declared in sources/mod.rs but neither `sources/{src}/mod.rs` nor `sources/{src}.rs` exists"
578                ));
579                continue;
580            };
581            let src_contents = std::fs::read_to_string(&source_path)
582                .unwrap_or_else(|e| panic!("read {}: {e}", source_path.display()));
583            let tools = discover_source_tools(&src_contents);
584            if tools.is_empty() {
585                failures.push(format!(
586                    "`{src}` at {} exposes no tools (no `Tool::new(` call and no `.on_reply(` handler found)",
587                    source_path.display()
588                ));
589            }
590            for tool in &tools {
591                let token = format!("mcp__{src}__{tool}");
592                if !allowed.contains(token.as_str()) {
593                    failures.push(format!(
594                        "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)",
595                        source_path.display()
596                    ));
597                }
598            }
599        }
600
601        assert!(
602            failures.is_empty(),
603            "netsky-io 3-place sync drift detected (sources: {sources:?}):\n  - {}",
604            failures.join("\n  - ")
605        );
606    }
607}