@phnx-labs/agents-cli 1.22.23 → 1.22.24

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 (88) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +5 -4
  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/defaults.js +2 -0
  7. package/dist/commands/doctor.js +5 -0
  8. package/dist/commands/exec.js +80 -28
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +34 -3
  12. package/dist/commands/focus.js +300 -56
  13. package/dist/commands/go.d.ts +14 -1
  14. package/dist/commands/go.js +49 -5
  15. package/dist/commands/insights.js +6 -2
  16. package/dist/commands/inspect.js +39 -5
  17. package/dist/commands/menubar.js +6 -1
  18. package/dist/commands/models.js +1 -0
  19. package/dist/commands/modes.d.ts +12 -0
  20. package/dist/commands/modes.js +147 -0
  21. package/dist/commands/secrets-sync.js +11 -13
  22. package/dist/commands/secrets.d.ts +2 -0
  23. package/dist/commands/secrets.js +54 -17
  24. package/dist/commands/sessions-browser.d.ts +35 -0
  25. package/dist/commands/sessions-browser.js +140 -14
  26. package/dist/commands/sessions-resume.d.ts +2 -0
  27. package/dist/commands/sessions-resume.js +26 -11
  28. package/dist/commands/sessions.d.ts +10 -0
  29. package/dist/commands/sessions.js +34 -59
  30. package/dist/commands/setup-secrets.js +1 -1
  31. package/dist/commands/sync.js +246 -42
  32. package/dist/commands/view.js +2 -0
  33. package/dist/index.js +2 -1
  34. package/dist/lib/agent-modes.d.ts +49 -0
  35. package/dist/lib/agent-modes.js +70 -0
  36. package/dist/lib/doctor-diff.d.ts +3 -0
  37. package/dist/lib/doctor-diff.js +15 -13
  38. package/dist/lib/event-stream.d.ts +3 -1
  39. package/dist/lib/event-stream.js +14 -1
  40. package/dist/lib/exec.d.ts +12 -0
  41. package/dist/lib/exec.js +88 -9
  42. package/dist/lib/hooks/cache.js +36 -3
  43. package/dist/lib/hooks.d.ts +19 -7
  44. package/dist/lib/hooks.js +88 -38
  45. package/dist/lib/hosts/session-index.d.ts +4 -0
  46. package/dist/lib/hosts/session-index.js +7 -0
  47. package/dist/lib/manifest.d.ts +12 -2
  48. package/dist/lib/manifest.js +60 -5
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  54. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  55. package/dist/lib/menubar/install-menubar.js +128 -6
  56. package/dist/lib/refresh.d.ts +5 -0
  57. package/dist/lib/refresh.js +37 -33
  58. package/dist/lib/resource-inventory.d.ts +79 -0
  59. package/dist/lib/resource-inventory.js +122 -0
  60. package/dist/lib/resources.js +8 -5
  61. package/dist/lib/run-defaults.d.ts +2 -0
  62. package/dist/lib/run-defaults.js +23 -2
  63. package/dist/lib/runner.js +22 -17
  64. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  65. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  66. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  67. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  68. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  69. package/dist/lib/secrets/bundles.js +157 -65
  70. package/dist/lib/secrets/filestore.d.ts +5 -3
  71. package/dist/lib/secrets/filestore.js +12 -8
  72. package/dist/lib/secrets/index.js +34 -8
  73. package/dist/lib/secrets/reaper.d.ts +10 -0
  74. package/dist/lib/secrets/reaper.js +50 -15
  75. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  76. package/dist/lib/secrets/sync-passphrase.js +78 -0
  77. package/dist/lib/session/recovery.d.ts +37 -0
  78. package/dist/lib/session/recovery.js +95 -0
  79. package/dist/lib/shims.d.ts +2 -2
  80. package/dist/lib/shims.js +24 -5
  81. package/dist/lib/startup/command-registry.d.ts +1 -0
  82. package/dist/lib/startup/command-registry.js +2 -0
  83. package/dist/lib/sync-umbrella.d.ts +5 -0
  84. package/dist/lib/sync-umbrella.js +5 -4
  85. package/dist/lib/tmux/session.d.ts +4 -2
  86. package/dist/lib/tmux/session.js +5 -5
  87. package/dist/lib/types.d.ts +2 -0
  88. package/package.json +1 -1
