@phnx-labs/agents-cli 1.20.63 → 1.20.65

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 (197) hide show
  1. package/CHANGELOG.md +56 -3
  2. package/README.md +46 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.js +149 -77
  10. package/dist/commands/feed.d.ts +4 -0
  11. package/dist/commands/feed.js +52 -19
  12. package/dist/commands/hosts.js +44 -6
  13. package/dist/commands/lease.d.ts +23 -0
  14. package/dist/commands/lease.js +201 -0
  15. package/dist/commands/mailboxes.d.ts +20 -0
  16. package/dist/commands/mailboxes.js +390 -0
  17. package/dist/commands/mcp.js +55 -5
  18. package/dist/commands/monitors.d.ts +12 -0
  19. package/dist/commands/monitors.js +740 -0
  20. package/dist/commands/output.js +2 -2
  21. package/dist/commands/routines.js +43 -16
  22. package/dist/commands/secrets.d.ts +16 -0
  23. package/dist/commands/secrets.js +215 -64
  24. package/dist/commands/serve.js +31 -0
  25. package/dist/commands/sessions-export.d.ts +2 -0
  26. package/dist/commands/sessions-export.js +284 -0
  27. package/dist/commands/sessions-import.d.ts +2 -0
  28. package/dist/commands/sessions-import.js +230 -0
  29. package/dist/commands/sessions.d.ts +16 -0
  30. package/dist/commands/sessions.js +40 -6
  31. package/dist/commands/ssh.js +143 -5
  32. package/dist/commands/usage.d.ts +2 -0
  33. package/dist/commands/usage.js +7 -2
  34. package/dist/commands/versions.js +7 -3
  35. package/dist/commands/view.d.ts +27 -1
  36. package/dist/commands/view.js +32 -9
  37. package/dist/commands/webhook.js +10 -2
  38. package/dist/index.js +35 -14
  39. package/dist/lib/agents.d.ts +36 -0
  40. package/dist/lib/agents.js +80 -20
  41. package/dist/lib/auto-dispatch-provider.js +7 -2
  42. package/dist/lib/auto-dispatch.d.ts +3 -0
  43. package/dist/lib/auto-dispatch.js +3 -0
  44. package/dist/lib/browser/chrome.js +2 -2
  45. package/dist/lib/browser/drivers/ssh.js +19 -2
  46. package/dist/lib/cloud/antigravity.js +2 -2
  47. package/dist/lib/cloud/host.d.ts +59 -0
  48. package/dist/lib/cloud/host.js +224 -0
  49. package/dist/lib/cloud/registry.js +4 -0
  50. package/dist/lib/cloud/types.d.ts +6 -4
  51. package/dist/lib/comms-render.d.ts +37 -0
  52. package/dist/lib/comms-render.js +89 -0
  53. package/dist/lib/computer-rpc.js +3 -1
  54. package/dist/lib/crabbox/cli.d.ts +72 -0
  55. package/dist/lib/crabbox/cli.js +162 -9
  56. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  57. package/dist/lib/crabbox/runtimes.js +35 -2
  58. package/dist/lib/daemon.d.ts +20 -4
  59. package/dist/lib/daemon.js +68 -20
  60. package/dist/lib/devices/fleet.d.ts +3 -2
  61. package/dist/lib/devices/fleet.js +9 -0
  62. package/dist/lib/devices/health.d.ts +77 -0
  63. package/dist/lib/devices/health.js +186 -0
  64. package/dist/lib/devices/registry.d.ts +15 -0
  65. package/dist/lib/devices/registry.js +9 -0
  66. package/dist/lib/exec.d.ts +19 -2
  67. package/dist/lib/exec.js +41 -13
  68. package/dist/lib/fleet/apply.d.ts +63 -0
  69. package/dist/lib/fleet/apply.js +214 -0
  70. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  71. package/dist/lib/fleet/auth-sync.js +142 -0
  72. package/dist/lib/fleet/manifest.d.ts +29 -0
  73. package/dist/lib/fleet/manifest.js +127 -0
  74. package/dist/lib/fleet/types.d.ts +129 -0
  75. package/dist/lib/fleet/types.js +13 -0
  76. package/dist/lib/git.d.ts +27 -0
  77. package/dist/lib/git.js +34 -2
  78. package/dist/lib/hosts/dispatch.d.ts +29 -8
  79. package/dist/lib/hosts/dispatch.js +46 -18
  80. package/dist/lib/hosts/passthrough.js +2 -0
  81. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  82. package/dist/lib/hosts/providers/devices.js +98 -0
  83. package/dist/lib/hosts/registry.d.ts +10 -16
  84. package/dist/lib/hosts/registry.js +17 -50
  85. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  86. package/dist/lib/hosts/remote-cmd.js +71 -0
  87. package/dist/lib/hosts/run-target.d.ts +84 -0
  88. package/dist/lib/hosts/run-target.js +99 -0
  89. package/dist/lib/hosts/types.d.ts +23 -5
  90. package/dist/lib/hosts/types.js +22 -4
  91. package/dist/lib/linear-autoclose.d.ts +30 -0
  92. package/dist/lib/linear-autoclose.js +22 -0
  93. package/dist/lib/mailbox.d.ts +39 -0
  94. package/dist/lib/mailbox.js +112 -0
  95. package/dist/lib/mcp.d.ts +27 -1
  96. package/dist/lib/mcp.js +126 -12
  97. package/dist/lib/monitors/config.d.ts +161 -0
  98. package/dist/lib/monitors/config.js +372 -0
  99. package/dist/lib/monitors/dispatch.d.ts +28 -0
  100. package/dist/lib/monitors/dispatch.js +91 -0
  101. package/dist/lib/monitors/engine.d.ts +61 -0
  102. package/dist/lib/monitors/engine.js +201 -0
  103. package/dist/lib/monitors/sources/command.d.ts +11 -0
  104. package/dist/lib/monitors/sources/command.js +31 -0
  105. package/dist/lib/monitors/sources/device.d.ts +13 -0
  106. package/dist/lib/monitors/sources/device.js +35 -0
  107. package/dist/lib/monitors/sources/file.d.ts +14 -0
  108. package/dist/lib/monitors/sources/file.js +57 -0
  109. package/dist/lib/monitors/sources/http.d.ts +10 -0
  110. package/dist/lib/monitors/sources/http.js +34 -0
  111. package/dist/lib/monitors/sources/index.d.ts +14 -0
  112. package/dist/lib/monitors/sources/index.js +31 -0
  113. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  114. package/dist/lib/monitors/sources/poll.js +9 -0
  115. package/dist/lib/monitors/sources/types.d.ts +18 -0
  116. package/dist/lib/monitors/sources/types.js +9 -0
  117. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  118. package/dist/lib/monitors/sources/webhook.js +47 -0
  119. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  120. package/dist/lib/monitors/sources/ws.js +45 -0
  121. package/dist/lib/monitors/state.d.ts +69 -0
  122. package/dist/lib/monitors/state.js +144 -0
  123. package/dist/lib/paths.d.ts +13 -0
  124. package/dist/lib/paths.js +26 -4
  125. package/dist/lib/platform/exec.d.ts +16 -0
  126. package/dist/lib/platform/exec.js +17 -0
  127. package/dist/lib/plugins.js +101 -2
  128. package/dist/lib/redact.d.ts +14 -1
  129. package/dist/lib/redact.js +47 -1
  130. package/dist/lib/remote-agents-json.js +7 -1
  131. package/dist/lib/rotate.d.ts +6 -3
  132. package/dist/lib/rotate.js +0 -1
  133. package/dist/lib/routines.d.ts +37 -2
  134. package/dist/lib/routines.js +54 -12
  135. package/dist/lib/runner.d.ts +1 -0
  136. package/dist/lib/runner.js +348 -13
  137. package/dist/lib/sandbox.d.ts +9 -1
  138. package/dist/lib/sandbox.js +11 -2
  139. package/dist/lib/secrets/agent.d.ts +48 -10
  140. package/dist/lib/secrets/agent.js +123 -15
  141. package/dist/lib/secrets/bundles.d.ts +26 -0
  142. package/dist/lib/secrets/bundles.js +59 -8
  143. package/dist/lib/secrets/mcp.js +4 -2
  144. package/dist/lib/secrets/remote.d.ts +17 -0
  145. package/dist/lib/secrets/remote.js +40 -0
  146. package/dist/lib/self-update.d.ts +20 -0
  147. package/dist/lib/self-update.js +54 -1
  148. package/dist/lib/serve/control.d.ts +95 -0
  149. package/dist/lib/serve/control.js +260 -0
  150. package/dist/lib/serve/server.d.ts +35 -1
  151. package/dist/lib/serve/server.js +106 -76
  152. package/dist/lib/serve/stream.d.ts +43 -0
  153. package/dist/lib/serve/stream.js +116 -0
  154. package/dist/lib/serve/token.d.ts +35 -0
  155. package/dist/lib/serve/token.js +85 -0
  156. package/dist/lib/session/bundle.d.ts +164 -0
  157. package/dist/lib/session/bundle.js +200 -0
  158. package/dist/lib/session/remote-bundle.d.ts +12 -0
  159. package/dist/lib/session/remote-bundle.js +61 -0
  160. package/dist/lib/session/remote-list.js +5 -1
  161. package/dist/lib/session/state.d.ts +7 -25
  162. package/dist/lib/session/state.js +16 -6
  163. package/dist/lib/session/sync/agents.d.ts +54 -6
  164. package/dist/lib/session/sync/agents.js +0 -0
  165. package/dist/lib/session/sync/config.js +8 -2
  166. package/dist/lib/session/sync/manifest.d.ts +14 -3
  167. package/dist/lib/session/sync/manifest.js +4 -0
  168. package/dist/lib/session/sync/sync.d.ts +23 -2
  169. package/dist/lib/session/sync/sync.js +177 -74
  170. package/dist/lib/session/types.d.ts +30 -0
  171. package/dist/lib/ssh-tunnel.d.ts +19 -1
  172. package/dist/lib/ssh-tunnel.js +99 -8
  173. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  174. package/dist/lib/staleness/detectors/subagents.js +5 -192
  175. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  176. package/dist/lib/staleness/writers/subagents.js +11 -102
  177. package/dist/lib/startup/command-registry.d.ts +4 -0
  178. package/dist/lib/startup/command-registry.js +9 -0
  179. package/dist/lib/state.d.ts +5 -0
  180. package/dist/lib/state.js +12 -0
  181. package/dist/lib/subagents-registry.d.ts +85 -0
  182. package/dist/lib/subagents-registry.js +393 -0
  183. package/dist/lib/subagents.d.ts +8 -8
  184. package/dist/lib/subagents.js +32 -663
  185. package/dist/lib/sync-umbrella.d.ts +1 -0
  186. package/dist/lib/sync-umbrella.js +14 -3
  187. package/dist/lib/tmux/session.d.ts +7 -0
  188. package/dist/lib/tmux/session.js +3 -1
  189. package/dist/lib/triggers/webhook.d.ts +18 -0
  190. package/dist/lib/triggers/webhook.js +105 -0
  191. package/dist/lib/types.d.ts +35 -1
  192. package/dist/lib/usage.d.ts +42 -3
  193. package/dist/lib/usage.js +163 -21
  194. package/dist/lib/versions.js +14 -11
  195. package/dist/lib/workflows.d.ts +20 -0
  196. package/dist/lib/workflows.js +24 -0
  197. package/package.json +2 -1
