@phnx-labs/agents-cli 1.22.23 → 1.22.25

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 +359 -0
  2. package/README.md +19 -8
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/cloud.js +9 -5
  7. package/dist/commands/defaults.js +2 -0
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +105 -9
  10. package/dist/commands/exec.js +99 -44
  11. package/dist/commands/feed.d.ts +23 -0
  12. package/dist/commands/feed.js +65 -2
  13. package/dist/commands/focus.d.ts +34 -3
  14. package/dist/commands/focus.js +301 -57
  15. package/dist/commands/go.d.ts +14 -1
  16. package/dist/commands/go.js +49 -5
  17. package/dist/commands/insights.js +6 -2
  18. package/dist/commands/inspect.js +39 -5
  19. package/dist/commands/menubar.js +14 -1
  20. package/dist/commands/models.js +1 -0
  21. package/dist/commands/modes.d.ts +12 -0
  22. package/dist/commands/modes.js +147 -0
  23. package/dist/commands/routines.d.ts +3 -0
  24. package/dist/commands/routines.js +70 -47
  25. package/dist/commands/run-cloud.js +1 -1
  26. package/dist/commands/secrets-sync.js +11 -13
  27. package/dist/commands/secrets.d.ts +2 -0
  28. package/dist/commands/secrets.js +54 -17
  29. package/dist/commands/sessions-browser.d.ts +36 -1
  30. package/dist/commands/sessions-browser.js +165 -19
  31. package/dist/commands/sessions-resume.d.ts +2 -0
  32. package/dist/commands/sessions-resume.js +28 -12
  33. package/dist/commands/sessions.d.ts +23 -1
  34. package/dist/commands/sessions.js +58 -61
  35. package/dist/commands/setup-secrets.js +1 -1
  36. package/dist/commands/setup-watchdog.js +5 -10
  37. package/dist/commands/setup.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/teams.js +3 -2
  40. package/dist/commands/view.js +2 -0
  41. package/dist/commands/watchdog.d.ts +3 -4
  42. package/dist/commands/watchdog.js +26 -66
  43. package/dist/index.js +38 -2
  44. package/dist/lib/agent-modes.d.ts +49 -0
  45. package/dist/lib/agent-modes.js +70 -0
  46. package/dist/lib/agents.js +126 -21
  47. package/dist/lib/cloud/cursor.d.ts +79 -0
  48. package/dist/lib/cloud/cursor.js +228 -0
  49. package/dist/lib/cloud/registry.js +2 -0
  50. package/dist/lib/cloud/types.d.ts +7 -2
  51. package/dist/lib/cloud/types.js +14 -0
  52. package/dist/lib/crabbox/cli.d.ts +2 -2
  53. package/dist/lib/crabbox/config.d.ts +7 -8
  54. package/dist/lib/crabbox/config.js +14 -14
  55. package/dist/lib/crabbox/lease.d.ts +11 -4
  56. package/dist/lib/crabbox/lease.js +40 -8
  57. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  58. package/dist/lib/crabbox/setup-copy.js +17 -1
  59. package/dist/lib/daemon.js +27 -1
  60. package/dist/lib/device-config.js +7 -0
  61. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  62. package/dist/lib/devices/doctor-findings.js +40 -1
  63. package/dist/lib/doctor-diff.d.ts +3 -0
  64. package/dist/lib/doctor-diff.js +15 -13
  65. package/dist/lib/event-stream.d.ts +3 -1
  66. package/dist/lib/event-stream.js +14 -1
  67. package/dist/lib/events.d.ts +9 -0
  68. package/dist/lib/events.js +58 -0
  69. package/dist/lib/exec.d.ts +15 -3
  70. package/dist/lib/exec.js +112 -19
  71. package/dist/lib/feed-outcome.d.ts +3 -0
  72. package/dist/lib/feed-outcome.js +18 -10
  73. package/dist/lib/feed.d.ts +4 -0
  74. package/dist/lib/hooks/cache.js +36 -3
  75. package/dist/lib/hooks.d.ts +19 -7
  76. package/dist/lib/hooks.js +88 -38
  77. package/dist/lib/hosts/passthrough.d.ts +21 -0
  78. package/dist/lib/hosts/passthrough.js +39 -12
  79. package/dist/lib/hosts/session-index.d.ts +4 -0
  80. package/dist/lib/hosts/session-index.js +7 -0
  81. package/dist/lib/manifest.d.ts +12 -2
  82. package/dist/lib/manifest.js +60 -5
  83. package/dist/lib/mcp.js +5 -1
  84. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  85. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  86. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  87. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  88. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  89. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  90. package/dist/lib/menubar/install-menubar.js +128 -6
  91. package/dist/lib/menubar/snapshot.d.ts +15 -0
  92. package/dist/lib/menubar/snapshot.js +40 -0
  93. package/dist/lib/plugins.js +13 -1
  94. package/dist/lib/refresh.d.ts +5 -0
  95. package/dist/lib/refresh.js +37 -33
  96. package/dist/lib/resource-inventory.d.ts +79 -0
  97. package/dist/lib/resource-inventory.js +122 -0
  98. package/dist/lib/resources/mcp.js +3 -0
  99. package/dist/lib/resources.js +8 -5
  100. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  101. package/dist/lib/routine-process-cleanup.js +73 -0
  102. package/dist/lib/run-defaults.d.ts +2 -0
  103. package/dist/lib/run-defaults.js +23 -2
  104. package/dist/lib/runner.js +27 -22
  105. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  106. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  107. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  108. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  109. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  110. package/dist/lib/secrets/bundles.js +158 -85
  111. package/dist/lib/secrets/filestore.d.ts +7 -3
  112. package/dist/lib/secrets/filestore.js +25 -8
  113. package/dist/lib/secrets/index.js +34 -8
  114. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  115. package/dist/lib/secrets/rc-hygiene.js +14 -1
  116. package/dist/lib/secrets/reaper.d.ts +10 -0
  117. package/dist/lib/secrets/reaper.js +50 -15
  118. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  119. package/dist/lib/secrets/sync-passphrase.js +78 -0
  120. package/dist/lib/session/active.d.ts +4 -0
  121. package/dist/lib/session/db.js +8 -2
  122. package/dist/lib/session/recovery.d.ts +37 -0
  123. package/dist/lib/session/recovery.js +95 -0
  124. package/dist/lib/session/remote-list.d.ts +2 -0
  125. package/dist/lib/session/remote-list.js +1 -0
  126. package/dist/lib/session/session-cache.d.ts +4 -4
  127. package/dist/lib/session/session-cache.js +4 -4
  128. package/dist/lib/shims.d.ts +2 -2
  129. package/dist/lib/shims.js +45 -6
  130. package/dist/lib/signin-badge.js +2 -0
  131. package/dist/lib/startup/command-registry.d.ts +16 -0
  132. package/dist/lib/startup/command-registry.js +44 -0
  133. package/dist/lib/sync-umbrella.d.ts +5 -0
  134. package/dist/lib/sync-umbrella.js +5 -4
  135. package/dist/lib/teams/agents.js +1 -1
  136. package/dist/lib/teams/parsers.d.ts +1 -1
  137. package/dist/lib/tmux/session.d.ts +4 -2
  138. package/dist/lib/tmux/session.js +5 -5
  139. package/dist/lib/types.d.ts +3 -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
