@phnx-labs/agents-cli 1.22.22 → 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 (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  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 +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -848,6 +848,11 @@ function renderTargetText(report, options) {
848
848
  // sees what was checked. options.requestedKinds drives this.
849
849
  if (options.requestedKinds && !options.requestedKinds.has(kind))
850
850
  continue;
851
+ if (kind === 'hooks' && report.hookInventory) {
852
+ const wired = report.hookInventory.wiringSupported ? String(report.hookInventory.wired.length) : 'unknown';
853
+ const unmanaged = report.hookInventory.unmanaged.length > 0 ? ` · unmanaged ${report.hookInventory.unmanaged.length}` : '';
854
+ console.log(chalk.gray(` inventory: capable ${report.hookInventory.capable ? 'yes' : 'no'} · on-disk ${report.hookInventory.onDisk.length} · wired ${wired}${unmanaged}`));
855
+ }
851
856
  renderKindSection(kind, rows, report.layers, options);
852
857
  // A hook file can reconcile "ok" above yet be absent from settings.json — a
853
858
  // present-but-dead hook. Surface that right under the hooks section.
@@ -1022,10 +1027,13 @@ function runCheckGate(opts, cwd) {
1022
1027
  const orphanNote = drift.orphanVersionCount > 0
1023
1028
  ? chalk.gray(` (${drift.orphanVersionCount} version(s) carry orphans — run \`agents prune cleanup\`)`)
1024
1029
  : '';
1025
- console.log(`${chalk.green('ok')} ${drift.syncRows.length} version(s) in sync${orphanNote}`);
1030
+ console.log(`${chalk.gray('check:')} ${chalk.green('ok')}${drift.syncRows.length} version(s) in sync${orphanNote}`);
1026
1031
  process.exit(0);
1027
1032
  }
1028
- // Drift: one-line verdict always, per-version detail unless --quiet.
1033
+ // Drift: one-line verdict always, per-version detail unless --quiet. The
1034
+ // `check:` prefix and total count make every verdict line grep/parse-alike
1035
+ // whether it's clean or drifted, instead of the bare `drift <parts>` shape
1036
+ // that gave CI logs no anchor to search on.
1029
1037
  const parts = [];
1030
1038
  if (drift.staleCount > 0)
1031
1039
  parts.push(`${drift.staleCount} stale`);
@@ -1035,16 +1043,21 @@ function runCheckGate(opts, cwd) {
1035
1043
  parts.push(`${drift.unwiredHookVersions} with unwired hooks`);
1036
1044
  if (drift.sourceBehind.length > 0)
1037
1045
  parts.push(`${drift.sourceBehind.length} source layer(s) behind origin`);
1038
- console.error(`${chalk.red('drift')} ${parts.join(', ')}`);
1046
+ console.error(`${chalk.gray('check:')} ${chalk.red('drift')}${parts.join(', ')} across ${drift.syncRows.length} version(s)`);
1039
1047
  if (!opts.quiet) {
1048
+ // Fixed-width, left-aligned so rows form a real column; the badge text is
1049
+ // the actual status name ('never-synced'), not the unrelated 'cold' label
1050
+ // that used to hide it. Width is 'never-synced'.length + a 2-space gap so
1051
+ // the longest badge still separates cleanly from the label that follows.
1052
+ const STATUS_BADGE_WIDTH = 'never-synced'.length + 2;
1040
1053
  for (const row of drift.syncRows) {
1041
1054
  const unwired = (row.unwiredHooks ?? 0) > 0;
1042
1055
  if (row.status === 'fresh' && !unwired)
1043
1056
  continue;
1044
- const tag = row.status === 'stale' ? chalk.yellow('stale')
1045
- : row.status === 'never-synced' ? chalk.gray('cold ')
1046
- : chalk.red('unwired'); // fresh but hooks not wired into settings.json
1047
- console.error(` ${tag} ${checkLabel(row)}`);
1057
+ const tag = row.status === 'stale' ? chalk.yellow('stale'.padEnd(STATUS_BADGE_WIDTH))
1058
+ : row.status === 'never-synced' ? chalk.gray('never-synced'.padEnd(STATUS_BADGE_WIDTH))
1059
+ : chalk.red('unwired'.padEnd(STATUS_BADGE_WIDTH)); // fresh but hooks not wired into settings.json
1060
+ console.error(` ${tag}${checkLabel(row)}`);
1048
1061
  for (const line of row.divergence ?? []) {
1049
1062
  console.error(chalk.gray(` ${line}`));
1050
1063
  }
@@ -516,7 +516,7 @@ export function registerRunCommand(program) {
516
516
  const runCmd = program
517
517
  .command('run <agent> [prompt]')
518
518
  .description('Execute an agent. Pass a prompt for headless runs; omit it to launch the agent interactively.')
519
- .option('-m, --mode <mode>', 'How much the agent can do: plan (read-only), edit (can write files), auto (smart classifier auto-approves safe ops, prompts for risky), skip (bypass all permission prompts). \'full\' accepted as alias for skip.', 'plan')
519
+ .option('-m, --mode <mode>', 'How much the agent can do: plan (read-only), edit (can write files), auto (smart classifier auto-approves safe ops, prompts for risky), skip (bypass all permission prompts). Omitted Codex mode defaults to safe writable edit; other harnesses default to plan. \'full\' accepted as alias for skip.', 'plan')
520
520
  .option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto (claude and codex only)', 'auto')
521
521
  .option('--model <model>', 'Cost tier (cheap|default|best|ultra) or a concrete model id; tiers resolve per harness+version to a supported model')
522
522
  .option('--env <key=value>', 'Pass environment variable to the agent (repeatable, e.g., --env DEBUG=1 --env API_KEY=xyz)', (val, prev) => [...prev, val], [])
@@ -532,7 +532,7 @@ export function registerRunCommand(program) {
532
532
  .option('--headless', 'Force headless mode. Auto-enabled when a prompt is provided; pass explicitly to stay headless with no prompt (reads the prompt from stdin).', false)
533
533
  .option('--no-auth-check', 'Skip the pre-launch "looks logged out" warning on an interactive run (advisory; never blocks anyway). Also silenced by AGENTS_NO_AUTH_CHECK=1.')
534
534
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided. Mutually exclusive with --headless.')
535
- .option('--resume [id]', 'Resume a previous conversation. Full IDs resolve locally first, then fleet-wide. Claude, Codex, Grok, Kimi, Droid, and Cursor use version-gated native resume; other agents replay via /continue. Pair with a prompt to continue headlessly.')
535
+ .option('--resume [id]', 'Recover a previous conversation on its origin device. The exact healthy origin uses native resume; otherwise a healthy version of the same harness replays via /continue. Pair with a prompt to continue headlessly.')
536
536
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
537
537
  .option('--name <slug>', 'Name the run — seeds the session label so it shows up as `<name>` in `agents sessions` and resolves by it (and `agents hosts logs <name>` for --host runs) instead of an opaque id. An agent-generated title later refines the label; your name shows until then. Optional.')
538
538
  .option('--notify', 'Post a desktop notification when a headless run finishes. Fired by this process on exit, so it survives whatever launched the run (the menu bar dispatching it, a terminal you closed).')
@@ -647,12 +647,14 @@ export function registerRunCommand(program) {
647
647
  agents run claude "fix the bug" -- --custom-flag
648
648
  `,
649
649
  notes: `
650
- Modes (not every agent supports every mode — check agents.yaml capabilities):
650
+ Modes (not every agent supports every mode — run \`agents modes <agent>\`):
651
651
  plan read-only investigation; no writes, no shell side-effects
652
652
  edit may edit files; prompts for shell / risky operations
653
653
  auto smart classifier auto-approves safe ops, prompts for risky (claude, copilot)
654
654
  skip bypass every permission prompt (dangerously-skip-permissions)
655
655
  Legacy 'full' is silently rewritten to 'skip'.
656
+ List per-harness support + native flags: agents modes · agents modes claude
657
+ Models (cheap|default|best|ultra): agents models <agent[@version]>
656
658
 
657
659
  Headless plan support (a prompt makes the run headless):
658
660
  plan works headless on claude, codex, droid, opencode.
@@ -689,7 +691,7 @@ export function registerRunCommand(program) {
689
691
  given. --cloud is mutually exclusive with --host/--lease and with
690
692
  local-run flags (--loop, --resume, --secrets, --terminal, …).
691
693
 
692
- Resume: --resume <id> resolves full IDs locally first, then fleet-wide, and restores the source version/device/mode. Claude, Codex, Grok, Kimi, Droid, and Cursor use version-gated native resume; others replay via /continue. agents resume <id> infers the harness too.
694
+ Resume: --resume <id> resolves full IDs locally first, then fleet-wide, and recovers on the source device with its cwd/mode. The exact healthy origin version uses native resume; otherwise a healthy version of the same harness replays via /continue. agents resume <id> infers the harness too.
693
695
 
694
696
  Passthrough: everything after -- is forwarded verbatim to the underlying agent CLI.
695
697
  agents run kimi -- --plan --some-native-flag value
@@ -835,6 +837,7 @@ export function registerRunCommand(program) {
835
837
  }
836
838
  let autoHarnessRequested = normalizedAgentSpec === RUN_AUTO_KEYWORD;
837
839
  let resolvedResumeSource;
840
+ let resolvedRecoveryTarget;
838
841
  // Concrete resume ids resolve BEFORE placement. Full UUIDs take the local
839
842
  // SQLite fast path; only a local miss fans out to the fleet. This lets a
840
843
  // command entered on zion discover that the owning version-home is on a
@@ -882,38 +885,48 @@ export function registerRunCommand(program) {
882
885
  // the normal new-run default as a real value, so consult its provenance
883
886
  // rather than mistaking the default for an explicit override.
884
887
  if (command.getOptionValueSource('mode') === 'default') {
885
- if (resolvedResumeSource.mode)
888
+ if (resolvedResumeSource.mode) {
886
889
  options.mode = resolvedResumeSource.mode;
890
+ command.setOptionValueWithSource('mode', resolvedResumeSource.mode, 'implied');
891
+ }
887
892
  else if (!options.quiet)
888
893
  process.stderr.write(chalk.yellow(`[agents] session ${resolvedResumeSource.shortId} predates stored launch modes; using --mode ${options.mode}\n`));
889
894
  }
890
895
  const { machineId } = await import('../lib/machine-id.js');
896
+ const { sessionRecoveryDestinationMatches, sessionRecoveryPeer, } = await import('../lib/session/recovery.js');
891
897
  const sourceMachine = resolvedResumeSource.machine;
898
+ const sourcePeer = sessionRecoveryPeer(resolvedResumeSource);
892
899
  const explicitPlacement = hostTargetGiven(options).length > 0;
893
- if (sourceMachine && sourceMachine !== machineId() && !explicitPlacement) {
894
- options.host = sourceMachine;
900
+ if (sourcePeer && !explicitPlacement) {
901
+ options.host = sourcePeer;
895
902
  }
896
- else if (!autoHarnessRequested && sourceMachine && sourceMachine !== machineId() && explicitPlacement && !hostTargetGiven(options).includes(sourceMachine)) {
897
- console.error(chalk.red(`Strict resume must run on ${sourceMachine}, where session ${resolvedResumeSource.shortId} is owned. ` +
898
- `Use agents run auto --resume ${resolvedResumeSource.id} to hand off elsewhere.`));
903
+ else if (sourcePeer && explicitPlacement && !hostTargetGiven(options).some((host) => sessionRecoveryDestinationMatches(resolvedResumeSource, host))) {
904
+ console.error(chalk.red(`Session ${resolvedResumeSource.shortId} must recover on ${sourcePeer}, where its indexed transcript and version history are owned; ` +
905
+ `the requested device was ${hostTargetGiven(options).join(', ')}.`));
899
906
  process.exit(1);
900
907
  }
901
- // On the owning machine, `auto` first asks the existing account router
902
- // whether the exact source version is healthy. If it is, native resume
903
- // wins. If not, keep `auto` so the normal harness/account router selects
904
- // a healthy local target and the later resume block performs /continue.
905
- if (autoHarnessRequested && (!sourceMachine || sourceMachine === machineId())) {
906
- const sourceAgent = resolvedResumeSource.agent;
907
- if (sourceAgent in AGENTS && resolvedResumeSource.version) {
908
- const { resolveRunVersion } = await import('../lib/rotate.js');
909
- const health = await resolveRunVersion(sourceAgent, 'available', resolvedResumeSource.cwd ?? process.cwd());
910
- if (!health.exhausted && health.version === resolvedResumeSource.version) {
911
- normalizedAgentSpec = `${sourceAgent}@${resolvedResumeSource.version}`;
912
- autoHarnessRequested = false;
913
- if (!options.quiet)
914
- process.stderr.write(chalk.gray(`[agents] auto resume → native ${normalizedAgentSpec} on ${sourceMachine ?? machineId()}\n`));
915
- }
908
+ // Recovery is resolved on the device that owns the transcript. A remote
909
+ // dispatch is pinned to the source HARNESS (not `run auto`'s cross-harness
910
+ // picker); the peer repeats this block with the injected SessionMeta and
911
+ // chooses its own healthy version. Locally, resolve it now.
912
+ const sourceAgent = resolvedResumeSource.agent;
913
+ if (!sourcePeer) {
914
+ try {
915
+ const { resolveSessionRecovery } = await import('../lib/session/recovery.js');
916
+ resolvedRecoveryTarget = await resolveSessionRecovery(resolvedResumeSource);
916
917
  }
918
+ catch (err) {
919
+ console.error(chalk.red(err.message));
920
+ process.exit(1);
921
+ }
922
+ normalizedAgentSpec = `${resolvedRecoveryTarget.agent}@${resolvedRecoveryTarget.version}`;
923
+ autoHarnessRequested = false;
924
+ if (!options.quiet)
925
+ process.stderr.write(chalk.gray(`[agents] session recovery → ${resolvedRecoveryTarget.mode} ${normalizedAgentSpec} on ${sourceMachine ?? machineId()} · ${resolvedRecoveryTarget.reason}\n`));
926
+ }
927
+ else if (autoHarnessRequested) {
928
+ normalizedAgentSpec = sourceAgent;
929
+ autoHarnessRequested = false;
917
930
  }
918
931
  }
919
932
  if (autoHarnessRequested) {
@@ -1540,13 +1553,15 @@ export function registerRunCommand(program) {
1540
1553
  });
1541
1554
  }
1542
1555
  const isRaw = options.raw || options.tmux === false || options.disableTmux === true;
1556
+ const { modeForRemoteDispatch } = await import('../lib/codex-policy.js');
1557
+ const forwardedMode = modeForRemoteDispatch(options.mode, command.getOptionValueSource('mode'));
1543
1558
  const exitCode = await runInteractiveOnHost(host, {
1544
1559
  agent: runAgent,
1545
1560
  version: resumeId ? undefined : runVersion,
1546
1561
  strategy: resumeId ? undefined : runStrategy,
1547
1562
  fallback: options.fallback,
1548
1563
  prompt,
1549
- mode: options.mode,
1564
+ mode: forwardedMode,
1550
1565
  model: options.model,
1551
1566
  effort: options.effort,
1552
1567
  env: hostEnv,
@@ -1625,13 +1640,15 @@ export function registerRunCommand(program) {
1625
1640
  }
1626
1641
  // Session-id mint, detached dispatch, and local session-index
1627
1642
  // registration all live in the shared helper (lib/hosts/run-target.ts).
1643
+ const { modeForRemoteDispatch } = await import('../lib/codex-policy.js');
1644
+ const forwardedMode = modeForRemoteDispatch(options.mode, command.getOptionValueSource('mode'));
1628
1645
  const { task, exitCode } = await dispatchPromptToHost(host, {
1629
1646
  agent: runAgent,
1630
1647
  version: resumeId ? undefined : runVersion,
1631
1648
  strategy: resumeId ? undefined : runStrategy,
1632
1649
  fallback: options.fallback,
1633
1650
  prompt,
1634
- mode: options.mode,
1651
+ mode: forwardedMode,
1635
1652
  model: options.model,
1636
1653
  effort: options.effort,
1637
1654
  env: options.env,
@@ -1770,7 +1787,7 @@ export function registerRunCommand(program) {
1770
1787
  await warnUnpushedWork(resumeExec.cwd ?? process.cwd());
1771
1788
  process.exit(resumeExit);
1772
1789
  }
1773
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveHostSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
1790
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, implicitModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveHostSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
1774
1791
  import('../lib/exec.js'),
1775
1792
  import('../lib/agents.js'),
1776
1793
  import('../lib/profiles.js'),
@@ -2211,10 +2228,44 @@ export function registerRunCommand(program) {
2211
2228
  session = picked.session;
2212
2229
  forceInteractive = true; // bare resume always lands in the agent's TUI
2213
2230
  }
2214
- // Native resume is valid only for the source harness + exact isolated
2215
- // version. `auto` may have selected another healthy harness/account; in
2216
- // that case keep the target version and hand off through /continue.
2217
- const canResumeNatively = session.agent === agent && nativeResume(agent, session.version);
2231
+ // Bare `run <harness> --resume` learns the chosen SessionMeta only after
2232
+ // the host-placement phase above. If the picker chose a synced session
2233
+ // from another device, route the recovery command now; resolving local
2234
+ // candidates would otherwise native-resume through this device's
2235
+ // unrelated isolated home.
2236
+ if (!resolvedResumeSource) {
2237
+ const { sessionRecoveryPeer, sessionRecoveryRunArgs, } = await import('../lib/session/recovery.js');
2238
+ const peer = sessionRecoveryPeer(session);
2239
+ if (peer) {
2240
+ const { runOnPeer } = await import('../lib/session/remote-list.js');
2241
+ const routed = await runOnPeer(sessionRecoveryRunArgs(session), peer, { tty: true });
2242
+ if (routed === 'no-target') {
2243
+ console.error(chalk.red(`Cannot recover session ${session.shortId}: origin device ${peer} is not a registered reachable peer.`));
2244
+ process.exitCode = 1;
2245
+ }
2246
+ return;
2247
+ }
2248
+ }
2249
+ // Bare interactive --resume selects the SessionMeta only down here, so
2250
+ // it has not gone through the early concrete-id resolver. Resolve it now;
2251
+ // concrete ids reuse the exact same target chosen above.
2252
+ if (!resolvedRecoveryTarget) {
2253
+ try {
2254
+ const { resolveSessionRecovery } = await import('../lib/session/recovery.js');
2255
+ resolvedRecoveryTarget = await resolveSessionRecovery(session);
2256
+ }
2257
+ catch (err) {
2258
+ console.error(chalk.red(err.message));
2259
+ process.exit(1);
2260
+ }
2261
+ }
2262
+ if (resolvedRecoveryTarget.agent !== agent) {
2263
+ console.error(chalk.red(`Session ${session.shortId} belongs to ${resolvedRecoveryTarget.agent}, not ${agent}. ` +
2264
+ `Use: agents run auto --resume ${session.id}`));
2265
+ process.exit(1);
2266
+ }
2267
+ version = resolvedRecoveryTarget.version;
2268
+ const canResumeNatively = resolvedRecoveryTarget.mode === 'native';
2218
2269
  if (canResumeNatively) {
2219
2270
  version = session.version;
2220
2271
  resumeNative = true;
@@ -2324,6 +2375,12 @@ export function registerRunCommand(program) {
2324
2375
  console.error(chalk.red(`agents: ${agent}@${launchTarget} is not runnable and could not be repaired. Try: ${hint}`));
2325
2376
  process.exit(1);
2326
2377
  }
2378
+ if (resolvedRecoveryTarget && healed !== launchTarget) {
2379
+ console.error(chalk.red(`agents: session recovery target ${agent}@${launchTarget} became unavailable on ` +
2380
+ `${resolvedResumeSource?.machine ?? 'this device'}; refusing to resume through another version home. ` +
2381
+ `Retry the command so recovery can select a healthy ${agent} version.`));
2382
+ process.exit(1);
2383
+ }
2327
2384
  // Always adopt the healed version explicitly. In the version-undefined
2328
2385
  // path a fallback re-pins the GLOBAL default, but `resolveVersion`
2329
2386
  // prefers a PROJECT pin — so leaving `version` undefined would let the
@@ -2407,7 +2464,14 @@ export function registerRunCommand(program) {
2407
2464
  // `skip` still hard-fails when unsupported — pretending we bypassed
2408
2465
  // permissions would be unsafe.
2409
2466
  const modeIsDefault = modeSource === 'default';
2410
- const requestedMode = normalizeMode(mode);
2467
+ let requestedMode = normalizeMode(mode);
2468
+ // Codex's intrinsic omitted-mode default is safe writable: workspace plus
2469
+ // common caches, network enabled, approvals on request. An explicit
2470
+ // --mode plan and a configured run default remain read-only.
2471
+ const { modeWasImplicit } = await import('../lib/codex-policy.js');
2472
+ const wasModeImplicit = modeWasImplicit(modeSource, modeFromRunDefault);
2473
+ if (wasModeImplicit)
2474
+ requestedMode = implicitModeFor(agent);
2411
2475
  let resolvedMode;
2412
2476
  try {
2413
2477
  resolvedMode = resolveMode(agent, requestedMode);
@@ -2434,7 +2498,8 @@ export function registerRunCommand(program) {
2434
2498
  console.error(chalk.gray(`Pass --mode plan explicitly if you really want a read-only run.`));
2435
2499
  process.exit(1);
2436
2500
  }
2437
- const effort = options.effort;
2501
+ const effortSource = runCmd.getOptionValueSource('effort');
2502
+ const effort = (effortSource === 'default' && runDefaults.effort ? runDefaults.effort : options.effort);
2438
2503
  if (!['low', 'medium', 'high', 'xhigh', 'max', 'auto'].includes(effort)) {
2439
2504
  console.error(chalk.red(`Invalid effort: ${effort}. Use 'low', 'medium', 'high', 'xhigh', 'max', or 'auto'`));
2440
2505
  process.exit(1);
@@ -2531,6 +2596,7 @@ export function registerRunCommand(program) {
2531
2596
  prompt,
2532
2597
  interactive: options.interactive || forceInteractive,
2533
2598
  mode: requestedMode,
2599
+ modeWasImplicit: wasModeImplicit,
2534
2600
  effort,
2535
2601
  cwd: options.cwd,
2536
2602
  model,
@@ -60,6 +60,24 @@ export declare function sessionHintsFromActive(sessions: Array<{
60
60
  slug?: string;
61
61
  };
62
62
  }>): SessionOutcomeHint[];
63
+ /**
64
+ * True when a SQLite open/query failed because another writer holds the lock.
65
+ * better-sqlite3 surfaces this as message "database is locked" and/or code
66
+ * SQLITE_BUSY (RUSH-2006).
67
+ */
68
+ export declare function isSqliteBusyError(err: unknown): boolean;
69
+ /**
70
+ * Load session metas for feed outcome enrichment. The sessions index can be
71
+ * locked by a concurrent scanner/daemon — enrichment is best-effort, so a lock
72
+ * degrades to an empty list instead of crashing `agents feed` (RUSH-2006).
73
+ *
74
+ * `load` is injected so tests can throw a real lock-shaped error without
75
+ * mocking the session module.
76
+ */
77
+ export declare function loadSessionMetasForFeedEnrichment<T>(load: () => Promise<T[]>): Promise<{
78
+ metas: T[];
79
+ skippedLock: boolean;
80
+ }>;
63
81
  export declare function registerFeedCommand(program: Command): void;
64
82
  /** Feed view selector (RUSH-2015): decisions, progress, or both. */
65
83
  export type FeedFilter = 'needs' | 'updates' | 'all';
@@ -253,6 +253,39 @@ export function sessionHintsFromActive(sessions) {
253
253
  project: s.cwd ? projectKeyFromCwd(s.cwd) : undefined,
254
254
  }));
255
255
  }
256
+ /**
257
+ * True when a SQLite open/query failed because another writer holds the lock.
258
+ * better-sqlite3 surfaces this as message "database is locked" and/or code
259
+ * SQLITE_BUSY (RUSH-2006).
260
+ */
261
+ export function isSqliteBusyError(err) {
262
+ if (err == null)
263
+ return false;
264
+ const msg = err instanceof Error ? err.message : String(err);
265
+ const code = typeof err === 'object' && err !== null && 'code' in err
266
+ ? String(err.code ?? '')
267
+ : '';
268
+ return /SQLITE_BUSY|database is locked/i.test(msg) || /SQLITE_BUSY/i.test(code);
269
+ }
270
+ /**
271
+ * Load session metas for feed outcome enrichment. The sessions index can be
272
+ * locked by a concurrent scanner/daemon — enrichment is best-effort, so a lock
273
+ * degrades to an empty list instead of crashing `agents feed` (RUSH-2006).
274
+ *
275
+ * `load` is injected so tests can throw a real lock-shaped error without
276
+ * mocking the session module.
277
+ */
278
+ export async function loadSessionMetasForFeedEnrichment(load) {
279
+ try {
280
+ return { metas: await load(), skippedLock: false };
281
+ }
282
+ catch (err) {
283
+ if (isSqliteBusyError(err)) {
284
+ return { metas: [], skippedLock: true };
285
+ }
286
+ throw err;
287
+ }
288
+ }
256
289
  export function registerFeedCommand(program) {
257
290
  const feed = program
258
291
  .command('feed')
@@ -446,7 +479,17 @@ export function registerFeedCommand(program) {
446
479
  if (includeLocal) {
447
480
  sessions = await getActiveSessions();
448
481
  }
449
- const sessionMetas = includeLocal && sessions.length > 0 ? await discoverSessions({ all: true, limit: 5000 }) : [];
482
+ // discoverSessions touches sessions.db; under concurrent scan pressure it
483
+ // can throw SQLITE_BUSY. Outcome enrichment is best-effort — degrade with
484
+ // a warning rather than crash the whole feed (RUSH-2006).
485
+ let sessionMetas = [];
486
+ if (includeLocal && sessions.length > 0) {
487
+ const loaded = await loadSessionMetasForFeedEnrichment(() => discoverSessions({ all: true, limit: 5000 }));
488
+ if (loaded.skippedLock) {
489
+ console.error(chalk.yellow('Feed: session index is locked; skipping local outcome enrichment'));
490
+ }
491
+ sessionMetas = loaded.metas;
492
+ }
450
493
  const localSignals = buildSessionSignals(sessions, sessionMetas);
451
494
  if (opts.pause || opts.kill) {
452
495
  if (!includeLocal) {
@@ -13,9 +13,57 @@
13
13
  * why `--tmux`-wrapped launches are worth it for sessions you'll want back live.
14
14
  */
15
15
  import type { Command } from 'commander';
16
- import { type UnreachableFallback } from './go.js';
16
+ import { probeAttachRail, type AttachRailLiveness, type UnreachableFallback } from './go.js';
17
17
  import type { ActiveSession } from '../lib/session/active.js';
18
- import type { SessionMeta } from '../lib/session/types.js';
18
+ import { type SessionMeta } from '../lib/session/types.js';
19
+ import { type LiveStatusFilter } from './sessions.js';
20
+ import { openSurfaces, type Backend } from '../lib/terminal/index.js';
21
+ /** Options for `sessions focus` — device/host scope + the `--active` live-state filters. */
22
+ export interface FocusOptions {
23
+ local?: boolean;
24
+ attachOnly?: boolean;
25
+ host?: string[];
26
+ device?: string[];
27
+ active?: boolean;
28
+ agent?: string;
29
+ claude?: boolean;
30
+ codex?: boolean;
31
+ kimi?: boolean;
32
+ antigravity?: boolean;
33
+ grok?: boolean;
34
+ opencode?: boolean;
35
+ all?: boolean;
36
+ teams?: boolean;
37
+ inTeam?: string;
38
+ routine?: boolean;
39
+ project?: string;
40
+ skill?: string;
41
+ plugin?: string;
42
+ since?: string;
43
+ until?: string;
44
+ limit?: string;
45
+ favorites?: boolean;
46
+ unmanaged?: boolean;
47
+ sort?: string;
48
+ working?: boolean;
49
+ idle?: boolean;
50
+ waiting?: boolean;
51
+ orphan?: boolean;
52
+ orphaned?: boolean;
53
+ crashed?: boolean;
54
+ closed?: boolean;
55
+ abandoned?: boolean;
56
+ queued?: boolean;
57
+ unknown?: boolean;
58
+ }
59
+ /** Commander gives overlapping `sessions` flags to the parent command, even
60
+ * when they appear after `focus`. Fold only values explicitly provided there
61
+ * into the child options; child defaults (limit/sort) otherwise stay intact. */
62
+ export declare function inheritFocusOptions(child: FocusOptions, parent?: Command): FocusOptions;
63
+ /** `--device` is an alias of `--host`; both are repeatable. Merge into one host list. */
64
+ export declare function mergeFocusHosts(opts: FocusOptions): string[];
65
+ /** Picker header that reflects the active filter + device, e.g. "Focus orphaned sessions on yosemite-s0:". */
66
+ export declare function focusHeader(statuses: LiveStatusFilter[], hosts: string[]): string;
19
67
  export declare function registerFocusCommand(program: Command): void;
20
68
  /**
21
69
  * Which fallback fires when a session has no attach rail. `--attach-only` (the old
@@ -23,9 +71,64 @@ export declare function registerFocusCommand(program: Command): void;
23
71
  * without touching `jumpTo`'s side effects.
24
72
  */
25
73
  export declare function selectFallback(attachOnly: boolean | undefined): UnreachableFallback;
26
- export declare function focusAction(id: string | undefined, opts: {
27
- local?: boolean;
74
+ export declare function focusAction(id: string | undefined, opts: FocusOptions): Promise<void>;
75
+ /** A retained pane is not attachable merely because tmux can still display it. */
76
+ export declare function isAttachableLiveSession(session: ActiveSession): boolean;
77
+ /**
78
+ * How a single selected live session opens in its new tab.
79
+ * - `attach` — a live tmux pane joined in the tab (a second client, no fork),
80
+ * local (`sh -c`) or remote (`ssh -tt`). tmux is the only rail that can be
81
+ * *joined* without forking (see the file header).
82
+ * - `resume` — no attach rail, so resume a copy in the tab (the original keeps
83
+ * running); never a silent drop.
84
+ * - `skip` — not resumable AND no rail: reported, not dropped.
85
+ */
86
+ export type FocusSurfacePlan = {
87
+ kind: 'attach';
88
+ command: string[];
89
+ note: string;
90
+ } | {
91
+ kind: 'resume';
92
+ command: string[];
93
+ note: string;
94
+ } | {
95
+ kind: 'skip';
96
+ note: string;
97
+ };
98
+ /**
99
+ * Shell that resolves a tmux pane to its session and attaches it — the exact form
100
+ * `jumpTo` uses, minus the pre-select-window nicety, so a batch tab joins the live
101
+ * session (a second client) without forking. Reused for local and (wrapped in ssh)
102
+ * remote panes.
103
+ */
104
+ export declare function tmuxAttachScript(mux: {
105
+ socket?: string;
106
+ pane: string;
107
+ }): string;
108
+ /**
109
+ * Decide how one live session opens as a tab. Pure over the session + a
110
+ * `resumeCommandFor` resolver (injected so the local version-pinned resume command
111
+ * and the tests stay decoupled from `discoverSessions`).
112
+ */
113
+ export declare function planFocusSurface(s: ActiveSession, self: string, resumeCommandFor: (s: ActiveSession) => string[] | null, rail?: AttachRailLiveness): FocusSurfacePlan;
114
+ /** The engine seam — real `openSurfaces`, overridable in tests to assert the tab requests. */
115
+ export type OpenSurfacesFn = typeof openSurfaces;
116
+ /** Test seams for `openFocusTabs`: inject the engine boundary + force a backend. */
117
+ export interface OpenFocusTabsDeps {
118
+ open?: OpenSurfacesFn;
119
+ /** Skip `resolveBackend` (which needs a live terminal) when set — tests pass 'tmux'. */
120
+ backend?: Backend | 'inplace';
121
+ /** Rich rows selected by the shared browser pipeline (including remote history). */
122
+ metas?: SessionMeta[];
123
+ /** Liveness boundary; production probes tmux, tests exercise planning deterministically. */
124
+ probe?: typeof probeAttachRail;
125
+ /** Strict focus mode: open living attach rails only; never recover a copy. */
28
126
  attachOnly?: boolean;
29
- }): Promise<void>;
127
+ }
128
+ /**
129
+ * Open each selected live session as a tab: attach its live pane where one exists,
130
+ * else resume a copy. Reuses `resume`'s backend resolution + flood guard.
131
+ */
132
+ export declare function openFocusTabs(targets: ActiveSession[], self: string, deps?: OpenFocusTabsDeps): Promise<void>;
30
133
  /** Minimal SessionMeta for a live session, enough for `buildResumeCommand` + placement. */
31
134
  export declare function metaFromActive(s: ActiveSession): SessionMeta;