@phnx-labs/agents-cli 1.20.89 → 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 (114) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/feed.js +77 -4
  6. package/dist/commands/harness.d.ts +27 -0
  7. package/dist/commands/harness.js +120 -13
  8. package/dist/commands/hooks.js +22 -6
  9. package/dist/commands/perf.d.ts +14 -0
  10. package/dist/commands/perf.js +221 -0
  11. package/dist/commands/profiles.d.ts +3 -0
  12. package/dist/commands/profiles.js +1 -1
  13. package/dist/commands/routines.d.ts +19 -0
  14. package/dist/commands/routines.js +58 -30
  15. package/dist/commands/secrets.d.ts +52 -4
  16. package/dist/commands/secrets.js +234 -37
  17. package/dist/commands/send.d.ts +5 -1
  18. package/dist/commands/send.js +1 -1
  19. package/dist/commands/sessions-browser.d.ts +4 -0
  20. package/dist/commands/sessions-browser.js +51 -9
  21. package/dist/commands/sessions-favorite.d.ts +20 -0
  22. package/dist/commands/sessions-favorite.js +120 -0
  23. package/dist/commands/sessions-picker.js +70 -1
  24. package/dist/commands/sessions.d.ts +103 -20
  25. package/dist/commands/sessions.js +356 -62
  26. package/dist/commands/setup-secrets.d.ts +7 -0
  27. package/dist/commands/setup-secrets.js +12 -9
  28. package/dist/commands/versions.js +12 -4
  29. package/dist/commands/view.d.ts +14 -1
  30. package/dist/commands/view.js +103 -128
  31. package/dist/index.js +18 -3
  32. package/dist/lib/activity.d.ts +11 -1
  33. package/dist/lib/activity.js +1 -0
  34. package/dist/lib/agents.d.ts +4 -2
  35. package/dist/lib/agents.js +21 -6
  36. package/dist/lib/catchup.d.ts +105 -0
  37. package/dist/lib/catchup.js +160 -0
  38. package/dist/lib/channels/providers/desktop.d.ts +49 -0
  39. package/dist/lib/channels/providers/desktop.js +132 -0
  40. package/dist/lib/channels/providers/index.js +2 -0
  41. package/dist/lib/daemon.js +74 -13
  42. package/dist/lib/events.d.ts +12 -0
  43. package/dist/lib/events.js +122 -9
  44. package/dist/lib/exec.js +10 -0
  45. package/dist/lib/feed-broadcast.d.ts +47 -0
  46. package/dist/lib/feed-broadcast.js +65 -1
  47. package/dist/lib/feed-post.d.ts +10 -0
  48. package/dist/lib/feed-post.js +1 -1
  49. package/dist/lib/feed.d.ts +47 -1
  50. package/dist/lib/feed.js +38 -0
  51. package/dist/lib/hooks/cache.d.ts +2 -0
  52. package/dist/lib/hooks/cache.js +24 -4
  53. package/dist/lib/hosts/dispatch.js +19 -1
  54. package/dist/lib/hq/floor.js +12 -0
  55. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  56. package/dist/lib/overdue.d.ts +14 -0
  57. package/dist/lib/overdue.js +37 -1
  58. package/dist/lib/perf/db.d.ts +25 -0
  59. package/dist/lib/perf/db.js +290 -0
  60. package/dist/lib/perf/spool.d.ts +18 -0
  61. package/dist/lib/perf/spool.js +79 -0
  62. package/dist/lib/perf/types.d.ts +45 -0
  63. package/dist/lib/perf/types.js +2 -0
  64. package/dist/lib/picker.d.ts +27 -2
  65. package/dist/lib/picker.js +71 -7
  66. package/dist/lib/profiles.d.ts +48 -0
  67. package/dist/lib/profiles.js +67 -0
  68. package/dist/lib/rotate.d.ts +24 -2
  69. package/dist/lib/rotate.js +63 -6
  70. package/dist/lib/routines-project.js +6 -0
  71. package/dist/lib/routines.d.ts +30 -1
  72. package/dist/lib/routines.js +11 -0
  73. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  74. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  75. package/dist/lib/secrets/list-filter.d.ts +94 -0
  76. package/dist/lib/secrets/list-filter.js +245 -0
  77. package/dist/lib/session/active.d.ts +109 -3
  78. package/dist/lib/session/active.js +269 -13
  79. package/dist/lib/session/db.d.ts +14 -0
  80. package/dist/lib/session/db.js +35 -0
  81. package/dist/lib/session/digest.d.ts +7 -0
  82. package/dist/lib/session/digest.js +29 -1
  83. package/dist/lib/session/discover.d.ts +1 -2
  84. package/dist/lib/session/discover.js +7 -24
  85. package/dist/lib/session/favorites.d.ts +39 -0
  86. package/dist/lib/session/favorites.js +101 -0
  87. package/dist/lib/session/highlights.d.ts +82 -0
  88. package/dist/lib/session/highlights.js +251 -0
  89. package/dist/lib/session/host-link.d.ts +68 -0
  90. package/dist/lib/session/host-link.js +64 -0
  91. package/dist/lib/session/parse.js +23 -1
  92. package/dist/lib/session/presence.d.ts +85 -0
  93. package/dist/lib/session/presence.js +150 -0
  94. package/dist/lib/session/relative-time.d.ts +14 -0
  95. package/dist/lib/session/relative-time.js +36 -0
  96. package/dist/lib/session/remote-list.d.ts +10 -0
  97. package/dist/lib/session/remote-list.js +47 -9
  98. package/dist/lib/session/render.d.ts +7 -0
  99. package/dist/lib/session/render.js +87 -17
  100. package/dist/lib/session/types.d.ts +4 -1
  101. package/dist/lib/startup/command-registry.d.ts +1 -0
  102. package/dist/lib/startup/command-registry.js +2 -0
  103. package/dist/lib/state.d.ts +9 -0
  104. package/dist/lib/state.js +11 -0
  105. package/dist/lib/tmux/binary.d.ts +7 -0
  106. package/dist/lib/tmux/binary.js +11 -1
  107. package/dist/lib/types.d.ts +4 -3
  108. package/dist/lib/usage-backoff.d.ts +29 -0
  109. package/dist/lib/usage-backoff.js +165 -0
  110. package/dist/lib/usage.d.ts +112 -5
  111. package/dist/lib/usage.js +464 -46
  112. package/dist/lib/watchdog/runner.d.ts +13 -0
  113. package/dist/lib/watchdog/runner.js +16 -1
  114. package/package.json +3 -1
