@phnx-labs/agents-cli 1.22.29 → 1.22.31

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 (82) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +44 -5
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.d.ts +13 -0
  5. package/dist/commands/accounts.js +32 -0
  6. package/dist/commands/daemon.d.ts +18 -0
  7. package/dist/commands/daemon.js +581 -0
  8. package/dist/commands/exec.js +66 -20
  9. package/dist/commands/focus.d.ts +4 -1
  10. package/dist/commands/focus.js +19 -4
  11. package/dist/commands/routines.js +29 -11
  12. package/dist/commands/secrets.d.ts +37 -0
  13. package/dist/commands/secrets.js +86 -105
  14. package/dist/commands/sessions-bookmark.d.ts +20 -0
  15. package/dist/commands/{sessions-favorite.js → sessions-bookmark.js} +42 -42
  16. package/dist/commands/sessions-browser.d.ts +10 -8
  17. package/dist/commands/sessions-browser.js +61 -32
  18. package/dist/commands/sessions-picker.d.ts +33 -1
  19. package/dist/commands/sessions-picker.js +102 -27
  20. package/dist/commands/sessions-stats.js +1 -1
  21. package/dist/commands/sessions.d.ts +21 -8
  22. package/dist/commands/sessions.js +328 -74
  23. package/dist/commands/view.d.ts +11 -0
  24. package/dist/commands/view.js +56 -29
  25. package/dist/index.js +37 -2
  26. package/dist/lib/account-labels.d.ts +24 -0
  27. package/dist/lib/account-labels.js +72 -0
  28. package/dist/lib/agents.d.ts +32 -1
  29. package/dist/lib/agents.js +96 -31
  30. package/dist/lib/daemon-health.d.ts +24 -0
  31. package/dist/lib/daemon-health.js +84 -0
  32. package/dist/lib/daemon-ticks.d.ts +81 -0
  33. package/dist/lib/daemon-ticks.js +190 -0
  34. package/dist/lib/daemon.d.ts +68 -18
  35. package/dist/lib/daemon.js +303 -338
  36. package/dist/lib/device-config.d.ts +10 -0
  37. package/dist/lib/device-config.js +27 -0
  38. package/dist/lib/exec.d.ts +27 -0
  39. package/dist/lib/exec.js +49 -2
  40. package/dist/lib/hosts/dispatch.d.ts +4 -0
  41. package/dist/lib/hosts/dispatch.js +4 -0
  42. package/dist/lib/hosts/remote-cmd.js +1 -0
  43. package/dist/lib/hosts/run-target.d.ts +1 -0
  44. package/dist/lib/hosts/run-target.js +1 -0
  45. package/dist/lib/import.js +7 -6
  46. package/dist/lib/memory-cache.d.ts +19 -0
  47. package/dist/lib/memory-cache.js +31 -0
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  49. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  50. package/dist/lib/migrate.d.ts +1 -1
  51. package/dist/lib/migrate.js +13 -2
  52. package/dist/lib/picker.d.ts +6 -3
  53. package/dist/lib/picker.js +7 -2
  54. package/dist/lib/routine-activation.d.ts +2 -0
  55. package/dist/lib/routine-activation.js +16 -0
  56. package/dist/lib/runner.d.ts +18 -0
  57. package/dist/lib/runner.js +52 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/agent.d.ts +19 -1
  61. package/dist/lib/secrets/agent.js +32 -6
  62. package/dist/lib/secrets/scope.d.ts +3 -3
  63. package/dist/lib/secrets/scope.js +3 -3
  64. package/dist/lib/secrets/session-store.d.ts +0 -4
  65. package/dist/lib/secrets/session-store.js +0 -5
  66. package/dist/lib/session/{favorites.d.ts → bookmarks.d.ts} +15 -15
  67. package/dist/lib/session/{favorites.js → bookmarks.js} +23 -23
  68. package/dist/lib/session/db.d.ts +15 -0
  69. package/dist/lib/session/db.js +90 -15
  70. package/dist/lib/session/discover.js +91 -39
  71. package/dist/lib/session/parse.d.ts +63 -0
  72. package/dist/lib/session/parse.js +165 -20
  73. package/dist/lib/session/session-cache.d.ts +9 -6
  74. package/dist/lib/session/session-cache.js +23 -6
  75. package/dist/lib/shims.js +12 -0
  76. package/dist/lib/startup/command-registry.d.ts +15 -1
  77. package/dist/lib/startup/command-registry.js +49 -0
  78. package/dist/lib/usage-refresh.js +3 -2
  79. package/dist/lib/usage.d.ts +12 -10
  80. package/dist/lib/usage.js +63 -144
  81. package/package.json +4 -1
  82. package/dist/commands/sessions-favorite.d.ts +0 -20