package/dist/lib/exec.js CHANGED
@@ -17,7 +17,7 @@ import { isTierToken, resolveTier } from './model-tiers.js';
17
17
  import { createTimer, redactPrompt, redactArgs } from './events.js';
18
18
  import { sanitizeProcessEnv } from './secrets/bundles.js';
19
19
  import { resolveActor, actorEnv } from './actor.js';
20
- import { getShimsDir, getHistoryDir } from './state.js';
20
+ import { getShimsDir, getHistoryDir, getRuntimeStateDir } from './state.js';
21
21
  import { resolveCodexHome } from './codex-home.js';
22
22
  import { readCodexConfiguredModel } from './shims.js';
23
23
  import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
@@ -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`);
@@ -362,6 +358,28 @@ export function buildExecEnv(options) {
362
358
  delete result.CODEX_HOME;
363
359
  delete result.COPILOT_HOME;
364
360
  }
361
+ else if (options.agent === 'muse') {
362
+ // Muse has no MUSE_CONFIG_DIR. Config is XDG-based:
363
+ // $XDG_CONFIG_HOME/muse (settings, skills, hooks, auth)
364
+ // $XDG_DATA_HOME/muse (sessions, plugins)
365
+ // Pin both into the version home so multi-version isolation matches
366
+ // Claude's CLAUDE_CONFIG_DIR / Codex's CODEX_HOME, and so Muse never
367
+ // resolves through the adopt-time ~/.config/muse symlink (SymlinkOrReparse).
368
+ const cwd = options.cwd || process.cwd();
369
+ const resolvedVersion = options.version ?? resolveVersion('muse', cwd);
370
+ const version = options.version
371
+ ? resolvedVersion
372
+ : (resolvedVersion && isVersionInstalled('muse', resolvedVersion) ? resolvedVersion : null);
373
+ if (version) {
374
+ const versionHome = getVersionHomePath('muse', version);
375
+ result.XDG_CONFIG_HOME = path.join(versionHome, '.config');
376
+ result.XDG_DATA_HOME = path.join(versionHome, '.local', 'share');
377
+ }
378
+ delete result.CLAUDE_CONFIG_DIR;
379
+ delete result.CODEX_HOME;
380
+ delete result.COPILOT_HOME;
381
+ delete result.KIMI_CODE_HOME;
382
+ }
365
383
  else {
366
384
  delete result.CLAUDE_CONFIG_DIR;
367
385
  delete result.CODEX_HOME;
@@ -472,6 +490,7 @@ export const AGENT_COMMANDS = {
472
490
  base: ['cursor-agent'],
473
491
  promptFlag: '-p',
474
492
  modeFlags: {
493
+ plan: ['--plan'],
475
494
  edit: [],
476
495
  skip: ['-f'],
477
496
  },
@@ -665,6 +684,23 @@ export const AGENT_COMMANDS = {
665
684
  // `resume` subcommand — special-cased in buildExecCommand.
666
685
  resume: { flag: '--session-id' },
667
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
+ },
668
704
  };
669
705
  /**
670
706
  * Whether `agent` has a native resume form (Tier 1). Derived solely from the
@@ -1167,13 +1203,53 @@ export function shouldWrapInTmux(ctx) {
1167
1203
  * real values; the stored/informational copy uses the redacted form (RUSH-1758).
1168
1204
  */
1169
1205
  export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
1206
+ const agentCmd = [executable, ...args].map(shellQuote).join(' ');
1207
+ // envFile: source the values instead of inlining them, so no VALUE ever lands
1208
+ // in the pane's argv. `exec env K=V …` put every resolved secret into the
1209
+ // process table, readable by any process of this user — on one fleet box six
1210
+ // live processes carried the secrets-store master passphrase, which decrypts
1211
+ // every file-backed bundle including the Claude OAuth tokens (RUSH-2100).
1212
+ // `set -a` exports what the file assigns; the file is unlinked before `exec`,
1213
+ // so it exists only for the sourcing itself. A missing file aborts the pane
1214
+ // rather than silently launching with a half-built env.
1215
+ if (opts.envFile) {
1216
+ const f = shellQuote(opts.envFile);
1217
+ // Remove the file whether or not sourcing succeeds — a bare `. f || exit 1`
1218
+ // strands the plaintext env (incl. the secrets-store master passphrase) on
1219
+ // disk on any source failure, worse than the argv leak this replaces
1220
+ // (RUSH-2100). Capture the source rc, unlink, then honor it.
1221
+ return `set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
1222
+ }
1170
1223
  const envPrefix = Object.entries(env)
