@phnx-labs/agents-cli 1.20.90 → 1.20.91

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 (65) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/feed.js +77 -4
  4. package/dist/commands/hooks.js +22 -6
  5. package/dist/commands/perf.d.ts +14 -0
  6. package/dist/commands/perf.js +221 -0
  7. package/dist/commands/routines.js +30 -24
  8. package/dist/commands/secrets.d.ts +43 -4
  9. package/dist/commands/secrets.js +217 -32
  10. package/dist/commands/send.d.ts +5 -1
  11. package/dist/commands/send.js +1 -1
  12. package/dist/commands/sessions-picker.js +70 -1
  13. package/dist/index.js +18 -3
  14. package/dist/lib/activity.d.ts +11 -1
  15. package/dist/lib/activity.js +1 -0
  16. package/dist/lib/catchup.d.ts +105 -0
  17. package/dist/lib/catchup.js +160 -0
  18. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  19. package/dist/lib/channels/providers/desktop.js +132 -0
  20. package/dist/lib/channels/providers/index.js +2 -0
  21. package/dist/lib/daemon.js +74 -13
  22. package/dist/lib/events.d.ts +12 -0
  23. package/dist/lib/events.js +122 -9
  24. package/dist/lib/exec.js +10 -0
  25. package/dist/lib/feed-broadcast.d.ts +47 -0
  26. package/dist/lib/feed-broadcast.js +65 -1
  27. package/dist/lib/feed-post.d.ts +10 -0
  28. package/dist/lib/feed-post.js +1 -1
  29. package/dist/lib/feed.d.ts +47 -1
  30. package/dist/lib/feed.js +38 -0
  31. package/dist/lib/hooks/cache.d.ts +2 -0
  32. package/dist/lib/hooks/cache.js +24 -4
  33. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  34. package/dist/lib/overdue.d.ts +14 -0
  35. package/dist/lib/overdue.js +37 -1
  36. package/dist/lib/perf/db.d.ts +25 -0
  37. package/dist/lib/perf/db.js +290 -0
  38. package/dist/lib/perf/spool.d.ts +18 -0
  39. package/dist/lib/perf/spool.js +79 -0
  40. package/dist/lib/perf/types.d.ts +45 -0
  41. package/dist/lib/perf/types.js +2 -0
  42. package/dist/lib/routines-project.js +6 -0
  43. package/dist/lib/routines.d.ts +30 -1
  44. package/dist/lib/routines.js +11 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  47. package/dist/lib/secrets/list-filter.d.ts +94 -0
  48. package/dist/lib/secrets/list-filter.js +245 -0
  49. package/dist/lib/session/digest.d.ts +7 -0
  50. package/dist/lib/session/digest.js +29 -1
  51. package/dist/lib/session/discover.d.ts +1 -2
  52. package/dist/lib/session/discover.js +7 -24
  53. package/dist/lib/session/highlights.d.ts +82 -0
  54. package/dist/lib/session/highlights.js +251 -0
  55. package/dist/lib/session/parse.js +23 -1
  56. package/dist/lib/session/relative-time.d.ts +14 -0
  57. package/dist/lib/session/relative-time.js +36 -0
  58. package/dist/lib/session/render.d.ts +7 -0
  59. package/dist/lib/session/render.js +87 -17
  60. package/dist/lib/session/types.d.ts +4 -1
  61. package/dist/lib/startup/command-registry.d.ts +1 -0
  62. package/dist/lib/startup/command-registry.js +2 -0
  63. package/dist/lib/state.d.ts +9 -0
  64. package/dist/lib/state.js +11 -0
  65. package/package.json +3 -1
@@ -19,9 +19,10 @@ import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js'
19
19
  import { getRoutinesDir } from '../lib/state.js';
20
20
  import { IS_WINDOWS } from '../lib/platform/index.js';
21
21
  import { safeJoin } from '../lib/paths.js';
22
- import { executeJob, executeJobDetached, monitorRunningJobs, ROUTINE_AGENT_IDS } from '../lib/runner.js';
22
+ import { executeJob, monitorRunningJobs, ROUTINE_AGENT_IDS } from '../lib/runner.js';
23
23
  import { JobScheduler } from '../lib/scheduler.js';
24
24
  import { detectOverdueJobs } from '../lib/overdue.js';
25
+ import { runCatchup } from '../lib/catchup.js';
25
26
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
26
27
  import { setHelpSections } from '../lib/help.js';
27
28
  import { loadDevices, loadDevicesSync } from '../lib/devices/registry.js';
