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/// Durable loop scheduler entries.
60pub const LOOPS_SUBDIR: &str = ".netsky/state/loops";
61/// Sender id used for loop-tick envelopes.
62pub const LOOP_FROM: &str = "agentloop";
63/// Envelope kind for loop scheduler dispatch.
64pub const LOOP_KIND: &str = "loop-tick";
65/// Default delay for dynamic loops when the agent does not override it.
66pub const LOOP_DYNAMIC_DEFAULT_DELAY_S: u64 = 1500;
67/// Watchdog-driven tmux ticker session name.
68pub const TICKER_SESSION: &str = "netsky-ticker";
69/// Watchdog gap threshold. If the watchdog log has not advanced in
70/// this long, the next tick records a durable ticker-stopped event.
71pub const WATCHDOG_TICK_GAP_WARN_S: u64 = 300;
72/// Watchdog escalation threshold for a stalled tick driver.
73pub const WATCHDOG_TICK_GAP_ESCALATE_S: u64 = 600;
74/// How long a detached restart may remain unverified before the
75/// watchdog marks it failed and pages the owner.
76pub const WATCHDOG_RESTART_VERIFY_WINDOW_S: u64 = 180;
77/// Handoff archive directory written by `netsky restart` alongside the
78/// inbox delivery. Durable record of every handoff to agent0.
79pub const HANDOFF_ARCHIVE_SUBDIR: &str = "Library/Logs/netsky-handoffs";
80/// Planned-restart request file claimed by the watchdog.
81pub const RESTART_REQUEST_FILE: &str = "/tmp/netsky-restart-request.txt";
82/// In-flight restart sentinel.
83pub const RESTART_PROCESSING_FILE: &str = "/tmp/netsky-restart-processing.txt";
84/// Runtime restart-handshake request timeout.
85pub const RESTART_REQUEST_TIMEOUT_S: u64 = 15;
86/// Listener confirm timeout after an ack.
87pub const RESTART_CONFIRM_TIMEOUT_S: u64 = 10;
88/// Listener inbox poll interval.
89pub const RESTART_LISTEN_POLL_MS: u64 = 100;
90/// Agentinfinity tmux window that runs `netsky watchdog listen`.
91pub const AGENTINFINITY_LISTENER_WINDOW: &str = "watchdog-listen";
92/// Test hook: when set, the listener writes the restart handoff path to
93/// this file instead of exec'ing `netsky restart`.
94pub const ENV_RESTART_EXEC_HOOK: &str = "NETSKY_RESTART_EXEC_HOOK";
95
96// ---- claude CLI flags (passed verbatim) -------------------------------------
97
98pub const CLAUDE: &str = "claude";
99pub const CLAUDE_FLAG_MODEL: &str = "--model";
100pub const CLAUDE_FLAG_EFFORT: &str = "--effort";
101pub const CLAUDE_FLAG_ALLOWED_TOOLS: &str = "--allowed-tools";
102pub const CLAUDE_FLAG_DISALLOWED_TOOLS: &str = "--disallowed-tools";
103pub const CLAUDE_FLAG_DANGEROUSLY_SKIP_PERMISSIONS: &str = "--dangerously-skip-permissions";
104pub const CLAUDE_FLAG_PERMISSION_MODE: &str = "--permission-mode";
105pub const CLAUDE_FLAG_MCP_CONFIG: &str = "--mcp-config";
106pub const CLAUDE_FLAG_STRICT_MCP_CONFIG: &str = "--strict-mcp-config";
107pub const CLAUDE_FLAG_APPEND_SYSTEM_PROMPT: &str = "--append-system-prompt";
108pub const CLAUDE_FLAG_SETTINGS: &str = "--settings";
109pub const CLAUDE_FLAG_LOAD_DEV_CHANNELS: &str = "--dangerously-load-development-channels";
110
111/// Default model for spawned agents. Overridable via `AGENT_MODEL` env.
112pub const DEFAULT_MODEL: &str = "opus[1m]";
113/// Default effort level for clones + agent0. agentinfinity overrides to "medium".
114pub const DEFAULT_EFFORT: &str = "high";
115pub const AGENTINFINITY_EFFORT: &str = "medium";
116/// Default clone count for `netsky up` (agent0 + this many clones).
117/// 0 means "agent0 + agentinfinity only" — clones spawn on-demand via
118/// `netsky agent <N>`. Pre-warming a constellation stays explicit
119/// (`netsky up 8`). Idle clones were burning tokens on /up + /down +
120/// /notes without ever executing a brief; lazy spawn keeps the bus
121/// cheap and matches the "use clones heavily, not always-on" policy.
122pub const DEFAULT_CLONE_COUNT: u32 = 0;
123
124// ---- cwd addendum filenames (relative to invocation cwd) --------------------
125
126/// cwd addendum loaded for agent0 on top of the baked base prompt.
127pub const CWD_ADDENDUM_AGENT0: &str = "0.md";
128/// cwd addendum loaded for agentinfinity on top of the baked base prompt.
129pub const CWD_ADDENDUM_AGENTINFINITY: &str = "agentinfinity.md";
130/// cwd addendum template for clone N: `N.md` where N > 0.
131pub const CWD_ADDENDUM_CLONE_EXT: &str = ".md";
132
133// ---- model + effort overrides ----------------------------------------------
134
135pub const ENV_AGENT_MODEL_OVERRIDE: &str = "AGENT_MODEL";
136pub const ENV_AGENT_EFFORT_OVERRIDE: &str = "AGENT_EFFORT";
137
138// ---- dependencies on PATH --------------------------------------------------
139
140pub const NETSKY_IO_BIN: &str = "netsky";
141pub const TMUX_BIN: &str = "tmux";
142
143// ---- claude tool + channel lists -------------------------------------------
144
145/// Tools clone sessions expose in Claude. Channel ops now route through
146/// the `netsky` CLI, so clones no longer need MCP mutation tools.
147pub const ALLOWED_TOOLS_CLONE: &str = "Bash,Edit,Glob,Grep,Read,WebFetch,WebSearch,Write";
148
149/// Clone-only deny set. Passed via `--disallowed-tools` and mirrored in
150/// the clone settings template + PreToolUse hook.
151pub const DISALLOWED_TOOLS_CLONE: &str = "Agent,NotebookEdit,Task";
152
153/// Tools agent0 exposes. Broader than clones by design: orchestration
154/// and harness managers remain available here. Channel request-response
155/// work now routes through the `netsky` CLI instead of MCP.
156pub const ALLOWED_TOOLS_AGENT: &str = "Bash,CronCreate,CronDelete,CronList,Edit,Glob,Grep,Monitor,Read,Skill,TaskCreate,TaskGet,TaskList,TaskStop,TaskUpdate,Write";
157/// Tools the watchdog exposes. Must be a superset of
158/// [`ALLOWED_TOOLS_AGENT`] — agentinfinity acts as a backstop for any
159/// tool the primary agents can invoke. No task/cron tools: agentinfinity
160/// does not orchestrate. WebFetch + WebSearch are agentinfinity-only
161/// (needed for meta-docs + repair research).
162pub const ALLOWED_TOOLS_AGENTINFINITY: &str = "Bash,CronCreate,CronDelete,CronList,Edit,Glob,Grep,Monitor,Read,Skill,TaskCreate,TaskGet,TaskList,TaskStop,TaskUpdate,WebFetch,WebSearch,Write";
163
164/// Tools explicitly denied for agent0 and agentinfinity. Clones use the
165/// narrower [`DISALLOWED_TOOLS_CLONE`] deny set.
166///
167/// All claude-side self-scheduling primitives are denied. The harness
168/// does not deduplicate them and they leak across sessions. Scheduling
169/// belongs in netsky:
170/// - one-shot delays + dynamic loops -> `netsky loop`
171/// - cron-shaped recurring prompts -> `netsky cron`
172/// - both fire via the netsky-ticker by writing envelopes into the
173///   target agent's inbox (durable, observable, deduped by id).
174///
175/// `Agent` stays denied to prevent sub-clone recursion via the built-in
176/// task-spawning path (clones use `netsky clone` instead).
177/// `RemoteTrigger` is denied to keep scheduling local; remote triggers
178/// route through `netsky cron` if they're needed.
179pub const DISALLOWED_TOOLS: &str =
180    "Agent,CronCreate,CronDelete,CronList,RemoteTrigger,ScheduleWakeup";
181
182/// `--permission-mode` value used across the board.
183pub const PERMISSION_MODE_BYPASS: &str = "bypassPermissions";
184
185/// Dev-channel identifiers passed to `--dangerously-load-development-channels`.
186pub const DEV_CHANNEL_AGENT: &str = "server:agent";
187pub const DEV_CHANNEL_IMESSAGE: &str = "server:imessage";
188
189// ---- per-agent MCP config layout -------------------------------------------
190
191/// Subdirectory of $HOME holding per-agent mcp-config.json files.
192/// Claude reads `~/.claude/channels/agent/<agent-name>/mcp-config.json`
193/// when launched with `--mcp-config` pointing into it.
194pub const MCP_CHANNEL_DIR_PREFIX: &str = ".claude/channels/agent";
195pub const MCP_CONFIG_FILENAME: &str = "mcp-config.json";
196
197// ---- agentinit (bootstrap helper) ------------------------------------------
198
199/// Haiku pin for agentinit. Fast cold-start, cheap, no orchestration needs.
200/// If deprecated, this pin breaks loudly at the next tick — intentional.
201pub const AGENTINIT_MODEL: &str = "claude-haiku-4-5-20251001";
202pub const AGENTINIT_EFFORT: &str = "low";
203pub const AGENTINIT_ALLOWED_TOOLS: &str = "Bash,Read";
204/// `-p` flag for non-interactive claude output.
205pub const CLAUDE_FLAG_PRINT: &str = "-p";
206/// Ceiling on a single `agentinit` claude-haiku invocation. Held under
207/// the watchdog's D1 lock, so unbounded waits cascade the same way
208/// escalate does. 90s accommodates a cold start + a slow turn; if we
209/// exceed it the agentinit-failure counter handles it.
210pub const AGENTINIT_TIMEOUT_S: u64 = 90;
211
212// ---- netsky binary name (PATH lookup) -------------------------------------
213
214pub const NETSKY_BIN: &str = "netsky";
215
216// ---- canonical source-checkout root (NETSKY_DIR resolution) ---------------
217
218/// Env var that pins the netsky source-checkout root. When set, takes
219/// precedence over the `$HOME/netsky` default; lets the owner relocate
220/// the checkout (e.g. `~/code/netsky`) without forking the binary. Read
221/// by [`paths::resolve_netsky_dir`] and passed through to launchd-spawned
222/// subprocesses so the watchdog tick agrees with the interactive shell.
223pub const ENV_NETSKY_DIR: &str = "NETSKY_DIR";
224
225/// Default location of the netsky source checkout, relative to `$HOME`.
226/// `$HOME/netsky` is the canonical convention referenced from
227/// `ONBOARDING.md`, `bin/onboard`, the launchd plist baker, and every
228/// skill that assumes a stable cwd.
229pub const NETSKY_DIR_DEFAULT_SUBDIR: &str = "netsky";
230/// Binary-mode state root, relative to `$HOME`.
231///
232/// When no checkout is found, the CLI falls back to `~/.netsky` and
233/// stores prompts, addenda, notes, and state there.
234pub const NETSKY_STATE_DIR: &str = ".netsky";
235
236// ---- launchd -----------------------------------------------------------------
237
238pub const LAUNCHD_LABEL: &str = "dev.dkdc.netsky-watchdog";
239pub const LAUNCHD_PLIST_SUBDIR: &str = "Library/LaunchAgents";
240pub const LAUNCHD_STDOUT_LOG: &str = "/tmp/netsky-watchdog.out.log";
241pub const LAUNCHD_STDERR_LOG: &str = "/tmp/netsky-watchdog.err.log";
242pub const LAUNCHD_BOOTSTRAP_ERR: &str = "/tmp/netsky-launchd-bootstrap.err";
243/// Watchdog cadence in seconds. macOS pauses StartInterval during sleep.
244pub const LAUNCHD_INTERVAL_S: u32 = 120;
245/// PATH baked into the LaunchAgent env. Includes `$HOME/.local/bin`
246/// substitution marker `<<HOME>>` replaced at install time.
247pub const LAUNCHD_JOB_PATH_TEMPLATE: &str =
248    "<<HOME>>/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
249
250// ---- tick driver -----------------------------------------------------------
251
252/// Default ticker interval. Override via [`ENV_TICKER_INTERVAL`].
253pub const TICKER_INTERVAL_DEFAULT_S: u64 = 60;
254pub const ENV_TICKER_INTERVAL: &str = "NETSKY_TICKER_INTERVAL_S";
255pub const TICKER_LOG_PATH: &str = "/tmp/netsky-watchdog.out.log";
256/// Rotate the watchdog log when its size exceeds this. The rotated
257/// file is renamed to `TICKER_LOG_PATH.1`, overwriting any prior
258/// rotation. One generation is enough: doctor/morning only read the
259/// live file, and `.1` is available for forensics.
260pub const TICKER_LOG_ROTATE_BYTES: u64 = 5 * 1024 * 1024;
261/// Config file holding the agent0 status-tick interval, written by
262/// `netsky tick enable <secs>`. Absence = ticks disabled.
263pub const TICK_INTERVAL_CONFIG: &str = "/tmp/netsky-tick-interval-s";
264/// Marker file touched on each successful tick-request drop; used to
265/// gate interval enforcement.
266pub const TICK_LAST_MARKER: &str = "/tmp/netsky-last-tick";
267/// Floor on status-tick interval. Below this = spam, rejected.
268pub const TICK_MIN_INTERVAL_S: u64 = 60;
269
270/// agent0 channel inbox, relative to `$HOME`. Envelopes written here
271/// are surfaced by netsky-io's agent poll loop.
272pub const AGENT0_INBOX_SUBDIR: &str = ".claude/channels/agent/agent0/inbox";
273
274// ---- watchdog-tick tunables -----------------------------------------------
275
276/// Watchdog D1 lock dir. `mkdir` is atomic on posix. The holder writes
277/// its PID to `WATCHDOG_LOCK_DIR/pid`; the next tick checks that PID
278/// with `kill -0` and force-releases only if the holder is dead. The
279/// stale-age threshold is a last-resort fallback for legacy locks with
280/// no PID file, sized to exceed the worst-case restart time.
281pub const WATCHDOG_LOCK_DIR: &str = "/tmp/netsky-watchdog.lock";
282pub const ENV_WATCHDOG_LOCK_DIR: &str = "NETSKY_WATCHDOG_LOCK_DIR";
283pub const WATCHDOG_LOCK_PID_FILE: &str = "pid";
284/// Upper bound on a legitimate tick. Covers a restart with 8 clones at
285/// 120s /up-wait each (~1100s) plus margin. Legacy locks older than
286/// this with no PID file are force-removed.
287pub const WATCHDOG_LOCK_STALE_S: u64 = 1500;
288/// Archive stale `.processing` files older than this (D2).
289pub const RESTART_PROCESSING_STALE_S: u64 = 600;
290/// Warn if /tmp partition has less than this many MB free (C5).
291pub const DISK_MIN_MB_DEFAULT: u64 = 500;
292pub const ENV_DISK_MIN_MB: &str = "NETSKY_DISK_MIN_MB";
293
294/// agentinit failure sliding-window state file (E2).
295pub const AGENTINIT_FAILURES_FILE: &str = ".netsky/state/agentinit-failures";
296pub const AGENTINIT_WINDOW_S_DEFAULT: u64 = 600;
297pub const AGENTINIT_THRESHOLD_DEFAULT: u64 = 3;
298pub const ENV_AGENTINIT_WINDOW_S: &str = "NETSKY_AGENTINIT_WINDOW_S";
299pub const ENV_AGENTINIT_THRESHOLD: &str = "NETSKY_AGENTINIT_THRESHOLD";
300
301/// B3 hang-detection state.
302pub const AGENT0_PANE_HASH_FILE: &str = ".netsky/state/agent0-pane-hash";
303pub const AGENT0_HANG_MARKER: &str = ".netsky/state/agent0-hang-suspected";
304pub const AGENT0_HANG_PAGED_MARKER: &str = ".netsky/state/agent0-hang-paged";
305
306/// P0-1 crashloop-detection state. Newline-delimited unix ts of restart
307/// attempts, pruned to a 600s sliding window. Paired with the crashloop
308/// marker (written once N attempts accumulate) + the restart-status
309/// subdir (P0-2) which captures the last-known restart error for the
310/// marker body + escalation page.
311pub const AGENT0_RESTART_ATTEMPTS_FILE: &str = ".netsky/state/agent0-restart-attempts";
312pub const AGENT0_CRASHLOOP_MARKER: &str = ".netsky/state/agent0-crashloop-suspected";
313pub const AGENT0_CRASHLOOP_WINDOW_S_DEFAULT: u64 = 600;
314pub const AGENT0_CRASHLOOP_THRESHOLD_DEFAULT: u64 = 3;
315pub const ENV_AGENT0_CRASHLOOP_WINDOW_S: &str = "NETSKY_AGENT0_CRASHLOOP_WINDOW_S";
316pub const ENV_AGENT0_CRASHLOOP_THRESHOLD: &str = "NETSKY_AGENT0_CRASHLOOP_THRESHOLD";
317
318/// P0-2 restart-child status subdir. The detached `netsky restart`
319/// subprocess writes one status file per invocation at known phase
320/// transitions (spawned / up-detected / errored). The next watchdog
321/// tick reads the most-recent file to feed the crashloop detector's
322/// marker body + escalation page with the actual failure cause.
323pub const RESTART_STATUS_SUBDIR: &str = ".netsky/state/restart-status";
324/// Max status files retained after each write. Older entries pruned by
325/// mtime. Mirrors the handoff-archive prune pattern in restart.rs.
326pub const RESTART_STATUS_KEEP: usize = 20;
327pub const AGENT0_HANG_S_DEFAULT: u64 = 1800;
328pub const AGENT0_HANG_REPAGE_S_DEFAULT: u64 = 21600;
329pub const ENV_AGENT0_HANG_S: &str = "NETSKY_AGENT0_HANG_S";
330pub const ENV_AGENT0_HANG_REPAGE_S: &str = "NETSKY_AGENT0_HANG_REPAGE_S";
331pub const ENV_HANG_DETECT: &str = "NETSKY_HANG_DETECT";
332
333/// Quiet-sentinel prefix. A file `agent0-quiet-until-<epoch>` in the
334/// state dir suppresses hang detection while `<epoch>` is in the future.
335/// Written by `netsky quiet <seconds>` before a legit long nap or a
336/// /loop stop; read by the watchdog tick. Past-epoch files are reaped
337/// by the reader so they self-clean.
338pub const AGENT0_QUIET_UNTIL_PREFIX: &str = "agent0-quiet-until-";
339
340/// Archived `/tmp/netsky-restart-processing.txt` forensic records land in
341/// [`RESTART_ARCHIVE_SUBDIR`] under `<prefix><stamp><suffix>`. Filenames
342/// only — the directory comes from the paths helper.
343pub const RESTART_PROCESSING_ARCHIVE_FILENAME_PREFIX: &str = "netsky-restart-processing.";
344pub const RESTART_PROCESSING_ARCHIVE_FILENAME_SUFFIX: &str = ".archived";
345
346/// Durable home for restart-related forensic artifacts: the detached
347/// restart log + archived stale-processing files. Out of the macOS /tmp
348/// reaper window so post-mortem traces survive reboots.
349pub const RESTART_ARCHIVE_SUBDIR: &str = ".netsky/state/restart-archive";
350
351/// Default TTL for entries in [`RESTART_ARCHIVE_SUBDIR`]. The sweep
352/// preflight deletes files older than this on every tick. 30 days
353/// matches the `find -mtime +30` guidance in the audit brief.
354pub const RESTART_ARCHIVE_TTL_S_DEFAULT: u64 = 30 * 24 * 60 * 60;
355pub const ENV_RESTART_ARCHIVE_TTL_S: &str = "NETSKY_RESTART_ARCHIVE_TTL_S";
356
357/// In-flight marker for a detached `netsky restart` subprocess. The
358/// watchdog tick writes `<pid>\n<iso-ts>\n` here after spawning the
359/// detached restart, then releases its own lock. Subsequent ticks read
360/// this file and, if the pid is still alive, skip their own
361/// mode-switch body — the restart is already in hand, and running it
362/// again would race with clone teardown.
363pub const RESTART_INFLIGHT_FILE: &str = "/tmp/netsky-restart-inflight";
364/// Consecutive-miss counter for the ticker tmux session. When the
365/// ticker disappears, the watchdog increments this state so the second
366/// consecutive miss can self-heal instead of requiring manual start.
367pub const TICKER_MISSING_COUNT_FILE: &str = ".netsky/state/netsky-ticker-missing-count";
368/// Hard ceiling on a detached restart's runtime before the in-flight
369/// marker is treated as stale and removed. A legitimate restart should
370/// finish in <20min even with 8 pathologically slow clones; anything
371/// beyond is a stuck subprocess and the next tick should take over.
372pub const RESTART_INFLIGHT_STALE_S: u64 = 1800;
373/// Filename of the detached restart subprocess stdout+stderr log under
374/// [`RESTART_ARCHIVE_SUBDIR`]. Captures what used to print directly to
375/// the tick's stdout so post-mortem debugging still has it. Resolved to
376/// a full path via `paths::restart_detached_log_path()`.
377pub const RESTART_DETACHED_LOG_FILENAME: &str = "netsky-restart-detached.log";
378
379/// Default clone-count fed into `netsky restart` by the watchdog.
380/// Aliased to [`DEFAULT_CLONE_COUNT`] so tuning one tunes the other —
381/// the two carry the same contract and drifted silently before.
382pub const WATCHDOG_RESTART_CLONE_COUNT: u32 = DEFAULT_CLONE_COUNT;
383
384// ---- owner identity (template substitutions + escalate) -------------------
385
386/// Display name for the owner, substituted into prompt templates that
387/// address the owner by name (currently `prompts/tick-request.md`).
388/// Defaults to a system-neutral phrase so a fresh deployment works
389/// without any env wiring; set `NETSKY_OWNER_NAME` in the per-deployment
390/// environment to personalize.
391pub const OWNER_NAME_DEFAULT: &str = "the owner";
392pub const ENV_OWNER_NAME: &str = "NETSKY_OWNER_NAME";
393
394// ---- escalate (iMessage floor page) ---------------------------------------
395
396pub const ENV_OWNER_IMESSAGE: &str = "NETSKY_OWNER_IMESSAGE";
397pub const ESCALATE_ERR_FILE: &str = "/tmp/netsky-escalate.err";
398pub const OSASCRIPT_BIN: &str = "osascript";
399/// Ceiling on osascript execution. Messages.app can hang on modal
400/// dialogs or a stuck iMessage sync; escalate runs under the watchdog's
401/// D1 lock, so an unbounded wait cascades into concurrent watchdog
402/// ticks. 15s is generous for a one-shot AppleScript send.
403pub const ESCALATE_TIMEOUT_S: u64 = 15;
404/// Backoff between the first osascript attempt and the retry. One-shot
405/// AppleScript sends hit transient Messages.app sync stalls; a 1s pause
406/// lets the sync settle without pushing total wall-time past the
407/// watchdog's D1 lock budget (2 * timeout + backoff < mode-switch).
408pub const ESCALATE_RETRY_BACKOFF_MS: u64 = 1000;
409/// Filename prefix for the durable failure marker written when both
410/// escalate attempts fall over. Full name is `escalate-failed-<ts>`
411/// under `state_dir()`. Surfaced by `netsky doctor` in a later pass.
412pub const ESCALATE_FAILED_MARKER_PREFIX: &str = "escalate-failed-";
413
414// ---- restart (constellation respawn) --------------------------------------
415
416pub const RESTART_AGENT0_TOS_WAIT_S: u64 = 30;
417pub const RESTART_AGENT0_UP_WAIT_S: u64 = 90;
418pub const RESTART_TEARDOWN_SETTLE_MS: u64 = 2000;
419pub const RESTART_TOS_PROBE: &str = "I am using this for local development";
420pub const RESTART_UP_DONE_REGEX: &str = r"session \d+";
421pub const HANDOFF_FROM: &str = "agentinfinity";
422pub const ENV_HANDOFF_KEEP: &str = "NETSKY_HANDOFF_KEEP";
423pub const HANDOFF_KEEP_DEFAULT: usize = 100;
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use std::collections::BTreeSet;
429
430    fn tool_set(raw: &str) -> BTreeSet<&str> {
431        raw.split(',')
432            .map(str::trim)
433            .filter(|s| !s.is_empty())
434            .collect()
435    }
436
437    #[test]
438    fn allowed_tools_agent_subset_of_agentinfinity() {
439        // Invariant: agentinfinity is a strict superset of the primary
440        // agent toolset. The watchdog acts as a backstop — it must never
441        // lack a tool the main agents can invoke.
442        let agent: BTreeSet<_> = tool_set(ALLOWED_TOOLS_AGENT);
443        let watchdog: BTreeSet<_> = tool_set(ALLOWED_TOOLS_AGENTINFINITY);
444        let missing: Vec<_> = agent.difference(&watchdog).copied().collect();
445        assert!(
446            missing.is_empty(),
447            "ALLOWED_TOOLS_AGENTINFINITY must be a superset of ALLOWED_TOOLS_AGENT; \
448             missing from watchdog: {missing:?}"
449        );
450    }
451
452    #[test]
453    fn allowed_tools_clone_subset_of_agentinfinity() {
454        let clone: BTreeSet<_> = tool_set(ALLOWED_TOOLS_CLONE);
455        let watchdog: BTreeSet<_> = tool_set(ALLOWED_TOOLS_AGENTINFINITY);
456        let missing: Vec<_> = clone.difference(&watchdog).copied().collect();
457        assert!(
458            missing.is_empty(),
459            "ALLOWED_TOOLS_AGENTINFINITY must include the clone floor; missing {missing:?}"
460        );
461    }
462
463    #[test]
464    fn allowed_tools_have_no_duplicates() {
465        for (name, raw) in [
466            ("ALLOWED_TOOLS_CLONE", ALLOWED_TOOLS_CLONE),
467            ("ALLOWED_TOOLS_AGENT", ALLOWED_TOOLS_AGENT),
468            ("ALLOWED_TOOLS_AGENTINFINITY", ALLOWED_TOOLS_AGENTINFINITY),
469        ] {
470            let parts: Vec<_> = raw.split(',').map(str::trim).collect();
471            let uniq: BTreeSet<_> = parts.iter().copied().collect();
472            assert_eq!(
473                parts.len(),
474                uniq.len(),
475                "{name} contains duplicate entries: {parts:?}"
476            );
477        }
478    }
479
480    #[test]
481    fn clone_allowlist_excludes_banned_tools() {
482        let clone = tool_set(ALLOWED_TOOLS_CLONE);
483        for tool in tool_set(DISALLOWED_TOOLS_CLONE) {
484            assert!(
485                !clone.contains(tool),
486                "ALLOWED_TOOLS_CLONE must not include banned tool `{tool}`"
487            );
488        }
489    }
490
491    #[test]
492    fn netsky_io_sources_have_3_place_sync() {
493        // MCP is now emit-only and intentionally narrower than the full
494        // source tree. Registered sources must match the async inbound set.
495
496        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
497            .ancestors()
498            .nth(3)
499            .expect("repo root sits 3 levels above netsky-core's manifest dir");
500        let mcp_path = repo_root.join(".mcp.json");
501        let mcp_v: serde_json::Value = serde_json::from_str(
502            &std::fs::read_to_string(&mcp_path)
503                .unwrap_or_else(|e| panic!("read {}: {e}", mcp_path.display())),
504        )
505        .unwrap_or_else(|e| panic!("parse {}: {e}", mcp_path.display()));
506        let mcp_servers = mcp_v
507            .get("mcpServers")
508            .and_then(|v| v.as_object())
509            .expect(".mcp.json missing top-level `mcpServers` object");
510        let settings_path = repo_root.join(".agents/settings.json");
511        let settings_v: serde_json::Value = serde_json::from_str(
512            &std::fs::read_to_string(&settings_path)
513                .unwrap_or_else(|e| panic!("read {}: {e}", settings_path.display())),
514        )
515        .unwrap_or_else(|e| panic!("parse {}: {e}", settings_path.display()));
516        let enabled: Vec<String> = settings_v
517            .get("enabledMcpjsonServers")
518            .and_then(|v| v.as_array())
519            .expect(".agents/settings.json missing `enabledMcpjsonServers` array")
520            .iter()
521            .filter_map(|v| v.as_str().map(str::to_owned))
522            .collect();
523        let mut failures: Vec<String> = Vec::new();
524        let expected = ["agent", "imessage", "email", "iroh"];
525        for src in &expected {
526            if !mcp_servers.contains_key(*src) {
527                failures.push(format!("`{src}` missing from .mcp.json `mcpServers`"));
528            }
529        }
530        for key in mcp_servers.keys() {
531            if !expected.contains(&key.as_str()) {
532                failures.push(format!(
533                    "unexpected `.mcp.json` server `{key}` present after CLI migration"
534                ));
535            }
536        }
537        if enabled != vec!["agent".to_string()] {
538            failures.push(format!(
539                ".agents/settings.json `enabledMcpjsonServers` must default to [\"agent\"], got {:?}",
540                enabled
541            ));
542        }
543
544        assert!(
545            failures.is_empty(),
546            "netsky-io 3-place sync drift detected:\n  - {}",
547            failures.join("\n  - ")
548        );
549    }
550}