@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
@@ -28,8 +28,9 @@ import { isPromptCancelled } from './utils.js';
28
28
  import { focusAction } from './focus.js';
29
29
  import { machineId } from '../lib/session/sync/config.js';
30
30
  import { attachTmux, runTmux } from '../lib/tmux/binary.js';
31
+ import { paneExitStatus } from '../lib/tmux/session.js';
31
32
  import { getDefaultSocketPath } from '../lib/tmux/paths.js';
32
- import { sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
33
+ import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
33
34
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
34
35
  const execFileAsync = promisify(execFile);
35
36
  export function registerGoCommand(program) {
@@ -208,15 +209,48 @@ export function describeWhere(s, self) {
208
209
  return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` };
209
210
  return { label: s.host ?? 'unknown terminal', action: 'resume it (no live attach rail)' };
210
211
  }
211
- /** Default (attach-only): open a login shell on the remote, or refuse locally. */
212
+ /** Probe the tmux process, not just retained provenance. This is deliberately
213
+ * called immediately before an attach so remain-on-exit panes cannot masquerade
214
+ * as living agent sessions. */
215
+ export async function probeAttachRail(s, self) {
216
+ const mux = s.provenance?.mux;
217
+ if (mux?.kind !== 'tmux' || !mux.pane)
218
+ return { state: 'missing' };
219
+ const remote = s.machine && s.machine !== self ? s.machine : undefined;
220
+ if (!remote) {
221
+ const pane = await paneExitStatus(mux.pane, mux.socket ?? getDefaultSocketPath());
222
+ if (!pane.found)
223
+ return { state: 'missing' };
224
+ return pane.dead ? { state: 'dead', exitStatus: pane.status } : { state: 'alive' };
225
+ }
226
+ assertValidSshTarget(remote);
227
+ const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
228
+ const pane = shellQuote(mux.pane);
229
+ const command = `v=$(tmux ${sock}display-message -pt ${pane} -p '#{pane_dead} #{pane_dead_status}' 2>/dev/null) || { echo missing; exit 0; }; ` +
230
+ `printf '%s\\n' "$v"`;
231
+ const result = sshExec(remote, command, { timeoutMs: 15_000, multiplex: true });
232
+ if (result.code !== 0)
233
+ return { state: 'missing' };
234
+ const value = result.stdout.trim();
235
+ if (value === 'missing' || !value)
236
+ return { state: 'missing' };
237
+ const [dead, rawStatus] = value.split(/\s+/);
238
+ const exitStatus = Number.parseInt(rawStatus ?? '', 10);
239
+ return dead === '1'
240
+ ? { state: 'dead', exitStatus: Number.isFinite(exitStatus) ? exitStatus : undefined }
241
+ : { state: 'alive' };
242
+ }
243
+ /** Strict attach-only fallback: no pane means no attach. Never open a shell or
244
+ * start recovery, because both would violate the caller's no-fork intent. */
212
245
  export async function refuseFallback(s, remote) {
213
246
  if (remote) {
214
- console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux opening a shell on ${remote} instead.`));
215
- assertValidSshTarget(remote);
216
- process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
247
+ console.log(chalk.yellow(`Can't attach ${shortId(s)} on ${remote} — it has no living tmux pane.`));
248
+ process.exitCode = 1;
249
+ return;
217
250
  }
218
251
  console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
219
252
  chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
253
+ process.exitCode = 1;
220
254
  }
