@phnx-labs/agents-cli 1.22.24 → 1.22.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +350 -1
  2. package/README.md +31 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/browser.js +35 -0
  7. package/dist/commands/cloud.js +9 -5
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +108 -9
  10. package/dist/commands/exec.js +19 -16
  11. package/dist/commands/feed.d.ts +5 -0
  12. package/dist/commands/feed.js +21 -1
  13. package/dist/commands/focus.js +2 -2
  14. package/dist/commands/insights.d.ts +25 -19
  15. package/dist/commands/insights.js +107 -33
  16. package/dist/commands/menubar.js +8 -0
  17. package/dist/commands/reconnect.d.ts +46 -0
  18. package/dist/commands/reconnect.js +109 -0
  19. package/dist/commands/routines.d.ts +3 -0
  20. package/dist/commands/routines.js +72 -49
  21. package/dist/commands/run-cloud.js +1 -1
  22. package/dist/commands/secrets.d.ts +2 -8
  23. package/dist/commands/secrets.js +29 -105
  24. package/dist/commands/sessions-browser.d.ts +1 -1
  25. package/dist/commands/sessions-browser.js +27 -7
  26. package/dist/commands/sessions-resume.js +3 -2
  27. package/dist/commands/sessions.d.ts +13 -1
  28. package/dist/commands/sessions.js +28 -2
  29. package/dist/commands/setup-secrets.d.ts +1 -0
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/setup-watchdog.js +5 -10
  32. package/dist/commands/setup.d.ts +26 -3
  33. package/dist/commands/setup.js +106 -47
  34. package/dist/commands/teams.d.ts +6 -0
  35. package/dist/commands/teams.js +46 -2
  36. package/dist/commands/trends.d.ts +8 -0
  37. package/dist/commands/trends.js +10 -156
  38. package/dist/commands/watchdog.d.ts +3 -4
  39. package/dist/commands/watchdog.js +26 -66
  40. package/dist/index.js +37 -2
  41. package/dist/lib/agents.d.ts +11 -0
  42. package/dist/lib/agents.js +155 -23
  43. package/dist/lib/analytics/dashboard.d.ts +10 -6
  44. package/dist/lib/analytics/dashboard.js +6 -4
  45. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  46. package/dist/lib/analytics/mix-commands.js +229 -0
  47. package/dist/lib/analytics/recipes.d.ts +19 -14
  48. package/dist/lib/analytics/recipes.js +4 -2
  49. package/dist/lib/browser/ipc.d.ts +26 -0
  50. package/dist/lib/browser/ipc.js +139 -24
  51. package/dist/lib/browser/profiles.d.ts +11 -0
  52. package/dist/lib/browser/profiles.js +1 -1
  53. package/dist/lib/browser/stream.d.ts +14 -0
  54. package/dist/lib/browser/stream.js +71 -0
  55. package/dist/lib/channels/owner-sink.d.ts +27 -0
  56. package/dist/lib/channels/owner-sink.js +93 -0
  57. package/dist/lib/cloud/cursor.d.ts +79 -0
  58. package/dist/lib/cloud/cursor.js +228 -0
  59. package/dist/lib/cloud/registry.js +2 -0
  60. package/dist/lib/cloud/types.d.ts +7 -2
  61. package/dist/lib/cloud/types.js +14 -0
  62. package/dist/lib/crabbox/cli.d.ts +2 -2
  63. package/dist/lib/crabbox/config.d.ts +7 -8
  64. package/dist/lib/crabbox/config.js +14 -14
  65. package/dist/lib/crabbox/lease.d.ts +11 -4
  66. package/dist/lib/crabbox/lease.js +40 -8
  67. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  68. package/dist/lib/crabbox/setup-copy.js +17 -1
  69. package/dist/lib/daemon.js +27 -1
  70. package/dist/lib/device-config.js +7 -0
  71. package/dist/lib/devices/doctor-findings.d.ts +13 -1
  72. package/dist/lib/devices/doctor-findings.js +73 -2
  73. package/dist/lib/events.d.ts +9 -0
  74. package/dist/lib/events.js +58 -0
  75. package/dist/lib/exec.d.ts +3 -3
  76. package/dist/lib/exec.js +24 -10
  77. package/dist/lib/feed-outcome.d.ts +3 -0
  78. package/dist/lib/feed-outcome.js +18 -10
  79. package/dist/lib/feed.d.ts +4 -0
  80. package/dist/lib/fleet/apply.d.ts +59 -3
  81. package/dist/lib/fleet/apply.js +183 -6
  82. package/dist/lib/fleet/types.d.ts +21 -2
  83. package/dist/lib/hooks/cache.js +15 -0
  84. package/dist/lib/hosts/passthrough.d.ts +44 -0
  85. package/dist/lib/hosts/passthrough.js +84 -12
  86. package/dist/lib/hosts/ready.d.ts +2 -0
  87. package/dist/lib/hosts/ready.js +10 -1
  88. package/dist/lib/hosts/reconnect.d.ts +14 -12
  89. package/dist/lib/hosts/reconnect.js +41 -40
  90. package/dist/lib/mcp.js +5 -1
  91. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  92. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  93. package/dist/lib/menubar/snapshot.d.ts +15 -0
  94. package/dist/lib/menubar/snapshot.js +40 -0
  95. package/dist/lib/plugins.js +13 -1
  96. package/dist/lib/resources/mcp.js +3 -0
  97. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  98. package/dist/lib/routine-process-cleanup.js +73 -0
  99. package/dist/lib/routines.js +14 -2
  100. package/dist/lib/runner.d.ts +0 -3
  101. package/dist/lib/runner.js +6 -19
  102. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  103. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  104. package/dist/lib/secrets/bundles.js +1 -20
  105. package/dist/lib/secrets/filestore.d.ts +2 -0
  106. package/dist/lib/secrets/filestore.js +13 -0
  107. package/dist/lib/secrets/push.d.ts +94 -0
  108. package/dist/lib/secrets/push.js +145 -0
  109. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  110. package/dist/lib/secrets/rc-hygiene.js +14 -1
  111. package/dist/lib/secrets/reaper.d.ts +15 -1
  112. package/dist/lib/secrets/reaper.js +30 -3
  113. package/dist/lib/session/active.d.ts +4 -0
  114. package/dist/lib/session/db.d.ts +21 -3
  115. package/dist/lib/session/db.js +229 -15
  116. package/dist/lib/session/discover.d.ts +1 -0
  117. package/dist/lib/session/discover.js +115 -19
  118. package/dist/lib/session/insights.d.ts +18 -0
  119. package/dist/lib/session/insights.js +143 -1
  120. package/dist/lib/session/remote-list.d.ts +2 -0
  121. package/dist/lib/session/remote-list.js +1 -0
  122. package/dist/lib/session/session-cache.d.ts +4 -4
  123. package/dist/lib/session/session-cache.js +4 -4
  124. package/dist/lib/session/tool-index.js +133 -22
  125. package/dist/lib/session/tool-store.d.ts +26 -2
  126. package/dist/lib/session/tool-store.js +36 -17
  127. package/dist/lib/shims.js +21 -1
  128. package/dist/lib/signin-badge.js +2 -0
  129. package/dist/lib/ssh-exec.js +8 -2
  130. package/dist/lib/startup/command-registry.d.ts +16 -0
  131. package/dist/lib/startup/command-registry.js +46 -0
  132. package/dist/lib/teams/agents.d.ts +13 -0
  133. package/dist/lib/teams/agents.js +76 -8
  134. package/dist/lib/teams/parsers.d.ts +1 -1
  135. package/dist/lib/teams/placement-probe.d.ts +21 -0
  136. package/dist/lib/teams/placement-probe.js +135 -0
  137. package/dist/lib/teams/scheduler.d.ts +74 -1
  138. package/dist/lib/teams/scheduler.js +187 -10
  139. package/dist/lib/types.d.ts +1 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Acquire a box → provision the picked runtime(s) + their credentials → run the