@@ -10,7 +10,7 @@ import { formatUsd, PRICING_VERSION } from '../lib/pricing/index.js';
10
10
  import { formatDuration } from '../lib/session/render.js';
11
11
  import { terminalWidth, truncateToWidth, padToWidth } from '../lib/session/width.js';
12
12
  import { collectGitOutput } from '../lib/output/git-output.js';
13
- import { loadDevices } from '../lib/devices/registry.js';
13
+ import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
14
14
  import { machineId } from '../lib/session/sync/config.js';
15
15
  const execFileAsync = promisify(execFile);
16
16
  export function registerOutputCommand(program) {
@@ -154,7 +154,7 @@ async function outputAction(options) {
154
154
  const self = machineId();
155
155
  const registry = await loadDevices();
156
156
  const remotes = Object.values(registry)
157
- .filter(d => d.tailscale?.online && d.name !== self)
157
+ .filter(d => d.tailscale?.online && d.name !== self && !isControlDevice(d))
158
158
  .map(d => d.name);
159
159
  if (!options.json)
160
160
  console.error(chalk.gray(`Folding in ${remotes.length} online device${remotes.length !== 1 ? 's' : ''}…`));
@@ -23,7 +23,7 @@ import { detectOverdueJobs } from '../lib/overdue.js';
23
23
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
24
24
  import { setHelpSections } from '../lib/help.js';
25
25
  import { loadDevices } from '../lib/devices/registry.js';
26
- import { normalizeHost } from '../lib/machine-id.js';
26
+ import { machineId, normalizeHost } from '../lib/machine-id.js';
27
27
  import { addHostOption } from '../lib/hosts/option.js';
28
28
  /**
29
29
  * Human-friendly wall-clock a run took (e.g. " · 3 min", " · 45 sec"), or ""
@@ -149,7 +149,7 @@ async function pickJob(message, filter, alternatives = [], cwd) {
149
149
  message,
150
150
  choices: jobs.map((job) => ({
151
151
  value: job.name,
152
- name: `${job.name} ${chalk.gray(`(${job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
152
+ name: `${job.name} ${chalk.gray(`(${job.command ? 'command' : job.workflow ? `wf:${job.workflow}` : job.agent}, ${job.schedule ?? fireConditionLabel(job)})`)}`,
153
153
  })),
154
154
  });
155
155
  }
@@ -282,12 +282,14 @@ export function registerRoutinesCommands(program) {
282
282
  name: job.name,
283
283
  agent: job.agent ?? null,
284
284
  workflow: job.workflow ?? null,
285
+ command: job.command ?? null,
285
286
  repo: job.repo ?? null,
286
287
  schedule: job.schedule ?? null,
287
288
  scheduleHuman: fireConditionLabel(job),
288
289
  trigger: job.trigger ?? null,
289
290
  timezone: job.timezone ?? null,
290
291
  devices: job.devices ?? [],
292
+ host: job.host ?? null,
291
293
  runsHere: jobRunsOnThisDevice(job),
292
294
  enabled: job.enabled,
293
295
  overdue: overdueSet.has(job.name),
@@ -339,7 +341,10 @@ export function registerRoutinesCommands(program) {
339
341
  // chalk adds escape codes; pad the raw word and let chalk wrap it.
340
342
  const enabledWord = job.enabled ? 'yes' : 'no';
341
343
  const enabledPad = Math.max(0, ENABLED_W - enabledWord.length);
342
- const deviceFull = job.devices?.join(',') ?? '';
344
+ // Placement (`→host`) rides in the Devices cell: eligibility →execution.
345
+ const deviceFull = [job.devices?.join(',') ?? '', job.host ? `→${job.host}` : '']
346
+ .filter(Boolean)
347
+ .join(' ');
343
348
  const deviceWord = deviceFull.length === 0
344
349
  ? 'all'
345
350
  : deviceFull.length > DEVICE_W
@@ -356,9 +361,11 @@ export function registerRoutinesCommands(program) {
356
361
  : lastStatus === 'timeout' ? chalk.yellow
357
362
  : chalk.gray;
358
363
  const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
359
- const agentLabelPadded = job.workflow
360
- ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
361
- : (job.agent || '').padEnd(10);
364
+ const agentLabelPadded = job.command
365
+ ? chalk.magenta('command'.padEnd(10))
366
+ : job.workflow
367
+ ? chalk.magenta(`wf:${job.workflow}`.padEnd(10))
368
+ : (job.agent || '').padEnd(10);
362
369
  console.log(` ${chalk.cyan(job.name.padEnd(NAME_W))} ${agentLabelPadded} ${repoCell}${' '.repeat(repoPadding)} ${deviceCell}${' '.repeat(devicePad)} ${schedStr.padEnd(SCHED_W)} ${enabledStr}${' '.repeat(enabledPad)} ${chalk.gray(nextStr.padEnd(NEXT_W))} ${statusColor(lastStatus)}${overdueTag}`);
363
370
  }
364
371
  if (overdueSet.size > 0) {
@@ -374,12 +381,15 @@ export function registerRoutinesCommands(program) {
374
381
  .option('-s, --schedule <cron>', 'Cron schedule in standard format (5 fields: minute hour day month weekday)')
375
382
  .option('-a, --agent <agent>', 'Which agent runs this routine: claude, codex, gemini, cursor, or opencode')
376
383
  .option('--workflow <name>', 'Run an installed workflow (~/.agents/workflows/<name>) via `agents run`. Mutually exclusive with --agent.')
384
+ .option('--command <sh>', 'Run a plain shell command directly (no agent, no auth, no sandbox) — for deterministic housekeeping routines. Mutually exclusive with --agent and --workflow; --prompt is not used.')
377
385
  .option('-p, --prompt <prompt>', 'Task instruction for the agent')
378
386
  .option('-m, --mode <mode>', "Execution mode: plan (read-only), edit (can write files), auto (smart classifier, the default), or skip (bypass all permission prompts). 'full' accepted as alias for skip.", 'auto')
379
387
  .option('-e, --effort <effort>', 'Reasoning effort: low | medium | high | xhigh | max | auto', 'auto')
380
388
  .option('-t, --timeout <timeout>', 'Kill the agent if it runs longer than this (e.g., 10m, 2h, 3d, 1w; max 1w)', '10m')
381
389
  .option('--timezone <tz>', 'Interpret schedule in this timezone (e.g., America/Los_Angeles)')
382
390
  .option('--devices <names>', 'Fleet allowlist (comma-separated): only listed devices schedule and fire this routine. Omit for unrestricted.')
391
+ .option('--run-on <name>', 'Execute the job body on this machine over SSH (a registered host, device, capability tag, or user@host). Placement, not eligibility — see --devices. Auto-pins devices to THIS machine unless --devices is given.')
392
+ .option('--run-cwd <dir>', 'Working directory on the --run-on host (--remote-cwd is taken by the remote-management passthrough)')
383
393
  .option('--at <time>', 'One-shot mode: run once at this time (e.g., "14:30" or "2026-02-24 09:00"), then disable')
384
394
  .option('--on <source:event>', 'Webhook trigger instead of/in addition to a schedule: github:pull_request or linear:Issue')
385
395
  .option('--repo <owner/name>', 'GitHub repo filter for --on github:<event>')
@@ -392,7 +402,7 @@ export function registerRoutinesCommands(program) {
392
402
  .option('--resume <sessionId>', 'At fire time, resume this existing session id (via `agents run <agent> --resume`) instead of starting fresh — the actual session reopens with full context and the prompt becomes its next turn. Powers self-scheduled wake-ups (e.g. /hibernate). Requires --agent claude or codex; runs un-sandboxed (the session store lives in the real home, not the job overlay).')
393
403
  .action(async (nameOrPath, options) => {
394
404
  // Check if inline mode (has flags) or file mode
395
- const hasInlineFlags = options.schedule || options.agent || options.workflow || options.prompt || options.at || options.on;
405
+ const hasInlineFlags = options.schedule || options.agent || options.workflow || options.command || options.prompt || options.at || options.on;
396
406
  if (hasInlineFlags) {
397
407
  // Inline mode: create job from flags
398
408
  if (!nameOrPath) {
@@ -400,9 +410,9 @@ export function registerRoutinesCommands(program) {
400
410
  console.log(chalk.gray('Usage: agents routines add <name> --schedule "..." --agent <agent> --prompt "..."'));
401
411
  process.exit(1);
402
412
  }
403
- // Validate mutually exclusive --agent / --workflow
404
- if (options.agent && options.workflow) {
405
- console.log(chalk.red('--agent and --workflow are mutually exclusive; specify exactly one'));
413
+ // Validate mutually exclusive --agent / --workflow / --command
414
+ if ([options.agent, options.workflow, options.command].filter(Boolean).length > 1) {
415
+ console.log(chalk.red('--agent, --workflow, and --command are mutually exclusive; specify exactly one'));
406
416
  process.exit(1);
407
417
  }
408
418
  let schedule = options.schedule;
@@ -430,11 +440,12 @@ export function registerRoutinesCommands(program) {
430
440
  console.log(chalk.red('Schedule or trigger is required (use --schedule, --at, or --on)'));
431
441
  process.exit(1);
432
442
  }
433
- if (!options.agent && !options.workflow) {
434
- console.log(chalk.red('An agent or workflow is required (use --agent or --workflow)'));
443
+ if (!options.agent && !options.workflow && !options.command) {
444
+ console.log(chalk.red('An agent, workflow, or command is required (use --agent, --workflow, or --command)'));
435
445
  process.exit(1);
436
446
  }
437
- if (!options.prompt) {
447
+ // Command routines run a plain shell and take no prompt; agent/workflow routines require one.
448
+ if (!options.command && !options.prompt) {
438
449
  console.log(chalk.red('Prompt is required (use --prompt)'));
439
450
  process.exit(1);
440
451
  }
@@ -443,19 +454,29 @@ export function registerRoutinesCommands(program) {
443
454
  if (options.devices !== undefined) {
444
455
  devices = await parseAndValidateDevices(options.devices);
445
456
  }
457
+ // --run-on without a --devices pin would fire on EVERY daemon in the
458
+ // fleet, each dispatching to the same host — duplicate runs. Pin to
459
+ // this machine unless the user chose an explicit eligibility set.
460
+ if (options.runOn && !devices) {
461
+ devices = [machineId()];
462
+ console.log(chalk.gray(`--run-on set with no --devices: pinned firing to this machine (${devices[0]}).`));
463
+ }
446
464
  const config = {
447
465
  name: nameOrPath,
448
466
  ...(schedule ? { schedule } : {}),
449
467
  ...(trigger ? { trigger } : {}),
450
- agent: options.agent,
468
+ ...(options.agent ? { agent: options.agent } : {}),
451
469
  ...(options.workflow ? { workflow: options.workflow } : {}),
470
+ ...(options.command ? { command: options.command } : {}),
452
471
  mode: options.mode,
453
472
  effort: options.effort,
454
473
  timeout: options.timeout,
455
474
  enabled: !options.disabled,
456
- prompt: options.prompt,
475
+ prompt: options.prompt ?? '',
457
476
  timezone: options.timezone,
458
477
  ...(devices ? { devices } : {}),
478
+ ...(options.runOn ? { host: options.runOn } : {}),
479
+ ...(options.runCwd ? { remoteCwd: options.runCwd } : {}),
459
480
  ...(runOnce ? { runOnce: true } : {}),
460
481
  ...(options.endAt ? { endAt: options.endAt } : {}),
461
482
  ...(options.resume ? { resume: options.resume } : {}),
@@ -514,6 +535,12 @@ export function registerRoutinesCommands(program) {
514
535
  enabled: true,
515
536
  ...parsed,
516
537
  };
538
+ // Same duplicate-fire guard as the --run-on flag: a host-placed routine
539
+ // with no eligibility pin would fire from every daemon in the fleet.
540
+ if (config.host && (!config.devices || config.devices.length === 0)) {
541
+ config.devices = [machineId()];
542
+ console.log(chalk.gray(`host: set with no devices pin: pinned firing to this machine (${config.devices[0]}).`));
543
+ }
517
544
  writeJob(config);
518
545
  console.log(chalk.green(`Job '${name}' added`));
519
546
  ensureSchedulerRunning();
@@ -664,7 +691,7 @@ export function registerRoutinesCommands(program) {
664
691
  console.log(chalk.gray(` ${eligibility.suggestion}`));
665
692
  process.exit(1);
666
693
  }
667
- const runLabel = job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
694
+ const runLabel = job.command ? 'command' : job.workflow ? `workflow: ${job.workflow}` : `agent: ${job.agent}`;
668
695
  console.log(chalk.bold(`Running job '${name}' (${runLabel}, mode: ${job.mode})\n`));
669
696
  const spinner = ora('Executing...').start();
670
697
  try {
@@ -66,11 +66,27 @@ export declare function buildSecretsExecEnv(parentEnv: NodeJS.ProcessEnv, secret
66
66
  * parse, so multi-line values are rejected rather than silently corrupted.
67
67
  */
68
68
  export declare function bundleEnvToDotenv(env: Record<string, string>): string;
69
+ /**
70
+ * Encrypt a resolved env map to an offline bundle file using AES-256-GCM
71
+ * (the same EncFile envelope as the per-item file store). Inner plaintext is
72
+ * JSON so multi-line values round-trip losslessly. Written with mode 0600;
73
+ * the passphrase must be supplied explicitly — never auto-provisioned.
74
+ */
75
+ export declare function exportBundleToFile(env: Record<string, string>, filePath: string, passphrase: string): void;
76
+ /**
77
+ * Decrypt and parse an offline bundle file produced by exportBundleToFile.
78
+ * Throws on a missing file, an invalid JSON envelope, or a wrong passphrase.
79
+ */
80
+ export declare function importBundleFromFile(filePath: string, passphrase: string): Record<string, string>;
69
81
  /** The POLICY column for `secrets list`: the prompt policy, plus a concise
70
82
  * state hint. `daily` shows `held Nh` when the secrets-agent is currently
71
83
  * caching the bundle; `always` and `never` show whether they prompt. `held`
72
84
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
73
85
  export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
86
+ /** Human-readable hold window for `secrets status`. Sub-hour values render in
87
+ * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
88
+ * hours up to 2 days, whole days beyond. Pure — unit-tested. */
89
+ export declare function formatHoldWindow(ms: number): string;
74
90
  /** Register the `agents secrets` command tree. */
75
91
  export declare function registerSecretsCommands(program: Command): void;
76
92
  /** Validate a prompt-policy value, throwing a clear message on a bad one (the
@@ -16,10 +16,13 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
16
16
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveSshTarget, } from '../lib/secrets/remote.js';
17
17
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
18
18
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
19
- import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
19
+ import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
20
+ import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
20
21
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
21
22
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
22
- import { DEFAULT_TTL_MS, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
23
+ import { secretsHoldMs, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
24
+ import { getCliVersionFresh } from '../lib/version.js';
25
+ import { readMeta } from '../lib/state.js';
23
26
  import { parseDuration } from '../lib/hooks/cache.js';
24
27
  import { emit } from '../lib/events.js';
25
28
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
@@ -298,6 +301,52 @@ export function bundleEnvToDotenv(env) {
298
301
  }
299
302
  return lines.join('\n') + '\n';
300
303
  }
304
+ /**
305
+ * Encrypt a resolved env map to an offline bundle file using AES-256-GCM
306
+ * (the same EncFile envelope as the per-item file store). Inner plaintext is
307
+ * JSON so multi-line values round-trip losslessly. Written with mode 0600;
308
+ * the passphrase must be supplied explicitly — never auto-provisioned.
309
+ */
310
+ export function exportBundleToFile(env, filePath, passphrase) {
311
+ const enc = encryptForFallback(JSON.stringify(env), passphrase);
312
+ fs.writeFileSync(filePath, JSON.stringify(enc), { mode: 0o600 });
313
+ }
314
+ /**
315
+ * Decrypt and parse an offline bundle file produced by exportBundleToFile.
316
+ * Throws on a missing file, an invalid JSON envelope, or a wrong passphrase.
317
+ */
318
+ export function importBundleFromFile(filePath, passphrase) {
319
+ const raw = fs.readFileSync(filePath, 'utf-8');
320
+ let enc;
321
+ try {
322
+ enc = JSON.parse(raw);
323
+ }
324
+ catch {
325
+ throw new Error(`Encrypted bundle file ${filePath} is corrupt (not valid JSON).`);
326
+ }
327
+ let plaintext;
328
+ try {
329
+ plaintext = decryptForFallback(enc, passphrase);
330
+ }
331
+ catch {
332
+ throw new Error(`Failed to decrypt bundle file ${filePath}. Wrong passphrase or tampered file.`);
333
+ }
334
+ let parsed;
335
+ try {
336
+ parsed = JSON.parse(plaintext);
337
+ }
338
+ catch {
339
+ throw new Error(`Decrypted bundle file ${filePath} has invalid content (expected JSON).`);
340
+ }
341
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
342
+ throw new Error(`Bundle file ${filePath} has unexpected structure.`);
343
+ }
344
+ const result = {};
345
+ for (const [k, v] of Object.entries(parsed)) {
346
+ result[k] = typeof v === 'string' ? v : String(v);
347
+ }
348
+ return result;
349
+ }
301
350
  /**
302
351
  * Browse `agents secrets <args>` on one or more remote hosts over SSH and print
303
352
  * each host's stdout verbatim (lossless — no parsing). With >1 host the output
@@ -400,6 +449,22 @@ export function renderPolicyCol(b, held) {
400
449
  const exp = held?.get(b.name);
401
450
  return exp ? chalk.green(`daily · held ${compactRemaining(exp)}`) : chalk.gray('daily');
402
451
  }
452
+ /** Human-readable hold window for `secrets status`. Sub-hour values render in
453
+ * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
454
+ * hours up to 2 days, whole days beyond. Pure — unit-tested. */
455
+ export function formatHoldWindow(ms) {
456
+ if (ms < 3_600_000) { // under an hour → minutes (never a confusing "0 hours")
457
+ const mins = Math.max(1, Math.round(ms / 60_000));
458
+ if (mins < 60)
459
+ return `${mins} minute${mins === 1 ? '' : 's'}`;
460
+ // 59.99m rounds to 60 — call it 1 hour rather than "60 minutes".
461
+ }
462
+ const hrs = Math.round(ms / 3_600_000);
463
+ if (hrs < 48)
464
+ return `${hrs} hour${hrs === 1 ? '' : 's'}`;
465
+ const days = Math.round(hrs / 24);
466
+ return `${days} day${days === 1 ? '' : 's'}`;
467
+ }
403
468
  /** Below this width the fixed date columns no longer fit; `list` uses cards. */
404
469
  const SECRETS_WIDE = 96;
405
470
  /** Format a single bundle as a table row for the `secrets list` output. */
@@ -568,6 +633,51 @@ function countExpiringSoon(meta) {
568
633
  }
569
634
  return n;
570
635
  }