1171
1224
  .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
1172
1225
  .map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
1173
1226
  .join(' ');
1174
- const agentCmd = [executable, ...args].map(shellQuote).join(' ');
1175
1227
  return `exec env ${envPrefix} ${agentCmd}`;
1176
1228
  }
1229
+ /**
1230
+ * Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
1231
+ * (`wx`) so it can never adopt a pre-existing file's mode or content.
1232
+ *
1233
+ * EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
1234
+ * has to be updated for each new credential and is wrong the moment someone
1235
+ * forgets, whereas routing all of it through the file makes the guarantee hold by
1236
+ * construction. Keys are filtered to valid shell identifiers for the same reason
1237
+ * `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
1238
+ */
1239
+ export function writeTmuxEnvFile(env, filePath) {
1240
+ const body = Object.entries(env)
1241
+ .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
1242
+ .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
1243
+ .join('\n');
1244
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1245
+ const fd = fs.openSync(filePath, 'wx', 0o600);
1246
+ try {
1247
+ fs.writeSync(fd, `${body}\n`);
1248
+ }
1249
+ finally {
1250
+ fs.closeSync(fd);
1251
+ }
1252
+ }
1177
1253
  /**
1178
1254
  * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
1179
1255
  * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
@@ -1231,15 +1307,32 @@ async function runInTmux(options, executable, args) {
1231
1307
  // SessionStart learns some harness IDs only after launch. Carry the wrapper
1232
1308
  // name into that hook so it can bind both identities durably.
1233
1309
  const execEnv = { ...buildExecEnv(options), AGENT_TMUX_SESSION_NAME: name };
1234
- // Launch with the real env (secret VALUES materialized into the pane); persist
1235
- // only a value-redacted copy in SessionMeta.cmd so resolved secrets never hit
1236
- // disk via the informational cmd field (RUSH-1758).
1237
- const cmd = buildTmuxAgentCommand(executable, args, execEnv);
1310
+ // The pane sources its env from a 0600 file it unlinks before exec, so no
1311
+ // resolved secret VALUE reaches the process table (RUSH-2100). SessionMeta.cmd
1312
+ // keeps the value-redacted inline form — the human-readable record of what ran,
1313
+ // which never carried real values anyway (RUSH-1758).
1314
+ const envFile = path.join(getRuntimeStateDir(), 'tmux-env', `${name}-${randomUUID().slice(0, 8)}.env`);
1315
+ writeTmuxEnvFile(execEnv, envFile);
1316
+ const cmd = buildTmuxAgentCommand(executable, args, execEnv, { envFile });
1238
1317
  const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
1239
1318
  const labels = { agent: options.agent };
1240
1319
  if (options.sessionId)
1241
1320
  labels.sessionId = options.sessionId;
1242
- const meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
1321
+ // Only a launched pane sources-and-unlinks the env file. If createSession
1322
+ // throws, the pane never runs, so the resolved secrets (incl. the master
1323
+ // passphrase) would linger on disk — remove it on that failure path
1324
+ // (RUSH-2100). On success the detached pane owns the unlink.
1325
+ let meta;
1326
+ try {
1327
+ meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
1328
+ }
1329
+ catch (err) {
1330
+ try {
1331
+ fs.rmSync(envFile, { force: true });
1332
+ }
1333
+ catch { /* best-effort */ }
1334
+ throw err;
1335
+ }
1243
1336
  const pane = meta.pane;