5
5
  * agent on the box (via `crabbox run`, which owns the SSH) → tear the box down.
6
- * Acquisition is reuse-first against the warm profile pool (a ready box carrying
7
- * the run's profile + netMode labels is reused and kept; `--fresh` opts out and
8
- * always leases a new, torn-down box). The whole box-side sequence rides a
9
- * single `--script-stdin` body so the token contents never touch argv.
6
+ * Acquisition is reuse-first against the shared warm pool (a ready box carrying
7
+ * the lease profile + netMode labels is reused and kept; `--fresh` opts out and
8
+ * always leases a new, torn-down box). Concurrent callers run from separate
9
+ * `~/workspaces/<repo>-<run>` directories on that box. The whole box-side
10
+ * sequence rides a single `--script-stdin` body so token contents never touch argv.
10
11
  *
11
12
  * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
12
13
  * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
@@ -54,6 +55,8 @@ export interface LeaseRunOptions {
54
55
  backend?: string;
55
56
  boxClass?: string;
56
57
  profile?: string;
58
+ /** Per-run directory under ~/workspaces; isolates concurrent runs on one box. */
59
+ workspaceId?: string;
57
60
  /** Runtimes to install on the box. */
58
61
  runtimes: AgentId[];
59
62
  /** Runtime credentials to copy; defaults to `runtimes`. */
@@ -112,6 +115,10 @@ export interface LeaseRunResult {
112
115
  exitCode: number | null;
113
116
  toreDown: boolean;
114
117
  }
118
+ /** Build a shell-safe, collision-resistant workspace id for one lease run. */
119
+ export declare function leaseWorkspaceId(repoRoot: string, startedAtMs?: number, pid?: number): string;
120
+ /** Isolated box-home path for one run, relative to the shared box user's home. */
121
+ export declare function leaseHomeDir(workspaceId: string): string;
115
122
  /**
116
123
  * Build the single bootstrap script run on the box: ensure agents-cli, install
117
124
  * the picked runtime CLIs, write their credentials, run the agent, then shred
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Acquire a box → provision the picked runtime(s) + their credentials → run the
5
5
  * agent on the box (via `crabbox run`, which owns the SSH) → tear the box down.
6
- * Acquisition is reuse-first against the warm profile pool (a ready box carrying
7
- * the run's profile + netMode labels is reused and kept; `--fresh` opts out and
8
- * always leases a new, torn-down box). The whole box-side sequence rides a
9
- * single `--script-stdin` body so the token contents never touch argv.
6
+ * Acquisition is reuse-first against the shared warm pool (a ready box carrying
7
+ * the lease profile + netMode labels is reused and kept; `--fresh` opts out and
8
+ * always leases a new, torn-down box). Concurrent callers run from separate
9
+ * `~/workspaces/<repo>-<run>` directories on that box. The whole box-side
10
+ * sequence rides a single `--script-stdin` body so token contents never touch argv.
10
11
  *
11
12
  * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
12
13
  * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
@@ -38,6 +39,16 @@ import { DEFAULT_CRABBOX_PROFILE } from './config.js';
38
39
  function q(s) {
39
40
  return "'" + s.replace(/'/g, "'\\''") + "'";
40
41
  }
42
+ /** Build a shell-safe, collision-resistant workspace id for one lease run. */
43
+ export function leaseWorkspaceId(repoRoot, startedAtMs = Date.now(), pid = process.pid) {
44
+ const repo = repoRoot.split(/[\\/]/).filter(Boolean).pop() ?? 'repo';
45
+ const safeRepo = repo.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'repo';
46
+ return `${safeRepo}-${startedAtMs.toString(36)}-${pid.toString(36)}`;
47
+ }
48
+ /** Isolated box-home path for one run, relative to the shared box user's home. */
49
+ export function leaseHomeDir(workspaceId) {
50
+ return `lease-homes/${workspaceId}`;
51
+ }
41
52
  function profileRemotePath(name) {
42
53
  return `.agents/profiles/${name}.yml`;
43
54
  }
@@ -124,15 +135,35 @@ export function buildBootstrapScript(opts) {
124
135
  // them as a structured step stream — they never appear as setup noise.
125
136
  const step = (name) => `echo ${q(leasePhaseSentinel(name))}`;
126
137
  const copySetup = opts.copySetup !== false; // default TRUE
138
+ const workspace = opts.workspaceId
139
+ ? [
140
+ 'BOX_HOME="$HOME"',
141
+ 'REPO_DIR="$(pwd)"',
142
+ `WORKSPACE_DIR="$BOX_HOME"/${q(`workspaces/${opts.workspaceId}`)}`,
143
+ 'mkdir -p "$WORKSPACE_DIR"',
144
+ 'rsync -a --delete --exclude=node_modules --exclude=.agents/worktrees "$REPO_DIR/" "$WORKSPACE_DIR/"',
145
+ 'cd "$WORKSPACE_DIR"',
146
+ ].join('\n')
147
+ : '';
148
+ const isolatedHome = opts.workspaceId
149
+ ? [
150
+ `export HOME="$BOX_HOME"/${q(leaseHomeDir(opts.workspaceId))}`,
151
+ 'mkdir -p "$HOME/.agents"',
152
+ 'ln -sfn "$BOX_HOME/.agents/.system" "$HOME/.agents/.system"',
153
+ 'export PATH="$BOX_HOME/.local/bin:$PATH"',
154
+ ].join('\n')
155
+ : '';
127
156
  return [
128
157
  'set -uo pipefail',
129
158
  // crabbox has finished its workspace resync by the time this script runs;
130
159
  // the sync sentinel marks the transition out of that (crabbox-driven) phase.
131
160
  step('sync'),
161
+ workspace,
132
162
  // Only meaningful on a tailnet lease — the box already joined during warmup.
133
163
  opts.netMode === 'tailscale' ? step('joined-tailnet') : '',
134
164
  step('install'),
135
165
  ENSURE_AGENTS_CLI,
166
+ isolatedHome,
136
167
  step('runtime'),
137
168
  installRuntimes,
138
169
  step('creds'),
@@ -287,6 +318,7 @@ export async function leaseAndRun(opts) {
287
318
  secretsBundle: opts.secretsBundle,
288
319
  onData: opts.onData,
289
320
  refresh: false,
321
+ remoteDir: opts.workspaceId ? `${leaseHomeDir(opts.workspaceId)}/.agents/` : undefined,
290
322
  });
291
323
  }
292
324
  catch {
@@ -303,10 +335,10 @@ export async function leaseAndRun(opts) {
303
335
  });
304
336
  }
