@phnx-labs/agents-cli 1.22.29 → 1.22.30

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 (73) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +39 -1
  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/routines.js +29 -11
  10. package/dist/commands/secrets.d.ts +17 -0
  11. package/dist/commands/secrets.js +30 -15
  12. package/dist/commands/sessions-browser.js +6 -6
  13. package/dist/commands/sessions-favorite.d.ts +7 -7
  14. package/dist/commands/sessions-favorite.js +30 -30
  15. package/dist/commands/sessions-picker.d.ts +33 -1
  16. package/dist/commands/sessions-picker.js +102 -27
  17. package/dist/commands/sessions.d.ts +12 -1
  18. package/dist/commands/sessions.js +259 -20
  19. package/dist/commands/view.d.ts +11 -0
  20. package/dist/commands/view.js +56 -29
  21. package/dist/index.js +37 -2
  22. package/dist/lib/account-labels.d.ts +24 -0
  23. package/dist/lib/account-labels.js +72 -0
  24. package/dist/lib/agents.d.ts +32 -1
  25. package/dist/lib/agents.js +96 -31
  26. package/dist/lib/daemon-health.d.ts +24 -0
  27. package/dist/lib/daemon-health.js +84 -0
  28. package/dist/lib/daemon-ticks.d.ts +81 -0
  29. package/dist/lib/daemon-ticks.js +190 -0
  30. package/dist/lib/daemon.d.ts +68 -18
  31. package/dist/lib/daemon.js +303 -338
  32. package/dist/lib/device-config.d.ts +10 -0
  33. package/dist/lib/device-config.js +27 -0
  34. package/dist/lib/exec.d.ts +27 -0
  35. package/dist/lib/exec.js +49 -2
  36. package/dist/lib/hosts/dispatch.d.ts +4 -0
  37. package/dist/lib/hosts/dispatch.js +4 -0
  38. package/dist/lib/hosts/remote-cmd.js +1 -0
  39. package/dist/lib/hosts/run-target.d.ts +1 -0
  40. package/dist/lib/hosts/run-target.js +1 -0
  41. package/dist/lib/import.js +7 -6
  42. package/dist/lib/memory-cache.d.ts +19 -0
  43. package/dist/lib/memory-cache.js +31 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  46. package/dist/lib/migrate.d.ts +1 -1
  47. package/dist/lib/migrate.js +7 -2
  48. package/dist/lib/routine-activation.d.ts +2 -0
  49. package/dist/lib/routine-activation.js +16 -0
  50. package/dist/lib/runner.d.ts +18 -0
  51. package/dist/lib/runner.js +52 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  54. package/dist/lib/secrets/agent.d.ts +19 -0
  55. package/dist/lib/secrets/agent.js +32 -2
  56. package/dist/lib/secrets/scope.d.ts +3 -3
  57. package/dist/lib/secrets/scope.js +3 -3
  58. package/dist/lib/session/db.d.ts +15 -0
  59. package/dist/lib/session/db.js +90 -15
  60. package/dist/lib/session/discover.js +91 -39
  61. package/dist/lib/session/favorites.d.ts +2 -2
  62. package/dist/lib/session/favorites.js +2 -2
  63. package/dist/lib/session/parse.d.ts +63 -0
  64. package/dist/lib/session/parse.js +165 -20
  65. package/dist/lib/session/session-cache.d.ts +9 -6
  66. package/dist/lib/session/session-cache.js +23 -6
  67. package/dist/lib/shims.js +12 -0
  68. package/dist/lib/startup/command-registry.d.ts +15 -1
  69. package/dist/lib/startup/command-registry.js +49 -0
  70. package/dist/lib/usage-refresh.js +3 -2
  71. package/dist/lib/usage.d.ts +12 -10
  72. package/dist/lib/usage.js +63 -144
  73. package/package.json +4 -1
