@phnx-labs/agents-cli 1.22.23 → 1.22.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/README.md +19 -8
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/cloud.js +9 -5
  7. package/dist/commands/defaults.js +2 -0
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +105 -9
  10. package/dist/commands/exec.js +99 -44
  11. package/dist/commands/feed.d.ts +23 -0
  12. package/dist/commands/feed.js +65 -2
  13. package/dist/commands/focus.d.ts +34 -3
  14. package/dist/commands/focus.js +301 -57
  15. package/dist/commands/go.d.ts +14 -1
  16. package/dist/commands/go.js +49 -5
  17. package/dist/commands/insights.js +6 -2
  18. package/dist/commands/inspect.js +39 -5
  19. package/dist/commands/menubar.js +14 -1
  20. package/dist/commands/models.js +1 -0
  21. package/dist/commands/modes.d.ts +12 -0
  22. package/dist/commands/modes.js +147 -0
  23. package/dist/commands/routines.d.ts +3 -0
  24. package/dist/commands/routines.js +70 -47
  25. package/dist/commands/run-cloud.js +1 -1
  26. package/dist/commands/secrets-sync.js +11 -13
  27. package/dist/commands/secrets.d.ts +2 -0
  28. package/dist/commands/secrets.js +54 -17
  29. package/dist/commands/sessions-browser.d.ts +36 -1
  30. package/dist/commands/sessions-browser.js +165 -19
  31. package/dist/commands/sessions-resume.d.ts +2 -0
  32. package/dist/commands/sessions-resume.js +28 -12
  33. package/dist/commands/sessions.d.ts +23 -1
  34. package/dist/commands/sessions.js +58 -61
  35. package/dist/commands/setup-secrets.js +1 -1
  36. package/dist/commands/setup-watchdog.js +5 -10
  37. package/dist/commands/setup.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/teams.js +3 -2
  40. package/dist/commands/view.js +2 -0
  41. package/dist/commands/watchdog.d.ts +3 -4
  42. package/dist/commands/watchdog.js +26 -66
  43. package/dist/index.js +38 -2
  44. package/dist/lib/agent-modes.d.ts +49 -0
  45. package/dist/lib/agent-modes.js +70 -0
  46. package/dist/lib/agents.js +126 -21
  47. package/dist/lib/cloud/cursor.d.ts +79 -0
  48. package/dist/lib/cloud/cursor.js +228 -0
  49. package/dist/lib/cloud/registry.js +2 -0
  50. package/dist/lib/cloud/types.d.ts +7 -2
  51. package/dist/lib/cloud/types.js +14 -0
  52. package/dist/lib/crabbox/cli.d.ts +2 -2
  53. package/dist/lib/crabbox/config.d.ts +7 -8
  54. package/dist/lib/crabbox/config.js +14 -14
  55. package/dist/lib/crabbox/lease.d.ts +11 -4
  56. package/dist/lib/crabbox/lease.js +40 -8
  57. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  58. package/dist/lib/crabbox/setup-copy.js +17 -1
  59. package/dist/lib/daemon.js +27 -1
  60. package/dist/lib/device-config.js +7 -0
  61. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  62. package/dist/lib/devices/doctor-findings.js +40 -1
  63. package/dist/lib/doctor-diff.d.ts +3 -0
  64. package/dist/lib/doctor-diff.js +15 -13
  65. package/dist/lib/event-stream.d.ts +3 -1
  66. package/dist/lib/event-stream.js +14 -1
  67. package/dist/lib/events.d.ts +9 -0
  68. package/dist/lib/events.js +58 -0
  69. package/dist/lib/exec.d.ts +15 -3
  70. package/dist/lib/exec.js +112 -19
  71. package/dist/lib/feed-outcome.d.ts +3 -0
  72. package/dist/lib/feed-outcome.js +18 -10
  73. package/dist/lib/feed.d.ts +4 -0
  74. package/dist/lib/hooks/cache.js +36 -3
  75. package/dist/lib/hooks.d.ts +19 -7
  76. package/dist/lib/hooks.js +88 -38
  77. package/dist/lib/hosts/passthrough.d.ts +21 -0
  78. package/dist/lib/hosts/passthrough.js +39 -12
  79. package/dist/lib/hosts/session-index.d.ts +4 -0
  80. package/dist/lib/hosts/session-index.js +7 -0
  81. package/dist/lib/manifest.d.ts +12 -2
  82. package/dist/lib/manifest.js +60 -5
  83. package/dist/lib/mcp.js +5 -1
  84. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  85. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  86. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  87. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  88. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  89. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  90. package/dist/lib/menubar/install-menubar.js +128 -6
  91. package/dist/lib/menubar/snapshot.d.ts +15 -0
  92. package/dist/lib/menubar/snapshot.js +40 -0
  93. package/dist/lib/plugins.js +13 -1
  94. package/dist/lib/refresh.d.ts +5 -0
  95. package/dist/lib/refresh.js +37 -33
  96. package/dist/lib/resource-inventory.d.ts +79 -0
  97. package/dist/lib/resource-inventory.js +122 -0
  98. package/dist/lib/resources/mcp.js +3 -0
  99. package/dist/lib/resources.js +8 -5
  100. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  101. package/dist/lib/routine-process-cleanup.js +73 -0
  102. package/dist/lib/run-defaults.d.ts +2 -0
  103. package/dist/lib/run-defaults.js +23 -2
  104. package/dist/lib/runner.js +27 -22
  105. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  106. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  107. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  108. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  109. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  110. package/dist/lib/secrets/bundles.js +158 -85
  111. package/dist/lib/secrets/filestore.d.ts +7 -3
  112. package/dist/lib/secrets/filestore.js +25 -8
  113. package/dist/lib/secrets/index.js +34 -8
  114. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  115. package/dist/lib/secrets/rc-hygiene.js +14 -1
  116. package/dist/lib/secrets/reaper.d.ts +10 -0
  117. package/dist/lib/secrets/reaper.js +50 -15
  118. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  119. package/dist/lib/secrets/sync-passphrase.js +78 -0
  120. package/dist/lib/session/active.d.ts +4 -0
  121. package/dist/lib/session/db.js +8 -2
  122. package/dist/lib/session/recovery.d.ts +37 -0
  123. package/dist/lib/session/recovery.js +95 -0
  124. package/dist/lib/session/remote-list.d.ts +2 -0
  125. package/dist/lib/session/remote-list.js +1 -0
  126. package/dist/lib/session/session-cache.d.ts +4 -4
  127. package/dist/lib/session/session-cache.js +4 -4
  128. package/dist/lib/shims.d.ts +2 -2
  129. package/dist/lib/shims.js +45 -6
  130. package/dist/lib/signin-badge.js +2 -0
  131. package/dist/lib/startup/command-registry.d.ts +16 -0
  132. package/dist/lib/startup/command-registry.js +44 -0
  133. package/dist/lib/sync-umbrella.d.ts +5 -0
  134. package/dist/lib/sync-umbrella.js +5 -4
  135. package/dist/lib/teams/agents.js +1 -1
  136. package/dist/lib/teams/parsers.d.ts +1 -1
  137. package/dist/lib/tmux/session.d.ts +4 -2
  138. package/dist/lib/tmux/session.js +5 -5
  139. package/dist/lib/types.d.ts +3 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -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`.'));
@@ -132,6 +137,14 @@ export function registerMenubarCommands(program) {
132
137
  again, run \`agents menubar disable\`.
133
138
  `,