@@ -242,7 +243,10 @@ function renderRoutineRows({ jobs, scheduler, overdueSet, link, now, local = tru
242
243
  const statusColor = lastStatus === 'completed' ? chalk.green
243
244
  : lastStatus === 'failed' ? chalk.red
244
245
  : lastStatus === 'timeout' ? chalk.yellow
245
- : chalk.gray;
246
+ // A miss is an infrastructure problem, not a task failure — the routine
247
+ // never ran. Distinct from red so the two prompt different reactions.
248
+ : lastStatus === 'missed' ? chalk.magenta
249
+ : chalk.gray;
246
250
  const overdueTag = overdueSet.has(job.name) ? chalk.yellow(' (overdue)') : '';
247
251
  const agentLabelPadded = job.command
248
252
  ? chalk.magenta('command'.padEnd(10))
@@ -544,6 +548,7 @@ export function registerRoutinesCommands(program) {
544
548
  sourceRepo: job.source?.repo ?? job.repo ?? null,
545
549
  sourceBranch: job.source?.branch ?? null,
546
550
  runOnce: Boolean(job.runOnce),
551
+ catchup: job.catchup !== false,
547
552
  oneShot: isOneShotRoutine(job),
548
553
  expired: isPastOneShotRoutine(job, nowJson),
549
554
  runsHere: jobRunsOnThisDevice(job),
@@ -622,6 +627,7 @@ export function registerRoutinesCommands(program) {
622
627
  .option('--state-to <name>', 'Linear current-state filter for --on linear:<event> (e.g. Plan)')
623
628
  .option('--state-from <name>', 'Linear previous-state filter for --on linear:<event> (e.g. Triage)')
624
629
  .option('--end-at <iso>', 'Stop firing on or after this ISO 8601 timestamp (e.g., "2026-12-31T23:59:00Z"); routine auto-disables.')
630
+ .option('--no-catchup', 'Do not run this routine late if its fire is missed (daemon down/asleep). The miss is still recorded. For routines whose value expires with their slot, e.g. a 9am brief.')
625
631
  .option('--disabled', 'Create the routine but keep it paused (enable later with resume)')
626
632
  .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).')
627
633
  .option('--json', 'Emit machine-readable JSON with the created routine id and status')
@@ -734,6 +740,7 @@ export function registerRoutinesCommands(program) {
734
740
  ...(hostStrategy ? { hostStrategy } : {}),
735
741
  ...(options.runCwd ? { remoteCwd: options.runCwd } : {}),
736
742
  ...(runOnce ? { runOnce: true } : {}),
743
+ ...(options.catchup === false ? { catchup: false } : {}),
737
744
  ...(options.endAt ? { endAt: options.endAt } : {}),
738
745
  ...(options.resume ? { resume: options.resume } : {}),
739
746
  };
@@ -1109,44 +1116,42 @@ export function registerRoutinesCommands(program) {
1109
1116
  });
1110
1117
  routinesCmd
1111
1118
  .command('catchup')
1112
- .description('Run any routines that missed their last scheduled fire (e.g. because your laptop was off). Detached runs in the background under the scheduler.')
1113
- .option('--dry-run', 'List overdue routines without running them')
1119
+ .description('Run any routines that missed their last scheduled fire on demand. The daemon already does this every 5 minutes — use this to force a pass now. Detached: runs in the background under the scheduler.')
1120
+ .option('--dry-run', 'Record the misses and list them without running anything')
1114
1121
  .action(async (options) => {
1115
1122
  const overdue = detectOverdueJobs();
1116
1123
  if (overdue.length === 0) {
1117
- console.log(chalk.gray('No overdue routines.'));
1124
+ console.log(chalk.gray('No missed fires.'));
1118
1125
  return;
1119
1126
  }
1120
- console.log(chalk.bold(`${overdue.length} overdue routine(s):\n`));
1127
+ console.log(chalk.bold(`${overdue.length} missed fire(s):\n`));
1121
1128
  for (const job of overdue) {
1122
1129
  const last = job.lastRanAt ? job.lastRanAt.toLocaleString() : 'never';
1123
1130
  console.log(` ${chalk.cyan(job.name)} — missed ${chalk.gray(job.expectedAt.toLocaleString())}, last ran ${chalk.gray(last)}`);
1124
1131
  }
1125
- if (options.dryRun) {
1126
- console.log(chalk.gray('\n(dry run — no jobs triggered)'));
1127
- return;
1128
- }
1129
1132
  // Need the daemon alive so spawned jobs are monitored and meta.json is
1130
1133
  // finalized. Start it if it isn't already running.
1131
- if (!isDaemonRunning()) {
1134
+ if (!options.dryRun && !isDaemonRunning()) {
1132
1135
  const started = startDaemon();
1133
1136
  if (started.pid) {
1134
1137
  console.log(chalk.gray(`\nStarted scheduler (PID: ${started.pid}) so catchup runs are monitored.`));
1135
1138
  }
1136
1139
  }
1137
- console.log(chalk.bold('\nTriggering catchup runs...'));
1138
- for (const job of overdue) {
1139
- const config = readJob(job.name);
1140
- if (!config) {
1141
- console.log(` ${job.name} → ${chalk.red('config not found')}`);
1142
- continue;
1140
+ console.log(chalk.bold(options.dryRun ? '\nRecording misses...' : '\nTriggering catchup runs...'));
1141
+ const outcomes = await runCatchup({ overdue, dryRun: options.dryRun });
1142
+ for (const o of outcomes) {
1143
+ if (o.result === 'ran') {
1144
+ console.log(` ${o.name} → ${chalk.green('started')} (run: ${o.runId})`);
1143
1145
  }
1144
- try {
1145
- const meta = await executeJobDetached(config);
1146
- console.log(` ${job.name} → ${chalk.green('started')} (run: ${meta.runId}, PID: ${meta.pid ?? 'n/a'})`);
1146
+ else if (o.result === 'recorded') {
1147
+ const why = options.dryRun ? 'dry run' : 'catchup: false';
1148
+ console.log(` ${o.name} → ${chalk.yellow('recorded as missed')} (${why})`);
1147
1149
  }
1148
- catch (err) {
1149
- console.log(` ${job.name} → ${chalk.red('failed to start')}: ${err.message}`);
1150
+ else if (o.result === 'claimed-elsewhere') {
1151
+ console.log(` ${o.name} → ${chalk.gray('already claimed by the scheduler')}`);
1152
+ }
1153
+ else {
1154
+ console.log(` ${o.name} → ${chalk.red('failed to start')}: ${o.error}`);
1150
1155
  }
1151
1156
  }
1152
1157
  console.log(chalk.gray('\nTrack progress with: agents routines runs <name>'));
@@ -1270,8 +1275,9 @@ export function registerRoutinesCommands(program) {
1270
1275
  // is the concise view. Falls back to a bounded stdout tail when no report
1271
1276
  // was extracted (e.g. the run failed before finishing).
1272
1277
  const statusColor = run.status === 'completed' ? chalk.green
1273
- : run.status === 'failed' || run.status === 'timeout' ? chalk.red
1274
- : chalk.yellow;
1278
+ : run.status === 'missed' ? chalk.magenta
1279
+ : run.status === 'failed' || run.status === 'timeout' ? chalk.red
1280
+ : chalk.yellow;
1275
1281
  console.log(chalk.bold(name) + chalk.gray(` run ${runId}`));
1276
1282
  console.log(statusColor(run.status) +
1277
1283
  chalk.gray(` ${run.startedAt}`) +
@@ -9,6 +9,7 @@ import { type Command } from 'commander';
9
9
  import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
10
10
  import { quoteWin32ExecArg } from '../lib/platform/index.js';
11
11
  import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
12
+ import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
12
13
  /** Read all available data from stdin synchronously, trimmed. */
13
14
  /**
14
15
  * Read the raw `.env` text for `import --from <path|->`. A `-` reads the .env
@@ -51,6 +52,21 @@ export declare function buildRemoteUnlockArgs(names: string[], opts: {
51
52
  ttl?: string;
52
53
  durable?: boolean;
53
54
  }): string[];
55
+ /**
56
+ * Build the remote `agents secrets list` argv for `list --host`. Every filter
57
+ * must be forwarded: `browseRemote` sends this argv verbatim, so a flag left out
58
+ * here is not an error — the remote just lists everything, and
59
+ * `secrets list --host zion --expired` reports every bundle on zion as expired.
60
+ * Silent and wrong beats loud and wrong only for the person who wrote the bug.
61
+ *
62
+ * Values pass through unparsed so the REMOTE validates them under its own rules,
63
+ * matching how `--ttl` is forwarded by buildRemoteUnlockArgs.
64
+ */
65
+ export declare function buildRemoteListArgs(opts: SecretsListFilterOpts & {
66
+ json?: boolean;
67
+ sort?: string;
68
+ limit?: string;
69
+ }, query?: string): string[];
54
70
  export { SSH_TARGET_RE, assertValidSshTarget };
55
71
  /**
56
72
  * Build the child environment for `agents secrets exec`. Strips
@@ -79,11 +95,19 @@ export declare function exportBundleToFile(env: Record<string, string>, filePath
79
95
  * Throws on a missing file, an invalid JSON envelope, or a wrong passphrase.
80
96
  */
81
97
  export declare function importBundleFromFile(filePath: string, passphrase: string): Record<string, string>;
82
- /** The POLICY column for `secrets list`: the prompt policy, plus a concise
83
- * state hint. `hold` shows `held Nh` when the secrets-agent is currently
84
- * caching the bundle; `always` and `never` show whether they prompt. `held`
98
+ /** Compact span for a fixed duration: "45m" / "19h" / "2d". Shared by
99
+ * `compactRemaining` (a countdown) and the POLICY column's hold-window
100
+ * annotation (a fixed length) so the two can never round onto different unit
101
+ * thresholds and disagree about the same number of milliseconds. */
102
+ export declare function compactDurationMs(ms: number): string;
103
+ /** The POLICY column for `secrets list`. `hold` is a duration, not a mode — it
104
+ * means "prompt once, then stay silent for this long" — so the column states
105
+ * the window (`hold 7d`) rather than the bare tier name, and appends `· held Nd`
106
+ * while the secrets-agent is actually caching the bundle. `always` and `never`
107
+ * carry no window and gain nothing here. `holdMs` is the configured global hold
108
+ * (`secretsHoldMs()`), passed in rather than read so this stays pure; `held`
85
109
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
86
- export declare function renderPolicyCol(b: SecretsBundle, held?: Map<string, number>): string;
110
+ export declare function renderPolicyCol(b: SecretsBundle, holdMs: number, held?: Map<string, number>): string;
87
111
  /** The hold-window line at the top of `secrets status`. Names the `hold` policy
88
112
  * the window belongs to — the rename in #1604 left this surface still saying
89
113
  * "daily", the one name the CLI no longer accepts in its own help. Pure so the
@@ -97,6 +121,21 @@ export declare const NO_BUNDLES_HELD_LINE = "No bundles held. The next read of e
97
121
  * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
98
122
  * hours up to 2 days, whole days beyond. Pure — unit-tested. */
99
123
  export declare function formatHoldWindow(ms: number): string;
124
+ /** A broker hold expiry, or null once it has lapsed. One definition of "held",
125
+ * shared by the POLICY column, the `--held` filter, and the JSON payload, so the
126
+ * three can never disagree about the same bundle. */
127
+ export declare function liveHold(expiresAt: number | undefined, now?: number): number | null;
128
+ /**
129
+ * The EXPIRING cell. Counts keys needing attention — already expired plus due
130
+ * within 30 days — and colours by the worst of the two: red once anything has
131
+ * lapsed, yellow while everything is merely upcoming.
132
+ *
133
+ * Expired keys used to be invisible here. `countExpiringSoon` requires
134
+ * `d >= 0`, so a bundle whose token died last month rendered `-`, identical to
135
+ * one with no expiry at all; the only places it surfaced were `secrets view`
136
+ * and a hard abort at inject time, i.e. after it had already broken something.
137
+ */
138
+ export declare function renderExpiringCol(b: SecretsBundle, now?: number): string;
100
139
  /** Register the `agents secrets` command tree. */
101
140
  export declare function registerSecretsCommands(program: Command): void;
102
141
  /** Validate a prompt-policy value, throwing a clear message on a bad one (the
@@ -18,7 +18,8 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
18
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } from '../lib/secrets/remote.js';
19
19
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
20
20
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
21
- import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, } from '../lib/secrets/bundles.js';
21
+ import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
22
+ import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
22
23
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
23
24
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
24
25
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
@@ -274,6 +275,35 @@ export function buildRemoteUnlockArgs(names, opts) {
274
275
  ...(opts.durable ? ['--durable'] : []),
275
276
  ];
276
277
  }
278
+ /**
279
+ * Build the remote `agents secrets list` argv for `list --host`. Every filter
280
+ * must be forwarded: `browseRemote` sends this argv verbatim, so a flag left out
281
+ * here is not an error — the remote just lists everything, and
282
+ * `secrets list --host zion --expired` reports every bundle on zion as expired.
283
+ * Silent and wrong beats loud and wrong only for the person who wrote the bug.
284
+ *
285
+ * Values pass through unparsed so the REMOTE validates them under its own rules,
286
+ * matching how `--ttl` is forwarded by buildRemoteUnlockArgs.
287
+ */
288
+ export function buildRemoteListArgs(opts, query) {
289
+ return [
290
+ 'list',
291
+ ...(query ? [query] : []),
292
+ ...(opts.json ? ['--json'] : []),
293
+ ...(opts.policy ? ['--policy', opts.policy] : []),
294
+ ...(opts.backend ? ['--backend', opts.backend] : []),
295
+ ...(opts.type ? ['--type', opts.type] : []),
296
+ ...(opts.kind ? ['--kind', opts.kind] : []),
297
+ ...(opts.held ? ['--held'] : []),
298
+ ...(opts.notHeld ? ['--not-held'] : []),
299
+ ...(opts.expired ? ['--expired'] : []),
300
+ // `--expiring` is optional-value: `true` means the bare flag was passed.
301
+ ...(opts.expiring === true ? ['--expiring'] : opts.expiring ? ['--expiring', String(opts.expiring)] : []),
302
+ ...(opts.unused ? ['--unused', opts.unused] : []),
303
+ ...(opts.sort ? ['--sort', opts.sort] : []),
304
+ ...(opts.limit ? ['--limit', opts.limit] : []),
305
+ ];
306
+ }
277
307
  // SSH target validation is defined canonically in src/lib/ssh-exec.ts and
278
308
  // re-exported here for back-compat with existing importers of these symbols.
279
309
  export { SSH_TARGET_RE, assertValidSshTarget };
@@ -446,11 +476,11 @@ function humanAge(iso) {
446
476
  return age;
447
477
  return `${age} ago`;
448
478
  }
449
- /** Compact remaining-time for the list POLICY column: "19h" / "45m" / "2d". */
450
- function compactRemaining(expiresAt) {
451
- const ms = expiresAt - Date.now();
452
- if (ms <= 0)
453
- return 'expired';
479
+ /** Compact span for a fixed duration: "45m" / "19h" / "2d". Shared by
480
+ * `compactRemaining` (a countdown) and the POLICY column's hold-window
481
+ * annotation (a fixed length) so the two can never round onto different unit
482
+ * thresholds and disagree about the same number of milliseconds. */
483
+ export function compactDurationMs(ms) {
454
484
  const mins = Math.round(ms / 60000);
455
485
  if (mins < 60)
456
486
  return `${mins}m`;
@@ -459,18 +489,33 @@ function compactRemaining(expiresAt) {
459
489
  return `${hours}h`;
460
490
  return `${Math.round(hours / 24)}d`;
461
491
  }
462
- /** The POLICY column for `secrets list`: the prompt policy, plus a concise
463
- * state hint. `hold` shows `held Nh` when the secrets-agent is currently
464
- * caching the bundle; `always` and `never` show whether they prompt. `held`
492
+ /** Compact remaining-time for the list POLICY column: "19h" / "45m" / "2d". */
493
+ function compactRemaining(expiresAt) {
494
+ const ms = expiresAt - Date.now();
495
+ if (ms <= 0)
496
+ return 'expired';
497
+ return compactDurationMs(ms);
498
+ }
499
+ /** The POLICY column for `secrets list`. `hold` is a duration, not a mode — it
500
+ * means "prompt once, then stay silent for this long" — so the column states
501
+ * the window (`hold 7d`) rather than the bare tier name, and appends `· held Nd`
502
+ * while the secrets-agent is actually caching the bundle. `always` and `never`
503
+ * carry no window and gain nothing here. `holdMs` is the configured global hold
504
+ * (`secretsHoldMs()`), passed in rather than read so this stays pure; `held`
465
505
  * maps bundle name → expiry epoch-ms (from agentStatus()). */
466
- export function renderPolicyCol(b, held) {
506
+ export function renderPolicyCol(b, holdMs, held) {
467
507
  // `never` is loud on purpose — it's the only tier with no user-presence gate.
468
508
  if (bundlePolicy(b) === 'never')
469
509
  return chalk.red.bold('never · no prompt');
470
510
  if (bundlePolicy(b) === 'always')
471
511
  return chalk.yellow('always · prompt');
472
- const exp = held?.get(b.name);
473
- return exp ? chalk.green(`hold · held ${compactRemaining(exp)}`) : chalk.gray('hold');
512
+ const window = compactDurationMs(holdMs);
513
+ // A lapsed entry is not held. Without this guard a stale broker row renders
514
+ // the countdown's `expired` sentinel as if it were a live hold.
515
+ const exp = liveHold(held?.get(b.name));
516
+ return exp !== null
517
+ ? chalk.green(`hold ${window} · held ${compactRemaining(exp)}`)
518
+ : chalk.gray(`hold ${window}`);
474
519
  }
475
520
  /** The hold-window line at the top of `secrets status`. Names the `hold` policy
476
521
  * the window belongs to — the rename in #1604 left this surface still saying
@@ -502,12 +547,21 @@ export function formatHoldWindow(ms) {
502
547
  }
503
548
  /** Below this width the fixed date columns no longer fit; `list` uses cards. */
504
549
  const SECRETS_WIDE = 96;
550
+ /** A broker hold expiry, or null once it has lapsed. One definition of "held",
551
+ * shared by the POLICY column, the `--held` filter, and the JSON payload, so the
552
+ * three can never disagree about the same bundle. */
553
+ export function liveHold(expiresAt, now = Date.now()) {
554
+ return expiresAt !== undefined && expiresAt > now ? expiresAt : null;
555
+ }
556
+ /** Width of the POLICY column. The widest cell is `hold 30d · held 30d` (19
557
+ * visible chars) — `clampHoldMs` caps the window at 30d and `renderPolicyCol`
558
+ * drops lapsed holds, so nothing longer can be produced. */
559
+ const POLICY_COL_WIDTH = 20;
505
560
  /** Format a single bundle as a table row for the `secrets list` output. */
506
- function renderBundleRow(b, held, cols = terminalWidth()) {
561
+ function renderBundleRow(b, holdMs, held, cols = terminalWidth()) {
507
562
  const entries = describeBundle(b);
508
563
  const keys = entries.length;
509
- const expiringCount = countExpiringSoon(b.meta);
510
- const expiring = expiringCount > 0 ? chalk.yellow(String(expiringCount)) : chalk.gray('-');
564
+ const expiring = renderExpiringCol(b);
511
565
  // Timestamp distinction:
512
566
  // "?" -> legacy bundle, never written under the timestamping code.
513
567
  // "never" -> bundle has been written but the action never happened
@@ -521,7 +575,7 @@ function renderBundleRow(b, held, cols = terminalWidth()) {
521
575
  : (b.created_at ? chalk.gray('never') : chalk.gray('?'));
522
576
  const head = `${chalk.cyan(b.name.padEnd(20))} ` +
523
577
  `${String(keys).padEnd(5)} ` +
524
- `${padVisible(renderPolicyCol(b, held), 18)} ` +
578
+ `${padVisible(renderPolicyCol(b, holdMs, held), POLICY_COL_WIDTH)} ` +
525
579
  `${padVisible(expiring, 9)} ` +
526
580
  `${padVisible(created, 9)} ` +
527
581
  `${padVisible(updated, 9)} ` +
@@ -542,7 +596,7 @@ function renderBundleRow(b, held, cols = terminalWidth()) {
542
596
  return trailer ? `${head} ${trailer}` : head.trimEnd();
543
597
  }
544
598
  /** Narrow-terminal card: name + compact meta on one line, description below. */
545
- function renderBundleCard(b, held, cols) {
599
+ function renderBundleCard(b, holdMs, held, cols) {
546
600
  const keys = describeBundle(b).length;
547
601
  const used = b.last_used ? relativeAge(b.last_used) : (b.created_at ? 'never' : '?');
548
602
  const tag = b.backend === 'file'
@@ -550,7 +604,7 @@ function renderBundleCard(b, held, cols) {
550
604
  : b.backend === 'vault'
551
605
  ? chalk.blue(' [synced]')
552
606
  : '';
553
- const meta = chalk.gray(`${keys} key${keys === 1 ? '' : 's'} · `) + renderPolicyCol(b, held) + chalk.gray(` · used ${used}`);
607
+ const meta = chalk.gray(`${keys} key${keys === 1 ? '' : 's'} · `) + renderPolicyCol(b, holdMs, held) + chalk.gray(` · used ${used}`);
554
608
  const line1 = `${chalk.cyan(b.name)} ${meta}${tag}`;
555
609
  if (!b.description)
556
610
  return line1;
@@ -662,7 +716,10 @@ function renderMetaLine(meta, reveal) {
662
716
  return '';
663
717
  return ` ${parts.join(' ')}`;
664
718
  }
665
- /** Count entries in `meta` whose `expires` falls in the next 30 days. */
719
+ /** Count entries in `meta` whose `expires` falls in the next 30 days. Excludes
720
+ * keys that have ALREADY expired — those are counted by `bundleExpiry().expired`
721
+ * and rendered separately, since a lapsed key is a different problem from one
722
+ * coming due. */
666
723
  function countExpiringSoon(meta) {
667
724
  if (!meta)
668
725
  return 0;
@@ -676,6 +733,23 @@ function countExpiringSoon(meta) {
676
733
  }
677
734
  return n;
678
735
  }
736
+ /**
737
+ * The EXPIRING cell. Counts keys needing attention — already expired plus due
738
+ * within 30 days — and colours by the worst of the two: red once anything has
739
+ * lapsed, yellow while everything is merely upcoming.
740
+ *
741
+ * Expired keys used to be invisible here. `countExpiringSoon` requires
742
+ * `d >= 0`, so a bundle whose token died last month rendered `-`, identical to
743
+ * one with no expiry at all; the only places it surfaced were `secrets view`
744
+ * and a hard abort at inject time, i.e. after it had already broken something.
745
+ */
746
+ export function renderExpiringCol(b, now = Date.now()) {
747
+ const { expired, soon } = bundleExpiry(b, now);
748
+ const total = expired + soon;
749
+ if (total === 0)
750
+ return chalk.gray('-');
751
+ return expired > 0 ? chalk.red(String(total)) : chalk.yellow(String(total));
752
+ }
679
753
  /**
680
754
  * Resolve an existing import target bundle (inheriting its backend) or create a
681
755
  * new one with the requested backend. Refuses to silently downgrade a
@@ -801,22 +875,61 @@ export function registerSecretsCommands(program) {
801
875
  { title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
802
876
  { title: 'Utilities', names: ['exec', 'mcp', 'generate', 'migrate-acl'] },
803
877
  ]);
804
- cmd
805
- .command('list')
878
+ const listCmd = cmd
879
+ .command('list [query]')
806
880
  .alias('ls')
807
- .description('List configured secrets bundles (use --host/--hosts to list bundles on other machines over SSH)')
881
+ .description('List configured secrets bundles, optionally filtered (use --host/--hosts for other machines over SSH)')
808
882
  .option('--host <target>', 'List bundles on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
809
883
  .option('--hosts <list>', 'Comma-separated hosts to list in one shot, e.g. yosemite-s0,yosemite-s1')
810
884
  .option('--device <target>', 'Alias for --host')
811
885
  .option('--devices <list>', 'Alias for --hosts')
812
886
  .option('--json', 'Emit machine-readable JSON (bundle metadata only — never secret values) instead of the table')
813
- .action(async (opts) => {
887
+ .option('--policy <list>', "Only these prompt policies (comma-separated): hold, always, never. '--policy never' is the audit for bundles that read with no Touch ID at all")
888
+ .option('--backend <list>', 'Only these backends (comma-separated): keychain, file, vault')
889
+ .option('--type <list>', `Only bundles carrying a key of these types (comma-separated): ${SECRET_TYPES.join(', ')}`)
890
+ .option('--kind <list>', `Only bundles carrying a value of these ref kinds (comma-separated): ${REF_KINDS.join(', ')}. '--kind literal' finds raw values stored inline; '--kind exec' finds bundles that shell out`)
891
+ .option('--held', 'Only bundles the secrets-agent is holding right now (read silently, no Touch ID). macOS only')
892
+ .option('--not-held', 'Only bundles the agent is NOT holding — the next read of each will prompt. macOS only')
893
+ .option('--expired', 'Only bundles with at least one key whose expiry has already passed')
894
+ .option('--expiring [days]', `Only bundles with a key falling due within N days (default ${DEFAULT_EXPIRING_DAYS})`)
895
+ .option('--unused <duration>', 'Only bundles not read since this far back (e.g. 30d, 4w, 3mo). Never-used bundles always match')
896
+ .addOption(new Option('--sort <field>', 'Sort by: name (default), used, created, updated, expiry').choices([...SORT_FIELDS]))
897
+ .option('-n, --limit <n>', 'Show at most this many bundles (after filtering and sorting)')
898
+ .action(async (query, opts) => {
814
899
  const targets = parseHostsOption(opts);
815
900
  if (targets.length > 0) {
816
- await browseRemote(targets, opts.json ? ['list', '--json'] : ['list'], false);
901
+ // Forward the filters browseRemote sends this argv verbatim, so a
902
+ // dropped flag would make the remote silently list everything.
903
+ await browseRemote(targets, buildRemoteListArgs(opts, query), false);
817
904
  return;
818
905
  }
819
- const bundles = listBundles();
906
+ // Parse before touching the keychain so a typo'd flag fails instantly,
907
+ // and render the message rather than a stack trace (the `policy` pattern).
908
+ let filter;
909
+ let sortField;
910
+ let limit;
911
+ try {
912
+ filter = parseListFilters(opts, query);
913
+ sortField = parseSortField(opts.sort);
914
+ limit = opts.limit === undefined ? undefined : Number(opts.limit);
915
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
916
+ throw new Error(`Invalid --limit '${opts.limit}'. Use a whole number of bundles, e.g. --limit 10.`);
917
+ }
918
+ // The broker is macOS-only, so off darwin `held` is always empty and a
919
+ // hold-state filter would answer from no data — every bundle would look
920
+ // not-held. Refuse rather than return a confidently wrong list.
921
+ if (filter.held !== undefined && process.platform !== 'darwin') {
922
+ throw new Error('--held/--not-held need the secrets-agent, which is macOS-only. Run it on a Mac, or with --host <mac>.');
923
+ }
924
+ }
925
+ catch (err) {
926
+ console.error(chalk.red(err.message));
927
+ process.exit(1);
928
+ }
929
+ const all = listBundles();
930
+ // The configured hold window, read once for the whole listing — it is
931
+ // global, so every `hold` bundle renders the same span.
932
+ const holdMs = secretsHoldMs();
820
933
  // Cross-reference the secrets-agent so `hold` bundles that are currently
821
934
  // held can show "· held Nh". Soft-fails to no hint if the broker is down.
822
935
  const held = new Map();
@@ -829,6 +942,12 @@ export function registerSecretsCommands(program) {
829
942
  /* broker not running — render policy without the countdown */
830
943
  }
831
944
  }
945
+ // Filter, sort, then cap — before the --json branch, so the machine
946
+ // payload is the exact twin of the table (the sessions ordering rule).
947
+ const now = Date.now();
948
+ const matched = all.filter((b) => bundleMatchesFilter(b, filter, { held, now }));
949
+ const sorted = sortBundles(matched, sortField);
950
+ const bundles = limit === undefined ? sorted : sorted.slice(0, limit);
832
951
  if (opts.json) {
833
952
  // Discovery payload for agents: metadata only, no secret values. Gated on
834
953
  // the explicit --json flag (not stdout.isTTY) so piping the human table to
@@ -837,36 +956,96 @@ export function registerSecretsCommands(program) {
837
956
  name: b.name,
838
957
  keys: describeBundle(b).length,
839
958
  policy: bundlePolicy(b),
959
+ // The window `policy: "hold"` is shorthand for. Null on always/never,
960
+ // which have no window at all.
961
+ holdMs: bundlePolicy(b) === 'hold' ? holdMs : null,
840
962
  backend: b.backend === 'file' ? 'file' : 'keychain',
841
963
  allowExec: Boolean(b.allow_exec),
842
964
  expiringSoon: countExpiringSoon(b.meta),
965
+ // Already-lapsed keys, which `expiringSoon` deliberately excludes. A
966
+ // machine caller polling for rotation needs both numbers.
967
+ expired: bundleExpiry(b, now).expired,
843
968
  description: b.description ?? null,
844
969
  createdAt: b.created_at ?? null,
845
970
  updatedAt: b.updated_at ?? null,
846
971
  lastUsed: b.last_used ?? null,
847
- heldExpiresAt: held.get(b.name) ?? null,
972
+ // Same liveness rule the POLICY column and --held use: a broker entry
973
+ // past its expiry is not held. Without the check this field reported a
974
+ // stale past timestamp as if the bundle were still warm, disagreeing
975
+ // with the table and the filter about the very same bundle.
976
+ heldExpiresAt: liveHold(held.get(b.name), now),
848
977
  }));
849
978
  process.stdout.write(JSON.stringify(payload) + '\n');
850
979
  return;
851
980
  }
852
981
  if (bundles.length === 0) {
853
- console.log(chalk.gray('No secrets bundles configured.'));
854
- console.log(chalk.gray('Try: agents secrets create <name>'));
982
+ // Distinguish "you have none" from "your filter excluded all of them",
983
+ // and name the axes that did it — otherwise the only way to find out
984
+ // which flag emptied the list is to remove them one at a time.
985
+ if (filterIsActive(filter)) {
986
+ console.log(chalk.gray(`No bundles ${describeFilter(filter)}. ${all.length} bundle${all.length === 1 ? '' : 's'} total.`));
987
+ console.log(chalk.gray('Try: agents secrets list (no filters)'));
988
+ }
989
+ else {
990
+ console.log(chalk.gray('No secrets bundles configured.'));
991
+ console.log(chalk.gray('Try: agents secrets create <name>'));
992
+ }
855
993
  return;
856
994
  }
857
995
  const cols = terminalWidth();
858
996
  if (cols >= SECRETS_WIDE) {
859
- console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'POLICY'.padEnd(18)} ${'EXPIRING'.padEnd(9)} ${'CREATED'.padEnd(9)} ${'UPDATED'.padEnd(9)} ${'USED'.padEnd(7)} DESCRIPTION`));
997
+ console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'POLICY'.padEnd(POLICY_COL_WIDTH)} ${'EXPIRING'.padEnd(9)} ${'CREATED'.padEnd(9)} ${'UPDATED'.padEnd(9)} ${'USED'.padEnd(7)} DESCRIPTION`));
860
998
  for (const b of bundles) {
861
- console.log(renderBundleRow(b, held, cols));
999
+ console.log(renderBundleRow(b, holdMs, held, cols));
862
1000
  }
863
1001
  }
864
1002
  else {
865
1003
  for (const b of bundles) {
866
- console.log(renderBundleCard(b, held, cols));
1004
+ console.log(renderBundleCard(b, holdMs, held, cols));
867
1005
  }
868
1006
  }
869
1007
  });
1008
+ setHelpSections(listCmd, {
1009
+ examples: `
1010
+ # Everything, newest-used first
1011
+ agents secrets list --sort used
1012
+
1013
+ # Find one by name or description
1014
+ agents secrets list github
1015
+
1016
+ # The security audit: which bundles read with NO Touch ID at all?
1017
+ agents secrets list --policy never
1018
+
1019
+ # Which still store a raw value inline, or can shell out?
1020
+ agents secrets list --kind literal
1021
+ agents secrets list --kind exec
1022
+
1023
+ # What has already lapsed, and what is about to
1024
+ agents secrets list --expired
1025
+ agents secrets list --expiring 7 --sort expiry
1026
+
1027
+ # What can I delete? Untouched in three months
1028
+ agents secrets list --unused 3mo --sort used
1029
+
1030
+ # Which bundles will prompt me on the next read (macOS)
1031
+ agents secrets list --not-held
1032
+
1033
+ # Combine axes — every filter narrows further
1034
+ agents secrets list --policy hold --backend file --expiring
1035
+
1036
+ # Same filters, on another machine, machine-readable
1037
+ agents secrets list --expired --host mac-mini --json
1038
+ `,
1039
+ notes: `
1040
+ - Filters compose: every flag you add narrows the list further.
1041
+ - An unknown value is an error, not an empty list — '--policy hodl' names the valid set.
1042
+ - --held/--not-held read live broker state, so they need macOS. Use --host <mac> from elsewhere.
1043
+ - --expired and --expiring are different questions: already lapsed vs coming due. The EXPIRING column counts both and turns red once anything has lapsed.
1044
+ - --unused matches bundles never read at all, not just old ones.
1045
+ - Filters apply before --json, so the JSON is the exact twin of the table.
1046
+ - Filters are forwarded over --host, so a remote list narrows the same way.
1047
+ `,
1048
+ });
870
1049
  cmd
871
1050
  .command('view [name]')
872
1051
  .alias('show')
@@ -971,6 +1150,9 @@ export function registerSecretsCommands(program) {
971
1150
  name: bundle.name,
972
1151
  description: bundle.description ?? null,
973
1152
  policy: bundlePolicy(bundle),
1153
+ // The window `policy: "hold"` is shorthand for. Null on
1154
+ // always/never, which have no window at all.
1155
+ holdMs: bundlePolicy(bundle) === 'hold' ? secretsHoldMs() : null,
974
1156
  backend: bundle.backend === 'file' ? 'file' : 'keychain',
975
1157
  allowExec: Boolean(bundle.allow_exec),
976
1158
  createdAt: bundle.created_at ?? null,
@@ -995,7 +1177,10 @@ export function registerSecretsCommands(program) {
995
1177
  }
996
1178
  else {
997
1179
  console.log(bundlePolicy(bundle) === 'hold'
998
- ? chalk.gray('policy: hold (ask once, then held for the hold window 7d by default — until sleep / logout; screen-lock does not drop it)')
1180
+ // The window comes from secretsHoldMs(), never a literalthis
1181
+ // line used to hardcode "7d by default" and so misstated the
1182
+ // window for anyone who had configured secrets.agent.holdMs.
1183
+ ? chalk.gray(`policy: hold (ask once, then held for ${formatHoldWindow(secretsHoldMs())} — until sleep / logout; screen-lock does not drop it)`)
999
1184
  : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
1000
1185
  }
1001
1186
  if (bundle.created_at)
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * `agents send <text> --channel <name> --to <target>` — deliver a message over
3
- * any registered channel (mailbox, telegram, imessage, slack, discord, apps).
3
+ * any registered channel (mailbox, telegram, imessage, slack, discord, desktop, apps).
4
+ *
5
+ * `desktop` is the local machine's notification centre — the only channel with no
6
+ * external dependency, so it still reaches the operator at their Mac when a
7
+ * messaging gateway is down.
4
8
  *
5
9
  * `agents notify <text>` — owner-facing alias. Fills channel + target from the
6
10
  * `notify.owner` block in agents.yaml when not passed, so routines/agents ping
@@ -29,7 +29,7 @@ async function doSend(text, channel, to, opts) {
29
29
  export function registerSendCommand(program) {
30
30
  program
31
31
  .command('send <text>')
32
- .description('Send a message through a channel provider (mailbox, telegram, imessage, slack, discord).')
32
+ .description('Send a message through a channel provider (mailbox, telegram, imessage, slack, discord, desktop).')
33
33
  .requiredOption('--channel <name>', 'channel / provider name')
34
34
  .requiredOption('--to <target>', 'channel-specific recipient id')
35
35
  .option('--thread <id>', 'channel thread id / timestamp')