@phnx-labs/agents-cli 1.22.22 → 1.22.24

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 (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -10,9 +10,10 @@ import chalk from 'chalk';
10
10
  import { listRemoteBundles, MIN_PASSPHRASE_LEN, pullBundle, pushBundle, } from '../lib/secrets/sync.js';
11
11
  import { bundleExists, listBundles } from '../lib/secrets/bundles.js';
12
12
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
13
+ import { missingSyncPassphraseMessage, resolveSyncPassphraseFromEnv, warnEnvPassphraseReadableOnce, } from '../lib/secrets/sync-passphrase.js';
13
14
  async function promptPassphrase(message, confirm = false) {
14
15
  if (!isInteractiveTerminal()) {
15
- throw new Error('A sync passphrase is required. Run from a TTY, or set AGENTS_SECRETS_PASSPHRASE.');
16
+ throw new Error(missingSyncPassphraseMessage());
16
17
  }
17
18
  const { password } = await import('@inquirer/prompts');
18
19
  const first = await password({ message, mask: true });
@@ -25,21 +26,18 @@ async function promptPassphrase(message, confirm = false) {
25
26
  throw new Error('Passphrases do not match.');
26
27
  return first;
27
28
  }
28
- // Print the env-var-source warning at most once per process so a `--all` push
29
- // over many bundles doesn't flood stderr with the same notice.
30
- let envPassphraseWarned = false;
31
29
  function passphraseFromEnvOrPrompt(confirm) {
32
- const fromEnv = process.env.AGENTS_SECRETS_PASSPHRASE;
33
- if (fromEnv) {
34
- if (fromEnv.length < MIN_PASSPHRASE_LEN) {
30
+ // Env resolution (current name, then the deprecated master-key name) lives in
31
+ // lib/secrets/sync-passphrase.ts so this command and `agents sync` share one
32
+ // chokepoint otherwise each call site warns separately and the "once per
33
+ // process" promise is a lie.
34
+ const { value } = resolveSyncPassphraseFromEnv();
35
+ if (value) {
36
+ if (value.length < MIN_PASSPHRASE_LEN) {
35
37
  return Promise.reject(new Error(`Passphrase must be at least ${MIN_PASSPHRASE_LEN} characters.`));
36
38
  }
37
- if (!envPassphraseWarned) {
38
- envPassphraseWarned = true;
39
- process.stderr.write(chalk.yellow('warn: using AGENTS_SECRETS_PASSPHRASE. Env vars are readable by other same-user processes ' +
40
- '(/proc, ps, crash dumps, CI logs) — rotate the passphrase after CI use.\n'));
41
- }
42
- return Promise.resolve(fromEnv);
39
+ warnEnvPassphraseReadableOnce();
40
+ return Promise.resolve(value);
43
41
  }
44
42
  return promptPassphrase('Sync passphrase', confirm);
45
43
  }
@@ -48,9 +48,11 @@ export declare function parseImportSource(opts: {
48
48
  * defaults). Shared with the command action so the wiring is unit-testable
49
49
  * without a live SSH session.
50
50
  */
51
+ export declare function resolveUnlockTtlMs(ttl: string | undefined, until: string | undefined, now?: number): number;
51
52
  export declare function buildRemoteUnlockArgs(names: string[], opts: {
52
53
  all?: boolean;
53
54
  ttl?: string;
55
+ until?: string;
54
56
  durable?: boolean;
55
57
  }): string[];
56
58
  /**
@@ -15,7 +15,7 @@ import * as path from 'path';
15
15
  import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
16
16
  import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
17
17
  import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
- import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } from '../lib/secrets/remote.js';
18
+ import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, buildRemoteFileImportCommand, } 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
21
  import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, isHeadlessSecretsContext, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
@@ -31,6 +31,7 @@ import { readMeta } from '../lib/state.js';
31
31
  import { parseDuration } from '../lib/hooks/cache.js';
32
32
  import { emit, query } from '../lib/events.js';
33
33
  import { emitSecretAudit } from '../lib/secrets/audit.js';
34
+ import { SYNC_PASSPHRASE_ENV, resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
34
35
  import { getBundleUsage, getAllBundleUsage, getUsageHistory, SECRET_USAGE_EVENTS, } from '../lib/secrets/usage-db.js';
35
36
  import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
36
37
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
@@ -266,11 +267,31 @@ function maybePrintSyncedHint(name, stillPresent) {
266
267
  * defaults). Shared with the command action so the wiring is unit-testable
267
268
  * without a live SSH session.
268
269
  */
270
+ export function resolveUnlockTtlMs(ttl, until, now = Date.now()) {
271
+ if (ttl && until)
272
+ throw new Error('--ttl and --until are mutually exclusive.');
273
+ if (until) {
274
+ const expiresAt = Date.parse(until);
275
+ if (!Number.isFinite(expiresAt))
276
+ throw new Error("Invalid --until '" + until + "'. Use an ISO date or timestamp.");
277
+ if (expiresAt <= now)
278
+ throw new Error("Invalid --until '" + until + "': date must be in the future.");
279
+ return expiresAt - now;
280
+ }
281
+ if (ttl) {
282
+ const secs = parseDuration(ttl);
283
+ if (!secs)
284
+ throw new Error("Invalid --ttl '" + ttl + "'. Use e.g. 30m, 2h, 8h, 3d.");
285
+ return secs * 1000;
286
+ }
287
+ return secretsHoldMs();
288
+ }
269
289
  export function buildRemoteUnlockArgs(names, opts) {
270
290
  return [
271
291
  'unlock',
272
292
  ...(opts.all ? ['--all'] : names),
273
293
  ...(opts.ttl ? ['--ttl', opts.ttl] : []),
294
+ ...(opts.until ? ['--until', opts.until] : []),
274
295
  // Forward --durable so a remote unlock honors it too; without this the remote
275
296
  // silently falls back to its own secrets.agent.durable default (off).
276
297
  ...(opts.durable ? ['--durable'] : []),
@@ -333,10 +354,6 @@ function getCliVersion() {
333
354
  return '0.0.0';
334
355
  }
335
356
  }
336
- /** POSIX single-quote a string for safe interpolation into a remote shell command. */
337
- function shellQuote(s) {
338
- return `'${s.replace(/'/g, `'\\''`)}'`;
339
- }
340
357
  /**
341
358
  * Serialize a resolved env map to `.env` lines that round-trip losslessly through
342
359
  * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
@@ -1332,7 +1349,7 @@ export function registerSecretsCommands(program) {
1332
1349
  if (bundle.allow_exec)
1333
1350
  console.log(chalk.yellow('allow_exec: true'));
1334
1351
  if (bundle.backend === 'file')
1335
- console.log(chalk.gray('backend: file (passphrase-encrypted; reads need AGENTS_SECRETS_PASSPHRASE, no Touch ID)'));
1352
+ console.log(chalk.gray('backend: file (encrypted at rest; headless reads via a machine-local key, or AGENTS_SECRETS_PASSPHRASE if set — no Touch ID)'));
1336
1353
  if (bundle.backend === 'vault')
1337
1354
  console.log(chalk.gray('storage: synced (age-encrypted ~/.agents/vault.age; needs agents login)'));
1338
1355
  if (bundlePolicy(bundle) === 'never') {
@@ -1588,7 +1605,7 @@ export function registerSecretsCommands(program) {
1588
1605
  console.log(chalk.red('Stored without biometry protection — reads are silent. Automation-only; rotate anything sensitive out of it.'));
1589
1606
  }
1590
1607
  if (backend === 'file') {
1591
- console.log(chalk.gray('File-backed: items are AES-256-GCM encrypted under AGENTS_SECRETS_PASSPHRASE (no Touch ID).'));
1608
+ console.log(chalk.gray('File-backed: items are AES-256-GCM encrypted at rest under a machine-local key (or AGENTS_SECRETS_PASSPHRASE if set); headless reads, no Touch ID.'));
1592
1609
  }
1593
1610
  if (backend === 'vault') {
1594
1611
  console.log(chalk.gray('Synced: items are encrypted in ~/.agents/vault.age. Copy that file with your sync tool of choice.'));
@@ -1961,7 +1978,7 @@ Examples:
1961
1978
  .option('--synced', 'When creating the bundle, store it in the age-encrypted synced secrets file')
1962
1979
  .option('--force', 'Overwrite an existing key in the bundle')
1963
1980
  .option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
1964
- .option('--from-file <path>', 'Import from an AES-256-GCM encrypted offline bundle file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of export --to-file)')
1981
+ .option('--from-file <path>', `Import from an AES-256-GCM encrypted offline bundle file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of export --to-file)`)
1965
1982
  .option('--from-ssh', 'Pull the bundle from a fleet peer over SSH and import it locally (requires --host)')
1966
1983
  .option('--host <peer>', 'SSH peer to pull from when using --from-ssh (host alias or user@host)')
1967
1984
  .action(async (bundleName, opts) => {
@@ -1977,9 +1994,10 @@ Examples:
1977
1994
  throw new Error('--from-file, --from-ssh, and --from/--from-1password are mutually exclusive; pick one import source.');
1978
1995
  }
1979
1996
  if (opts.fromFile) {
1980
- const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1997
+ // Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
1998
+ const passphrase = resolveSyncPassphraseFromEnv().value ?? '';
1981
1999
  if (!passphrase) {
1982
- throw new Error('--from-file needs AGENTS_SECRETS_PASSPHRASE set to decrypt the bundle file.');
2000
+ throw new Error(`--from-file needs ${SYNC_PASSPHRASE_ENV} set to decrypt the bundle file.`);
1983
2001
  }
1984
2002
  const env = importBundleFromFile(opts.fromFile, passphrase);
1985
2003
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
@@ -2064,10 +2082,10 @@ Examples:
2064
2082
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
2065
2083
  .option('--host <target...>', 'Push the bundle over SSH to this target (host alias or user@host); repeatable for multiple machines')
2066
2084
  .option('--device <target...>', 'Alias for --host; repeatable')
2067
- .option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
2085
+ .option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file. file is headless-readable via the remote\'s machine-local key; it forwards AGENTS_SECRETS_PASSPHRASE over stdin only if set (opt-in).', 'keychain')
2068
2086
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
2069
2087
  .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
2070
- .option('--to-file <path>', 'Write the bundle as an AES-256-GCM encrypted offline file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of import --from-file)')
2088
+ .option('--to-file <path>', `Write the bundle as an AES-256-GCM encrypted offline file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of import --from-file)`)
2071
2089
  .action(async (bundleName, opts) => {
2072
2090
  try {
2073
2091
  // `--device` is an alias for `--host` (fleet vocabulary parity with
@@ -2078,9 +2096,10 @@ Examples:
2078
2096
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
2079
2097
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
2080
2098
  if (opts.toFile) {
2081
- const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
2099
+ // Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
2100
+ const passphrase = resolveSyncPassphraseFromEnv().value ?? '';
2082
2101
  if (!passphrase) {
2083
- throw new Error('--to-file needs AGENTS_SECRETS_PASSPHRASE set to encrypt the bundle. ' +
2102
+ throw new Error(`--to-file needs ${SYNC_PASSPHRASE_ENV} set to encrypt the bundle. ` +
2084
2103
  'Set it for this command, then supply the same value when importing.');
2085
2104
  }
2086
2105
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly: true });
@@ -2097,20 +2116,16 @@ Examples:
2097
2116
  for (const h of hosts)
2098
2117
  assertValidSshTarget(h);
2099
2118
  const remoteBackend = parseBackendOpt(opts.remoteBackend);
2100
- // For a file-backed remote bundle the remote must encrypt at rest with
2101
- // a passphrase. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE — the
2102
- // operator unlocks it once on this (trusted, biometry-gated) machine
2103
- // and ship it as the FIRST stdin line so it never lands in argv / `ps`
2104
- // / the remote shell history. The remote `read -r` consumes that line;
2105
- // `agents secrets import --from /dev/stdin` reads the .env remainder.
2106
- let remotePassphrase = '';
2107
- if (remoteBackend === 'file') {
2108
- remotePassphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
2109
- if (!remotePassphrase) {
2110
- throw new Error('--remote-backend file needs AGENTS_SECRETS_PASSPHRASE set locally to encrypt the ' +
2111
- 'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
2112
- }
2113
- }
2119
+ // For a file-backed remote bundle a passphrase is OPTIONAL. The file
2120
+ // store is passphrase-free by default: with AGENTS_SECRETS_PASSPHRASE
2121
+ // unset the remote `import --backend file` auto-provisions the remote's
2122
+ // own machine-local key (0600 under ~/.agents/.secrets-key/), so reads
2123
+ // are HEADLESS. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE only when
2124
+ // the operator opts in by setting it (e.g. to key the bundle off-disk
2125
+ // under a shared secret) — shipped as the FIRST stdin line so it never
2126
+ // lands in argv / `ps` / the remote shell history. Forcing a shared
2127
+ // passphrase would defeat headless reads, so we no longer require one.
2128
+ const remotePassphrase = remoteBackend === 'file' ? (process.env.AGENTS_SECRETS_PASSPHRASE ?? '') : '';
2114
2129
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, keyMode: 'storage', agentOnly: true });
2115
2130
  const dotenv = bundleEnvToDotenv(env);
2116
2131
  const keyCount = Object.keys(env).length;
@@ -2126,19 +2141,22 @@ Examples:
2126
2141
  for (const host of hosts) {
2127
2142
  let res;
2128
2143
  if (remoteBackend === 'file') {
2129
- // File backend forwards AGENTS_SECRETS_PASSPHRASE as the FIRST stdin
2130
- // line (consumed by `read`, so it never lands in argv / `ps` /
2131
- // remote history), then the .env. That `read`/`export` prologue is
2132
- // POSIX shell refuse a Windows target cleanly rather than emit
2133
- // broken PowerShell.
2144
+ // File backend: headless-readable via the remote's machine-local key
2145
+ // when no passphrase is set; otherwise forwards AGENTS_SECRETS_PASSPHRASE
2146
+ // as the FIRST stdin line (consumed by `read`, so it never lands in
2147
+ // argv / `ps` / remote history), then the .env. Both build a POSIX
2148
+ // `bash -lc` command — refuse a Windows target cleanly rather than
2149
+ // emit broken PowerShell.
2134
2150
  if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
2135
2151
  failures++;
2136
2152
  console.error(chalk.red(`${host}: file backend export to a Windows target is not yet supported.`));
2137
2153
  continue;
2138
2154
  }
2139
- const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
2140
- `agents secrets import ${shellQuote(resolvedBundleName)} --from - --backend file${opts.force ? ' --force' : ''}`;
2141
- res = sshExec(host, `bash -lc ${shellQuote(remoteAgents)}`, { input: `${remotePassphrase}\n${dotenv}` });
2155
+ const { remoteCmd, input } = buildRemoteFileImportCommand(resolvedBundleName, dotenv, {
2156
+ passphrase: remotePassphrase,
2157
+ force: opts.force,
2158
+ });
2159
+ res = sshExec(host, remoteCmd, { input });
2142
2160
  }
2143
2161
  else if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
2144
2162
  // Keychain on a Windows target: the `agents.ps1` shim doesn't
@@ -2488,11 +2506,16 @@ Examples:
2488
2506
  .command('unlock [names...]')
2489
2507
  .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.')
2490
2508
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
2509
+ .option('--until <date>', 'Hold until this absolute date or timestamp (for example 2026-08-06T12:00:00Z). Mutually exclusive with --ttl.')
2491
2510
  .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.')
2492
2511
  .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.')
2493
2512
  .option('--all', 'Unlock every configured bundle')
2494
2513
  .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>`.')
2495
2514
  .action(async (names, opts) => {
2515
+ if (opts.ttl && opts.until) {
2516
+ console.error(chalk.red('--ttl and --until are mutually exclusive.'));
2517
+ process.exit(1);
2518
+ }
2496
2519
  // Single-valued (not variadic): a variadic --host greedily consumes the
2497
2520
  // positional bundle name (`unlock --host mac wztest` -> host=[mac,wztest],
2498
2521
  // names=[]). Unlock targets one remote at a time anyway.
@@ -2529,6 +2552,15 @@ Examples:
2529
2552
  process.exit(1);
2530
2553
  return;
2531
2554
  }
2555
+ if (opts.until) {
2556
+ try {
2557
+ resolveUnlockTtlMs(undefined, opts.until);
2558
+ }
2559
+ catch (err) {
2560
+ console.error(chalk.red(err.message));
2561
+ process.exit(1);
2562
+ }
2563
+ }
2532
2564
  if (process.platform !== 'darwin') {
2533
2565
  // No broker + no biometry prompt off darwin: secrets already resolve
2534
2566
  // durably from the OS store (libsecret / Credential Manager) on every
@@ -2542,15 +2574,15 @@ Examples:
2542
2574
  console.error(chalk.red('Specify one or more bundle names, or --all.'));
2543
2575
  process.exit(1);
2544
2576
  }
2545
- let ttlMs = secretsHoldMs(); // default hold, capped by secrets.agent.holdMs
2546
- if (opts.ttl) {
2547
- const secs = parseDuration(opts.ttl);
2548
- if (!secs) {
2549
- console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h, 3d.`));
2550
- process.exit(1);
2551
- }
2552
- ttlMs = secs * 1000;
2577
+ let ttlMs;
2578
+ try {
2579
+ ttlMs = resolveUnlockTtlMs(opts.ttl, opts.until);
2580
+ }
2581
+ catch (err) {
2582
+ console.error(chalk.red(err.message));
2583
+ process.exit(1);
2553
2584
  }
2585
+ const expiresAt = Date.now() + ttlMs;
2554
2586
  if (!(await ensureAgentRunning())) {
2555
2587
  console.error(chalk.red('Could not start the secrets broker.'));
2556
2588
  process.exit(1);
@@ -2576,7 +2608,7 @@ Examples:
2576
2608
  noAgent: true,
2577
2609
  caller: 'unlock secrets',
2578
2610
  agent: harness,
2579
- duration: humanRemaining(Date.now() + ttlMs),
2611
+ duration: humanRemaining(expiresAt),
2580
2612
  keyMode: 'storage',
2581
2613
  });
2582
2614
  // Migrations are authorized only by this explicit unlock. The bundle
@@ -2592,7 +2624,7 @@ Examples:
2592
2624
  saveSession(name, {
2593
2625
  bundle,
2594
2626
  env,
2595
- expiresAt: Date.now() + ttlMs,
2627
+ expiresAt,
2596
2628
  sleepPersist: durable,
2597
2629
  harness,
2598
2630
  });
@@ -2611,7 +2643,7 @@ Examples:
2611
2643
  agent: harness,
2612
2644
  ttlMs,
2613
2645
  });
2614
- console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
2646
+ console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(expiresAt)})`)}`);
2615
2647
  }
2616
2648
  else {
2617
2649
  console.error(chalk.red(`Failed to load '${name}' into the agent.`));
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { type SessionMeta } from '../lib/session/types.js';
13
13
  import type { ActiveSession } from '../lib/session/active.js';
14
+ import { type LiveStatusFilter } from './sessions.js';
14
15
  /**
15
16
  * The single canonical filter state. Every field has a flag equivalent, so the
16
17
  * same view is reachable interactively (hotkeys) or from the command line (flags).
@@ -32,6 +33,23 @@ export interface BrowserFilter {
32
33
  projectScope: 'repo' | 'all';
33
34
  /** time window (undefined = all time) — the `W` key / `--since`. */
34
35
  window?: string;
36
+ /** individual live states; an OR-union and, like the CLI flags, running-only. */
37
+ statuses: LiveStatusFilter[];
38
+ /** named project scope, independent of the repo/all hotkey. */
39
+ project?: string;
40
+ /** upper time bound from --until. */
41
+ until?: string;
42
+ /** retain only routine-origin sessions. */
43
+ routine: boolean;
44
+ /** indexed resource filters shared by the flag and focus surfaces. */
45
+ skill?: string;
46
+ plugin?: string;
47
+ /** discovery cap. */
48
+ limit: number;
49
+ /** include unmanaged native-home transcripts. */
50
+ unmanaged: boolean;
51
+ /** candidate order shared with --sort. */
52
+ sort: 'timestamp' | 'cost' | 'duration';
35
53
  }
36
54
  /**
37
55
  * Complete a seed into the filter the picker actually runs on.
@@ -97,6 +115,9 @@ export declare function bareBrowserSeed(opts: {
97
115
  inTeam?: string;
98
116
  favorites?: boolean;
99
117
  }): Partial<BrowserFilter>;
118
+ /** Build the static peer query. Keeping it pure pins the per-device alias
119
+ * contract: latest/oldest travel unresolved and are resolved by each peer. */
120
+ export declare function remotePoolArgs(f: BrowserFilter, fixedFilters: boolean): string[];
100
121
  /**
101
122
  * A live session's stable row key: its session id when the agent reported one,
102
123
  * else a per-machine handle so an id-less live session (a just-booted harness, a
@@ -135,6 +156,20 @@ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string,
135
156
  * listing that has no live rows to explain it.
136
157
  */
137
158
  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
+ /** One-shot form of the browser's canonical candidate pipeline. Focus uses this
162
+ * instead of maintaining a second discovery/filter implementation. */
163
+ export declare function collectSessionCandidates(initial: Partial<BrowserFilter>, opts?: {
164
+ local?: boolean;
165
+ hosts?: string[];
166
+ includeLive?: boolean;
167
+ }): Promise<{
168
+ sessions: SessionMeta[];
169
+ liveById: Map<string, ActiveSession>;
170
+ self: string;
171
+ unreachable: string[];
172
+ }>;
138
173
  /**
139
174
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
140
175
  * `{ running: true }` for `--active`). Resolves after the user resumes a session