636
+ /**
637
+ * Resolve an existing import target bundle (inheriting its backend) or create a
638
+ * new one with the requested backend. Refuses to silently downgrade a
639
+ * keychain-backed bundle to `file` — shared by every `import` source so the
640
+ * guard can't drift between them.
641
+ */
642
+ function resolveImportBundle(name, backendOpt) {
643
+ const requestedBackend = parseBackendOpt(backendOpt);
644
+ if (bundleExists(name)) {
645
+ const bundle = readBundle(name);
646
+ if (requestedBackend === 'file' && bundle.backend !== 'file') {
647
+ throw new Error(`Bundle '${name}' already exists with a keychain backend; ` +
648
+ `--backend file cannot change it. Delete it first to recreate as file-backed.`);
649
+ }
650
+ return bundle;
651
+ }
652
+ return { name, backend: requestedBackend === 'file' ? 'file' : undefined, vars: {} };
653
+ }
654
+ /**
655
+ * Apply KEY=VALUE entries into a bundle (keychain item or plaintext literal),
656
+ * honoring `--force`, then persist. Returns the added/skipped tally. Shared by
657
+ * the .env, --from-file, and --from-ssh import paths.
658
+ */
659
+ function applyEnvToBundle(bundle, env, opts) {
660
+ const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
661
+ let added = 0;
662
+ let skipped = 0;
663
+ for (const [key, value] of Object.entries(env)) {
664
+ if (!opts.force && key in bundle.vars) {
665
+ skipped++;
666
+ continue;
667
+ }
668
+ if (opts.allPlaintext) {
669
+ bundle.vars[key] = { value };
670
+ }
671
+ else {
672
+ const item = secretsKeychainItem(bundle.name, key);
673
+ store.set(item, value);
674
+ bundle.vars[key] = keychainRef(key);
675
+ }
676
+ added++;
677
+ }
678
+ writeBundle(bundle);
679
+ return { added, skipped };
680
+ }
571
681
  /** Register the `agents secrets` command tree. */