@@ -33,7 +33,8 @@ import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } fr
33
33
  import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
34
34
  import { supports } from './capabilities.js';
35
35
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
36
- import { listHooksInVersionHome, listHookEntriesFromDir, checkVersionHookWiring } from './hooks.js';
36
+ import { listHookEntriesFromDir } from './hooks.js';
37
+ import { getResourceInventory } from './resource-inventory.js';
37
38
  const RULES_DOC_FILENAME = 'README.md';
38
39
  const ALL_KINDS = [
39
40
  'commands',
@@ -300,11 +301,10 @@ function diffSkills(agent, version, cwd, excludeProject = false) {
300
301
  return rows.sort((a, b) => a.name.localeCompare(b.name));
301
302
  }
302
303
  // ─── hooks ────────────────────────────────────────────────────────────────────
303
- function diffHooks(agent, version, cwd) {
304
+ function diffHooks(agent, version, cwd, inventory) {
304
305
  if (!AGENTS[agent].supportsHooks)
305
306
  return [];
306
- const installedEntries = listHooksInVersionHome(agent, version);
307
- const installedByName = new Map(installedEntries.map((e) => [e.name, e]));
307
+ const installedByName = new Map(inventory.onDisk.map((e) => [e.name, e]));
308
308
  // Sync intentionally excludes project/.agents/hooks/ — mirror that.
309
309
  const layerBases = buildLayerBases(cwd, 'hooks', { excludeProject: true });
310
310
  // Group source files the same way the hook installer does (basename across
@@ -329,14 +329,14 @@ function diffHooks(agent, version, cwd) {
329
329
  continue;
330
330
  }
331
331
  const a = readSafe(src.entry.scriptPath);
332
- const b = readSafe(installed.scriptPath);
332
+ const b = readSafe(installed.path);
333
333
  let matches = a != null && b != null && normalize(a) === normalize(b);
334
- if (matches && src.entry.dataFile && installed.dataFile) {
334
+ if (matches && src.entry.dataFile && installed.detail) {
335
335
  const ad = readSafe(src.entry.dataFile);
336
- const bd = readSafe(installed.dataFile);
336
+ const bd = readSafe(installed.detail);
337
337
  matches = ad != null && bd != null && normalize(ad) === normalize(bd);
338
338
  }
339
- else if (matches && (!!src.entry.dataFile !== !!installed.dataFile)) {
339
+ else if (matches && (!!src.entry.dataFile !== !!installed.detail)) {
340
340
  matches = false;
341
341
  }
342
342
  rows.push({
@@ -345,13 +345,13 @@ function diffHooks(agent, version, cwd) {
345
345
  status: matches ? 'ok' : 'diff',
346
346
  source: src.layer,
347
347
  sourcePath: src.entry.scriptPath,
348
- homePath: installed.scriptPath,
348
+ homePath: installed.path,
349
349
  });
350
350
  }
351
351
  for (const [name, installed] of installedByName) {
352
352
  if (seen.has(name))
353
353
  continue;
354
- rows.push({ kind: 'hooks', name, status: 'extra', homePath: installed.scriptPath });
354
+ rows.push({ kind: 'hooks', name, status: 'extra', homePath: installed.path });
355
355
  }
356
356
  return rows.sort((a, b) => a.name.localeCompare(b.name));
357
357
  }
@@ -609,12 +609,13 @@ export function diffVersionResources(agent, version, options = {}) {
609
609
  empty.commands = diffCommands(agent, version, cwd, excludeProject);
610
610
  if (requested.has('skills'))
611
611
  empty.skills = diffSkills(agent, version, cwd, excludeProject);
612
- if (requested.has('hooks'))
613
- empty.hooks = diffHooks(agent, version, cwd);
612
+ const hookInventory = requested.has('hooks') ? getResourceInventory(agent, version, 'hooks', { cwd }) : undefined;
613
+ if (hookInventory)
614
+ empty.hooks = diffHooks(agent, version, cwd, hookInventory);
614
615
  // Wiring check: a hook FILE can be present and byte-identical to source (ok
615
616
  // above) yet never referenced in settings.json, so it never fires. Only
616
617
  // meaningful when hooks are in scope.
617
- const hookWiring = requested.has('hooks') ? checkVersionHookWiring(agent, version) : undefined;
618
+ const hookWiring = hookInventory?.wiring;
618
619
  if (requested.has('rules'))
619
620
  empty.rules = diffRules(agent, version, cwd, excludeProject);
620
621
  if (requested.has('mcp'))
@@ -654,6 +655,7 @@ export function diffVersionResources(agent, version, options = {}) {
654
655
  kinds: empty,
655
656
  summary: { ok, diff, missing, extra },
656
657
  ...(hookWiring ? { hookWiring } : {}),
658
+ ...(hookInventory ? { hookInventory } : {}),
657
659
  };
658
660
  }
659
661
  export const DOCTOR_ALL_KINDS = ALL_KINDS;
@@ -36,6 +36,8 @@ export interface UnifiedQuery {
36
36
  * Read a unified, newest-first event stream. Operational events come from
37
37
  * events.ts `query()`; agent-semantic events from the activity logs, normalized
38
38
  * to the same record shape and filtered identically. `limit` caps the merged
39
- * result (each source is fetched up to `limit`, so the top-N is exact).
39
+ * result (each source is fetched up to `limit` *after* its primary filters
40
+ * eventTypes for activity, eventTypes/module/bundle for ops — so the top-N is
41
+ * exact for those filters).
40
42
  */
41
43
  export declare function readUnifiedEvents(q?: UnifiedQuery): EventRecord[];
@@ -46,7 +46,9 @@ function matches(r, q) {
46
46
  * Read a unified, newest-first event stream. Operational events come from
47
47
  * events.ts `query()`; agent-semantic events from the activity logs, normalized
48
48
  * to the same record shape and filtered identically. `limit` caps the merged
49
- * result (each source is fetched up to `limit`, so the top-N is exact).
49
+ * result (each source is fetched up to `limit` *after* its primary filters
50
+ * eventTypes for activity, eventTypes/module/bundle for ops — so the top-N is
51
+ * exact for those filters).
50
52
  */
51
53
  export function readUnifiedEvents(q = {}) {
52
54
  // `bundle` is filtered inside query()'s scan (before its limit cutoff) so a
@@ -66,10 +68,21 @@ export function readUnifiedEvents(q = {}) {
66
68
  });
67
69
  if (q.includeActivity === false)
68
70
  return ops;
71
+ // Activity events always stamp module: 'activity'. A non-activity module
72
+ // filter can never match them — skip the activity scan entirely.
73
+ if (q.module != null && q.module !== 'activity')
74
+ return ops;
75
+ // Push eventTypes into the activity reader so `limit` is applied AFTER the
76
+ // event-type filter (readRecentActivity already does this for `events`).
77
+ // Without this, a rare match older than the newest-`limit` window of routine
78
+ // churn is silently dropped — the same class of bug as the ops-side bundle
79
+ // pre-filter above (RUSH-2093). Remaining filters (agent, sessionId, …) still
80
+ // run via matches() for fields activity.ts does not pre-filter.
69
81
  const acts = readActivityAsEventRecords({
70
82
  sinceMs: q.startDate?.getTime(),
71
83
  limit: q.limit,
72
84
  root: q.activityRoot,
85
+ events: q.eventTypes,
73
86
  }).filter((r) => matches(r, q));
74
87
  const merged = [...ops, ...acts].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
75
88
  return typeof q.limit === 'number' ? merged.slice(0, q.limit) : merged;
@@ -389,7 +389,19 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
389
389
  */
390
390
  export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
391
391
  redactEnvValues?: boolean;
392
+ envFile?: string;
392
393
  }): string;
394
+ /**
395
+ * Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
396
+ * (`wx`) so it can never adopt a pre-existing file's mode or content.
397
+ *
398
+ * EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
399
+ * has to be updated for each new credential and is wrong the moment someone
400
+ * forgets, whereas routing all of it through the file makes the guarantee hold by
401
+ * construction. Keys are filtered to valid shell identifiers for the same reason
402
+ * `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
403
+ */
404
+ export declare function writeTmuxEnvFile(env: NodeJS.ProcessEnv, filePath: string): void;
393
405
  /**
394
406
  * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
395
407
  * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
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';
@@ -362,6 +362,28 @@ export function buildExecEnv(options) {
362
362
  delete result.CODEX_HOME;
363
363
  delete result.COPILOT_HOME;
364
364
  }
365
+ else if (options.agent === 'muse') {
366
+ // Muse has no MUSE_CONFIG_DIR. Config is XDG-based:
367
+ // $XDG_CONFIG_HOME/muse (settings, skills, hooks, auth)
368
+ // $XDG_DATA_HOME/muse (sessions, plugins)
369
+ // Pin both into the version home so multi-version isolation matches
370
+ // Claude's CLAUDE_CONFIG_DIR / Codex's CODEX_HOME, and so Muse never
371
+ // resolves through the adopt-time ~/.config/muse symlink (SymlinkOrReparse).
372
+ const cwd = options.cwd || process.cwd();
373
+ const resolvedVersion = options.version ?? resolveVersion('muse', cwd);
374
+ const version = options.version
375
+ ? resolvedVersion
376
+ : (resolvedVersion && isVersionInstalled('muse', resolvedVersion) ? resolvedVersion : null);
377
+ if (version) {
378
+ const versionHome = getVersionHomePath('muse', version);
379
+ result.XDG_CONFIG_HOME = path.join(versionHome, '.config');
380
+ result.XDG_DATA_HOME = path.join(versionHome, '.local', 'share');
381
+ }
382
+ delete result.CLAUDE_CONFIG_DIR;
383
+ delete result.CODEX_HOME;
384
+ delete result.COPILOT_HOME;
385
+ delete result.KIMI_CODE_HOME;
386
+ }
365
387
  else {
366
388
  delete result.CLAUDE_CONFIG_DIR;
367
389
  delete result.CODEX_HOME;
@@ -1167,13 +1189,53 @@ export function shouldWrapInTmux(ctx) {
1167
1189
  * real values; the stored/informational copy uses the redacted form (RUSH-1758).
1168
1190
  */
1169
1191
  export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
1192
+ const agentCmd = [executable, ...args].map(shellQuote).join(' ');
1193
+ // envFile: source the values instead of inlining them, so no VALUE ever lands
1194
+ // in the pane's argv. `exec env K=V …` put every resolved secret into the
1195
+ // process table, readable by any process of this user — on one fleet box six
1196
+ // live processes carried the secrets-store master passphrase, which decrypts
1197
+ // every file-backed bundle including the Claude OAuth tokens (RUSH-2100).
1198
+ // `set -a` exports what the file assigns; the file is unlinked before `exec`,
1199
+ // so it exists only for the sourcing itself. A missing file aborts the pane
1200
+ // rather than silently launching with a half-built env.
1201
+ if (opts.envFile) {
1202
+ const f = shellQuote(opts.envFile);
1203
+ // Remove the file whether or not sourcing succeeds — a bare `. f || exit 1`
1204
+ // strands the plaintext env (incl. the secrets-store master passphrase) on
1205
+ // disk on any source failure, worse than the argv leak this replaces
1206
+ // (RUSH-2100). Capture the source rc, unlink, then honor it.
1207
+ return `set -a; . ${f}; __agents_rc=$?; set +a; rm -f ${f}; [ "$__agents_rc" -eq 0 ] || exit 1; exec ${agentCmd}`;
1208
+ }
1170
1209
  const envPrefix = Object.entries(env)
1171
1210
  .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
1172
1211
  .map(([k, v]) => `${k}=${opts.redactEnvValues ? '<redacted>' : shellQuote(String(v))}`)
1173
1212
  .join(' ');
1174
- const agentCmd = [executable, ...args].map(shellQuote).join(' ');
1175
1213
  return `exec env ${envPrefix} ${agentCmd}`;
1176
1214
  }
1215
+ /**
1216
+ * Serialize the pane env to a shell-sourceable file, created 0600 and exclusively
1217
+ * (`wx`) so it can never adopt a pre-existing file's mode or content.
1218
+ *
1219
+ * EVERY key goes in the file, not a curated "secret-bearing" subset: a denylist
1220
+ * has to be updated for each new credential and is wrong the moment someone
1221
+ * forgets, whereas routing all of it through the file makes the guarantee hold by
1222
+ * construction. Keys are filtered to valid shell identifiers for the same reason
1223
+ * `env` needed it — an exported function (`BASH_FUNC_x%%`) is not assignable.
1224
+ */
1225
+ export function writeTmuxEnvFile(env, filePath) {
1226
+ const body = Object.entries(env)
1227
+ .filter(([k, v]) => v !== undefined && EXEC_ENV_KEY_PATTERN.test(k))
1228
+ .map(([k, v]) => `${k}=${shellQuote(String(v))}`)
1229
+ .join('\n');
1230
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1231
+ const fd = fs.openSync(filePath, 'wx', 0o600);
1232
+ try {
1233
+ fs.writeSync(fd, `${body}\n`);
1234
+ }
1235
+ finally {
1236
+ fs.closeSync(fd);
1237
+ }
1238
+ }
1177
1239
  /**
1178
1240
  * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
1179
1241
  * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
@@ -1231,15 +1293,32 @@ async function runInTmux(options, executable, args) {
1231
1293
  // SessionStart learns some harness IDs only after launch. Carry the wrapper
1232
1294
  // name into that hook so it can bind both identities durably.
1233
1295
  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);
1296
+ // The pane sources its env from a 0600 file it unlinks before exec, so no
1297
+ // resolved secret VALUE reaches the process table (RUSH-2100). SessionMeta.cmd
1298
+ // keeps the value-redacted inline form — the human-readable record of what ran,
1299
+ // which never carried real values anyway (RUSH-1758).
1300
+ const envFile = path.join(getRuntimeStateDir(), 'tmux-env', `${name}-${randomUUID().slice(0, 8)}.env`);
1301
+ writeTmuxEnvFile(execEnv, envFile);
1302
+ const cmd = buildTmuxAgentCommand(executable, args, execEnv, { envFile });
1238
1303
  const metaCmd = buildTmuxAgentCommand(executable, args, execEnv, { redactEnvValues: true });
1239
1304
  const labels = { agent: options.agent };
1240
1305
  if (options.sessionId)
1241
1306
  labels.sessionId = options.sessionId;
1242
- const meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
1307
+ // Only a launched pane sources-and-unlinks the env file. If createSession
1308
+ // throws, the pane never runs, so the resolved secrets (incl. the master
1309
+ // passphrase) would linger on disk — remove it on that failure path
1310
+ // (RUSH-2100). On success the detached pane owns the unlink.
1311
+ let meta;
1312
+ try {
1313
+ meta = await createSession({ name, cmd, metaCmd, cwd, socket, source: 'cli', labels });
1314
+ }
1315
+ catch (err) {
1316
+ try {
1317
+ fs.rmSync(envFile, { force: true });
1318
+ }
1319
+ catch { /* best-effort */ }
1320
+ throw err;
1321
+ }
1243
1322
  const pane = meta.pane;
1244
1323
  if (options.sessionId)
1245
1324
  writeSessionAliasRecord(options.sessionId, name);
@@ -1317,7 +1396,7 @@ async function runInTmux(options, executable, args) {
1317
1396
  };
1318
1397
  // The agent could exit before we attach (fast failure). Don't attach to an
1319
1398
  // already-dead pane — surface its output + status directly and tear down.
1320
- const before = pane ? await paneExitStatus(pane, socket) : { dead: false };
1399
+ const before = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
1321
1400
  if (before.dead) {
1322
1401
  // F2 (RUSH-2185 / EXEC-23a): for interactive runs, ALWAYS recap — a clean
1323
1402
  // exit-0 before attach means the harness has no interactive REPL and the
@@ -1343,7 +1422,7 @@ async function runInTmux(options, executable, args) {
1343
1422
  return false;
1344
1423
  }
1345
1424
  };
1346
- const after = pane ? await paneExitStatus(pane, socket) : { dead: false };
1425
+ const after = pane ? await paneExitStatus(pane, socket) : { found: false, dead: false, status: undefined };
1347
1426
  if (after.dead) {
1348
1427
  // Nonzero exit after attach → the agent crashed rather than the user
1349
1428
  // detaching cleanly (a clean detach leaves the pane ALIVE, handled below).
@@ -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
@@ -11,6 +11,10 @@
11
11
  * issued from, so the run appears in that project's session listing like any
12
12
  * local run. The `[host/<name>]` label mirrors the cloud path's
13
13
  * `[cloud/<status>]` convention.
14
+ *
15
+ * `machine` is the dispatch host, not this box. Both the transcript and the
16
+ * version-isolated harness home live there; omitting it makes the SQLite upsert
17
+ * infer this box from the empty file path and sends recovery to the wrong home.
14
18
  */
15
19
  import type { SessionMeta } from '../session/types.js';
16
20
  import { type HostTask } from './tasks.js';