@@ -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,19 +489,46 @@ 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. `daily` 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}`);
519
+ }
520
+ /** The hold-window line at the top of `secrets status`. Names the `hold` policy
521
+ * the window belongs to — the rename in #1604 left this surface still saying
522
+ * "daily", the one name the CLI no longer accepts in its own help. Pure so the
523
+ * vocabulary is pinned by a test rather than re-drifting on the next rename. */
524
+ export function renderHoldSummary(holdStr, configured) {
525
+ const source = configured ? ' (secrets.agent.holdMs)' : ' (default)';
526
+ return `hold: ${holdStr}${source} — a bundle on the hold policy prompts once, then stays silent for this long or until sleep/logout.`;
474
527
  }
528
+ /** The empty-broker line under the hold summary. Named here, beside
529
+ * `renderHoldSummary`, for the same reason: it is the second line the rename
530
+ * left saying `daily`, and a test pins both. */
531
+ export const NO_BUNDLES_HELD_LINE = 'No bundles held. The next read of each hold-policy bundle will prompt once, then hold.';
475
532
  /** Human-readable hold window for `secrets status`. Sub-hour values render in
476
533
  * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
477
534
  * hours up to 2 days, whole days beyond. Pure — unit-tested. */
@@ -490,12 +547,21 @@ export function formatHoldWindow(ms) {
490
547
  }
491
548
  /** Below this width the fixed date columns no longer fit; `list` uses cards. */
492
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;
493
560
  /** Format a single bundle as a table row for the `secrets list` output. */
494
- function renderBundleRow(b, held, cols = terminalWidth()) {
561
+ function renderBundleRow(b, holdMs, held, cols = terminalWidth()) {
495
562
  const entries = describeBundle(b);
496
563
  const keys = entries.length;
497
- const expiringCount = countExpiringSoon(b.meta);
498
- const expiring = expiringCount > 0 ? chalk.yellow(String(expiringCount)) : chalk.gray('-');
564
+ const expiring = renderExpiringCol(b);
499
565
  // Timestamp distinction:
500
566
  // "?" -> legacy bundle, never written under the timestamping code.
501
567
  // "never" -> bundle has been written but the action never happened
@@ -509,7 +575,7 @@ function renderBundleRow(b, held, cols = terminalWidth()) {
509
575
  : (b.created_at ? chalk.gray('never') : chalk.gray('?'));
510
576
  const head = `${chalk.cyan(b.name.padEnd(20))} ` +
511
577
  `${String(keys).padEnd(5)} ` +
512
- `${padVisible(renderPolicyCol(b, held), 18)} ` +
578
+ `${padVisible(renderPolicyCol(b, holdMs, held), POLICY_COL_WIDTH)} ` +
513
579
  `${padVisible(expiring, 9)} ` +
514
580
  `${padVisible(created, 9)} ` +
515
581
  `${padVisible(updated, 9)} ` +
@@ -530,7 +596,7 @@ function renderBundleRow(b, held, cols = terminalWidth()) {
530
596
  return trailer ? `${head} ${trailer}` : head.trimEnd();
531
597
  }
532
598
  /** Narrow-terminal card: name + compact meta on one line, description below. */
533
- function renderBundleCard(b, held, cols) {
599
+ function renderBundleCard(b, holdMs, held, cols) {
534
600
  const keys = describeBundle(b).length;
535
601
  const used = b.last_used ? relativeAge(b.last_used) : (b.created_at ? 'never' : '?');
536
602
  const tag = b.backend === 'file'
@@ -538,7 +604,7 @@ function renderBundleCard(b, held, cols) {
538
604
  : b.backend === 'vault'
539
605
  ? chalk.blue(' [synced]')
540
606
  : '';
541
- 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}`);
542
608
  const line1 = `${chalk.cyan(b.name)} ${meta}${tag}`;