572
682
  export function registerSecretsCommands(program) {
573
683
  const cmd = program
@@ -854,7 +964,10 @@ export function registerSecretsCommands(program) {
854
964
  console.error(chalk.red(`Secrets bundle '${item}' not found.`));
855
965
  process.exit(1);
856
966
  }
857
- const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key] });
967
+ // `secrets get` is the scriptable automation primitive ($(agents secrets
968
+ // get bundle KEY)); when embedded in a headless routine/CI script it must
969
+ // not pop an unwatched Touch ID prompt. Interactive use still prompts.
970
+ const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], agentOnly: isHeadlessSecretsContext() });
858
971
  if (!(key in env)) {
859
972
  console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
860
973
  process.exit(1);
@@ -1301,8 +1414,46 @@ Examples:
1301
1414
  .option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
1302
1415
  .option('--force', 'Overwrite an existing key in the bundle')
1303
1416
  .option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
1417
+ .option('--from-file <path>', 'Import from an AES-256-GCM encrypted offline bundle file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of export --to-file)')
1418
+ .option('--from-ssh', 'Pull the bundle from a fleet peer over SSH and import it locally (requires --host)')
1419
+ .option('--host <peer>', 'SSH peer to pull from when using --from-ssh (host alias or user@host)')
1304
1420
  .action(async (bundleName, opts) => {
1305
1421
  try {
1422
+ // A single import can name only one source. --from-file / --from-ssh are
1423
+ // early-return paths, so guard them against each other and the --from /
1424
+ // --from-1password pair (which parseImportSource guards on its own).
1425
+ const namedFileOrSsh = [
1426
+ opts.fromFile ? '--from-file' : null,
1427
+ opts.fromSsh ? '--from-ssh' : null,
1428
+ ].filter(Boolean);
1429
+ if (namedFileOrSsh.length > 1 || (namedFileOrSsh.length > 0 && (opts.from || opts.from1password))) {
1430
+ throw new Error('--from-file, --from-ssh, and --from/--from-1password are mutually exclusive; pick one import source.');
1431
+ }
1432
+ if (opts.fromFile) {
1433
+ const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1434
+ if (!passphrase) {
1435
+ throw new Error('--from-file needs AGENTS_SECRETS_PASSPHRASE set to decrypt the bundle file.');
1436
+ }
1437
+ const env = importBundleFromFile(opts.fromFile, passphrase);
1438
+ const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1439
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
1440
+ const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1441
+ console.log(chalk.green(`Imported ${added} key(s) from file${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1442
+ return;
1443
+ }
1444
+ if (opts.fromSsh) {
1445
+ if (!opts.host) {
1446
+ throw new Error('--from-ssh requires --host <peer>.');
1447
+ }
1448
+ assertValidSshTarget(opts.host);
1449
+ const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1450
+ const target = await resolveSshTarget(opts.host);
1451
+ const env = await remoteResolveEnv(target, resolvedBundleName, { osLookupName: opts.host });
1452
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
1453
+ const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1454
+ console.log(chalk.green(`Imported ${added} key(s) from ${opts.host}${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1455
+ return;
1456
+ }
1306
1457
  const source = parseImportSource(opts);
1307
1458
  if (opts.purge && source.kind !== 'icloud') {
1308
1459
  throw new Error('--purge only applies to --from icloud.');
@@ -1321,49 +1472,20 @@ Examples:
1321
1472
  return;
1322
1473
  }
1323
1474
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1324
- // Read the bundle if it exists (inheriting its backend); otherwise
1325
- // create it with the requested backend so a single `import --backend
1326
- // file` works (this is what `export --host ... --remote-backend file`
1327
- // drives on the remote).
1328
- let bundle;
1329
- if (bundleExists(resolvedBundleName)) {
1330
- bundle = readBundle(resolvedBundleName);
1331
- if (requestedBackend === 'file' && bundle.backend !== 'file') {
1332
- throw new Error(`Bundle '${resolvedBundleName}' already exists with a keychain backend; ` +
1333
- `--backend file cannot change it. Delete it first to recreate as file-backed.`);
1334
- }
1335
- }
1336
- else {
1337
- bundle = {
1338
- name: resolvedBundleName,
1339
- backend: requestedBackend === 'file' ? 'file' : undefined,
1340
- vars: {},
1341
- };
1342
- }
1343
- const store = bundleItemStore(bundle.backend, { noAcl: bundlePolicy(bundle) === 'never' });
1344
- let added = 0;
1345
- let skipped = 0;
1475
+ // resolveImportBundle inherits an existing bundle's backend (and refuses
1476
+ // to downgrade keychain -> file) or creates it with the requested backend
1477
+ // so a single `import --backend file` works (what `export --host ...
1478
+ // --remote-backend file` drives on the remote).
1479
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend);
1346
1480
  if (source.kind === '1password') {
1347
1481
  assertOpAvailable();
1348
1482
  const vault = await resolveVault(source.vault);
1349
1483
  const items = listItems(vault);
1350
1484
  const { secrets, skipped: opSkipped } = extractSecrets(items, vault);
1351
- for (const { envKey, value } of secrets) {
1352
- if (!opts.force && envKey in bundle.vars) {
1353
- skipped++;
1354
- continue;
1355
- }
1356
- if (opts.allPlaintext) {
1357
- bundle.vars[envKey] = { value };
1358
- }
1359
- else {
1360
- const item = secretsKeychainItem(resolvedBundleName, envKey);
1361
- store.set(item, value);
1362
- bundle.vars[envKey] = keychainRef(envKey);
1363
- }
1364
- added++;
1365
- }
1366
- writeBundle(bundle);
1485
+ const env = {};
1486
+ for (const { envKey, value } of secrets)
1487
+ env[envKey] = value;
1488
+ const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1367
1489
  if (opSkipped.length) {
1368
1490
  console.log(chalk.yellow(`Skipped ${opSkipped.length} item(s) with no importable fields.`));
1369
1491
  }
@@ -1372,22 +1494,7 @@ Examples:
1372
1494
  else {
1373
1495
  const raw = readImportDotenv(source.path);
1374
1496
  const pairs = parseDotenv(raw);
1375
- for (const [key, value] of Object.entries(pairs)) {
1376
- if (!opts.force && key in bundle.vars) {
1377
- skipped++;
1378
- continue;
1379
- }
1380
- if (opts.allPlaintext) {
1381
- bundle.vars[key] = { value };
1382
- }
1383
- else {
1384
- const item = secretsKeychainItem(resolvedBundleName, key);
1385
- store.set(item, value);
1386
- bundle.vars[key] = keychainRef(key);
1387
- }
1388
- added++;
1389
- }
1390
- writeBundle(bundle);
1497
+ const { added, skipped } = applyEnvToBundle(bundle, pairs, opts);
1391
1498
  console.log(chalk.green(`Imported ${added} key(s)${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1392
1499
  }
1393
1500
  }
@@ -1408,10 +1515,22 @@ Examples:
1408
1515
  .option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
1409
1516
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
1410
1517
  .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
1518
+ .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)')
1411
1519
  .action(async (bundleName, opts) => {
1412
1520
  try {
1413
1521
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
1414
1522
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
1523
+ if (opts.toFile) {
1524
+ const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1525
+ if (!passphrase) {
1526
+ throw new Error('--to-file needs AGENTS_SECRETS_PASSPHRASE set to encrypt the bundle. ' +
1527
+ 'Set it for this command, then supply the same value when importing.');
1528
+ }
1529
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', agentOnly: isHeadlessSecretsContext() });
1530
+ exportBundleToFile(env, opts.toFile, passphrase);
1531
+ console.log(chalk.green(`Exported ${Object.keys(env).length} key(s) to ${opts.toFile}`));
1532
+ return;
1533
+ }
1415
1534
  // The presence of --host selects SSH push: --host is the destination
1416
1535
  // and carries the mode (no separate --to-ssh needed — it would be
1417
1536
  // strictly redundant since SSH always requires at least one host).
@@ -1434,7 +1553,7 @@ Examples:
1434
1553
  'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
1435
1554
  }
1436
1555
  }
1437
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
1556
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, agentOnly: isHeadlessSecretsContext() });
1438
1557
  const dotenv = bundleEnvToDotenv(env);
1439
1558
  const keyCount = Object.keys(env).length;
1440
1559
  // Drive the remote's own `agents secrets import --from -` so the values
@@ -1499,7 +1618,7 @@ Examples:
1499
1618
  if (opts.to1password) {
1500
1619
  assertOpAvailable();
1501
1620
  const vault = await resolveVault(opts.vault);
1502
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}` });
1621
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}`, agentOnly: isHeadlessSecretsContext() });
1503
1622
  let created = 0;
1504
1623
  let overwritten = 0;
1505
1624
  let skipped = 0;
@@ -1537,7 +1656,15 @@ Examples:
1537
1656
  console.error(chalk.red('export prints secrets in the clear and requires --plaintext (works for TTY and pipes alike).'));
1538
1657
  process.exit(1);
1539
1658
  }
1540
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `export to shell` });
1659
+ // `agents secrets export --plaintext` is what release/CI scripts eval.
1660
+ // When it runs detached (both stdio non-TTY) or under a headless agent,
1661
+ // resolve broker-only so it can never pop a Touch ID sheet on the
1662
+ // interactive user's screen. An interactive `eval "$(...)"` keeps its
1663
+ // terminal stdin, so it is not headless and still prompts.
1664
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, {
1665
+ caller: `export to shell`,
1666
+ agentOnly: isHeadlessSecretsContext(),
1667
+ });
1541
1668
  if (opts.format === 'json') {
1542
1669
  // Lossless, machine-readable form consumed by `remoteResolveEnv` over
1543
1670
  // SSH. Single object of KEY -> value; values verbatim (newlines, quotes).
@@ -1592,6 +1719,7 @@ Examples:
1592
1719
  caller: `command ${cmd}`,
1593
1720
  keys: keysSubset,
1594
1721
  allowExpired: execOpts.allowExpired,
1722
+ agentOnly: isHeadlessSecretsContext(),
1595
1723
  }).env;
1596
1724
  }
1597
1725
  const { spawn } = await import('child_process');
@@ -1760,7 +1888,7 @@ Examples:
1760
1888
  console.error(chalk.red('Specify one or more bundle names, or --all.'));
1761
1889
  process.exit(1);
1762
1890
  }
1763
- let ttlMs = DEFAULT_TTL_MS;
1891
+ let ttlMs = secretsHoldMs(); // default hold, capped by secrets.agent.holdMs
1764
1892
  if (opts.ttl) {
1765
1893
  const secs = parseDuration(opts.ttl);
1766
1894
  if (!secs) {
@@ -1830,21 +1958,44 @@ Examples:
1830
1958
  console.log(chalk.gray('secrets-agent is macOS-only.'));
1831
1959
  return;
1832
1960
  }
1833
- const brokerUp = (await agentPing()).reachable;
1961
+ const ping = await agentPing();
1962
+ const brokerUp = ping.reachable;
1834
1963
  console.log(chalk.gray('broker: ') +
1835
1964
  (brokerUp
1836
1965
  ? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
1837
1966
  : chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
1967
+ // Diagnostic: version skew is the top reason a `daily` bundle keeps
1968
+ // re-prompting — a broker on an older build gets torn down when the CLI
1969
+ // version changes (e.g. `agents-cli-update`), wiping every held bundle.
1970
+ const onDisk = getCliVersionFresh();
1971
+ if (brokerUp && ping.cliVersion && ping.cliVersion !== onDisk) {
1972
+ console.log(chalk.yellow(` warning: broker is running an older build (${ping.cliVersion} vs ${onDisk} on disk). ` +
1973
+ `A version change can wipe held bundles — reads re-warm on the next access.`));
1974
+ }
1975
+ // Surface the hold window so "why did it prompt again" is answerable.
1976
+ const holdStr = formatHoldWindow(secretsHoldMs());
1977
+ // Only claim "(secrets.agent.holdMs)" when the config is actually honored —
1978
+ // an invalid value (0/NaN/negative) falls back to the default via
1979
+ // clampHoldMs, so it must read "(default)", not misattribute to config.
1980
+ const configured = (() => { try {
1981
+ const v = readMeta().secrets?.agent?.holdMs;
1982
+ return typeof v === 'number' && Number.isFinite(v) && v > 0;
1983
+ }
1984
+ catch {
1985
+ return false;
1986
+ } })();
1987
+ console.log(chalk.gray(`hold: ${holdStr}${configured ? ' (secrets.agent.holdMs)' : ' (default)'} — a daily bundle prompts once, then stays silent for this long or until sleep/logout.`));
1838
1988
  const entries = await agentStatus();
1839
1989
  if (entries.length === 0) {
1840
- console.log(chalk.gray('No bundles unlocked. The secrets broker is idle or not running.'));
1841
- console.log(chalk.gray('Try: agents secrets unlock <bundle>'));
1990
+ console.log(chalk.gray('No bundles held. The next read of each daily bundle will prompt once, then hold.'));
1991
+ console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
1842
1992
  return;
1843
1993
  }
1844
1994
  console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
1845
1995
  for (const e of entries) {
1846
1996
  console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
1847
1997
  }
1998
+ console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
1848
1999
  });
1849
2000
  cmd
1850
2001
  .command('policy <bundle> [policy]')