221
255
  export async function jumpTo(s, self, fallback = refuseFallback) {
222
256
  const remote = s.machine && s.machine !== self ? s.machine : undefined;
@@ -224,6 +258,11 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
224
258
  // Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
225
259
  if (remote) {
226
260
  if (mux?.kind === 'tmux' && mux.pane) {
261
+ const liveness = await probeAttachRail(s, self);
262
+ if (liveness.state !== 'alive') {
263
+ await fallback(s, remote);
264
+ return;
265
+ }
227
266
  assertValidSshTarget(remote);
228
267
  const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
229
268
  const p = shellQuote(mux.pane);
@@ -240,6 +279,11 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
240
279
  }
241
280
  // Path B: local tmux — attach (or switch-client if we're already inside tmux).
242
281
  if (mux?.kind === 'tmux' && mux.pane) {
282
+ const liveness = await probeAttachRail(s, self);
283
+ if (liveness.state !== 'alive') {
284
+ await fallback(s, undefined);
285
+ return;
286
+ }
243
287
  const socket = mux.socket ?? getDefaultSocketPath();
244
288
  const { session, window } = await resolveLocalPane(socket, mux.pane);
245
289
  if (session && window != null) {
@@ -340,7 +340,10 @@ async function insightsAction(options) {
340
340
  console.error(chalk.red('error: --min-messages must be a non-negative integer'));
341
341
  process.exit(1);
342
342
  }
343
- const since = options.since ?? '30d';
343
+ // `--all` is the spelling people reach for; `--since all` is what the window parser
344
+ // speaks. Accept both rather than failing on an unknown option, and let an explicit
345
+ // --since win so `--all --since 7d` is not silently contradictory.
346
+ const since = options.since ?? (options.all ? 'all' : '30d');
344
347
  const sinceMs = since === 'all' ? undefined : parseTimeFilter(since);
345
348
  // Refresh the index first, exactly as `agents cost` does, so a report never silently
346
349
  // describes a stale picture of disk.
@@ -436,6 +439,7 @@ export function registerInsightsCommand(program) {
436
439
  .description('How you work — tools, friction, and rhythm, split by the account that did the work')
437
440
  .option('--json', 'Output the full report as JSON')
438
441
  .option('--since <time>', 'Window: 7d, 4w, 3mo, an ISO date, or "all" (default 30d)')
442
+ .option('--all', 'Every session ever indexed. Alias for --since all')
439
443
  .option('--by <dimension>', 'Group by: account (default), agent, project, or day')
440
444
  .option('--account <match>', 'Only sessions whose account key, email, or org contains this')
441
445
  .option('--agent <id>', 'Only one harness (claude, codex, droid, …)')
@@ -454,7 +458,7 @@ export function registerInsightsCommand(program) {
454
458
  agents insights --by project --since 90d
455
459
 
456
460
  # One account only, all of its history
457
- agents insights --account "Turing Labs" --since all
461
+ agents insights --account "Turing Labs" --all
458
462
 
459
463
  # Machine-readable, for a dashboard or a slash command
460
464
  agents insights --json
@@ -22,12 +22,14 @@ import * as yaml from 'yaml';
22
22
  import { AGENTS, getCliState, resolveAgentName } from '../lib/agents.js';
23
23
  import { supports } from '../lib/capabilities.js';
24
24
  import { resolveConfiguredModel } from '../lib/models.js';
25
+ import { getAgentModesCatalog } from '../lib/agent-modes.js';
25
26
  import { resolveSingleAgentTarget, AgentSpecError } from '../lib/agent-spec/index.js';
26
27
  import { readMeta, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getEnabledExtraRepos, } from '../lib/state.js';
27
28
  import { getVersionHomePath, isVersionIsolated, getIsolatedDefault, } from '../lib/versions.js';
28
29
  import { getShimsDir, getVersionedAliasPath } from '../lib/shims.js';
29
30
  import { getAgentResources, listResources, } from '../lib/resources.js';
30
31
  import { listHookEntriesFromDir } from '../lib/hooks.js';
32
+ import { getResourceInventory } from '../lib/resource-inventory.js';
31
33
  import { listMcpServerConfigs, discoverMcpConfigsFromRepo } from '../lib/mcp.js';
32
34
  import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups } from '../lib/plugins.js';
33
35
  import { PLUGIN_GROUP_COLORS } from './plugins.js';
@@ -113,7 +115,7 @@ function truncateValueForPrefix(prefix, value) {
113
115
  // ─── Command registration ────────────────────────────────────────────────────
114
116
  export function registerInspectCommand(program) {
115
117
  const cmd = addHostOption(program.command('inspect <target>'))
116
- .description('Inspect one installed agent at one version, or a DotAgents repo (user|system|project|alias|path) — paths, capabilities, resources, drill into any kind.')
118
+ .description('Inspect one installed agent harness at one version (not a model), or a DotAgents repo — paths, capabilities, resources, and hook capable/on-disk/wired state.')
117
119
  .option('--brief', 'header + capabilities only; skip resources/sessions')
118
120
  .option('--json', 'machine-readable JSON output');
119
121
  for (const kind of DRILLABLE_KINDS) {
@@ -575,12 +577,14 @@ async function renderSummary(agent, version, versionHome, options) {
575
577
  const isIsolatedDefault = isolated && getIsolatedDefault(agent) === version;
576
578
  const capabilities = collectCapabilities(agent, version);
577
579
  const itemsByKind = options.brief ? null : collectItemsByKind(agent, versionHome);
580
+ const hookInventory = options.brief ? null : getResourceInventory(agent, version, 'hooks');
578
581
  const hookByScript = options.brief ? null : hookManifestByScript(loadCentralHookManifest());
579
582
  const mcpConfigs = options.brief ? null : new Map(listMcpServerConfigs().map(s => [s.name, s.config]));
580
583
  const sessions = options.brief ? null : {
581
584
  total: safeCountSessions(agent),
582
585
  };
583
586
  if (options.json) {
587
+ const modeCat = getAgentModesCatalog(agent, version);
584
588
  const json = {
585
589
  agent,
586
590
  version,
@@ -594,7 +598,14 @@ async function renderSummary(agent, version, versionHome, options) {
594
598
  strategy,
595
599
  installedShim: cliState?.installed === true ? cliState.path : null,
596
600
  capabilities,
597
- resources: itemsByKind ? summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs) : null,
601
+ modes: {
602
+ supported: modeCat.modes.map((m) => m.mode),
603
+ defaultMode: modeCat.defaultMode,
604
+ configuredMode: modeCat.configuredMode,
605
+ headlessPlan: modeCat.headlessPlan,
606
+ unsupported: modeCat.unsupported,
607
+ },
608
+ resources: itemsByKind ? summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs, hookInventory) : null,
598
609
  sessions,
599
610
  };
600
611
  console.log(JSON.stringify(json, null, 2));
@@ -623,12 +634,23 @@ async function renderSummary(agent, version, versionHome, options) {
623
634
  const reason = res.ok ? '' : chalk.gray(`(${res.reason}${res.need ? ' ' + res.need : ''})`);
624
635
  console.log(` ${cap.padEnd(10)} ${mark} ${reason}`);
625
636
  }
637
+ // Permission modes are a separate axis from the capability booleans above —
638
+ // same data as `agents modes <agent>`, kept next to the rest of the inspect
639
+ // surface so a human/agent does not have to leave this view.
640
+ {
641
+ const modeCat = getAgentModesCatalog(agent, version);
642
+ const modeList = modeCat.modes.map((m) => (m.isDefault ? `${m.mode}*` : m.mode)).join('/');
643
+ const cfg = modeCat.configuredMode ? chalk.gray(` run default: ${modeCat.configuredMode}`) : '';
644
+ console.log(` ${'modes'.padEnd(10)} ${chalk.cyan(modeList)}${cfg}`);
645
+ console.log(chalk.gray(` agents modes ${agent} · agents models ${agent}@${version}`));
646
+ }
626
647
  if (itemsByKind) {
627
648
  console.log('\n' + chalk.bold('Resources'));
628
649
  for (const kind of SIMPLE_KINDS) {
629
650
  printSimpleResourceRow(kind, itemsByKind[kind]);
630
651
  }
631
- printExpandedSection('Hooks', hookRows(itemsByKind.hooks, hookByScript));
652
+ console.log(` ${chalk.bold('Hooks')} ${chalk.gray(formatHookInventoryCounts(hookInventory))}`);
653
+ printExpandedSection('Hook files', hookRows(itemsByKind.hooks, hookByScript));
632
654
  printExpandedSection('Plugins', pluginRows(itemsByKind.plugins));
633
655
  printExpandedSection('MCP', mcpRows(itemsByKind.mcp, mcpConfigs));
634
656
  }
@@ -769,13 +791,20 @@ function printSimpleResourceRow(kind, items) {
769
791
  * simple kinds add `names`, and the rich kinds add structured `items` (hook
770
792
  * events/predicates, mcp transport/url/command, plugin version + group counts).
771
793
  */
772
- function summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs) {
794
+ function summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs, hookInventory) {
773
795
  const out = {};
774
796
  for (const kind of DRILLABLE_KINDS) {
775
797
  const items = itemsByKind[kind];
776
798
  const base = { total: items.length, bySource: countBySource(items.map(i => i.source)) };
777
799
  if (kind === 'hooks') {
778
- out[kind] = { ...base, items: items.map(i => {
800
+ out[kind] = { ...base,
801
+ capable: hookInventory.capable,
802
+ declared: hookInventory.declared,
803
+ onDisk: hookInventory.onDisk,
804
+ wired: hookInventory.wired,
805
+ unmanaged: hookInventory.unmanaged,
806
+ wiringSupported: hookInventory.wiringSupported,
807
+ items: items.map(i => {
779
808
  const h = hookByScript.get(i.name);
780
809
  return { name: i.name, source: i.source, events: h?.events ?? [], matcher: h?.matcher, matches: h?.matches, cache: h?.cache };
781
810
  }) };
@@ -800,6 +829,11 @@ function summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs) {
800
829
  }
801
830
  return out;
802
831
  }
832
+ function formatHookInventoryCounts(inventory) {
833
+ const wired = inventory.wiringSupported ? String(inventory.wired.length) : 'unknown';
834
+ const unmanaged = inventory.unmanaged.length > 0 ? ` · unmanaged ${inventory.unmanaged.length}` : '';
835
+ return `capable ${inventory.capable ? 'yes' : 'no'} · on-disk ${inventory.onDisk.length} · wired ${wired}${unmanaged}`;
836
+ }
803
837
  function collectKind(agent, versionHome, kind) {
804
838
  switch (kind) {
805
839
  case 'commands':
@@ -51,7 +51,12 @@ function printStatus(s, opts = {}) {
51
51
  console.log(chalk.gray(' End them with `agents menubar setup`.'));
52
52
  }
53
53
  if (s.stale) {
54
- console.log(chalk.yellow('\n Installed AGI Menu is stale — runs on next `agents` startup, or `agents menubar setup` now.'));
54
+ // Not "runs on next startup": the self-heal only reinstalls from the install
55
+ // that owns the helper, or from another one once the takeover cooldown has
56
+ // passed (mayInstallMenubarHelper) — so on a box with several agents-cli
57
+ // copies this can persist for a while. `setup` bypasses the gate and is the
58
+ // immediate fix.
59
+ console.log(chalk.yellow('\n Installed AGI Menu is stale — `agents menubar setup` updates it now.'));
55
60
  }
56
61
  else if (!s.serviceInstalled && !s.disabledByUser) {
57
62
  console.log(chalk.gray('\n Set it up with `agents menubar setup`.'));
@@ -72,6 +72,7 @@ export function registerModelsCommand(program) {
72
72
  if (!agentSpec && !all) {
73
73
  console.log(chalk.gray('\n `agents models <agent>` for one harness · `--all` for the full model list'));
74
74
  console.log(chalk.gray(' `agents models tier set <agent> <tier> <model>` to override a tier'));
75
+ console.log(chalk.gray(' Permission modes (--mode plan|edit|auto|skip): `agents modes <agent>`'));
75
76
  }
76
77
  });
77
78
  // Override subcommands. These WRITE agents.yaml so the user never hand-edits it;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `agents modes` — list the permission modes a harness accepts for
3
+ * `agents run` / `agents teams add`.
4
+ *
5
+ * Mirror of `agents models`: humans and orchestrating agents read this before
6
+ * picking `--mode plan|edit|auto|skip`. Modes are per-agent today (not version-
7
+ * gated); `agent@version` is accepted so the configured run.defaults mode for
8
+ * that version can be shown beside the catalog.
9
+ */
10
+ import type { Command } from 'commander';
11
+ /** Register `agents modes [agent[@version]]`. */
12
+ export declare function registerModesCommand(program: Command): void;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `agents modes` — list the permission modes a harness accepts for
3
+ * `agents run` / `agents teams add`.
4
+ *
5
+ * Mirror of `agents models`: humans and orchestrating agents read this before
6
+ * picking `--mode plan|edit|auto|skip`. Modes are per-agent today (not version-
7
+ * gated); `agent@version` is accepted so the configured run.defaults mode for
8
+ * that version can be shown beside the catalog.
9
+ */
10
+ import chalk from 'chalk';
11
+ import { AGENTS, ALL_AGENT_IDS, agentLabel, formatAgentError, resolveAgentName, } from '../lib/agents.js';
12
+ import { formatModeFlags, getAgentModesCatalog, } from '../lib/agent-modes.js';
13
+ import { setHelpSections } from '../lib/help.js';
14
+ import { getGlobalDefault, listInstalledVersions, resolveVersion, resolveVersionAlias } from '../lib/versions.js';
15
+ /** Agents that show in the no-arg overview (skip hard-deprecated). */
16
+ const MODE_AGENTS = ALL_AGENT_IDS.filter((id) => !AGENTS[id].deprecated?.hard);
17
+ /** Register `agents modes [agent[@version]]`. */
18
+ export function registerModesCommand(program) {
19
+ const modes = program
20
+ .command('modes [agentSpec]')
21
+ .description('Show which permission modes (--mode plan|edit|auto|skip) a harness supports for agents run / teams add')
22
+ .option('--json', 'Output machine-readable JSON')
23
+ .action((agentSpec, options) => {
24
+ const targets = resolveTargets(agentSpec);
25
+ if (targets.length === 0)
26
+ process.exit(1);
27
+ if (options.json) {
28
+ const out = targets.map(({ agent, version }) => {
29
+ const catalog = getAgentModesCatalog(agent, version);
30
+ return {
31
+ agent,
32
+ version,
33
+ defaultMode: catalog.defaultMode,
34
+ configuredMode: catalog.configuredMode,
35
+ configuredModeSource: catalog.configuredModeSource,
36
+ headlessPlan: catalog.headlessPlan,
37
+ modes: catalog.modes.map((m) => ({
38
+ mode: m.mode,
39
+ flags: m.flags,
40
+ description: m.description,
41
+ isDefault: m.isDefault,
42
+ })),
43
+ unsupported: catalog.unsupported,
44
+ notes: catalog.notes,
45
+ };
46
+ });
47
+ console.log(JSON.stringify(out, null, 2));
48
+ return;
49
+ }
50
+ let printed = 0;
51
+ for (const { agent, version, isDefault } of targets) {
52
+ if (printed > 0)
53
+ console.log();
54
+ printCatalog(getAgentModesCatalog(agent, version), version, isDefault);
55
+ printed++;
56
+ }
57
+ if (!agentSpec) {
58
+ console.log(chalk.gray('\n `agents modes <agent>` for one harness · `agents modes claude@2.1.219` for a version\'s run default'));
59
+ console.log(chalk.gray(' Pass --mode on `agents run` / `agents teams add`. Models: `agents models <agent>`.'));
60
+ }
61
+ });
62
+ setHelpSections(modes, {
63
+ examples: `
64
+ agents modes
65
+ agents modes claude
66
+ agents modes claude@2.1.219
67
+ agents modes cursor --json
68
+ `,
69
+ notes: `
70
+ Permission modes control how much the agent can do (not which LLM model
71
+ runs — that is --model / agents models). Supported modes come from each
72
+ harness's capabilities table; unsupported requests degrade (auto→edit,
73
+ plan→safest native) or error (skip when missing).
74
+ 'full' is a silent alias for skip.
75
+ agent@version is accepted so any configured run.defaults mode for that
76
+ version is shown; the mode list itself is per-agent today.
77
+ `,
78
+ });
79
+ }
80
+ function resolveTargets(agentSpec) {
81
+ if (!agentSpec) {
82
+ const targets = [];
83
+ for (const agent of MODE_AGENTS) {
84
+ const version = getGlobalDefault(agent) || listInstalledVersions(agent)[0] || null;
85
+ targets.push({ agent, version, isDefault: true });
86
+ }
87
+ return targets;
88
+ }
89
+ const [agentName, versionSpec] = agentSpec.split('@');
90
+ const agent = resolveAgentName(agentName);
91
+ if (!agent) {
92
+ console.error(chalk.red(formatAgentError(agentName, MODE_AGENTS)));
93
+ return [];
94
+ }
95
+ if (AGENTS[agent].deprecated?.hard) {
96
+ console.error(chalk.yellow(`${agent} is deprecated and no longer supports run modes.`));
97
+ return [];
98
+ }
99
+ if (versionSpec === 'all') {
100
+ const installed = listInstalledVersions(agent);
101
+ if (installed.length === 0) {
102
+ return [{ agent, version: null, isDefault: true }];
103
+ }
104
+ return installed.map((v) => ({
105
+ agent,
106
+ version: v,
107
+ isDefault: v === getGlobalDefault(agent),
108
+ }));
109
+ }
110
+ if (versionSpec) {
111
+ // resolveVersionAlias exits process if the concrete version is not installed.
112
+ const version = resolveVersionAlias(agent, versionSpec) ?? null;
113
+ return [{ agent, version, isDefault: version === getGlobalDefault(agent) }];
114
+ }
115
+ const version = resolveVersion(agent, process.cwd()) || getGlobalDefault(agent) || null;
116
+ return [{ agent, version, isDefault: true }];
117
+ }
118
+ function printCatalog(catalog, version, isDefault) {
119
+ const tag = version
120
+ ? ` ${chalk.bold(version)}${isDefault ? chalk.gray(' (default)') : ''}`
121
+ : chalk.gray(' (not installed)');
122
+ console.log(`${agentLabel(catalog.agent)}${tag}`);
123
+ console.log(chalk.gray(' modes:'));
124
+ for (const entry of catalog.modes) {
125
+ const star = entry.isDefault ? chalk.cyan('*') : ' ';
126
+ const flags = chalk.gray(formatModeFlags(entry.flags));
127
+ console.log(` ${star} ${chalk.cyan(entry.mode.padEnd(6))} ${chalk.bold(entry.description)} ${flags}`);
128
+ }
129
+ if (catalog.unsupported.length > 0) {
130
+ console.log(chalk.gray(` unsupported: ${catalog.unsupported.join(', ')}`));
131
+ }
132
+ const defaultBits = [`native default ${chalk.white(catalog.defaultMode)}`];
133
+ if (catalog.configuredMode) {
134
+ const src = catalog.configuredModeSource ? chalk.gray(` (${catalog.configuredModeSource})`) : '';
135
+ defaultBits.push(`run default ${chalk.white(catalog.configuredMode)}${src}`);
136
+ }
137
+ console.log(chalk.gray(` ${defaultBits.join(' · ')}`));
138
+ if (!catalog.headlessPlan && catalog.modes.some((m) => m.mode === 'plan')) {
139
+ console.log(chalk.yellow(' headless plan: not supported (interactive plan still works)'));
140
+ }
141
+ for (const note of catalog.notes) {
142
+ // Keep the full-alias note quiet; surface degrades more visibly.
143
+ if (note.startsWith("'full'"))
144
+ continue;
145
+ console.log(chalk.gray(` note: ${note}`));
146
+ }
147
+ }
@@ -10,9 +10,10 @@ import chalk from 'chalk';
10
10
  import { listRemoteBundles, MIN_PASSPHRASE_LEN, pullBundle, pushBundle, } from '../lib/secrets/sync.js';
11
11
  import { bundleExists, listBundles } from '../lib/secrets/bundles.js';
12
12
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
13
+ import { missingSyncPassphraseMessage, resolveSyncPassphraseFromEnv, warnEnvPassphraseReadableOnce, } from '../lib/secrets/sync-passphrase.js';
13
14
  async function promptPassphrase(message, confirm = false) {
14
15
  if (!isInteractiveTerminal()) {
15
- throw new Error('A sync passphrase is required. Run from a TTY, or set AGENTS_SECRETS_PASSPHRASE.');
16
+ throw new Error(missingSyncPassphraseMessage());
16
17
  }
17
18
  const { password } = await import('@inquirer/prompts');
18
19
  const first = await password({ message, mask: true });
@@ -25,21 +26,18 @@ async function promptPassphrase(message, confirm = false) {
25
26
  throw new Error('Passphrases do not match.');
26
27
  return first;
27
28
  }
28
- // Print the env-var-source warning at most once per process so a `--all` push
29
- // over many bundles doesn't flood stderr with the same notice.
30
- let envPassphraseWarned = false;
31
29
  function passphraseFromEnvOrPrompt(confirm) {
32
- const fromEnv = process.env.AGENTS_SECRETS_PASSPHRASE;
33
- if (fromEnv) {
34
- if (fromEnv.length < MIN_PASSPHRASE_LEN) {
30
+ // Env resolution (current name, then the deprecated master-key name) lives in
31
+ // lib/secrets/sync-passphrase.ts so this command and `agents sync` share one
32
+ // chokepoint otherwise each call site warns separately and the "once per
33
+ // process" promise is a lie.
34
+ const { value } = resolveSyncPassphraseFromEnv();
35
+ if (value) {
36
+ if (value.length < MIN_PASSPHRASE_LEN) {
35
37
  return Promise.reject(new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`));
36
38
  }
37
- if (!envPassphraseWarned) {
38
- envPassphraseWarned = true;
39
- process.stderr.write(chalk.yellow('warn: using AGENTS_SECRETS_PASSPHRASE. Env vars are readable by other same-user processes ' +
40
- '(/proc, ps, crash dumps, CI logs) — rotate the passphrase after CI use.\n'));
41
- }
42
- return Promise.resolve(fromEnv);
39
+ warnEnvPassphraseReadableOnce();
40
+ return Promise.resolve(value);
43
41
  }
44
42
  return promptPassphrase('Sync passphrase', confirm);
45
43
  }
@@ -48,9 +48,11 @@ export declare function parseImportSource(opts: {
48
48
  * defaults). Shared with the command action so the wiring is unit-testable
49
49
  * without a live SSH session.
50
50
  */
51
+ export declare function resolveUnlockTtlMs(ttl: string | undefined, until: string | undefined, now?: number): number;
51
52
  export declare function buildRemoteUnlockArgs(names: string[], opts: {
52
53
  all?: boolean;
53
54
  ttl?: string;
55
+ until?: string;
54
56
  durable?: boolean;
55
57
  }): string[];
56
58
  /**
@@ -31,6 +31,7 @@ import { readMeta } from '../lib/state.js';
31
31
  import { parseDuration } from '../lib/hooks/cache.js';
32
32
  import { emit, query } from '../lib/events.js';
33
33
  import { emitSecretAudit } from '../lib/secrets/audit.js';
34
+ import { SYNC_PASSPHRASE_ENV, resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
34
35
  import { getBundleUsage, getAllBundleUsage, getUsageHistory, SECRET_USAGE_EVENTS, } from '../lib/secrets/usage-db.js';
35
36
  import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
36
37
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
@@ -266,11 +267,31 @@ function maybePrintSyncedHint(name, stillPresent) {
266
267
  * defaults). Shared with the command action so the wiring is unit-testable
267
268
  * without a live SSH session.
268
269
  */
270
+ export function resolveUnlockTtlMs(ttl, until, now = Date.now()) {
271
+ if (ttl && until)
272
+ throw new Error('--ttl and --until are mutually exclusive.');
273
+ if (until) {
274
+ const expiresAt = Date.parse(until);
275
+ if (!Number.isFinite(expiresAt))
276
+ throw new Error("Invalid --until '" + until + "'. Use an ISO date or timestamp.");
277
+ if (expiresAt <= now)
278
+ throw new Error("Invalid --until '" + until + "': date must be in the future.");
279
+ return expiresAt - now;
280
+ }
281
+ if (ttl) {
282
+ const secs = parseDuration(ttl);
283
+ if (!secs)
284
+ throw new Error("Invalid --ttl '" + ttl + "'. Use e.g. 30m, 2h, 8h, 3d.");
285
+ return secs * 1000;
286
+ }
287
+ return secretsHoldMs();
288
+ }
269
289
  export function buildRemoteUnlockArgs(names, opts) {
270
290
  return [
271
291
  'unlock',
272
292
  ...(opts.all ? ['--all'] : names),
273
293
  ...(opts.ttl ? ['--ttl', opts.ttl] : []),
294
+ ...(opts.until ? ['--until', opts.until] : []),
274
295
  // Forward --durable so a remote unlock honors it too; without this the remote
275
296
  // silently falls back to its own secrets.agent.durable default (off).
276
297
  ...(opts.durable ? ['--durable'] : []),
@@ -1957,7 +1978,7 @@ Examples:
1957
1978
  .option('--synced', 'When creating the bundle, store it in the age-encrypted synced secrets file')
1958
1979
  .option('--force', 'Overwrite an existing key in the bundle')
1959
1980
  .option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
1960
- .option('--from-file <path>', 'Import from an AES-256-GCM encrypted offline bundle file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of export --to-file)')
1981
+ .option('--from-file <path>', `Import from an AES-256-GCM encrypted offline bundle file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of export --to-file)`)
1961
1982
  .option('--from-ssh', 'Pull the bundle from a fleet peer over SSH and import it locally (requires --host)')
1962
1983
  .option('--host <peer>', 'SSH peer to pull from when using --from-ssh (host alias or user@host)')
1963
1984
  .action(async (bundleName, opts) => {
@@ -1973,9 +1994,10 @@ Examples:
1973
1994
  throw new Error('--from-file, --from-ssh, and --from/--from-1password are mutually exclusive; pick one import source.');
1974
1995
  }
1975
1996
  if (opts.fromFile) {
1976
- const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1997
+ // Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
1998
+ const passphrase = resolveSyncPassphraseFromEnv().value ?? '';
1977
1999
  if (!passphrase) {
1978
- throw new Error('--from-file needs AGENTS_SECRETS_PASSPHRASE set to decrypt the bundle file.');
2000
+ throw new Error(`--from-file needs ${SYNC_PASSPHRASE_ENV} set to decrypt the bundle file.`);
1979
2001
  }
1980
2002
  const env = importBundleFromFile(opts.fromFile, passphrase);
1981
2003
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
@@ -2063,7 +2085,7 @@ Examples:
2063
2085
  .option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file. file is headless-readable via the remote\'s machine-local key; it forwards AGENTS_SECRETS_PASSPHRASE over stdin only if set (opt-in).', 'keychain')
2064
2086
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
2065
2087
  .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
2066
- .option('--to-file <path>', 'Write the bundle as an AES-256-GCM encrypted offline file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of import --from-file)')
2088
+ .option('--to-file <path>', `Write the bundle as an AES-256-GCM encrypted offline file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of import --from-file)`)
2067
2089
  .action(async (bundleName, opts) => {
2068
2090
  try {
2069
2091
  // `--device` is an alias for `--host` (fleet vocabulary parity with
@@ -2074,9 +2096,10 @@ Examples:
2074
2096
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
2075
2097
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
2076
2098
  if (opts.toFile) {
2077
- const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
2099
+ // Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
2100
+ const passphrase = resolveSyncPassphraseFromEnv().value ?? '';
2078
2101
  if (!passphrase) {
2079
- throw new Error('--to-file needs AGENTS_SECRETS_PASSPHRASE set to encrypt the bundle. ' +
2102
+ throw new Error(`--to-file needs ${SYNC_PASSPHRASE_ENV} set to encrypt the bundle. ` +
2080
2103
  'Set it for this command, then supply the same value when importing.');
2081
2104
  }
2082
2105
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly: true });
@@ -2483,11 +2506,16 @@ Examples:
2483
2506
  .command('unlock [names...]')
2484
2507
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
2485
2508
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
2509
+ .option('--until <date>', 'Hold until this absolute date or timestamp (for example 2026-08-06T12:00:00Z). Mutually exclusive with --ttl.')
2486
2510
  .option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
2487
2511
  .option('--for <agent>', 'Narrow the unlock to ONE harness type (for example claude, codex, or kimi). Default: the grant is global — every harness and a plain shell can read it, so one Touch ID covers them all.')
2488
2512
  .option('--all', 'Unlock every configured bundle')
2489
2513
  .option('--host <target>', 'Unlock the bundle(s) on this remote machine over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --host <machine>`.')
2490
2514
  .action(async (names, opts) => {
2515
+ if (opts.ttl && opts.until) {
2516
+ console.error(chalk.red('--ttl and --until are mutually exclusive.'));
2517
+ process.exit(1);
2518
+ }
2491
2519
  // Single-valued (not variadic): a variadic --host greedily consumes the
2492
2520
  // positional bundle name (`unlock --host mac wztest` -> host=[mac,wztest],
2493
2521
  // names=[]). Unlock targets one remote at a time anyway.
@@ -2524,6 +2552,15 @@ Examples:
2524
2552
  process.exit(1);
2525
2553
  return;
2526
2554
  }
2555
+ if (opts.until) {
2556
+ try {
2557
+ resolveUnlockTtlMs(undefined, opts.until);
2558
+ }
2559
+ catch (err) {
2560
+ console.error(chalk.red(err.message));
2561
+ process.exit(1);
2562
+ }
2563
+ }
2527
2564
  if (process.platform !== 'darwin') {
2528
2565
  // No broker + no biometry prompt off darwin: secrets already resolve
2529
2566
  // durably from the OS store (libsecret / Credential Manager) on every
@@ -2537,15 +2574,15 @@ Examples:
2537
2574
  console.error(chalk.red('Specify one or more bundle names, or --all.'));
2538
2575
  process.exit(1);
2539
2576
  }
2540
- let ttlMs = secretsHoldMs(); // default hold, capped by secrets.agent.holdMs
2541
- if (opts.ttl) {
2542
- const secs = parseDuration(opts.ttl);
2543
- if (!secs) {
2544
- console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h, 3d.`));
2545
- process.exit(1);
2546
- }
2547
- ttlMs = secs * 1000;
2577
+ let ttlMs;
2578
+ try {
2579
+ ttlMs = resolveUnlockTtlMs(opts.ttl, opts.until);
2580
+ }
2581
+ catch (err) {
2582
+ console.error(chalk.red(err.message));
2583
+ process.exit(1);
2548
2584
  }
2585
+ const expiresAt = Date.now() + ttlMs;
2549
2586
  if (!(await ensureAgentRunning())) {
2550
2587
  console.error(chalk.red('Could not start the secrets broker.'));
2551
2588
  process.exit(1);
@@ -2571,7 +2608,7 @@ Examples:
2571
2608
  noAgent: true,
2572
2609
  caller: 'unlock secrets',
2573
2610
  agent: harness,
2574
- duration: humanRemaining(Date.now() + ttlMs),
2611
+ duration: humanRemaining(expiresAt),
2575
2612
  keyMode: 'storage',
2576
2613
  });
2577
2614
  // Migrations are authorized only by this explicit unlock. The bundle
@@ -2587,7 +2624,7 @@ Examples:
2587
2624
  saveSession(name, {
2588
2625
  bundle,
2589
2626
  env,
2590
- expiresAt: Date.now() + ttlMs,
2627
+ expiresAt,
2591
2628
  sleepPersist: durable,
2592
2629
  harness,
2593
2630
  });
@@ -2606,7 +2643,7 @@ Examples:
2606
2643
  agent: harness,
2607
2644
  ttlMs,
2608
2645
  });
2609
- console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
2646
+ console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(expiresAt)})`)}`);
2610
2647
  }
2611
2648
  else {
2612
2649
  console.error(chalk.red(`Failed to load '${name}' into the agent.`));