134
139
  });
140
+ menubar
141
+ .command('snapshot', { hidden: true })
142
+ .description('Emit the consolidated AGI Menu polling snapshot.')
143
+ .option('--json', 'Emit machine-readable JSON')
144
+ .action(async () => {
145
+ const { computeMenubarSnapshot } = await import('../lib/menubar/snapshot.js');
146
+ process.stdout.write(`${JSON.stringify(await computeMenubarSnapshot())}\n`);
147
+ });
135
148
  menubar
136
149
  .command('enable')
137
150
  .description('Install and start AGI Menu (launches at login)')
@@ -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
+ }
@@ -46,5 +46,8 @@ export declare function groupRoutineJobsByDevice(jobs: JobConfig[], registry: De
46
46
  */
47
47
  export declare function groupRoutineJobsByProject(jobs: JobConfig[], knownProjectNames: Set<string>): RoutineListGroup[];
48
48
  export declare function buildRunsJson(runs: RunMeta[]): Record<string, unknown>[];
49
+ /** Build the exact structured routine rows shared by `routines list --json`
50
+ * and the one-process AGI Menu snapshot. */
51
+ export declare function buildRoutineListJson(): Record<string, unknown>[];
49
52
  /** Register the `agents routines` command tree. */
50
53
  export declare function registerRoutinesCommands(program: Command): void;
@@ -405,6 +405,72 @@ function runMetaJson(run) {
405
405
  export function buildRunsJson(runs) {
406
406
  return runs.map(runMetaJson);
407
407
  }
408
+ /** Build the exact structured routine rows shared by `routines list --json`
409
+ * and the one-process AGI Menu snapshot. */
410
+ export function buildRoutineListJson() {
411
+ try {
412
+ monitorRunningJobs();
413
+ }
414
+ catch { /* best-effort orphan reap */ }
415
+ const jobs = listAllJobs(process.cwd());
416
+ if (jobs.length === 0)
417
+ return [];
418
+ const scheduler = new JobScheduler(async () => { });
419
+ scheduler.loadAll();
420
+ try {
421
+ const overdueSet = new Set();
422
+ try {
423
+ for (const job of detectOverdueJobs())
424
+ overdueSet.add(job.name);
425
+ }
426
+ catch {
427
+ // Best-effort indicator; never block the list on detection errors.
428
+ }
429
+ const now = new Date();
430
+ const knownProjectNames = new Set(listProjectDefs().map((project) => project.name));
431
+ return jobs.map((job) => {
432
+ const latestRun = localLatestRun(job);
433
+ const enabledDevices = devicesWithRoutineEnabled(job.name);
434
+ return {
435
+ name: job.name,
436
+ agent: job.agent ?? null,
437
+ workflow: job.workflow ?? null,
438
+ command: job.command ?? null,
439
+ repo: job.repo ?? null,
440
+ schedule: job.schedule ?? null,
441
+ scheduleHuman: fireConditionLabel(job),
442
+ trigger: job.trigger ?? null,
443
+ timezone: job.timezone ?? null,
444
+ devices: enabledDevices,
445
+ enabledDevices,
446
+ host: job.host ?? null,
447
+ hostStrategy: resolveHostStrategy(job),
448
+ source: job.source ?? null,
449
+ sourceRepo: job.source?.repo ?? job.repo ?? null,
450
+ sourceBranch: job.source?.branch ?? null,
451
+ runOnce: Boolean(job.runOnce),
452
+ catchup: job.catchup !== false,
453
+ oneShot: isOneShotRoutine(job),
454
+ expired: isPastOneShotRoutine(job, now),
455
+ runsHere: jobRunsOnThisDevice(job),
456
+ enabled: job.enabled,
457
+ overdue: overdueSet.has(job.name),
458
+ nextRun: nextRunForDisplay(job, scheduler)?.toISOString() ?? null,
459
+ nextRunHuman: nextRunLabel(job, scheduler, now),
460
+ lastStatus: latestRun?.status ?? null,
461
+ exitCode: latestRun?.exitCode ?? null,
462
+ failureReason: latestRun?.errorMessage ?? null,
463
+ lastRunStartedAt: latestRun?.startedAt ?? null,
464
+ lastRunCompletedAt: latestRun?.completedAt ?? null,
465
+ projects: job.projects ?? [],
466
+ projectGroup: computeProjectGroup(job.projects, knownProjectNames),
467
+ };
468
+ });
469
+ }
470
+ finally {
471
+ scheduler.stopAll();
472
+ }
473
+ }
408
474
  /** Detect Ctrl+C or premature stream close during an interactive prompt. */
409
475
  function isPromptCancelled(err) {
410
476
  return (err instanceof Error &&
@@ -554,6 +620,10 @@ export function registerRoutinesCommands(program) {
554
620
  console.error(chalk.red(`Unsupported --group-by '${options.groupBy}'. Use: project (default) or device`));
555
621
  process.exit(1);
556
622
  }
623
+ if (options.json) {
624
+ process.stdout.write(JSON.stringify(buildRoutineListJson()) + '\n');
625
+ return;
626
+ }
557
627
  try {
558
628
  monitorRunningJobs();
559
629
  }
@@ -579,53 +649,6 @@ export function registerRoutinesCommands(program) {
579
649
  catch {
580
650
  // Best-effort indicator; never block the list on detection errors.
581
651
  }
582
- // Machine-readable path: same data the table renders, but structured.
583
- // The menu bar helper relies on this so it never reimplements cron math.
584
- if (options.json) {
585
- const nowJson = new Date();
586
- const knownProjectNames = new Set(listProjectDefs().map((p) => p.name));
587
- const payload = jobs.map((job) => {
588
- const latestRun = localLatestRun(job);
589
- const enabledDevices = devicesWithRoutineEnabled(job.name);
590
- return {
591
- name: job.name,
592
- agent: job.agent ?? null,
593
- workflow: job.workflow ?? null,
594
- command: job.command ?? null,
595
- repo: job.repo ?? null,
596
- schedule: job.schedule ?? null,
597
- scheduleHuman: fireConditionLabel(job),
598
- trigger: job.trigger ?? null,
599
- timezone: job.timezone ?? null,
600
- devices: enabledDevices,
601
- enabledDevices,
602
- host: job.host ?? null,
603
- hostStrategy: resolveHostStrategy(job),
604
- source: job.source ?? null,
605
- sourceRepo: job.source?.repo ?? job.repo ?? null,
606
- sourceBranch: job.source?.branch ?? null,
607
- runOnce: Boolean(job.runOnce),
608
- catchup: job.catchup !== false,
609
- oneShot: isOneShotRoutine(job),
610
- expired: isPastOneShotRoutine(job, nowJson),
611
- runsHere: jobRunsOnThisDevice(job),
612
- enabled: job.enabled,
613
- overdue: overdueSet.has(job.name),
614
- nextRun: nextRunForDisplay(job, scheduler)?.toISOString() ?? null,
615
- nextRunHuman: nextRunLabel(job, scheduler, nowJson),
616
- lastStatus: latestRun?.status ?? null,
617
- exitCode: latestRun?.exitCode ?? null,
618
- failureReason: latestRun?.errorMessage ?? null,
619
- lastRunStartedAt: latestRun?.startedAt ?? null,
620
- lastRunCompletedAt: latestRun?.completedAt ?? null,
621
- projects: job.projects ?? [],
622
- projectGroup: computeProjectGroup(job.projects, knownProjectNames),
623
- };
624
- });
625
- scheduler.stopAll();
626
- process.stdout.write(JSON.stringify(payload) + '\n');
627
- return;
628
- }
629
652
  console.log(chalk.bold('Scheduled Jobs\n'));
630
653
  // OSC 8 hyperlink helper — renders as a clickable link in supporting terminals.
631
654
  // Guarded on process.stdout.isTTY so that piped/redirected output never
@@ -91,7 +91,7 @@ export function resolveRunCloudProvider(agentId, explicitProvider) {
91
91
  return resolveProvider(explicitProvider);
92
92
  if (!nativeProviderForAgent(agentId)) {
93
93
  throw new RunCloudError(`${agentId} has no native cloud. Cloud-capable agents: ${cloudCapableAgentIds().join(', ')}. ` +
94
- `Override with --provider <id> (rush | codex | factory | antigravity | host).`);
94
+ `Override with --provider <id> (rush | codex | cursor | factory | antigravity | host).`);
95
95
  }
96
96
  return resolveProvider(undefined, agentId);
97
97
  }
@@ -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
  /**