543
609
  if (!b.description)
544
610
  return line1;
@@ -650,7 +716,10 @@ function renderMetaLine(meta, reveal) {
650
716
  return '';
651
717
  return ` ${parts.join(' ')}`;
652
718
  }
653
- /** 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. */
654
723
  function countExpiringSoon(meta) {
655
724
  if (!meta)
656
725
  return 0;
@@ -664,6 +733,23 @@ function countExpiringSoon(meta) {
664
733
  }
665
734
  return n;
666
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
+ }
667
753
  /**
668
754
  * Resolve an existing import target bundle (inheriting its backend) or create a
669
755
  * new one with the requested backend. Refuses to silently downgrade a
@@ -789,23 +875,62 @@ export function registerSecretsCommands(program) {
789
875
  { title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
790
876
  { title: 'Utilities', names: ['exec', 'mcp', 'generate', 'migrate-acl'] },
791
877
  ]);
792
- cmd
793
- .command('list')
878
+ const listCmd = cmd
879
+ .command('list [query]')
794
880
  .alias('ls')
795
- .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)')
796
882
  .option('--host <target>', 'List bundles on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
797
883
  .option('--hosts <list>', 'Comma-separated hosts to list in one shot, e.g. yosemite-s0,yosemite-s1')
798
884
  .option('--device <target>', 'Alias for --host')
799
885
  .option('--devices <list>', 'Alias for --hosts')
800
886
  .option('--json', 'Emit machine-readable JSON (bundle metadata only — never secret values) instead of the table')
801
- .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) => {
802
899
  const targets = parseHostsOption(opts);
803
900
  if (targets.length > 0) {
804
- 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);
805
904
  return;
806
905
  }
807
- const bundles = listBundles();
808
- // Cross-reference the secrets-agent so `daily` bundles that are currently
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();
933
+ // Cross-reference the secrets-agent so `hold` bundles that are currently
809
934
  // held can show "· held Nh". Soft-fails to no hint if the broker is down.
810
935
  const held = new Map();
811
936
  if (process.platform === 'darwin') {
@@ -817,6 +942,12 @@ export function registerSecretsCommands(program) {
817
942
  /* broker not running — render policy without the countdown */
818
943
  }
819
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);
820
951
  if (opts.json) {
821
952
  // Discovery payload for agents: metadata only, no secret values. Gated on
822
953
  // the explicit --json flag (not stdout.isTTY) so piping the human table to
@@ -825,36 +956,96 @@ export function registerSecretsCommands(program) {
825
956
  name: b.name,
826
957
  keys: describeBundle(b).length,
827
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,
828
962
  backend: b.backend === 'file' ? 'file' : 'keychain',
829
963
  allowExec: Boolean(b.allow_exec),
830
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,
831
968
  description: b.description ?? null,
832
969
  createdAt: b.created_at ?? null,
833
970
  updatedAt: b.updated_at ?? null,
834
971
  lastUsed: b.last_used ?? null,
835
- 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),
836
977
  }));