1244
1337
  if (options.sessionId)
1245
1338
  writeSessionAliasRecord(options.sessionId, name);
@@ -1317,7 +1410,7 @@ async function runInTmux(options, executable, args) {
1317
1410
  };
1318
1411
  // The agent could exit before we attach (fast failure). Don't attach to an
1319
1412
  // already-dead pane — surface its output + status directly and tear down.
1320
- const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
1413
+ const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
1321
1414
  if (before.dead) {
1322
1415
  // F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
1323
1416
  // exit-0 before attach means the harness has no interactive REPL and the
@@ -1343,7 +1436,7 @@ async function runInTmux(options, executable, args) {
1343
1436
  return false;
1344
1437
  }
1345
1438
  };
1346
- const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
1439
+ const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
1347
1440
  if (after.dead) {
1348
1441
  // Nonzero exit after attach → the agent crashed rather than the user
1349
1442
  // detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
@@ -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/
@@ -152,21 +152,22 @@ export function groupBlocksByOutcome(blocks) {
152
152
  const groups = [];
153
153
  for (const { outcome, blocks: members } of byKey.values()) {
154
154
  const mailboxes = new Set(members.map((b) => b.mailboxId));
155
- let open = 0;
156
- let answered = 0;
157
- let parked = 0;
155
+ const states = new Map();
158
156
  for (const b of members) {
159
- if (b.parkedAt)
160
- parked += 1;
161
- else if (b.answer || b.defaultedAt || b.continuedAt)
162
- answered += 1;
163
- else
164
- open += 1;
157
+ const next = b.parkedAt
158
+ ? 'parked'
159
+ : b.answer || b.defaultedAt || b.continuedAt ? 'answered' : 'open';
160
+ const current = states.get(b.mailboxId);
161
+ // One agent occupies one state. Its most actionable block wins.
162
+ if (!current || next === 'open' || (next === 'parked' && current === 'answered')) {
163
+ states.set(b.mailboxId, next);
164
+ }
165
165
  }
166
+ const count = (state) => [...states.values()].filter((value) => value === state).length;
166
167
  groups.push({
167
168
  outcome,
168
169
  blocks: members,
169
- counts: { agents: mailboxes.size, open, answered, parked },
170
+ counts: { agents: mailboxes.size, open: count('open'), answered: count('answered'), parked: count('parked') },
170
171
  });
171
172
  }
172
173
  groups.sort((a, b) => {
@@ -222,6 +223,13 @@ export function enrichBlockFromSession(block, hint) {
222
223
  next.worktreeSlug = hint.worktreeSlug;
223
224
  if (!next.project && hint.project)
224
225
  next.project = hint.project;
226
+ if (hint.host && hint.host !== 'terminal' && next.runtime === 'terminal')
227
+ next.runtime = hint.host;
228
+ if (hint.origin === 'routine') {
229
+ next.origin = 'routine';
230
+ if (hint.routineName)
231
+ next.routineName = hint.routineName;
232
+ }
225
233
  return next;
226
234
  }
227
235
  /**
@@ -36,6 +36,10 @@ export interface OpenBlock {
36
36
  mailboxId: string;
37
37
  host: string;
38
38
  runtime: string;
39
+ /** Indexed launch origin, added at read time when the live session is known. */
40
+ origin?: 'cli' | 'routine';
41
+ /** Routine definition name when origin is `routine`. */
42
+ routineName?: string;
39
43
  /** Project/repo name this block belongs to (derived from cwd, worktree-aware). */
40
44
  project?: string;
41
45
  ts: string;
@@ -463,6 +463,9 @@ except Exception: pass' 2>/dev/null || true)"
463
463
  ;;
464
464
  esac
465
465
  CACHE_FILE="$CACHE_DIR/$HOOK_NAME$cache_suffix.out"
466
+ # Failure sentinel: touched on bg-refresh failure; mtime drives the backoff window.
467
+ FAIL_FILE="$CACHE_FILE.fail"
468
+ BACKOFF_SEC=60
466
469
 
467
470
  # Monotonic-ish nanosecond timer (macOS \`date\` has no %N).
468
471
  now_ns() { "$PY" -c 'import time; print(int(time.time()*1e9))'; }
@@ -491,9 +494,38 @@ if [ "$CACHE_STATUS" = miss ]; then
491
494
  # Stale-while-revalidate: serve stale immediately, refresh in detached child.
492
495
  cat "$CACHE_FILE"
493
496
  CACHE_STATUS=stale-prefetch
494
- tmp="$CACHE_FILE.new.$$"
495
- ( printf '%s' "$STDIN_PAYLOAD" | "$SOURCE" >"$tmp" 2>/dev/null && mv -f "$tmp" "$CACHE_FILE" || rm -f "$tmp" ) >/dev/null 2>&1 &
496
- disown 2>/dev/null || true
497
+ # Fix 2: backoff — skip bg refresh if last attempt failed within BACKOFF_SEC.
498
+ _in_backoff=0
499
+ if [ -f "$FAIL_FILE" ]; then
500
+ _fail_mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$FAIL_FILE" 2>/dev/null || echo 0)
501
+ _now_s=$(date +%s)
502
+ _fail_age=$((_now_s - \${_fail_mtime:-0}))
503
+ [ "$_fail_age" -lt "$BACKOFF_SEC" ] && _in_backoff=1
504
+ fi
505
+ if [ "$_in_backoff" -eq 0 ]; then
506
+ # Fix 1: lockdir — only one background refresh runs at a time.
507
+ LOCK_DIR="$CACHE_FILE.bg.lck"
508
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
509
+ tmp="$CACHE_FILE.new.$$"
510
+ # Fix 3: background subshell captures and logs its own real exit code.
511
+ (
512
+ trap 'rm -rf "$LOCK_DIR"' EXIT
513
+ _bg_exit=0
514
+ if printf '%s' "$STDIN_PAYLOAD" | "$SOURCE" >"$tmp" 2>/dev/null; then
515
+ mv -f "$tmp" "$CACHE_FILE" && rm -f "$FAIL_FILE"
516
+ else
517
+ _bg_exit=$?
518
+ rm -f "$tmp"
519
+ touch "$FAIL_FILE"
520
+ fi
521
+ _bg_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
522
+ _bg_log_file="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
523
+ printf '{"ts":"%s","event":"hook.cache.refresh","hook":"%s","cache":"bg-refresh","exit":%d}\\n' \\
524
+ "$_bg_ts" "$HOOK_NAME" "$_bg_exit" >>"$_bg_log_file" 2>/dev/null || true
525
+ ) >/dev/null 2>&1 &
526
+ disown 2>/dev/null || true
527
+ fi
528
+ fi
497
529
  else
498
530
  # Synchronous fetch + cache.
499
531
  tmp="$CACHE_FILE.new.$$"
@@ -501,6 +533,7 @@ if [ "$CACHE_STATUS" = miss ]; then
501
533
  EXIT=0
502
534
  cat "$tmp"
503
535
  mv -f "$tmp" "$CACHE_FILE"
536
+ rm -f "$FAIL_FILE"
504
537
  else
505
538
  EXIT=$?
506
539
  rm -f "$tmp"
@@ -73,6 +73,14 @@ export declare function hookExists(agentId: AgentId, hookName: string): boolean;
73
73
  * Compares both script file and data file (if present).
74
74
  */
75
75
  export declare function hookContentMatches(agentId: AgentId, hookName: string, sourceEntry: HookEntry): boolean;
76
+ /**
77
+ * Hooks dir for an agent under an arbitrary home (version home or effective
78
+ * home). An agent whose `hooksDir` is configured absolute under `$HOME` (grok,
79
+ * kimi) must be translated to config-dir-relative first — a raw `path.join`
80
+ * would embed the absolute path as a relative segment and produce a hybrid
81
+ * path that never exists (RUSH-2237).
82
+ */
83
+ export declare function getHooksDirInHome(agentId: AgentId, home: string): string;
76
84
  export declare function listInstalledHooksWithScope(agentId: AgentId, cwd?: string, options?: {
77
85
  home?: string;
78
86
  }): InstalledHook[];
@@ -99,7 +107,7 @@ export interface HookWiringIssue {
99
107
  * group). Real hooks scope by matcher — ask-user-question-guard=AskUserQuestion,
100
108
  * user-message-guard=Bash — so wiring is verified per (event, matcher). */
101
109
  matcher: string;
102
- /** The command settings.json should reference for this hook under `event`. */
110
+ /** The command the harness-native config should reference under `event`. */
103
111
  command: string;
104
112
  }
105
113
  export interface HookWiringReport {
@@ -109,18 +117,22 @@ export interface HookWiringReport {
109
117
  settingsPath?: string;
110
118
  /** Number of hooks the manifest says should be wired for this version. */
111
119
  expected?: number;
112
- /** settings.json does not exist — nothing declared can be wired. */
120
+ /** Native hook config does not exist — nothing declared can be wired. */
113
121
  settingsMissing?: boolean;
114
- /** settings.json exists but is not valid JSON — wiring can't be verified. */
122
+ /** Native hook config cannot be parsed — wiring can't be verified. */
115
123
  settingsUnparseable?: boolean;
116
124
  /** Hooks whose file is present/resolvable but that are NOT referenced in the
117
- * event array settings.json should carry them in. */
125
+ * native event group/entry should carry them in. */
118
126
  unwired: HookWiringIssue[];
127
+ /** Expected hooks that ARE referenced in native config (expected − unwired).
128
+ * Empty whenever wiring cannot be verified (unsupported family, missing or
129
+ * unparseable settings). */
130
+ wired: HookWiringIssue[];
119
131
  }
120
132
  /**
121
- * Verify that every hook the manifest says should be wired for a (claude|droid)
122
- * version is actually REFERENCED in that version's native settings.json not
123
- * merely present as a file on disk.
133
+ * Verify that every hook the manifest says should be wired is actually
134
+ * referenced in that version's harness-native config, not merely present as a
135
+ * file on disk.
124
136
  *
125
137
  * `agents doctor` compares hook FILES against source (see diffHooks in
126
138
  * doctor-diff.ts) but never checks the wiring, so a hook whose script is
package/dist/lib/hooks.js CHANGED
@@ -673,6 +673,20 @@ export function hookContentMatches(agentId, hookName, sourceEntry) {
673
673
  return false;
674
674
  }
675
675
  }
676
+ /**
677
+ * Hooks dir for an agent under an arbitrary home (version home or effective
678
+ * home). An agent whose `hooksDir` is configured absolute under `$HOME` (grok,
679
+ * kimi) must be translated to config-dir-relative first — a raw `path.join`
680
+ * would embed the absolute path as a relative segment and produce a hybrid
681
+ * path that never exists (RUSH-2237).
682
+ */
683
+ export function getHooksDirInHome(agentId, home) {
684
+ const config = AGENTS[agentId];
685
+ const hooksDir = path.isAbsolute(config.hooksDir)
686
+ ? path.relative(config.configDir, config.hooksDir)
687
+ : config.hooksDir;
688
+ return path.join(home, agentConfigDirName(agentId), hooksDir);
689
+ }
676
690
  export function listInstalledHooksWithScope(agentId, cwd = process.cwd(), options) {
677
691
  const agent = AGENTS[agentId];
678
692
  if (!agent.supportsHooks) {
@@ -702,7 +716,7 @@ export function listInstalledHooksWithScope(agentId, cwd = process.cwd(), option
702
716
  }
703
717
  // User-scoped hooks (version-aware when home is provided)
704
718
  const home = options?.home || getEffectiveHome(agentId);
705
- const userDir = path.join(home, agentConfigDirName(agentId), agent.hooksDir);
719
+ const userDir = getHooksDirInHome(agentId, home);
706
720
  const userHooks = listHookEntriesFromDir(userDir);
707
721
  for (const hook of userHooks) {
708
722
  addHook(hook, 'user', agentId);
@@ -740,8 +754,7 @@ export async function installHooks(source, agents, options = {}) {
740
754
  * Path to the hooks dir of a specific version home (not the active one).
741
755
  */
742
756
  export function getVersionHooksDir(agent, version) {
743
- const home = getVersionHomePath(agent, version);
744
- return path.join(home, agentConfigDirName(agent), AGENTS[agent].hooksDir);
757
+ return getHooksDirInHome(agent, getVersionHomePath(agent, version));
745
758
  }
746
759
  /**
747
760
  * List hook entries in a specific version home.
@@ -749,20 +762,20 @@ export function getVersionHooksDir(agent, version) {
749
762
  export function listHooksInVersionHome(agent, version) {
750
763
  return listHookEntriesFromDir(getVersionHooksDir(agent, version));
751
764
  }
752
- // ─── wiring inspection (settings.json family: claude, droid) ──────────────────
765
+ // ─── wiring inspection ────────────────────────────────────────────────────────
753
766
  /**
754
- * Agents whose hooks register through {@link registerHooksForClaude} a native
755
- * settings.json shaped `hooks[event] = [{ matcher, hooks: [{ command }] }]`, with
756
- * no event renaming. These are the only agents this read-only wiring inspector
757
- * understands; every other harness uses a divergent config format and/or event
758
- * map (Gemini/Antigravity settings.json variants, Codex config.toml, the OpenCode
759
- * plugin, …), so it reports them unsupported rather than risk a false verdict.
767
+ * Native hook-config families understood by this read-only inspector. Claude,
768
+ * Droid, and Muse share settings.json; Grok uses the same grouped event shape
769
+ * in hooks/hooks.json; Kimi stores one hook per [[hooks]] config.toml table.
770
+ * Other harnesses report unsupported rather than risk a false verdict.
760
771
  */
761
772
  const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid', 'muse'];
773
+ const HOOKS_JSON_HOOK_FAMILY = ['grok'];
774
+ const TOML_ARRAY_HOOK_FAMILY = ['kimi'];
762
775
  /**
763
- * Verify that every hook the manifest says should be wired for a (claude|droid)
764
- * version is actually REFERENCED in that version's native settings.json not
765
- * merely present as a file on disk.
776
+ * Verify that every hook the manifest says should be wired is actually
777
+ * referenced in that version's harness-native config, not merely present as a
778
+ * file on disk.
766
779
  *
767
780
  * `agents doctor` compares hook FILES against source (see diffHooks in
768
781
  * doctor-diff.ts) but never checks the wiring, so a hook whose script is
@@ -774,11 +787,18 @@ const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid', 'muse'];
774
787
  * resolveHookCommand performs, so it never mutates the version home.
775
788
  */
776
789
  export function checkVersionHookWiring(agent, version) {
777
- if (!AGENTS[agent].supportsHooks || !SETTINGS_JSON_HOOK_FAMILY.includes(agent)) {
778
- return { supported: false, unwired: [] };
790
+ if (!AGENTS[agent].supportsHooks ||
791
+ (!SETTINGS_JSON_HOOK_FAMILY.includes(agent) &&
792
+ !HOOKS_JSON_HOOK_FAMILY.includes(agent) &&
793
+ !TOML_ARRAY_HOOK_FAMILY.includes(agent))) {
794
+ return { supported: false, unwired: [], wired: [] };
779
795
  }
780
796
  const versionHome = getVersionHomePath(agent, version);
781
- const settingsPath = path.join(versionHome, agentConfigDirName(agent), 'settings.json');
797
+ const settingsPath = HOOKS_JSON_HOOK_FAMILY.includes(agent)
798
+ ? path.join(versionHome, '.grok', 'hooks', 'hooks.json')
799
+ : TOML_ARRAY_HOOK_FAMILY.includes(agent)
800
+ ? path.join(versionHome, '.kimi-code', 'config.toml')
801
+ : path.join(versionHome, agentConfigDirName(agent), 'settings.json');
782
802
  const localHooksDir = getVersionHooksDir(agent, version);
783
803
  // Resolve ONLY to a script that was actually synced for THIS agent+version: the
784
804
  // copy in the version home hooks dir, or an absolute subrule-dir path (those are
@@ -815,11 +835,17 @@ export function checkVersionHookWiring(agent, version) {
815
835
  const command = expectedCommand(name, hookDef);
816
836
  if (!command)
817
837
  continue; // script unresolved — a file gap, reported by diffHooks
818
- // Mirror registerHooksForClaude: a hook registers under the matcher group
819
- // `hookDef.matcher || ''` for each of its events.
820
- const matcher = hookDef.matcher || '';
821
- for (const event of hookDef.events)
822
- expected.push({ name, event, matcher, command });
838
+ for (const event of hookDef.events) {
839
+ if (HOOKS_JSON_HOOK_FAMILY.includes(agent)) {
840
+ const matcher = GROK_MATCHER_EVENTS.has(event)
841
+ ? (GROK_MATCHER_ALIASES[hookDef.matcher || ''] ?? hookDef.matcher ?? '')
842
+ : '';
843
+ expected.push({ name, event, matcher, command });
844
+ }
845
+ else {
846
+ expected.push({ name, event, matcher: hookDef.matcher || '', command });
847
+ }
848
+ }
823
849
  }
824
850
  if (!fs.existsSync(settingsPath)) {
825
851
  return {
@@ -828,11 +854,15 @@ export function checkVersionHookWiring(agent, version) {
828
854
  expected: expected.length,
829
855
  settingsMissing: expected.length > 0,
830
856
  unwired: [],
857
+ wired: [],
831
858
  };
832
859
  }
833
860
  let config;
834
861
  try {
835
- config = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
862
+ const raw = fs.readFileSync(settingsPath, 'utf-8');
863
+ config = TOML_ARRAY_HOOK_FAMILY.includes(agent)
864
+ ? TOML.parse(raw)
865
+ : JSON.parse(raw);
836
866
  }
837
867
  catch {
838
868
  return {
@@ -841,6 +871,7 @@ export function checkVersionHookWiring(agent, version) {
841
871
  expected: expected.length,
842
872
  settingsUnparseable: true,
843
873
  unwired: [],
874
+ wired: [],
844
875
  };
845
876
  }
846
877
  // Command strings actually referenced, keyed by (event, matcher) — a hook wired
@@ -848,30 +879,49 @@ export function checkVersionHookWiring(agent, version) {
848
879
  // not just by event.
849
880
  const wiredByGroup = new Map();
850
881
  const groupKey = (event, matcher) => `${event}\n${matcher}`;
851
- const hooks = config.hooks && typeof config.hooks === 'object'
852
- ? config.hooks
853
- : {};
854
- for (const [event, groups] of Object.entries(hooks)) {
855
- if (!Array.isArray(groups))
856
- continue;
857
- for (const group of groups) {
858
- if (!group || !Array.isArray(group.hooks))
882
+ if (TOML_ARRAY_HOOK_FAMILY.includes(agent)) {
883
+ const hooks = Array.isArray(config.hooks) ? config.hooks : [];
884
+ for (const hook of hooks) {
885
+ if (typeof hook.event !== 'string' || typeof hook.command !== 'string')
859
886
  continue;
860
- const matcher = typeof group.matcher === 'string' ? group.matcher : '';
861
- const key = groupKey(event, matcher);
887
+ const matcher = typeof hook.matcher === 'string' ? hook.matcher : '';
888
+ const key = groupKey(hook.event, matcher);
862
889
  let cmds = wiredByGroup.get(key);
863
890
  if (!cmds) {
864
891
  cmds = new Set();
865
892
  wiredByGroup.set(key, cmds);
866
893
  }
867
- for (const h of group.hooks) {
868
- if (h && typeof h.command === 'string')
869
- cmds.add(h.command);
894
+ cmds.add(hook.command);
895
+ }
896
+ }
897
+ else {
898
+ const hooks = config.hooks && typeof config.hooks === 'object'
899
+ ? config.hooks
900
+ : {};
901
+ for (const [event, groups] of Object.entries(hooks)) {
902
+ if (!Array.isArray(groups))
903
+ continue;
904
+ for (const group of groups) {
905
+ if (!group || !Array.isArray(group.hooks))
906
+ continue;
907
+ const matcher = typeof group.matcher === 'string' ? group.matcher : '';
908
+ const key = groupKey(event, matcher);
909
+ let cmds = wiredByGroup.get(key);
910
+ if (!cmds) {
911
+ cmds = new Set();
912
+ wiredByGroup.set(key, cmds);
913
+ }
914
+ for (const h of group.hooks) {
915
+ if (h && typeof h.command === 'string')
916
+ cmds.add(h.command);
917
+ }
870
918
  }
871
919
  }
872
920
  }
873
- const unwired = expected.filter((e) => !wiredByGroup.get(groupKey(e.event, e.matcher))?.has(e.command));
874
- return { supported: true, settingsPath, expected: expected.length, unwired };
921
+ const isWired = (entry) => wiredByGroup.get(groupKey(entry.event, entry.matcher))?.has(entry.command) ?? false;
922
+ const unwired = expected.filter((entry) => !isWired(entry));
923
+ const wired = expected.filter(isWired);
924
+ return { supported: true, settingsPath, expected: expected.length, unwired, wired };
875
925
  }
876
926
  /**
877
927
  * Check if a hook installed in a specific version matches central content.
@@ -1402,7 +1452,7 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1402
1452
  // Scripts are copied into the version home during sync — prefer that stable
1403
1453
  // local path so registered commands don't break when source dirs change.
1404
1454
  const localHooksDir = !overrideRoots
1405
- ? path.join(versionHome, agentConfigDirName(agentId), AGENTS[agentId].hooksDir)
1455
+ ? getHooksDirInHome(agentId, versionHome)
1406
1456
  : null;
1407
1457
  const resolveScript = (script) => {
1408
1458
  // Subrule-dir hooks declare an already-absolute script path. Use it
@@ -16,6 +16,7 @@
16
16
  * table or, when `--host`/`--device` is present, exits with a clear
17
17
  * "not supported" message — never commander's raw `unknown option`.
18
18
  */
19
+ import { type Host } from './types.js';
19
20
  import { type DeviceProfile, type DeviceRegistry } from '../devices/registry.js';
20
21
  import { runLocalCommand, runOnDevice } from '../devices/fleet.js';
21
22
  /** Per-command remote behaviour. Absence from this map = not host-routable here. */
@@ -60,4 +61,24 @@ export declare function runFleetPassthrough(command: string, allArgs: string[],
60
61
  * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
61
62
  */
62
63
  export declare function maybeRunOnHost(command: string, allArgs: string[], opts?: FleetPassthroughOptions): Promise<boolean>;
64
+ /**
65
+ * Run `agents <forwardedArgs>` on `host` over SSH, streaming its output, and
66
+ * return the exit code. The single place the SSH hop is built, so every remote
67
+ * `agents` invocation carries identical env semantics.
68
+ *
69
+ * Forwards actor provenance (`AGENTS_ACTOR`/`GIT_` vars) across the hop, merged UNDER
70
+ * `extraEnv` so a caller-supplied PATH still wins — without this the remote
71
+ * re-resolves the actor from THIS box's SSH_CONNECTION and mis-credits it
72
+ * (RUSH-2028). Flows to both POSIX (export) and Windows ($env:) dialects.
73
+ * AGENTS_FLEET_REMOTE marks this as a fleet-dispatched run so the far side can
74
+ * gate consent-sensitive actions — the browser consent gate
75
+ * (lib/browser/remote-control.ts) reads it to allow/deny a cross-machine drive.
76
+ */
77
+ export declare function streamAgentsOnHost(host: Host, forwardedArgs: string[], opts?: {
78
+ remoteCwd?: string;
79
+ interactive?: boolean;
80
+ extraEnv?: Record<string, string>;
81
+ remoteOs?: string;
82
+ target?: string;
83
+ }): number;
63
84
  export {};