@@ -25,8 +25,8 @@ import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainTok
25
25
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
26
26
  import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
27
27
  import { createSecretLease, selectLeasedEnv } from '../lib/secrets/lease.js';
28
- import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentSocketExists, agentStatus, agentRevoke, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
29
- import { activeLeaseSessions, saveSession, deleteBundleSessions, deleteAllSessions, deleteLeaseSession } from '../lib/secrets/session-store.js';
28
+ import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
29
+ import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
30
30
  import { getCliVersionFresh } from '../lib/version.js';
31
31
  import { readMeta } from '../lib/state.js';
32
32
  import { parseDuration } from '../lib/hooks/cache.js';
@@ -287,6 +287,30 @@ export function resolveUnlockTtlMs(ttl, until, now = Date.now()) {
287
287
  }
288
288
  return secretsHoldMs();
289
289
  }
290
+ /**
291
+ * Decide what an `unlock` holds: the whole bundle env, or — with --keys — only
292
+ * the resolved subset behind a lease that scopes the broker entry (agent.ts
293
+ * re-selects on load) and its own expiry. Fails closed: createSecretLease throws
294
+ * on an unknown or empty key subset. The unlock action's single scoping seam,
295
+ * exported so the whole-bundle vs scoped-subset decision is unit-testable without
296
+ * a live broker.
297
+ */
298
+ export function scopeHeldEnv(input) {
299
+ // null = no --keys → hold the whole bundle. An empty-string --keys is a
300
+ // deliberate (if empty) scope request and falls through to createSecretLease,
301
+ // which fails closed ('requires at least one key') rather than holding it all.
302
+ if (input.keys === null)
303
+ return { heldEnv: input.env };
304
+ const lease = createSecretLease({
305
+ bundle: input.bundle,
306
+ keys: input.keys.split(','),
307
+ availableKeys: Object.keys(input.env),
308
+ ttlMs: input.ttlMs,
309
+ harness: input.harness,
310
+ sleepPersist: input.sleepPersist,
311
+ });
312
+ return { heldEnv: selectLeasedEnv(lease, input.env), lease };
313
+ }
290
314
  export function buildRemoteUnlockArgs(names, opts) {
291
315
  return [
292
316
  'unlock',
@@ -804,16 +828,31 @@ export function renderExpiringCol(b, now = Date.now()) {
804
828
  * new one with the requested backend. Refuses to silently downgrade a
805
829
  * keychain-backed bundle to `file` — shared by every `import` source so the
806
830
  * guard can't drift between them.
831
+ *
832
+ * `force` additionally recreates a bundle whose METADATA RECORD is present but
833
+ * undecryptable — a file store whose key was lost or rotated out from under it.
834
+ * That is precisely the state provisioning exists to repair (import is how a box
835
+ * gets its bundles back), and without this the import dies on `readBundle` and
836
+ * the only route left is deleting the record by hand on an already-degraded
837
+ * store. It is gated on `--force` on purpose: recreating unconditionally would
838
+ * destroy a perfectly healthy bundle for someone who merely forgot to set
839
+ * `AGENTS_SECRETS_PASSPHRASE`, which is the hazard `readBundleIfDecryptable`
840
+ * exists to describe. `--force` already means "overwrite what is there".
807
841
  */
808
- function resolveImportBundle(name, backendOpt, synced = false) {
842
+ export function resolveImportBundle(name, backendOpt, synced = false, force = false) {
809
843
  const requestedBackend = synced ? 'vault' : resolveBackendOpt(backendOpt);
810
844
  if (bundleExists(name)) {
811
- const bundle = readBundle(name);
812
- if (requestedBackend !== 'keychain' && bundle.backend !== requestedBackend) {
813
- throw new Error(`Bundle '${name}' already exists with a different backend; ` +
814
- `delete it first to recreate it as ${requestedBackend === 'vault' ? 'synced' : `${requestedBackend}-backed`}.`);
845
+ // readBundleIfDecryptable nulls ONLY on BundleUndecryptableError; a locked
846
+ // keychain or logged-out vault still throws, so a recoverable state can
847
+ // never be mistaken for a lost key and silently overwritten.
848
+ const bundle = force ? readBundleIfDecryptable(name) : readBundle(name);
849
+ if (bundle) {
850
+ if (requestedBackend !== 'keychain' && bundle.backend !== requestedBackend) {
851
+ throw new Error(`Bundle '${name}' already exists with a different backend; ` +
852
+ `delete it first to recreate it as ${requestedBackend === 'vault' ? 'synced' : `${requestedBackend}-backed`}.`);
853
+ }
854
+ return bundle;
815
855
  }
816
- return bundle;
817
856
  }
818
857
  return { name, backend: requestedBackend === 'keychain' ? undefined : requestedBackend, vars: {} };
819
858
  }
@@ -1984,7 +2023,7 @@ Examples:
1984
2023
  }
1985
2024
  const env = importBundleFromFile(opts.fromFile, passphrase);
1986
2025
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1987
- const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced);
2026
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force);
1988
2027
  const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1989
2028
  emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from-file', source: 'file', status: 'success', keyCount: added });
1990
2029
  console.log(chalk.green(`Imported ${added} key(s) from file${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
@@ -1998,7 +2037,7 @@ Examples:
1998
2037
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1999
2038
  const target = await resolveHostSshTarget(opts.host);
2000
2039
  const env = await remoteResolveEnv(target, resolvedBundleName, { osLookupName: opts.host });
2001
- const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced);
2040
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force);
2002
2041
  const { added, skipped } = applyEnvToBundle(bundle, env, opts);
2003
2042
  emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from-ssh', source: 'ssh', host: opts.host, status: 'success', keyCount: added });
2004
2043
  console.log(chalk.green(`Imported ${added} key(s) from ${opts.host}${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
@@ -2026,7 +2065,7 @@ Examples:
2026
2065
  // to downgrade keychain -> file) or creates it with the requested backend
2027
2066
  // so a single `import --backend file` works (what `export --host ...
2028
2067
  // --remote-backend file` drives on the remote).
2029
- const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced);
2068
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force);
2030
2069
  if (source.kind === '1password') {
2031
2070
  assertOpAvailable();
2032
2071
  const vault = await resolveVault(source.vault);
@@ -2427,96 +2466,14 @@ Examples:
2427
2466
  console.log(password);
2428
2467
  }
2429
2468
  });
2430
- cmd
2431
- .command('lease <bundle>')
2432
- .description('Hold only an explicit subset of a bundle until an independent expiry.')
2433
- .requiredOption('--keys <keys>', 'Comma-separated key subset')
2434
- .requiredOption('--for <duration>', 'Lease duration, for example 30m or 8h')
2435
- .option('--agent <agent>', 'Narrow the lease to one harness; default is global')
2436
- .option('--durable', 'Keep the lease across sleep as well as broker restart')
2437
- .action(async (name, opts) => {
2438
- if (process.platform !== 'darwin') {
2439
- throw new Error('Scoped lease brokering is not available on this platform yet.');
2440
- }
2441
- const seconds = parseDuration(opts.for);
2442
- if (seconds === null)
2443
- throw new Error(`Invalid lease duration '${opts.for}'.`);
2444
- const ttlMs = seconds * 1000;
2445
- const harness = opts.agent || GLOBAL_HARNESS;
2446
- const { bundle, env } = readAndResolveBundleEnv(name, {
2447
- noAgent: true,
2448
- interactiveUnlock: true,
2449
- caller: 'lease secrets',
2450
- agent: harness,
2451
- keyMode: 'storage',
2452
- });
2453
- const lease = createSecretLease({
2454
- bundle: name,
2455
- keys: opts.keys.split(','),
2456
- availableKeys: Object.keys(env),
2457
- ttlMs,
2458
- harness,
2459
- sleepPersist: opts.durable,
2460
- });
2461
- const leasedEnv = selectLeasedEnv(lease, env);
2462
- if (!(await ensureAgentRunning()) || !(await agentLoad(name, bundle, leasedEnv, ttlMs, harness, lease))) {
2463
- throw new Error('Could not load the scoped lease into the secrets broker.');
2464
- }
2465
- ensureDaemonStarted();
2466
- saveSession(name, {
2467
- bundle,
2468
- env: leasedEnv,
2469
- expiresAt: lease.expiresAt,
2470
- sleepPersist: lease.sleepPersist,
2471
- harness,
2472
- lease,
2473
- });
2474
- emitSecretAudit({
2475
- event: 'secrets.unlocked', bundle: name, operation: 'lease', source: lease.sleepPersist ? 'broker+durable' : 'broker',
2476
- status: 'success', keys: lease.keys, keyCount: lease.keys.length, agent: harness, ttlMs: lease.expiresAt - lease.createdAt,
2477
- });
2478
- console.log(`${chalk.green('leased')} ${chalk.cyan(lease.id)} ${chalk.gray(`(${name}: ${lease.keys.join(', ')}, ${humanRemaining(lease.expiresAt)})`)}`);
2479
- });
2480
- cmd
2481
- .command('leases')
2482
- .description('List active scoped secret leases.')
2483
- .action(async () => {
2484
- const brokerLeases = (await agentStatus()).filter((entry) => entry.leaseId);
2485
- const byId = new Map(brokerLeases.map((entry) => [entry.leaseId, entry]));
2486
- for (const { name, lease } of activeLeaseSessions()) {
2487
- if (!byId.has(lease.id))
2488
- byId.set(lease.id, { name, expiresAt: lease.expiresAt, keyCount: lease.keys.length, harness: lease.harness, leaseId: lease.id, keys: lease.keys });
2489
- }
2490
- const leases = [...byId.values()];
2491
- if (leases.length === 0) {
2492
- console.log(chalk.gray('No active secret leases.'));
2493
- return;
2494
- }
2495
- console.log(`${'ID'.padEnd(24)} ${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(24)} EXPIRES IN`);
2496
- for (const lease of leases) {
2497
- console.log(`${lease.leaseId.padEnd(24)} ${lease.name.padEnd(24)} ${(lease.keys ?? []).join(',').padEnd(24)} ${humanRemaining(lease.expiresAt)}`);
2498
- }
2499
- });
2500
- cmd
2501
- .command('revoke <lease-id>')
2502
- .description('Revoke one scoped secret lease immediately.')
2503
- .action(async (leaseId) => {
2504
- if (agentSocketExists() && !(await agentPing()).reachable) {
2505
- throw new Error('Cannot revoke while the secrets broker is using an incompatible protocol. Restart the broker and retry.');
2506
- }
2507
- const persisted = deleteLeaseSession(leaseId);
2508
- const wiped = await agentRevoke(leaseId);
2509
- if (wiped + persisted === 0)
2510
- throw new Error(`Secret lease '${leaseId}' is not active.`);
2511
- console.log(chalk.green(`Revoked secret lease ${leaseId}.`));
2512
- });
2513
2469
  cmd
2514
2470
  .command('unlock [names...]')
2515
2471
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
2516
2472
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
2517
2473
  .option('--until <date>', 'Hold until this absolute date or timestamp (for example 2026-08-06T12:00:00Z). Mutually exclusive with --ttl.')
2518
2474
  .option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
2519
- .option('--for <agent>', 'Narrow the unlock to ONE harness type (for example claude, codex, or kimi). Default: the grant is global — every harness and a plain shell can read it, so one Touch ID covers them all.')
2475
+ .option('--agent <agent>', 'Narrow the unlock to ONE harness type (for example claude, codex, or kimi). Default: the grant is global — every harness and a plain shell can read it, so one Touch ID covers them all.')
2476
+ .option('--keys <keys>', 'Hold ONLY this comma-separated subset of the bundle\'s keys instead of the whole bundle. Scopes exactly one bundle; fails closed on an unknown key. `secrets status` shows which keys a scoped hold covers.')
2520
2477
  .option('--all', 'Unlock every configured bundle')
2521
2478
  .option('--host <target>', 'Unlock the bundle(s) on this remote machine over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --host <machine>`.')
2522
2479
  .action(async (names, opts) => {
@@ -2524,6 +2481,19 @@ Examples:
2524
2481
  console.error(chalk.red('--ttl and --until are mutually exclusive.'));
2525
2482
  process.exit(1);
2526
2483
  }
2484
+ if (opts.keys !== undefined) {
2485
+ // --keys scopes ONE bundle to an explicit subset. It is local-only (a
2486
+ // remote --host unlock holds the whole file-backed bundle) and cannot
2487
+ // combine with --all, which would hold every bundle whole.
2488
+ if (opts.host) {
2489
+ console.error(chalk.red('--keys is local-only; a remote (--host) unlock holds the whole file-backed bundle.'));
2490
+ process.exit(1);
2491
+ }
2492
+ if (opts.all || !names || names.length !== 1) {
2493
+ console.error(chalk.red('--keys scopes ONE bundle to a subset of its keys: name exactly one bundle (not --all).'));
2494
+ process.exit(1);
2495
+ }
2496
+ }
2527
2497
  // Single-valued (not variadic): a variadic --host greedily consumes the
2528
2498
  // positional bundle name (`unlock --host mac wztest` -> host=[mac,wztest],
2529
2499
  // names=[]). Unlock targets one remote at a time anyway.
@@ -2602,12 +2572,13 @@ Examples:
2602
2572
  // (single-instance start lock, #414) and best-effort — never blocks unlock.
2603
2573
  ensureDaemonStarted();
2604
2574
  let loaded = 0;
2605
- // An unlock is a deliberate act, so it grants GLOBALLY unless `--for`
2575
+ // An unlock is a deliberate act, so it grants GLOBALLY unless `--agent`
2606
2576
  // narrows it to one harness. It must NOT inherit the ambient
2607
2577
  // AGENTS_AGENT_NAME: that silently scoped a terminal unlock to whichever
2608
2578
  // agent happened to launch the shell, leaving the grant unreadable to
2609
2579
  // every other reader for its whole TTL.
2610
- const harness = opts.for || GLOBAL_HARNESS;
2580
+ const harness = opts.agent || GLOBAL_HARNESS;
2581
+ const durable = opts.durable ?? secretsAgentDurable();
2611
2582
  for (const name of targets) {
2612
2583
  try {
2613
2584
  // noAgent: read the real keychain (one Touch ID) rather than the
@@ -2624,17 +2595,23 @@ Examples:
2624
2595
  // ACL heal can rewrite that already-read value without another read.
2625
2596
  maybeAutoRekey();
2626
2597
  healKeychainBundleMetadataAclOnce(new Map([[bundle.name, JSON.stringify(bundle)]]));
2627
- if (await agentLoad(name, bundle, env, ttlMs, harness)) {
2598
+ // With --keys, hold ONLY the resolved subset behind a lease that scopes
2599
+ // the broker entry and its own expiry; without it, the whole bundle is
2600
+ // held exactly as before. scopeHeldEnv fails closed on an unknown key.
2601
+ const { heldEnv, lease } = scopeHeldEnv({ bundle: name, env, keys: opts.keys ?? null, ttlMs, harness, sleepPersist: durable });
2602
+ const heldExpiresAt = lease ? lease.expiresAt : expiresAt;
2603
+ if (await agentLoad(name, bundle, heldEnv, ttlMs, harness, lease)) {
2628
2604
  loaded++;
2629
2605
  // Persist a durable session snapshot so the unlock survives a daemon
2630
2606
  // restart / upgrade (and sleep too, with --durable). session-store.ts.
2631
- const durable = opts.durable ?? secretsAgentDurable();
2607
+ // A scoped hold persists its lease so it rehydrates scoped.
2632
2608
  saveSession(name, {
2633
2609
  bundle,
2634
- env,
2635
- expiresAt,
2610
+ env: heldEnv,
2611
+ expiresAt: heldExpiresAt,
2636
2612
  sleepPersist: durable,
2637
2613
  harness,
2614
+ lease,
2638
2615
  });
2639
2616
  // Audit the GRANT itself — the broker + durable session now serve this
2640
2617
  // bundle prompt-free for the whole TTL, to every reader in `harness`
@@ -2646,12 +2623,13 @@ Examples:
2646
2623
  operation: 'unlock',
2647
2624
  source: durable ? 'broker+durable' : 'broker',
2648
2625
  status: 'success',
2649
- keyCount: Object.keys(env).length,
2650
- keys: Object.keys(env).sort(),
2626
+ keyCount: Object.keys(heldEnv).length,
2627
+ keys: Object.keys(heldEnv).sort(),
2651
2628
  agent: harness,
2652
2629
  ttlMs,
2653
2630
  });
2654
- console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(expiresAt)})`)}`);
2631
+ const scoped = lease ? chalk.gray(` scoped to ${lease.keys.join(', ')}`) : '';
2632
+ console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(heldEnv).length} keys, ${humanRemaining(heldExpiresAt)})`)}${scoped}`);
2655
2633
  }