837
978
  process.stdout.write(JSON.stringify(payload) + '\n');
838
979
  return;
839
980
  }
840
981
  if (bundles.length === 0) {
841
- console.log(chalk.gray('No secrets bundles configured.'));
842
- 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
+ }
843
993
  return;
844
994
  }
845
995
  const cols = terminalWidth();
846
996
  if (cols >= SECRETS_WIDE) {
847
- 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`));
848
998
  for (const b of bundles) {
849
- console.log(renderBundleRow(b, held, cols));
999
+ console.log(renderBundleRow(b, holdMs, held, cols));
850
1000
  }
851
1001
  }
852
1002
  else {
853
1003
  for (const b of bundles) {
854
- console.log(renderBundleCard(b, held, cols));
1004
+ console.log(renderBundleCard(b, holdMs, held, cols));
855
1005
  }
856
1006
  }
857
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
+ });
858
1049
  cmd
859
1050
  .command('view [name]')
860
1051
  .alias('show')
@@ -959,6 +1150,9 @@ export function registerSecretsCommands(program) {
959
1150
  name: bundle.name,
960
1151
  description: bundle.description ?? null,
961
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,
962
1156
  backend: bundle.backend === 'file' ? 'file' : 'keychain',
963
1157
  allowExec: Boolean(bundle.allow_exec),
964
1158
  createdAt: bundle.created_at ?? null,
@@ -983,7 +1177,10 @@ export function registerSecretsCommands(program) {
983
1177
  }
984
1178
  else {
985
1179
  console.log(bundlePolicy(bundle) === 'hold'
986
- ? 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)`)
987
1184
  : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
988
1185
  }
989
1186
  if (bundle.created_at)
@@ -1174,7 +1371,7 @@ export function registerSecretsCommands(program) {
1174
1371
  const resolvedName = name ?? (await promptBundleName());
1175
1372
  validateBundleName(resolvedName);
1176
1373
  // Leave policy unset unless the user explicitly chose one, so the bundle
1177
- // inherits the configured default (`daily`) instead of being pinned.
1374
+ // inherits the configured default (`hold`) instead of being pinned.
1178
1375
  const policyOpt = opts.policy ?? opts.tier;
1179
1376
  const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
1180
1377
  const backend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
@@ -2266,7 +2463,7 @@ Examples:
2266
2463
  (brokerUp
2267
2464
  ? chalk.green('running') + chalk.gray(isDaemonRunning() ? ' (hosted by the daemon)' : ' (standalone)')
2268
2465
  : chalk.yellow('not running — starts on demand, or run `agents secrets start` to bring the daemon up now')));
2269
- // Diagnostic: version skew is the top reason a `daily` bundle keeps
2466
+ // Diagnostic: version skew is the top reason a `hold` bundle keeps
2270
2467
  // re-prompting — a broker on an older build gets torn down when the CLI
2271
2468
  // version changes (e.g. `agents-cli-update`), wiping every held bundle.
2272
2469
  const onDisk = getCliVersionFresh();
@@ -2286,11 +2483,11 @@ Examples:
2286
2483
  catch {
2287
2484
  return false;
2288
2485
  } })();