305
337
  finally {
306
- // Always attempt teardown (bounds credential lifetime to the run) unless the
307
- // caller explicitly asked to keep the box or the box was reused (an explicit
308
- // --box target or a warm pool box both outlive this run).
309
- if (!opts.keep && !reused) {
338
+ // Normal --lease establishes or reuses the warm pool, so the box outlives
339
+ // this run; credentials are still shredded inside the script above. Only
340
+ // --fresh requests the old one-shot lifecycle and tears its new box down.
341
+ if (!opts.keep && opts.fresh && !reused) {
310
342
  opts.onPhase?.({ kind: 'teardown' });
311
343
  toreDown = crabboxStop(box.slug, { secretsBundle: opts.secretsBundle });
312
344
  }
@@ -31,6 +31,11 @@ export interface CopySetupOptions {
31
31
  secretsBundle?: string;
32
32
  /** Override the local `~/.agents` dir (defaults to `getUserAgentsDir()`). */
33
33
  userAgentsDir?: string;
34
+ /**
35
+ * Destination relative to the box user's home. Lease runs set this to their
36
+ * isolated home; other callers retain the historical `~/.agents/` target.
37
+ */
38
+ remoteDir?: string;
34
39
  /** Receives combined stdout/stderr of the rsync + refresh, if set. */
35
40
  onData?: (chunk: string) => void;
36
41
  /**
@@ -109,9 +109,25 @@ export async function copySetupToBox(opts) {
109
109
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-setup-copy-'));
110
110
  const listPath = path.join(tmp, 'files.lst');
111
111
  try {
112
+ if (opts.remoteDir) {
113
+ if (!/^[a-zA-Z0-9._/-]+$/.test(opts.remoteDir) || opts.remoteDir.startsWith('/') || opts.remoteDir.split('/').includes('..')) {
114
+ throw new Error(`remote setup directory must stay under the box home: ${opts.remoteDir}`);
115
+ }
116
+ const remoteDir = opts.remoteDir.replace(/\/$/, '');
117
+ const prepareArgs = [
118
+ ...sshArgv.slice(1),
119
+ 'bash',
120
+ '-lc',
121
+ `mkdir -p "$HOME"/${JSON.stringify(remoteDir)}`,
122
+ ];
123
+ const prepareExitCode = await runStreaming('ssh', prepareArgs, env, opts.onData);
124
+ if (prepareExitCode !== 0) {
125
+ return { files, pushExitCode: prepareExitCode, refreshExitCode: null };
126
+ }
127
+ }
112
128
  // NUL-separated list, matching `buildSetupRsyncArgs`'s `--from0`.
113
129
  fs.writeFileSync(listPath, files.join('\0'), 'utf-8');
114
- const rsyncArgs = buildSetupRsyncArgs({ rsh, host, filesFrom: listPath, source: dir });
130
+ const rsyncArgs = buildSetupRsyncArgs({ rsh, host, filesFrom: listPath, source: dir, remoteDir: opts.remoteDir });
115
131
  const pushExitCode = await runStreaming('rsync', rsyncArgs, env, opts.onData);
116
132
  let refreshExitCode = null;
117
133
  if (pushExitCode === 0 && opts.refresh !== false) {
@@ -24,7 +24,9 @@ import { BrowserService } from './browser/service.js';
24
24
  import { BrowserIPCServer } from './browser/ipc.js';
25
25
  import { redactSecrets } from './redact.js';
26
26
  import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
27
- import { isSchedulerEnabled, assertSchedulerEnabled } from './device-config.js';
27
+ import { getConfigValue, isSchedulerEnabled, assertSchedulerEnabled } from './device-config.js';
28
+ import { reapTerminalRoutineProcesses } from './routine-process-cleanup.js';
29
+ import { runWatchdogPass } from './watchdog/service.js';
28
30
  const PID_FILE = 'daemon.pid';
29
31
  const LOCK_FILE = 'daemon.lock';
30
32
  const LOG_FILE = 'logs.jsonl';
@@ -34,6 +36,7 @@ const LOG_ROTATE_COUNT = 3;
34
36
  const PLIST_NAME = 'com.phnx-labs.agents-daemon';
35
37
  const SYSTEMD_UNIT = 'agents-daemon.service';
36
38
  const MONITOR_TICK_MS = 60_000;
39
+ const WATCHDOG_TICK_MS = 3 * 60_000;
37
40
  /**
38
41
  * How often to re-scan for missed fires. Deliberately slower than the monitor
39
42
  * tick: detection walks a week of cron occurrences per routine
@@ -572,6 +575,24 @@ export async function runDaemon() {
572
575
  }
573
576
  if (schedulerEnabledAtBoot)
574
577
  bootScheduler();
578
+ let watchdogInFlight = false;
579
+ const runDaemonWatchdog = async () => {
580
+ if (watchdogInFlight || getConfigValue('watchdog.enabled').value !== true)
581
+ return;
582
+ watchdogInFlight = true;
583
+ try {
584
+ const result = await runWatchdogPass({ nudge: true });
585
+ log('INFO', `watchdog: ${result.counts.total} live, ${result.counts.stalled} stalled, ${result.counts.nudged} nudged`);
586
+ }
587
+ catch (err) {
588
+ log('ERROR', `watchdog tick failed: ${err.message}`);
589
+ }
590
+ finally {
591
+ watchdogInFlight = false;
592
+ }
593
+ };
594
+ const watchdogInterval = setInterval(() => { void runDaemonWatchdog(); }, WATCHDOG_TICK_MS);
595
+ const watchdogKickoff = setTimeout(() => { void runDaemonWatchdog(); }, 30_000);
575
596
  // Monitor engine: event-triggered watchers, beside the cron scheduler. Same
576
597
  // daemon, same dispatch seam — a monitor is a routine whose trigger is a
577
598
  // watched source instead of a clock. Reloads on SIGHUP alongside the scheduler.
@@ -682,6 +703,9 @@ export async function runDaemon() {
682
703
  const monitorInterval = setInterval(() => {
683
704
  writeHeartbeat();
684
705
  monitorRunningJobs();
706
+ const reaped = reapTerminalRoutineProcesses();
707
+ if (reaped.length > 0)
708
+ log('WARN', `Reaped ${reaped.length} terminal routine process group(s): ${reaped.join(', ')}`);
685
709
  }, MONITOR_TICK_MS);
686
710
  // Resource safety check: heal gaps between what DotAgents repos define and
687
711
  // what's actually installed in each agent home — the slow rot that nothing
@@ -1066,6 +1090,8 @@ export async function runDaemon() {
1066
1090
  monitorEngine.stop();
1067
1091
  await browserIPC.stop();
1068
1092
  clearInterval(monitorInterval);
1093
+ clearInterval(watchdogInterval);
1094
+ clearTimeout(watchdogKickoff);
1069
1095
  clearInterval(healInterval);
1070
1096
  clearTimeout(healKickoff);
1071
1097
  clearInterval(autoDispatchInterval);
@@ -67,6 +67,13 @@ export const CONFIG_KEYS = [
67
67
  type: 'bool',
68
68
  description: 'Whether the routines scheduler (daemon) may fire on this device.',
69
69
  },
70
+ {
71
+ name: 'watchdog.enabled',
72
+ yamlKey: 'watchdogEnabled',
73
+ scope: 'device',
74
+ type: 'bool',
75
+ description: 'Whether the daemon runs the watchdog pass on this device.',
76
+ },
70
77
  {
71
78
  name: 'browser.remote-control',
72
79
  yamlKey: 'browserRemoteControl',
@@ -1,6 +1,7 @@
1
1
  import type { AgentId } from '../types.js';
2
2
  import type { DuplicateVersionHook } from '../hooks.js';
3
3
  import type { RcSecretFinding } from '../secrets/rc-hygiene.js';
4
+ import type { OwnerSinkStatus } from '../channels/owner-sink.js';
4
5
  import type { SyncStatusRow, OrphanRow } from '../drift.js';
5
6
  import type { FetchStatusMarker } from '../auto-pull.js';
6
7
  import type { VersionResourceReport } from '../doctor-diff.js';
@@ -10,7 +11,7 @@ export type FindingSeverity = 'critical' | 'warning';
10
11
  * the JSON consumer group by kind. */
11
12
  /** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
12
13
  * below owns it, and a second copy in these comments is a fourth place to drift. */
13
- export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "exec-policy", "stale-cli"];
14
+ export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "stale-cli", "owner-sink-unreachable"];
14
15
  /**
15
16
  * The severity each kind is emitted with - the SINGLE source of truth, read by
16
17
  * the builders below and asserted against both prose rubrics by
@@ -89,6 +90,12 @@ export interface LocalFindingInputs {
89
90
  duplicateHooks?: DuplicateVersionHook[];
90
91
  /** Credential-shaped exports found in the user's shell rc files (RUSH-1968). */
91
92
  rcSecrets?: RcSecretFinding[];
93
+ /** True when the file-store master key is live in THIS process's environment.
94
+ * Distinct from `rcSecrets`, which scans FILES: a value inherited by a
95
+ * long-lived process survives deleting the rc line that set it, so the rc
96
+ * scan reports clean while the leak is still in flight (RUSH-1968). Never the
97
+ * value — only whether it is set. */
98
+ masterPassphraseInEnv?: boolean;
92
99
  /** The effective PowerShell execution policy and the platform it was read on.
93
100
  * Only `win32` yields a finding — the `agents.ps1` launcher is Windows-only. */
94
101
  execPolicy?: {
@@ -100,6 +107,11 @@ export interface LocalFindingInputs {
100
107
  * sweep deliberately skips isolated copies, so a collapsed row would print a
101
108
  * remediation that does not fix them. */
102
109
  isolatedVersions?: string[];
110
+ /** Whether the feed/notify owner-delivery lane can reach the owner from this box
111
+ * (RUSH-2262). Collected by `probeOwnerSink` in the command (it spawns the real
112
+ * `rush` transport, so it stays out of this pure module). Absent → no probe ran
113
+ * → no finding. */
114
+ ownerSink?: OwnerSinkStatus;
103
115
  }
104
116
  /**
105
117
  * Fold this machine's signals into findings. Missing hooks/plugins and unwired
@@ -18,12 +18,13 @@
18
18
  * with. Keep this list exhaustive; a kind missing from it is a doc that lies.
19
19
  * CRITICAL — logged-out (provable) · missing-hook · missing-plugin ·
20
20
  * unwired-hook (a hook on disk that settings.json never fires) ·
21
- * cli-missing.
21
+ * cli-missing · owner-sink-unreachable (the feed/notify owner lane
22
+ * cannot reach the owner from this box).
22
23
  * WARNING — logout-unprovable (hedged) · missing-resource · content-drift ·
23
24
  * never-synced · stale · repo-behind · repo-drift · version-skew ·
24
25
  * fleet-resource-gap · orphan · duplicate-hook ·
25
26
  * duplicate-hook-drift · host-cli-missing · host-cli-invalid ·
26
- * rc-secret-export · exec-policy · stale-cli.
27
+ * rc-secret-export · env-secret-export · exec-policy · stale-cli.
27
28
  * (RUSH-2162 moved never-synced and duplicate-hook-drift to WARNING: both are
28
29
  * stale-sync states one `agents sync` resolves, not "needs you now".)
29
30
  *
@@ -94,8 +95,10 @@ export const ALL_FINDING_KINDS = [
94
95
  'duplicate-hook', // one hook materialized in several version homes, byte-identical
95
96
  'duplicate-hook-drift', // …with differing content, so a stale copy can disagree
96
97
  'rc-secret-export', // credential-shaped export in a shell rc file
98
+ 'env-secret-export', // the file-store master key live in THIS process's env
97
99
  'exec-policy', // Windows execution policy blocks agents.ps1
98
100
  'stale-cli',
101
+ 'owner-sink-unreachable', // the feed/notify owner-delivery lane can't reach the owner from this box
99
102
  ];
100
103
  /**
101
104
  * The severity each kind is emitted with - the SINGLE source of truth, read by
@@ -115,6 +118,9 @@ export const FINDING_SEVERITY = {
115
118
  'missing-plugin': 'critical',
116
119
  'unwired-hook': 'critical',
117
120
  'cli-missing': 'critical',
121
+ // A factory that cannot escalate a blocked agent to the owner is not healthy,
122
+ // and the failure is otherwise silent until a block is filed (RUSH-2262/2258).
123
+ 'owner-sink-unreachable': 'critical',
118
124
  // Everything else is resolvable by a routine sync/cleanup and does not block
119
125
  // the harness right now. RUSH-2162 moved never-synced and duplicate-hook-drift
120
126
  // here: both are stale-sync states that one `agents sync` resolves.
@@ -133,6 +139,7 @@ export const FINDING_SEVERITY = {
133
139
  'host-cli-missing': 'warning',
134
140
  'host-cli-invalid': 'warning',
135
141
  'rc-secret-export': 'warning',
142
+ 'env-secret-export': 'warning',
136
143
  'exec-policy': 'warning',
137
144
  'stale-cli': 'warning',
138
145
  };
@@ -240,10 +247,22 @@ export function remediationFor(finding) {
240
247
  return 'fix the manifest';
241
248
  case 'rc-secret-export':
242
249
  return 'agents secrets add';
250
+ case 'env-secret-export':
251
+ // Not just "restart the shell": the value is in every long-lived parent
252
+ // that inherited it — an editor, a tmux server, the agents daemon — and
253
+ // each keeps handing it to new children until IT restarts.
254
+ return 'unset at the source, then restart every process that inherited it (shells, editor, tmux, agents daemon)';
243
255
  case 'exec-policy':
244
256
  return 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned';
245
257
  case 'stale-cli':
246
258
  return 'upgrade';
259
+ case 'owner-sink-unreachable':
260
+ // The lane delivers over the rush-backed owner channel, which needs rush on
261
+ // PATH AND a usable session in THIS context. Non-interactive shells miss a
262
+ // ~/.zshrc export (RUSH-2258), and the session is keychain-bound, not in
263
+ // ~/.rush/user.yaml (RUSH-2262) — so `rush login` here, or the Rush App for
264
+ // a GUI keychain, is what makes it reachable.
265
+ return "put rush on PATH for non-interactive shells (~/.zshenv) and run 'rush login'";
247
266
  }
248
267
  }
249
268
  function finding(f) {
@@ -286,6 +305,22 @@ export function buildLocalFindings(input) {
286
305
  message: `${agentName(agent)} binary not found`,
287
306
  }));
288
307
  }
308
+ // owner-sink-unreachable — the feed/notify owner-delivery lane can't reach the
309
+ // owner from this box (RUSH-2262). Only when owner delivery is CONFIGURED for the
310
+ // fleet; an un-opted-in box is not broken. The message names the concrete reason.
311
+ const sink = input.ownerSink;
312
+ if (sink?.configured && !sink.reachable) {
313
+ const chan = sink.channel ?? 'owner';
314
+ const why = sink.reason === 'rush-not-on-path'
315
+ ? `rush CLI not on this box's PATH`
316
+ : sink.reason === 'rush-signed-out'
317
+ ? 'rush has no usable session here'
318
+ : 'transport unreachable';
319
+ out.push(finding({
320
+ severity: FINDING_SEVERITY['owner-sink-unreachable'], kind: 'owner-sink-unreachable', device,
321
+ message: `${chan} → owner unreachable: ${why}`,
322
+ }));
323
+ }
289
324
  // Per-version resource reports → missing hook/plugin (critical), unwired hook
290
325
  // (critical), other missing kinds (warning), content drift (warning).
291
326
  for (const report of input.reports) {
@@ -450,6 +485,10 @@ export function buildLocalFindings(input) {
450
485
  // into `agents secrets`.
451
486
  for (const f of rcSecretFindings(device, input.rcSecrets ?? []))
452
487
  out.push(f);
488
+ // The same key, live in this process's environment rather than in a file.
489
+ const envFinding = envSecretFinding(device, input.masterPassphraseInEnv ?? false);
490
+ if (envFinding)
491
+ out.push(envFinding);
453
492
  // Windows execution policy blocking the generated agents.ps1 launcher.
454
493
  const policyFinding = execPolicyFinding(device, input.execPolicy);
455
494
  if (policyFinding)
@@ -572,6 +611,32 @@ function rcSecretFindings(device, rc) {
572
611
  * policy itself. Returns null off Windows or on a permissive policy — pure, so
573
612
  * both branches are testable without invoking PowerShell.
574
613
  */
614
+ /**
615
+ * The file-store master key sitting in THIS process's environment.
616
+ *
617
+ * `rc-hygiene.ts` scans FILES, which leaves a real hole: a value inherited by a
618
+ * long-lived process outlives the rc line that set it, so an operator who
619
+ * deletes `~/.zshenv:8` gets a clean `rc-secret-export` while every shell,
620
+ * editor and agent started before the edit still carries the key — and hands it
621
+ * to everything they spawn. Verified on a box with no rc export whose live
622
+ * environment still held the value, hashing identical to its key file.
623
+ *
624
+ * Warning, not critical: on the release home base the export is deliberate and
625
+ * scoped (`headless-sign-context.sh`), so the message names that exception
626
+ * rather than the check trying to detect it.
627
+ */
628
+ function envSecretFinding(device, present) {
629
+ if (!present)
630
+ return null;
631
+ return finding({
632
+ severity: FINDING_SEVERITY['env-secret-export'], kind: 'env-secret-export', device,
633
+ // Never the value — only that it is set.
634
+ message: 'AGENTS_SECRETS_PASSPHRASE is set in this process environment — every '
635
+ + 'child inherits it and any same-user process can read it from /proc/<pid>/environ. '
636
+ + 'It outlives the shell rc line that set it, so deleting that line is not enough. '
637
+ + '(Expected inside a release sign context, which sets it deliberately.)',
638
+ });
639
+ }
575
640
  function execPolicyFinding(device, execPolicy) {
576
641
  if (!execPolicy || execPolicy.platform !== 'win32')
577
642
  return null;
@@ -745,6 +810,10 @@ function subjectLabel(f) {
745
810
  return f.version ? `${f.agent} @${f.version}` : f.agent;
746
811
  }
747
812
  function critLabel(f) {
813
+ // Machine-level criticals with no agent get a category subject so the left
814
+ // column is not blank; the owner-sink row reads `owner …`, not an empty label.
815
+ if (f.kind === 'owner-sink-unreachable')
816
+ return { left: 'owner', account: '', message: f.message };
748
817
  return {
749
818
  left: subjectLabel(f),
750
819
  account: f.account ?? '',
@@ -881,6 +950,8 @@ function warningSubject(f) {
881
950
  return 'orphans';
882
951
  if (f.kind === 'rc-secret-export')
883
952
  return 'shell rc';
953
+ if (f.kind === 'env-secret-export')
954
+ return 'environment';
884
955
  if (f.kind === 'exec-policy')
885
956
  return 'PowerShell';
886
957
  if (f.kind === 'fleet-resource-gap')
@@ -226,6 +226,15 @@ export declare function query(options: {
226
226
  bundle?: string;
227
227
  limit?: number;
228
228
  }): EventRecord[];
229
+ /**
230
+ * Scan event logs once for a set of session IDs and return browser/computer
231
+ * usage flags for each. O(files) instead of O(N × files) — safe to call
232
+ * outside a SQLite write transaction.
233
+ */
234
+ export declare function queryToolUsageForSessions(sessionIds: ReadonlySet<string>): Map<string, {
235
+ usedBrowser: boolean;
236
+ usedComputer: boolean;
237
+ }>;
229
238
  /**
230
239
  * Get performance stats for a specific label.
231
240
  */
@@ -1052,6 +1052,64 @@ export function query(options) {
1052
1052
  }
1053
1053
  return results;
1054
1054
  }
1055
+ /**
1056
+ * Scan event logs once for a set of session IDs and return browser/computer
1057
+ * usage flags for each. O(files) instead of O(N × files) — safe to call
1058
+ * outside a SQLite write transaction.
1059
+ */
1060
+ export function queryToolUsageForSessions(sessionIds) {
1061
+ const result = new Map();
1062
+ if (sessionIds.size === 0)
1063
+ return result;
1064
+ for (const id of sessionIds) {
1065
+ result.set(id, { usedBrowser: false, usedComputer: false });
1066
+ }
1067
+ const BROWSER_EVENTS = new Set(['browser.navigate', 'browser.screenshot']);
1068
+ const COMPUTER_EVENTS = new Set(['computer.action']);
1069
+ eventsPath();
1070
+ migrateLegacyEventLogs();
1071
+ const files = listEventLogFiles().sort((a, b) => Number(b.currentActive) - Number(a.currentActive) || b.mtimeMs - a.mtimeMs || b.path.localeCompare(a.path));
1072
+ let remaining = sessionIds.size * 2; // each session needs up to 2 flags set
1073
+ for (const file of files) {
1074
+ if (remaining <= 0)
1075
+ break;
1076
+ let content;
1077
+ if (file.gzip) {
1078
+ try {
1079
+ content = gunzipSync(fs.readFileSync(file.path)).toString('utf-8');
1080
+ }
1081
+ catch {
1082
+ continue;
1083
+ }
1084
+ }
1085
+ else {
1086
+ content = fs.readFileSync(file.path, 'utf-8');
1087
+ }
1088
+ for (const line of content.trim().split('\n')) {
1089
+ if (!line)
1090
+ continue;
1091
+ try {
1092
+ const record = JSON.parse(line);
1093
+ const sid = record.sessionId;
1094
+ if (!sid || !result.has(sid))
1095
+ continue;
1096
+ const entry = result.get(sid);
1097
+ if (BROWSER_EVENTS.has(record.event) && !entry.usedBrowser) {
1098
+ entry.usedBrowser = true;
1099
+ remaining--;
1100
+ }
1101
+ else if (COMPUTER_EVENTS.has(record.event) && !entry.usedComputer) {
1102
+ entry.usedComputer = true;
1103
+ remaining--;
1104
+ }
1105
+ }
1106
+ catch {
1107
+ // skip malformed lines
1108
+ }
1109
+ }
1110
+ }
1111
+ return result;
1112
+ }
1055
1113
  // ─── Stats ────────────────────────────────────────────────────────────────────
1056
1114
  /**
1057
1115
  * Get performance stats for a specific label.
@@ -42,7 +42,7 @@ export declare function headlessPlanStallCommand(args: {
42
42
  * (every agent supports edit-like behavior as its default).
43
43
  * - `plan` on an agent without a read-only mode degrades to the agent's
44
44
  * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
45
- * like antigravity/cursor/kiro have no plan flag; hard-failing made
45
+ * like antigravity/kiro have no plan flag; hard-failing made
46
46
  * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
47
47
  * from `agents teams add`, which already defaults to `edit`. Callers that
48
48
  * care (the `agents run` CLI) must surface a warning when requested ≠
@@ -61,8 +61,8 @@ export declare function resolveMode(agent: AgentId, requested: Mode): Mode;
61
61
  * `--prompt` + `--plan`, and grok's `--permission-mode plan` silently stalls at
62
62
  * its ExitPlanMode gate. For those agents, a headless plan request degrades to
63
63
  * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
64
- * one-line stderr warning, mirroring the graceful plan→edit degrade cursor and
65
- * antigravity get for having no plan flag at all. Interactive runs are never
64
+ * one-line stderr warning, mirroring the graceful plan→edit degrade antigravity
65
+ * and kiro get for having no plan flag at all. Interactive runs are never
66
66
  * downgraded. This is the single source of truth shared by buildExecCommand
67
67
  * (agents run / teams) and the routine runner.
68
68
  */
package/dist/lib/exec.js CHANGED
@@ -86,7 +86,7 @@ export function headlessPlanStallCommand(args) {
86
86
  * (every agent supports edit-like behavior as its default).
87
87
  * - `plan` on an agent without a read-only mode degrades to the agent's
88
88
  * safest native mode (`capabilities.modes[0]`, typically `edit`). Agents
89
- * like antigravity/cursor/kiro have no plan flag; hard-failing made
89
+ * like antigravity/kiro have no plan flag; hard-failing made
90
90
  * multi-agent scripts (`--mode plan` for everyone) unusable and diverged
91
91
  * from `agents teams add`, which already defaults to `edit`. Callers that
92
92
  * care (the `agents run` CLI) must surface a warning when requested ≠
@@ -106,7 +106,7 @@ export function resolveMode(agent, requested) {
106
106
  }
107
107
  if (requested === 'plan') {
108
108
  // No read-only mode on this agent. modes[0] is the declared safest mode
109
- // (edit for antigravity/cursor/kiro/…). Prefer that over hard-fail so
109
+ // (edit for antigravity/kiro/…). Prefer that over hard-fail so
110
110
  // uniform multi-agent `--mode plan` dispatches still run.
111
111
  return supported[0];
112
112
  }
@@ -121,8 +121,8 @@ export function resolveMode(agent, requested) {
121
121
  * `--prompt` + `--plan`, and grok's `--permission-mode plan` silently stalls at
122
122
  * its ExitPlanMode gate. For those agents, a headless plan request degrades to
123
123
  * `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
124
- * one-line stderr warning, mirroring the graceful plan→edit degrade cursor and
125
- * antigravity get for having no plan flag at all. Interactive runs are never
124
+ * one-line stderr warning, mirroring the graceful plan→edit degrade antigravity
125
+ * and kiro get for having no plan flag at all. Interactive runs are never
126
126
  * downgraded. This is the single source of truth shared by buildExecCommand
127
127
  * (agents run / teams) and the routine runner.
128
128
  */
@@ -142,12 +142,8 @@ export function resolveHeadlessMode(agent, requested, interactive, warningContex
142
142
  if (mode !== requested) {
143
143
  const subject = warningContext ? `${warningContext}: ` : '';
144
144
  if (requested === 'plan') {
145
- const limitation = agent === 'cursor'
146
- ? "cursor's read-only plan mode is not enabled in this build"
147
- : `${agent} has no read-only 'plan' mode`;
148
- warn(`[agents] ${subject}${limitation}; ` +
149
- `running '${mode}' (writable) instead${agent === 'cursor' ? ' (RUSH-2101)' : ''}. ` +
150
- `Pass --mode ${mode} to silence this.\n`);
145
+ warn(`[agents] ${subject}${agent} has no read-only 'plan' mode; ` +
146
+ `running '${mode}' (writable) instead. Pass --mode ${mode} to silence this.\n`);
151
147
  }
152
148
  else {
153
149
  warn(`[agents] ${subject}${agent} has no '${requested}' mode; using '${mode}'.\n`);
@@ -494,6 +490,7 @@ export const AGENT_COMMANDS = {
494
490
  base: ['cursor-agent'],
495
491
  promptFlag: '-p',
496
492
  modeFlags: {
493
+ plan: ['--plan'],
497
494
  edit: [],
498
495
  skip: ['-f'],
499
496
  },
@@ -687,6 +684,23 @@ export const AGENT_COMMANDS = {
687
684
  // `resume` subcommand — special-cased in buildExecCommand.
688
685
  resume: { flag: '--session-id' },
689
686
  },
687
+ // Warp Agent CLI (`oz agent run`). Headless is `-p/--prompt "<task>"`; JSON is
688
+ // the global `--output-format json`. Autonomy comes from the selected agent
689
+ // profile (`--profile`), not a per-run permission flag, so the single `edit`
690
+ // mode maps to no flags (mirrors hermes). `--model` overrides the base model
691
+ // (`oz model list`). No `resume`: Oz's `oz agent run --conversation <id>`
692
+ // continues a SERVER-SIDE conversation by id, and warp is not session-tracked
693
+ // (absent from SESSION_AGENTS), so agents-cli has no local id to resume from —
694
+ // declaring it would make nativeResume(warp) true against an unreachable path.
695
+ warp: {
696
+ base: ['oz', 'agent', 'run'],
697
+ promptFlag: '-p',
698
+ modeFlags: {
699
+ edit: [],
700
+ },
701
+ jsonFlags: ['--output-format', 'json'],
702
+ modelFlag: '--model',
703
+ },
690
704
  };
691
705
  /**
692
706
  * Whether `agent` has a native resume form (Tier 1). Derived solely from the
@@ -89,6 +89,9 @@ export interface SessionOutcomeHint {
89
89
  worktreeSlug?: string | null;
90
90
  branch?: string | null;
91
91
  project?: string | null;
92
+ host?: string | null;
93
+ origin?: 'cli' | 'routine' | null;
94
+ routineName?: string | null;
92
95
  }
93
96
  /**
94
97
  * Overlay session meta onto a block when the block itself is missing ticket/PR/