2656
2634
  else {
2657
2635
  console.error(chalk.red(`Failed to load '${name}' into the agent.`));
@@ -2733,7 +2711,10 @@ Examples:
2733
2711
  else {
2734
2712
  console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
2735
2713
  for (const e of entries) {
2736
- console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
2714
+ // A scoped hold (unlock --keys) carries the held key names, so name them
2715
+ // rather than leave the bundle looking whole.
2716
+ const scoped = e.keys?.length ? chalk.gray(` scoped: ${e.keys.join(', ')}`) : '';
2717
+ console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}${scoped}`);
2737
2718
  }
2738
2719
  console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
2739
2720
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `agents sessions bookmark` — the non-TTY half of the bookmark hotkey.
3
+ *
4
+ * The `*` hotkey in the interactive browser is how a human bookmarks a session;
5
+ * this is how a script, an agent, or a machine without a TTY does the same thing,
6
+ * and it is what makes the feature testable end to end without driving a terminal
7
+ * UI. Both write the one store in `lib/session/bookmarks.ts`.
8
+ */
9
+ import type { Command } from 'commander';
10
+ /**
11
+ * Resolve one user-typed id (usually the 8-char short id the listing prints) to
12
+ * a full session id. Ambiguity is an ERROR, not a silent first-match: bookmarking
13
+ * the wrong session is invisible until the user wonders where their bookmark went.
14
+ */
15
+ export declare function resolveBookmarkTarget(idQuery: string): {
16
+ id: string;
17
+ } | {
18
+ error: string;
19
+ };
20
+ export declare function registerSessionsBookmarkCommand(sessionsCmd: Command): void;
@@ -1,27 +1,27 @@
1
1
  /**
2
- * `agents sessions favorite` — the non-TTY half of the star.
2
+ * `agents sessions bookmark` — the non-TTY half of the bookmark hotkey.
3
3
  *
4
- * The `*` hotkey in the interactive browser is how a human stars a session; this
5
- * is how a script, an agent, or a machine without a TTY does the same thing, and
6
- * it is what makes the feature testable end to end without driving a terminal UI.
7
- * Both write the one store in `lib/session/favorites.ts`.
4
+ * The `*` hotkey in the interactive browser is how a human bookmarks a session;
5
+ * this is how a script, an agent, or a machine without a TTY does the same thing,
6
+ * and it is what makes the feature testable end to end without driving a terminal
7
+ * UI. Both write the one store in `lib/session/bookmarks.ts`.
8
8
  */
9
9
  import chalk from 'chalk';
10
10
  import { setHelpSections } from '../lib/help.js';
11
11
  import { findSessionsById } from '../lib/session/db.js';
12
12
  import { isCompleteSessionId } from '../lib/session/discover.js';
13
- import { isFavorite, listFavorites, setFavorite } from '../lib/session/favorites.js';
13
+ import { isBookmarked, listBookmarks, setBookmark } from '../lib/session/bookmarks.js';
14
14
  /**
15
15
  * Resolve one user-typed id (usually the 8-char short id the listing prints) to
16
- * a full session id. Ambiguity is an ERROR, not a silent first-match: starring
17
- * the wrong session is invisible until the user wonders where their star went.
16
+ * a full session id. Ambiguity is an ERROR, not a silent first-match: bookmarking
17
+ * the wrong session is invisible until the user wonders where their bookmark went.
18
18
  */
19
- export function resolveFavoriteTarget(idQuery) {
19
+ export function resolveBookmarkTarget(idQuery) {
20
20
  const matches = findSessionsById(idQuery);
21
21
  // A COMPLETE id needs no index entry: the id is the key the store is built on,
22
22
  // and requiring a transcript row would refuse exactly the newest sessions — a
23
- // live one that has not been indexed yet. The browser's `*` stars those from
24
- // the live row, so demanding a DB hit here would make the two disagree.
23
+ // live one that has not been indexed yet. The browser's `*` bookmarks those
24
+ // from the live row, so demanding a DB hit here would make the two disagree.
25
25
  if (matches.length === 0) {
26
26
  return isCompleteSessionId(idQuery.trim())
27
27
  ? { id: idQuery.trim() }
@@ -33,33 +33,33 @@ export function resolveFavoriteTarget(idQuery) {
33
33
  }
34
34
  return { id: matches[0].id };
35
35
  }
36
- export function registerSessionsFavoriteCommand(sessionsCmd) {
36
+ export function registerSessionsBookmarkCommand(sessionsCmd) {
37
37
  const cmd = sessionsCmd
38
- .command('favorite')
39
- .argument('[ids...]', 'Session ids to star (full or short id prefix)')
40
- .description('Star sessions so they are easy to find again — list them with --favorites, or `f` in the browser.')
41
- .option('--remove', 'Unstar the given sessions instead of starring them')
42
- .option('--list', 'List the starred sessions (the default when no ids are given)')
38
+ .command('bookmark')
39
+ .argument('[ids...]', 'Session ids to bookmark (full or short id prefix)')
40
+ .description('Bookmark sessions so they are easy to find again — list them with --bookmarks, or `b` in the browser.')
41
+ .option('--remove', 'Remove the given sessions from bookmarks instead of adding them')
42
+ .option('--list', 'List the bookmarked sessions (the default when no ids are given)')
43
43
  .option('--json', 'Output JSON');
44
44
  setHelpSections(cmd, {
45
45
  examples: `
46
- # Star a session by its short id (the 8 chars the listing prints)
47
- agents sessions favorite 26c27162
46
+ # Bookmark a session by its short id (the 8 chars the listing prints)
47
+ agents sessions bookmark 26c27162
48
48
 
49
- # See what is starred
50
- agents sessions favorite --list
49
+ # See what is bookmarked
50
+ agents sessions bookmark --list
51
51
 
52
- # Browse only the starred ones
53
- agents sessions --favorites
52
+ # Browse only the bookmarked ones
53
+ agents sessions --bookmarks
54
54
 
55
- # Unstar it again
56
- agents sessions favorite 26c27162 --remove
55
+ # Remove it from bookmarks again
56
+ agents sessions bookmark 26c27162 --remove
57
57
  `,
58
58
  notes: `
59
- In the interactive browser (\`agents sessions\`), \`*\` stars the highlighted
60
- session and \`f\` filters the list down to the starred ones.
59
+ In the interactive browser (\`agents sessions\`), \`*\` bookmarks the highlighted
60
+ session and \`b\` filters the list down to the bookmarked ones.
61
61
 
62
- Stars live in ~/.agents/.history/favorites.json, keyed by session id, so
62
+ Bookmarks live in ~/.agents/.history/bookmarks.json, keyed by session id, so
63
63
  they survive a reindex of the session cache. They are per-machine: session
64
64
  sync carries transcripts, not this file.
65
65
  `,
@@ -74,32 +74,32 @@ export function registerSessionsFavoriteCommand(sessionsCmd) {
74
74
  // owns; it is still declared on this command so `--help` documents it.
75
75
  const json = self.optsWithGlobals().json === true;
76
76
  if (options.list || ids.length === 0) {
77
- const starred = [...listFavorites()].sort();
77
+ const bookmarked = [...listBookmarks()].sort();
78
78
  if (json) {
79
- process.stdout.write(JSON.stringify({ favorites: starred }, null, 2) + '\n');
79
+ process.stdout.write(JSON.stringify({ bookmarks: bookmarked }, null, 2) + '\n');
80
80
  return;
81
81
  }
82
- if (starred.length === 0) {
83
- console.log(chalk.gray('No favorited sessions. Star one with `agents sessions favorite <id>`.'));
82
+ if (bookmarked.length === 0) {
83
+ console.log(chalk.gray('No bookmarked sessions. Bookmark one with `agents sessions bookmark <id>`.'));
84
84
  return;
85
85
  }
86
- for (const id of starred)
86
+ for (const id of bookmarked)
87
87
  console.log(`${chalk.yellow('★')} ${id}`);
88
- console.log(chalk.gray(`\n${starred.length} favorite${starred.length === 1 ? '' : 's'}.`));
88
+ console.log(chalk.gray(`\n${bookmarked.length} bookmark${bookmarked.length === 1 ? '' : 's'}.`));
89
89
  return;
90
90
  }
91
91
  const on = !options.remove;
92
92
  const results = [];
93
93
  for (const idQuery of ids) {
94
- const resolved = resolveFavoriteTarget(idQuery);
94
+ const resolved = resolveBookmarkTarget(idQuery);
95
95
  if ('error' in resolved) {
96
96
  results.push({ query: idQuery, error: resolved.error });
97
97
  continue;
98
98
  }
99
- // Unstarring something that was never starred, or starring it twice, is a
100
- // no-op the store already short-circuits — report the resulting state.
101
- setFavorite(resolved.id, on);
102
- results.push({ query: idQuery, id: resolved.id, favorite: isFavorite(resolved.id) });
99
+ // Removing a bookmark that does not exist, or bookmarking it twice,
100
+ // is a no-op the store already short-circuits — report the resulting state.
101
+ setBookmark(resolved.id, on);
102
+ results.push({ query: idQuery, id: resolved.id, bookmark: isBookmarked(resolved.id) });
103
103
  }
104
104
  if (json) {
105
105
  process.stdout.write(JSON.stringify({ results }, null, 2) + '\n');
@@ -109,11 +109,11 @@ export function registerSessionsFavoriteCommand(sessionsCmd) {
109
109
  if (r.error)
110
110
  console.error(chalk.red(r.error));
111
111
  else
112
- console.log(`${r.favorite ? chalk.yellow('★ favorited') : chalk.gray('☆ unfavorited')} ${r.id}`);
112
+ console.log(`${r.bookmark ? chalk.yellow('★ bookmarked') : chalk.gray('☆ unbookmarked')} ${r.id}`);
113
113
  }
114
114
  }
115
- // A failed lookup is a failed command — a script must not read "starred" from
116
- // a zero exit when nothing was starred.
115
+ // A failed lookup is a failed command — a script must not read "bookmarked"
116
+ // from a zero exit when nothing was bookmarked.
117
117
  if (results.some((r) => r.error))
118
118
  process.exitCode = 1;
119
119
  });
@@ -27,8 +27,8 @@ export interface BrowserFilter {
27
27
  device?: string;
28
28
  /** filter to one team's lineage, or all — the `T` key / `--in-team`. */
29
29
  team?: string;
30
- /** favorited-only — the `f` key / `--favorites`. */
31
- favorites: boolean;
30
+ /** bookmarked-only — the `b` key / `--bookmarks`. */
31
+ bookmarks: boolean;
32
32
  /** this-repo subtree vs every directory — the `P` key / `--all`. */
33
33
  projectScope: 'repo' | 'all';
34
34
  /** time window (undefined = all time) — the `W` key / `--since`. */
@@ -39,8 +39,8 @@ export interface BrowserFilter {
39
39
  project?: string;
40
40
  /** upper time bound from --until. */
41
41
  until?: string;
42
- /** retain only routine-origin sessions. */
43
- routine: boolean;
42
+ /** retain only routine-origin sessions, optionally narrowed to one routine. */
43
+ routine: boolean | string;
44
44
  /** indexed resource filters shared by the flag and focus surfaces. */
45
45
  skill?: string;
46
46
  plugin?: string;
@@ -97,7 +97,8 @@ export declare function activeBrowserSeed(opts: {
97
97
  host?: string[];
98
98
  since?: string;
99
99
  all?: boolean;
100
- favorites?: boolean;
100
+ bookmarks?: boolean;
101
+ routine?: boolean | string;
101
102
  }): Partial<BrowserFilter>;
102
103
  /**
103
104
  * The initial filter for the bare interactive listing: current-repo subtree by
@@ -113,7 +114,8 @@ export declare function bareBrowserSeed(opts: {
113
114
  since?: string;
114
115
  host?: string[];
115
116
  inTeam?: string;
116
- favorites?: boolean;
117
+ bookmarks?: boolean;
118
+ routine?: boolean | string;
117
119
  }): Partial<BrowserFilter>;
118
120
  /** Build the static peer query. Keeping it pure pins the per-device alias
119
121
  * contract: latest/oldest travel unresolved and are resolved by each peer. */
@@ -156,8 +158,8 @@ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string,
156
158
  * listing that has no live rows to explain it.
157
159
  */
158
160
  export declare function shouldShowHostColumn(f: BrowserFilter, live: Map<string, ActiveSession> | null, rows: SessionMeta[]): boolean;
159
- /** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
160
- export declare function applyFilters(rows: SessionMeta[], live: Map<string, ActiveSession>, f: BrowserFilter, self: string, favorites: Set<string>): SessionMeta[];
161
+ /** Apply the cheap in-memory filters (agent / device / project / running / bookmarks). */
162
+ export declare function applyFilters(rows: SessionMeta[], live: Map<string, ActiveSession>, f: BrowserFilter, self: string, bookmarks: Set<string>): SessionMeta[];
161
163
  /** One-shot form of the browser's canonical candidate pipeline. Focus uses this
162
164
  * instead of maintaining a second discovery/filter implementation. */
163
165
  export declare function collectSessionCandidates(initial: Partial<BrowserFilter>, opts?: {