2289
- 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.`));
2486
+ console.log(chalk.gray(renderHoldSummary(holdStr, configured)));
2290
2487
  const entries = await agentStatus();
2291
2488
  const held = new Set(entries.map((e) => e.name));
2292
2489
  if (entries.length === 0) {
2293
- console.log(chalk.gray('No bundles held. The next read of each daily bundle will prompt once, then hold.'));
2490
+ console.log(chalk.gray(NO_BUNDLES_HELD_LINE));
2294
2491
  console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
2295
2492
  }
2296
2493
  else {
@@ -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')
@@ -26,6 +26,8 @@ export interface BrowserFilter {
26
26
  device?: string;
27
27
  /** filter to one team's lineage, or all — the `T` key / `--in-team`. */
28
28
  team?: string;
29
+ /** favorited-only — the `f` key / `--favorites`. */
30
+ favorites: boolean;
29
31
  /** this-repo subtree vs every directory — the `P` key / `--all`. */
30
32
  projectScope: 'repo' | 'all';
31
33
  /** time window (undefined = all time) — the `W` key / `--since`. */
@@ -77,6 +79,7 @@ export declare function activeBrowserSeed(opts: {
77
79
  host?: string[];
78
80
  since?: string;
79
81
  all?: boolean;
82
+ favorites?: boolean;
80
83
  }): Partial<BrowserFilter>;
81
84
  /**
82
85
  * The initial filter for the bare interactive listing: current-repo subtree by
@@ -92,6 +95,7 @@ export declare function bareBrowserSeed(opts: {
92
95
  since?: string;
93
96
  host?: string[];
94
97
  inTeam?: string;
98
+ favorites?: boolean;
95
99
  }): Partial<BrowserFilter>;
96
100
  /**
97
101
  * A live session's stable row key: its session id when the agent reported one,
@@ -17,9 +17,10 @@ import { isSessionTrackedAgent } from '../lib/session/types.js';
17
17
  import { discoverSessions } from '../lib/session/discover.js';
18
18
  import { gatherRemoteList } from '../lib/session/remote-list.js';
19
19
  import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
20
+ import { listFavorites, toggleFavorite } from '../lib/session/favorites.js';
20
21
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
21
22
  import { buildPreview } from './sessions-picker.js';
22
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, } from './sessions.js';
23
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, } from './sessions.js';
23
24
  /**
24
25
  * Complete a seed into the filter the picker actually runs on.
25
26
  *
@@ -37,6 +38,7 @@ export function buildInitialFilter(initial) {
37
38
  return {
38
39
  running: initial.running ?? false,
39
40
  teams: initial.teams ?? false,
41
+ favorites: initial.favorites ?? false,
40
42
  agent: initial.agent,
41
43
  device: initial.device,
42
44
  team: initial.team,
@@ -107,6 +109,8 @@ export function browserFilterToArgv(f, query = '') {
107
109
  a.push('--active');
108
110
  if (f.teams)
109
111
  a.push('--teams');
112
+ if (f.favorites)
113
+ a.push('--favorites');
110
114
  if (f.agent)
111
115
  a.push('-a', f.agent);
112
116
  if (f.device)
@@ -140,6 +144,7 @@ export function activeBrowserSeed(opts) {
140
144
  return {
141
145
  running: true,
142
146
  teams: !!opts.teams,
147
+ favorites: !!opts.favorites,
143
148
  agent: opts.agent,
144
149
  projectScope: 'all',
145
150
  device: normalizeDeviceSeed(opts.host?.[0]),
@@ -170,6 +175,7 @@ export function bareBrowserSeed(opts) {
170
175
  const wholeTeam = !!opts.inTeam;
171
176
  return {
172
177
  teams: !!opts.teams,
178
+ favorites: !!opts.favorites,
173
179
  agent: opts.agent,
174
180
  // The filter carries one device; seed it only when the scope names exactly
175
181
  // one, so a two-device scope isn't narrowed to the first of them.
@@ -359,9 +365,14 @@ export function shouldShowHostColumn(f, live, rows) {
359
365
  return false;
360
366
  return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
361
367
  }
362
- /** Apply the cheap in-memory filters (agent / device / project / running). */
363
- function applyFilters(rows, live, f, self) {
368
+ /** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
369
+ function applyFilters(rows, live, f, self, favorites) {
364
370
  let out = rows;
371
+ // A projected live row is keyed by pid/task when it has no session id, and a
372
+ // favorite is always keyed by a real session id — so an id-less row can never
373
+ // be favorited and correctly drops out here.
374
+ if (f.favorites)
375
+ out = out.filter((r) => favorites.has(r.id));
365
376
  if (f.agent)
366
377
  out = out.filter((r) => r.agent === f.agent);
367
378
  if (f.device)
@@ -398,13 +409,15 @@ function headerFor(f) {
398
409
  bits.push('running');
399
410
  if (f.teams)
400
411
  bits.push('teams');
412
+ if (f.favorites)
413
+ bits.push('favorites');
401
414
  return bits.join(' · ');
402
415
  }
403
416
  function helpFor(_f, mode) {
404
417
  if (mode === 'search') {
405
418
  return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
406
419
  }
407
- return 's search · r running · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
420
+ return 's search · r running · f favorites · * star · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
408
421
  }
409
422
  /**
410
423
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
@@ -432,6 +445,10 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
432
445
  // The live index is slow (a full ps/tmux scan) and only the running filter
433
446
  // needs it — fetch it once, lazily, the first time running is toggled on.
434
447
  let liveCache = null;
448
+ // Re-read every load (it's an mtime-memoized parse of one small file), so the
449
+ // `*` key's reload picks up the star it just wrote — and so does a favorite
450
+ // starred by another session on this machine.
451
+ let favorites = new Set();
435
452
  // Generation guard: two quick keypresses can start overlapping loads whose
436
453
  // SSH fan-outs settle out of order. dynamicPicker's own gen ref guards which
437
454
  // rows become `items`, but the shared closure state below (cols / cycle pools /
@@ -489,9 +506,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
489
506
  ...rows.map((r) => safeTeamText(r.spawnedTeam)),
490
507
  ...rows.map((r) => safeTeamText(r.teamOrigin?.team)),
491
508
  ]);
492
- const filtered = applyFilters(rows, live ?? new Map(), f, self);
509
+ favorites = listFavorites();
510
+ const filtered = applyFilters(rows, live ?? new Map(), f, self, favorites);
493
511
  cols = pickerColumnsFor(filtered);
494
512
  cols.showHost = shouldShowHostColumn(f, live, filtered);
513
+ // Status rides the same gate as the host column: both come from the live
514
+ // scan, so both belong to the running view and neither should widen a plain
515
+ // transcript listing that has no live rows to fill them.
516
+ cols.showStatus = !!f.running && !!live;
495
517
  return filtered;
496
518
  };
497
519
  const picked = await dynamicPicker({
@@ -499,9 +521,17 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
499
521
  initialFilter,
500
522
  load,
501
523
  keyFor: (s) => s.id,
502
- labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
524
+ labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id)), favorites.has(s.id), liveCache?.get(s.id)),
503
525
  matches: sessionMatchesQuery,
504
- buildPreview,
526
+ // Lead the preview with the live status banner — the one place a `crashed` /
527
+ // `orphaned` session gets a sentence instead of a glyph. `buildPreview` is
528
+ // memoized per session, so the volatile live half is prepended here rather
529
+ // than baked into the cached body.
530
+ buildPreview: (s) => {
531
+ const headline = formatLiveStatusHeadline(liveCache?.get(s.id), favorites.has(s.id));
532
+ const body = buildPreview(s);
533
+ return headline ? `${headline}\n${body}` : body;
534
+ },
505
535
  headerFor: (f) => unreachable.length > 0
506
536
  ? `${headerFor(f)} · ${chalk.yellow(`${unreachable.join(', ')}: unreachable`)}`
507
537
  : headerFor(f),
@@ -511,6 +541,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
511
541
  loadingMessage: local ? 'Loading…' : 'Loading (reaching other machines)…',
512
542
  keyBindings: {
513
543
  r: (f) => ({ ...f, running: !f.running }),
544
+ f: (f) => ({ ...f, favorites: !f.favorites }),
514
545
  c: (f) => ({ ...f, teams: !f.teams }),
515
546
  a: (f) => ({ ...f, agent: cycle(f.agent, agentsInPool) }),
516
547
  d: (f) => ({ ...f, device: cycle(f.device, devicesInPool) }),
@@ -521,8 +552,19 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
521
552
  p: (f) => (hosts ? f : { ...f, projectScope: f.projectScope === 'repo' ? 'all' : 'repo' }),
522
553
  w: (f) => ({ ...f, window: cycleWindow(f.window) }),
523
554
  },
524
- onKey: (name, f, _active, query) => {
525
- if (name === 'y') {
555
+ onKey: (name, f, active, query) => {
556
+ if (name === '*') {
557
+ // Only a row with a real session id can be starred: a projected live row
558
+ // with no id is keyed by pid, which is gone the moment the process is.
559
+ if (!active || active.id.startsWith(LIVE_ROW_PREFIX))
560
+ return 'nothing to star on this row';
561
+ const on = toggleFavorite(active.id);
562
+ // reload so the row's star is repainted — labels are memoized per row.
563
+ return { flash: on ? `★ favorited ${active.shortId}` : `☆ unfavorited ${active.shortId}`, reload: true };
564
+ }
565
+ // Both cases: `hotkeyToken` hands `onKey` the literal character, and this
566
+ // key worked with caps lock on before it existed.
567
+ if (name === 'y' || name === 'Y') {
526
568
  // Thread the live search query so the copied command reproduces the
527
569
  // exact view — the human→agent bridge must include the search term.
528
570
  const cmd = 'ag ' + browserFilterToArgv(f, query).join(' ');