@@ -445,17 +445,18 @@ async function handleTerminalHandoff(agentSpec, options, prompt) {
445
445
  // the whole Kimi/DeepSeek/Qwen/GLM path — for `--terminal` runs only.
446
446
  const rawTarget = parseRunAccountPickerRequest(agentSpec).normalizedAgentSpec.split('@')[0];
447
447
  const knownAgent = resolveAgentName(rawTarget);
448
- if (knownAgent && isAgentHardDeprecated(knownAgent)) {
448
+ const [{ profileExists }, { resolveWorkflowRef }] = await Promise.all([
449
+ import('../lib/profiles.js'),
450
+ import('../lib/workflows.js'),
451
+ ]);
452
+ const hasProfile = profileExists(rawTarget);
453
+ if (knownAgent && !hasProfile && isAgentHardDeprecated(knownAgent)) {
449
454
  console.error(chalk.red(hardDeprecationError(knownAgent)));
450
455
  process.exit(1);
451
456
  }
452
457
  if (!knownAgent) {
453
- const [{ profileExists }, { resolveWorkflowRef }] = await Promise.all([
454
- import('../lib/profiles.js'),
455
- import('../lib/workflows.js'),
456
- ]);
457
458
  const probeCwd = options.cwd ?? process.cwd();
458
- if (!profileExists(rawTarget) && !resolveWorkflowRef(rawTarget, probeCwd)) {
459
+ if (!hasProfile && !resolveWorkflowRef(rawTarget, probeCwd)) {
459
460
  console.error(chalk.red(`Unknown agent, profile, or workflow: ${rawTarget}. See \`agents list\` for the installed harnesses.`));
460
461
  process.exit(1);
461
462
  }
@@ -545,6 +546,7 @@ export function registerRunCommand(program) {
545
546
  .option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,antigravity). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
546
547
  .option('-b, --balanced', 'Shortcut for --strategy balanced. Ignored when @version is pinned.')
547
548
  .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then balanced (spreads load across healthy accounts and skips any that are rate-limited). (Legacy `rotate` accepted as alias for `balanced`.)')
549
+ .option('--account <label>', 'Run a healthy installed version currently signed into this named account (never falls back to another identity)')
548
550
  .option('--acp', 'Route through the Agent Client Protocol instead of direct exec. Supported for claude via @zed-industries/claude-code-acp adapter. Unified event stream; emits ndjson when --json.')
549
551
  .option('-y, --yes', 'Skip the interactive budget-confirm prompt (require_confirm_over). Never skips a hard budget block.', false)
550
552
  .option('--loop', 'Re-inject the prompt/entrypoint each iteration until a stop condition (issue #332). Guards (--max-iterations, --budget, --until) are enforced outside the agent. Writes a checkpoint after every iteration for --resume-checkpoint.')
@@ -812,8 +814,10 @@ export function registerRunCommand(program) {
812
814
  process.exit(1);
813
815
  }
814
816
  // Hard-deprecated harnesses cannot be run — point the user at the successor.
815
- const runBaseAgentId = resolveAgentName(normalizedAgentSpec.split('@')[0]);
816
- if (runBaseAgentId && isAgentHardDeprecated(runBaseAgentId)) {
817
+ const runBaseAgentName = normalizedAgentSpec.split('@')[0];
818
+ const runBaseAgentId = resolveAgentName(runBaseAgentName);
819
+ const { profileExists: runProfileExists } = await import('../lib/profiles.js');
820
+ if (runBaseAgentId && !runProfileExists(runBaseAgentName) && isAgentHardDeprecated(runBaseAgentId)) {
817
821
  console.error(chalk.red(hardDeprecationError(runBaseAgentId)));
818
822
  process.exit(1);
819
823
  }
@@ -1567,6 +1571,7 @@ export function registerRunCommand(program) {
1567
1571
  agent: runAgent,
1568
1572
  version: resumeId ? undefined : runVersion,
1569
1573
  strategy: resumeId ? undefined : runStrategy,
1574
+ account: resumeId ? undefined : options.account,
1570
1575
  fallback: options.fallback,
1571
1576
  prompt,
1572
1577
  mode: forwardedMode,
@@ -1654,6 +1659,7 @@ export function registerRunCommand(program) {
1654
1659
  agent: runAgent,
1655
1660
  version: resumeId ? undefined : runVersion,
1656
1661
  strategy: resumeId ? undefined : runStrategy,
1662
+ account: resumeId ? undefined : options.account,
1657
1663
  fallback: options.fallback,
1658
1664
  prompt,
1659
1665
  mode: forwardedMode,
@@ -1840,11 +1846,11 @@ export function registerRunCommand(program) {
1840
1846
  // degenerates to a no-op ("I'll wait for the completion notification").
1841
1847
  let workflowHasSubagents = false;
1842
1848
  const cwd = options.cwd ?? process.cwd();
1849
+ if (accountPickerRequested && profileExists(rawAgent)) {
1850
+ console.error(chalk.red(`Account selection is not available for custom harness '${rawAgent}'. Run its concrete host agent with @ instead.`));
1851
+ process.exit(1);
1852
+ }
1843
1853
  if (accountPickerRequested && !isValidAgent(rawAgent)) {
1844
- if (profileExists(rawAgent)) {
1845
- console.error(chalk.red(`Account selection is not available for custom harness '${rawAgent}'. Run its concrete host agent with @ instead.`));
1846
- process.exit(1);
1847
- }
1848
1854
  if (resolveWorkflowRef(rawAgent, cwd)) {
1849
1855
  console.error(chalk.red(`Account selection is not available for workflow '${rawAgent}'. Run a concrete agent with @ instead.`));
1850
1856
  process.exit(1);
@@ -1886,14 +1892,11 @@ export function registerRunCommand(program) {
1886
1892
  process.stderr.write(chalk.yellow(`[agents] --session-id ignored: auto picked ${agent} (only claude accepts a forced session id)\n`));
1887
1893
  }
1888
1894
  }
1889
- else if (isValidAgent(rawAgent)) {
1890
- agent = rawAgent;
1891
- }
1892
1895
  else if (profileExists(rawAgent)) {
1893
- // Not a known agent id, but a profile by this name exists. Profiles
1894
- // bind (host agent, version, env overrides, keychain-backed auth)
1895
- // so Chinese models (Kimi, DeepSeek, Qwen, GLM) can run inside
1896
- // Claude Code without a local proxy.
1896
+ // A profile by this exact name exists. Profiles bind (host agent,
1897
+ // version, env overrides, keychain-backed auth) so Chinese models
1898
+ // (Kimi, DeepSeek, Qwen, GLM) can run inside Claude Code without a
1899
+ // local proxy, including when the profile name matches a native id.
1897
1900
  try {
1898
1901
  const resolved = resolveProfileForRun(rawAgent, options.model);
1899
1902
  agent = resolved.agent;
@@ -1925,6 +1928,9 @@ export function registerRunCommand(program) {
1925
1928
  process.exit(1);
1926
1929
  }
1927
1930
  }
1931
+ else if (isValidAgent(rawAgent)) {
1932
+ agent = rawAgent;
1933
+ }
1928
1934
  else if (resolveWorkflowRef(rawAgent, cwd)) {
1929
1935
  // Workflow: explicit directory, project .agents/workflows/<name>, user, system, or extra repo.
1930
1936
  // Resolution follows resource precedence: direct path, then project > user > system > extras.
@@ -2148,6 +2154,23 @@ export function registerRunCommand(program) {
2148
2154
  }
2149
2155
  }
2150
2156
  version = resolveVersionAlias(agent, version);
2157
+ if (options.account) {
2158
+ if (options.cloud || options.provider || options.lease) {
2159
+ console.error(chalk.red('--account selects a local installed identity and cannot be combined with cloud or lease placement.'));
2160
+ process.exit(1);
2161
+ }
2162
+ const { resolveAccountLabel } = await import('../lib/account-labels.js');
2163
+ try {
2164
+ const selected = await resolveAccountLabel(agent, options.account);
2165
+ if (version && version !== selected)
2166
+ throw new Error(`${agent}@${version} is not a healthy match for account '${options.account}'.`);
2167
+ version ??= selected;
2168
+ }
2169
+ catch (err) {
2170
+ console.error(chalk.red(err.message));
2171
+ process.exit(1);
2172
+ }
2173
+ }
2151
2174
  // --resume: resolve a prior conversation and rewrite the run target to
2152
2175
  // continue it. `version` here is already the alias-resolved candidate-version
2153
2176
  // FILTER (undefined for default/any, concrete for @latest/@oldest/@x.y.z);
@@ -2320,7 +2343,7 @@ export function registerRunCommand(program) {
2320
2343
  // the bare primary still resolves through the strategy — otherwise every
2321
2344
  // `agents run claude --fallback codex` run lands on the pinned default
2322
2345
  // account and account rotation silently stops (the gh-monitor heal bug).
2323
- if (!accountPickerRequested && (strategy !== 'pinned' || options.balanced || explicitStrategy)) {
2346
+ if (!accountPickerRequested && !options.account && (strategy !== 'pinned' || options.balanced || explicitStrategy)) {
2324
2347
  if (version) {
2325
2348
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2326
2349
  }
@@ -2434,6 +2457,29 @@ export function registerRunCommand(program) {
2434
2457
  version = healed;
2435
2458
  }
2436
2459
  }
2460
+ // The harness may simply not be on this machine. The self-heal above only
2461
+ // runs when a managed version resolved, so with nothing installed we used
2462
+ // to fall through and spawn the bare `cliCommand`, which dies as
2463
+ // `exec: cursor-agent: not found` (exit 127) after a misleading
2464
+ // "looks logged out" banner (RUSH-2339). Probe the executable
2465
+ // buildExecCommand will actually spawn and fail loud instead.
2466
+ //
2467
+ // This is an EXISTENCE probe, not "does agents-cli manage a version". A
2468
+ // harness the user installed themselves (Homebrew, a vendor `curl | sh`, a
2469
+ // distro package) has no version home and MUST still launch — the PATH
2470
+ // branch of resolveLaunchBinary is what keeps that working.
2471
+ {
2472
+ const { resolveLaunchBinary } = await import('../lib/exec.js');
2473
+ // `version` already carries the self-heal's resolution above (it assigns
2474
+ // `version = healed` whenever a version resolved at all), so re-deriving
2475
+ // it with resolveVersion here would be dead.
2476
+ if (!resolveLaunchBinary(agent, version)) {
2477
+ const target = version ? `${agent}@${version}` : agent;
2478
+ console.error(chalk.red(`agents: ${target} is not installed on this machine.`));
2479
+ console.error(chalk.yellow(`Install it with: agents add ${target}`));
2480
+ process.exit(1);
2481
+ }
2482
+ }
2437
2483
  const defaultVersion = version ?? resolveVersion(agent, cwd);
2438
2484
  // Re-apply the active rules preset before every launch (issue: preset
2439
2485
  // changes via `setActiveRulesPreset` only took effect after an explicit
@@ -11,7 +11,7 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
13
  import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
14
- import { assertSchedulerEnabled } from '../lib/device-config.js';
14
+ import { assertSchedulerEnabled, assertDaemonEnabled, isDaemonEnabled } from '../lib/device-config.js';
15
15
  import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError, ROUTINE_AGENT_IDS } from '../lib/agents.js';
16
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
17
17
  import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, HOST_STRATEGIES, computeProjectGroup, computeProjectGroupKind, projectGroupKey, projectGroupTitle, projectGroupOrder, normalizeProjects, } from '../lib/routines.js';
@@ -344,6 +344,7 @@ function parseRoutineTrigger(options) {
344
344
  function ensureSchedulerRunning(opts = {}) {
345
345
  const log = opts.stderr ? console.error : console.log;
346
346
  try {
347
+ assertDaemonEnabled();
347
348
  assertSchedulerEnabled();
348
349
  }
349
350
  catch (err) {
@@ -1282,11 +1283,18 @@ export function registerRoutinesCommands(program) {
1282
1283
  console.log(` ${chalk.cyan(job.name)} — missed ${chalk.gray(job.expectedAt.toLocaleString())}, last ran ${chalk.gray(last)}`);
1283
1284
  }
1284
1285
  // Need the daemon alive so spawned jobs are monitored and meta.json is
1285
- // finalized. Start it if it isn't already running.
1286
+ // finalized. Start it if it isn't already running — unless daemon.enabled
1287
+ // is off, in which case this auto-start is skipped with a stated reason.
1286
1288
  if (!options.dryRun && !isDaemonRunning()) {
1287
- const started = startDaemon();
1288
- if (started.pid) {
1289
- console.log(chalk.gray(`\nStarted scheduler (PID: ${started.pid}) so catchup runs are monitored.`));
1289
+ try {
1290
+ assertDaemonEnabled();
1291
+ const started = startDaemon();
1292
+ if (started.pid) {
1293
+ console.log(chalk.gray(`\nStarted scheduler (PID: ${started.pid}) so catchup runs are monitored.`));
1294
+ }
1295
+ }
1296
+ catch (err) {
1297
+ console.log(chalk.yellow(`\n${err.message}`));
1290
1298
  }
1291
1299
  }
1292
1300
  console.log(chalk.bold(options.dryRun ? '\nRecording misses...' : '\nTriggering catchup runs...'));
@@ -1372,11 +1380,18 @@ export function registerRoutinesCommands(program) {
1372
1380
  return;
1373
1381
  }
1374
1382
  // Fired jobs run detached via executeJobDetached (the same path cron
1375
- // uses). Keep the daemon alive so each run's meta.json is finalized.
1383
+ // uses). Keep the daemon alive so each run's meta.json is finalized
1384
+ // unless daemon.enabled is off, in which case this auto-start is
1385
+ // skipped with a stated reason.
1376
1386
  if (!isDaemonRunning()) {
1377
- const started = startDaemon();
1378
- if (started.pid) {
1379
- console.log(chalk.gray(`Started scheduler (PID: ${started.pid}) so webhook runs are monitored.`));
1387
+ if (!isDaemonEnabled()) {
1388
+ console.log(chalk.yellow(`Daemon is disabled (daemon.enabled=false) — run(s) below fire but are not monitored. Re-enable with: agents daemon enable`));
1389
+ }
1390
+ else {
1391
+ const started = startDaemon();
1392
+ if (started.pid) {
1393
+ console.log(chalk.gray(`Started scheduler (PID: ${started.pid}) so webhook runs are monitored.`));
1394
+ }
1380
1395
  }
1381
1396
  }
1382
1397
  const fired = await fireWebhookJobs(webhook);
@@ -1649,8 +1664,11 @@ export function registerRoutinesCommands(program) {
1649
1664
  .description('Start the background scheduler. Usually unnecessary — it auto-starts when you add your first routine.')
1650
1665
  .action(() => {
1651
1666
  try {
1652
- // A manual start on a scheduler-disabled device refuses with the same
1653
- // message the auto-start surfaces give.
1667
+ // A manual start on a disabled device refuses with the same message the
1668
+ // auto-start surfaces give — `agents routines start` is a convenience
1669
+ // wrapper around the daemon, not the deliberate override. Use
1670
+ // `agents daemon start` to bypass daemon.enabled explicitly.
1671
+ assertDaemonEnabled();
1654
1672
  assertSchedulerEnabled();
1655
1673
  }
1656
1674
  catch (err) {
@@ -149,6 +149,23 @@ export declare function renderViewStatusLine(b: SecretsBundle, heldExpiresAt: nu
149
149
  * and a hard abort at inject time, i.e. after it had already broken something.
150
150
  */
151
151
  export declare function renderExpiringCol(b: SecretsBundle, now?: number): string;
152
+ /**
153
+ * Resolve an existing import target bundle (inheriting its backend) or create a
154
+ * new one with the requested backend. Refuses to silently downgrade a
155
+ * keychain-backed bundle to `file` — shared by every `import` source so the
156
+ * guard can't drift between them.
157
+ *
158
+ * `force` additionally recreates a bundle whose METADATA RECORD is present but
159
+ * undecryptable — a file store whose key was lost or rotated out from under it.
160
+ * That is precisely the state provisioning exists to repair (import is how a box
161
+ * gets its bundles back), and without this the import dies on `readBundle` and
162
+ * the only route left is deleting the record by hand on an already-degraded
163
+ * store. It is gated on `--force` on purpose: recreating unconditionally would
164
+ * destroy a perfectly healthy bundle for someone who merely forgot to set
165
+ * `AGENTS_SECRETS_PASSPHRASE`, which is the hazard `readBundleIfDecryptable`
166
+ * exists to describe. `--force` already means "overwrite what is there".
167
+ */
168
+ export declare function resolveImportBundle(name: string, backendOpt: string | undefined, synced?: boolean, force?: boolean): SecretsBundle;
152
169
  /** Register the `agents secrets` command tree. */
153
170
  export declare function registerSecretsCommands(program: Command): void;
154
171
  /** Validate a prompt-policy value, throwing a clear message on a bad one (the
@@ -804,16 +804,31 @@ export function renderExpiringCol(b, now = Date.now()) {
804
804
  * new one with the requested backend. Refuses to silently downgrade a
805
805
  * keychain-backed bundle to `file` — shared by every `import` source so the
806
806
  * guard can't drift between them.
807
+ *
808
+ * `force` additionally recreates a bundle whose METADATA RECORD is present but
809
+ * undecryptable — a file store whose key was lost or rotated out from under it.
810
+ * That is precisely the state provisioning exists to repair (import is how a box
811
+ * gets its bundles back), and without this the import dies on `readBundle` and
812
+ * the only route left is deleting the record by hand on an already-degraded
813
+ * store. It is gated on `--force` on purpose: recreating unconditionally would
814
+ * destroy a perfectly healthy bundle for someone who merely forgot to set
815
+ * `AGENTS_SECRETS_PASSPHRASE`, which is the hazard `readBundleIfDecryptable`
816
+ * exists to describe. `--force` already means "overwrite what is there".
807
817
  */
808
- function resolveImportBundle(name, backendOpt, synced = false) {
818
+ export function resolveImportBundle(name, backendOpt, synced = false, force = false) {
809
819
  const requestedBackend = synced ? 'vault' : resolveBackendOpt(backendOpt);
810
820
  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`}.`);
821
+ // readBundleIfDecryptable nulls ONLY on BundleUndecryptableError; a locked
822
+ // keychain or logged-out vault still throws, so a recoverable state can
823
+ // never be mistaken for a lost key and silently overwritten.
824
+ const bundle = force ? readBundleIfDecryptable(name) : readBundle(name);
825
+ if (bundle) {
826
+ if (requestedBackend !== 'keychain' && bundle.backend !== requestedBackend) {
827
+ throw new Error(`Bundle '${name}' already exists with a different backend; ` +
828
+ `delete it first to recreate it as ${requestedBackend === 'vault' ? 'synced' : `${requestedBackend}-backed`}.`);
829
+ }
830
+ return bundle;
815
831
  }
816
- return bundle;
817
832
  }
818
833
  return { name, backend: requestedBackend === 'keychain' ? undefined : requestedBackend, vars: {} };
819
834
  }
@@ -1984,7 +1999,7 @@ Examples:
1984
1999
  }
1985
2000
  const env = importBundleFromFile(opts.fromFile, passphrase);
1986
2001
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1987
- const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced);
2002
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force);
1988
2003
  const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1989
2004
  emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from-file', source: 'file', status: 'success', keyCount: added });
1990
2005
  console.log(chalk.green(`Imported ${added} key(s) from file${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
@@ -1998,7 +2013,7 @@ Examples:
1998
2013
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1999
2014
  const target = await resolveHostSshTarget(opts.host);
2000
2015
  const env = await remoteResolveEnv(target, resolvedBundleName, { osLookupName: opts.host });
2001
- const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced);
2016
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force);
2002
2017
  const { added, skipped } = applyEnvToBundle(bundle, env, opts);
2003
2018
  emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from-ssh', source: 'ssh', host: opts.host, status: 'success', keyCount: added });
2004
2019
  console.log(chalk.green(`Imported ${added} key(s) from ${opts.host}${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
@@ -2026,7 +2041,7 @@ Examples:
2026
2041
  // to downgrade keychain -> file) or creates it with the requested backend
2027
2042
  // so a single `import --backend file` works (what `export --host ...
2028
2043
  // --remote-backend file` drives on the remote).
2029
- const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced);
2044
+ const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced, opts.force);
2030
2045
  if (source.kind === '1password') {
2031
2046
  assertOpAvailable();
2032
2047
  const vault = await resolveVault(source.vault);
@@ -2431,16 +2446,16 @@ Examples:
2431
2446
  .command('lease <bundle>')
2432
2447
  .description('Hold only an explicit subset of a bundle until an independent expiry.')
2433
2448
  .requiredOption('--keys <keys>', 'Comma-separated key subset')
2434
- .requiredOption('--for <duration>', 'Lease duration, for example 30m or 8h')
2449
+ .requiredOption('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d)')
2435
2450
  .option('--agent <agent>', 'Narrow the lease to one harness; default is global')
2436
2451
  .option('--durable', 'Keep the lease across sleep as well as broker restart')
2437
2452
  .action(async (name, opts) => {
2438
2453
  if (process.platform !== 'darwin') {
2439
2454
  throw new Error('Scoped lease brokering is not available on this platform yet.');
2440
2455
  }
2441
- const seconds = parseDuration(opts.for);
2456
+ const seconds = parseDuration(opts.ttl);
2442
2457
  if (seconds === null)
2443
- throw new Error(`Invalid lease duration '${opts.for}'.`);
2458
+ throw new Error(`Invalid lease duration '${opts.ttl}'.`);
2444
2459
  const ttlMs = seconds * 1000;
2445
2460
  const harness = opts.agent || GLOBAL_HARNESS;
2446
2461
  const { bundle, env } = readAndResolveBundleEnv(name, {
@@ -2516,7 +2531,7 @@ Examples:
2516
2531
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
2517
2532
  .option('--until <date>', 'Hold until this absolute date or timestamp (for example 2026-08-06T12:00:00Z). Mutually exclusive with --ttl.')
2518
2533
  .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.')
2534
+ .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.')
2520
2535
  .option('--all', 'Unlock every configured bundle')
2521
2536
  .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
2537
  .action(async (names, opts) => {
@@ -2602,12 +2617,12 @@ Examples:
2602
2617
  // (single-instance start lock, #414) and best-effort — never blocks unlock.
2603
2618
  ensureDaemonStarted();
2604
2619
  let loaded = 0;
2605
- // An unlock is a deliberate act, so it grants GLOBALLY unless `--for`
2620
+ // An unlock is a deliberate act, so it grants GLOBALLY unless `--agent`
2606
2621
  // narrows it to one harness. It must NOT inherit the ambient
2607
2622
  // AGENTS_AGENT_NAME: that silently scoped a terminal unlock to whichever
2608
2623
  // agent happened to launch the shell, leaving the grant unreadable to
2609
2624
  // every other reader for its whole TTL.
2610
- const harness = opts.for || GLOBAL_HARNESS;
2625
+ const harness = opts.agent || GLOBAL_HARNESS;
2611
2626
  for (const name of targets) {
2612
2627
  try {
2613
2628
  // noAgent: read the real keychain (one Touch ID) rather than the
@@ -560,7 +560,7 @@ function helpFor(_f, mode) {
560
560
  if (mode === 'search') {
561
561
  return 'type to filter · ↑↓ navigate · esc exit search · ⏎ resume';
562
562
  }
563
- 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';
563
+ return 's search · r running · f favorites · * favorite · c teams · t team · a agent · d device · p project · w window · tab preview · y copy-cmd · ⏎ resume · esc quit';
564
564
  }
565
565
  /**
566
566
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
@@ -589,8 +589,8 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
589
589
  // needs it — fetch it once, lazily, the first time running is toggled on.
590
590
  let liveCache = null;
591
591
  // Re-read every load (it's an mtime-memoized parse of one small file), so the
592
- // `*` key's reload picks up the star it just wrote — and so does a favorite
593
- // starred by another session on this machine.
592
+ // `*` key's reload picks up the favorite it just wrote — and so does one
593
+ // favorited by another session on this machine.
594
594
  let favorites = new Set();
595
595
  // Generation guard: two quick keypresses can start overlapping loads whose
596
596
  // SSH fan-outs settle out of order. dynamicPicker's own gen ref guards which
@@ -700,12 +700,12 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
700
700
  },
701
701
  onKey: (name, f, active, query) => {
702
702
  if (name === '*') {
703
- // Only a row with a real session id can be starred: a projected live row
703
+ // Only a row with a real session id can be favorited: a projected live row
704
704
  // with no id is keyed by pid, which is gone the moment the process is.
705
705
  if (!active || active.id.startsWith(LIVE_ROW_PREFIX))
706
- return 'nothing to star on this row';
706
+ return 'nothing to favorite on this row';
707
707
  const on = toggleFavorite(active.id);
708
- // reload so the row's star is repainted — labels are memoized per row.
708
+ // reload so the row's favorite glyph is repainted — labels are memoized per row.
709
709
  return { flash: on ? `★ favorited ${active.shortId}` : `☆ unfavorited ${active.shortId}`, reload: true };
710
710
  }
711
711
  // Both cases: `hotkeyToken` hands `onKey` the literal character, and this
@@ -1,16 +1,16 @@
1
1
  /**
2
- * `agents sessions favorite` — the non-TTY half of the star.
2
+ * `agents sessions favorite` — the non-TTY half of the favorite 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 favorites 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/favorites.ts`.
8
8
  */
9
9
  import type { Command } from 'commander';
10
10
  /**
11
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: starring
13
- * the wrong session is invisible until the user wonders where their star went.
12
+ * a full session id. Ambiguity is an ERROR, not a silent first-match: favoriting
13
+ * the wrong session is invisible until the user wonders where their favorite went.
14
14
  */
15
15
  export declare function resolveFavoriteTarget(idQuery: string): {
16
16
  id: string;
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `agents sessions favorite` — the non-TTY half of the star.
2
+ * `agents sessions favorite` — the non-TTY half of the favorite 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 favorites 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/favorites.ts`.
8
8
  */
9
9
  import chalk from 'chalk';
10
10
  import { setHelpSections } from '../lib/help.js';
@@ -13,15 +13,15 @@ import { isCompleteSessionId } from '../lib/session/discover.js';
13
13
  import { isFavorite, listFavorites, setFavorite } from '../lib/session/favorites.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: favoriting
17
+ * the wrong session is invisible until the user wonders where their favorite went.
18
18
  */
19
19
  export function resolveFavoriteTarget(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 `*` favorites 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() }
@@ -36,30 +36,30 @@ export function resolveFavoriteTarget(idQuery) {
36
36
  export function registerSessionsFavoriteCommand(sessionsCmd) {
37
37
  const cmd = sessionsCmd
38
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)')
39
+ .argument('[ids...]', 'Session ids to favorite (full or short id prefix)')
40
+ .description('Favorite sessions so they are easy to find again — list them with --favorites, or `f` in the browser.')
41
+ .option('--remove', 'Remove the given sessions from favorites instead of adding them')
42
+ .option('--list', 'List the favorited 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)
46
+ # Favorite a session by its short id (the 8 chars the listing prints)
47
47
  agents sessions favorite 26c27162
48
48
 
49
- # See what is starred
49
+ # See what is favorited
50
50
  agents sessions favorite --list
51
51
 
52
- # Browse only the starred ones
52
+ # Browse only the favorited ones
53
53
  agents sessions --favorites
54
54
 
55
- # Unstar it again
55
+ # Remove it from favorites again
56
56
  agents sessions favorite 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\`), \`*\` favorites the highlighted
60
+ session and \`f\` filters the list down to the favorited ones.
61
61
 
62
- Stars live in ~/.agents/.history/favorites.json, keyed by session id, so
62
+ Favorites live in ~/.agents/.history/favorites.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,18 +74,18 @@ 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 favorited = [...listFavorites()].sort();
78
78
  if (json) {
79
- process.stdout.write(JSON.stringify({ favorites: starred }, null, 2) + '\n');
79
+ process.stdout.write(JSON.stringify({ favorites: favorited }, 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 (favorited.length === 0) {
83
+ console.log(chalk.gray('No favorited sessions. Favorite one with `agents sessions favorite <id>`.'));
84
84
  return;
85
85
  }
86
- for (const id of starred)
86
+ for (const id of favorited)
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${favorited.length} favorite${favorited.length === 1 ? '' : 's'}.`));
89
89
  return;
90
90
  }
91
91
  const on = !options.remove;
@@ -96,8 +96,8 @@ export function registerSessionsFavoriteCommand(sessionsCmd) {
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.
99
+ // Unfavoriting something that was never favorited, or favoriting it twice,
100
+ // is a no-op the store already short-circuits — report the resulting state.
101
101
  setFavorite(resolved.id, on);
102
102
  results.push({ query: idQuery, id: resolved.id, favorite: isFavorite(resolved.id) });
103
103
  }
@@ -112,8 +112,8 @@ export function registerSessionsFavoriteCommand(sessionsCmd) {
112
112
  console.log(`${r.favorite ? chalk.yellow('★ favorited') : chalk.gray('☆ unfavorited')} ${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 "favorited"
116
+ // from a zero exit when nothing was favorited.
117
117
  if (results.some((r) => r.error))
118
118
  process.exitCode = 1;
119
119
  });
@@ -1,5 +1,6 @@
1
1
  import type { SessionEvent, SessionMeta, TodoProgress } from '../lib/session/types.js';
2
- import { classifyFileChanges } from '../lib/session/digest.js';
2
+ import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
3
+ import { extractArtifacts, extractHooks, extractLinks, extractSkills } from '../lib/session/highlights.js';
3
4
  /**
4
5
  * Compact checklist tally for list rows and previews (RUSH-2045).
5
6
  * Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
@@ -31,6 +32,12 @@ export interface SessionPickerConfig {
31
32
  /** Lines the caller printed above the prompt (hidden-session footer). */
32
33
  linesAbovePrompt?: number;
33
34
  }
35
+ export declare function clearPreviewMemoryCacheForTest(): void;
36
+ export declare function loadSessionPreviewDigest(session: SessionMeta): {
37
+ digest?: SessionPreviewDigest;
38
+ events: SessionEvent[];
39
+ error?: string;
40
+ };
34
41
  /** Build a cached multi-line preview string for display in the session picker. */
35
42
  export declare function buildPreview(session: SessionMeta): string;
36
43
  /**
@@ -65,6 +72,31 @@ export declare function extractTiming(session: Pick<SessionMeta, 'timestamp' | '
65
72
  lastActiveAgo?: string;
66
73
  duration?: string;
67
74
  };
75
+ export interface SessionPreviewDigest {
76
+ schemaVersion: 1;
77
+ firstUser: string;
78
+ lastAssistant: string;
79
+ filesRead: number;
80
+ toolCalls: number;
81
+ planFile: string;
82
+ todos?: TodoProgress;
83
+ subAgentCount: number;
84
+ toolTags: string[];
85
+ changes: ReturnType<typeof changeCounts>;
86
+ dirs: string[];
87
+ repos: string[];
88
+ artifacts: ReturnType<typeof extractArtifacts>;
89
+ skills: ReturnType<typeof extractSkills>;
90
+ plugins: string[];
91
+ hooks: ReturnType<typeof extractHooks>;
92
+ links: ReturnType<typeof extractLinks>;
93
+ errorCount: number;
94
+ firstError?: string;
95
+ toolHistogram: ReturnType<typeof toolHistogram>;
96
+ test: ReturnType<typeof detectTestResult>;
97
+ }
98
+ /** Fold a harness-normalized event stream into the stable preview data model. */
99
+ export declare function buildSessionPreviewDigest(events: SessionEvent[], session: SessionMeta): SessionPreviewDigest;
68
100
  /**
69
101
  * Unique directories the session touched, compact and human-readable.
70
102
  * Prefer `session.recentDirectoriesTouched` — the scan records it on the row, so