@phnx-labs/agents-cli 1.20.18 → 1.20.20

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ **`agents secrets start`: persistent secrets-agent service (fixes the broker under heavy load)**
6
+
7
+ - On a heavily-loaded machine (many concurrent agents, high load average) the on-demand broker — a full CLI cold-start — couldn't get scheduled enough CPU to finish booting and bind its socket, so `unlock`/auto-cache silently failed and reads kept prompting. New `agents secrets start` installs the broker as a **launchd user service** (`RunAtLoad` + `KeepAlive`, `ProcessType: Interactive` for foreground scheduling priority): it starts once and stays up for the whole login session, so every read just connects — the cold start happens once (and launchd retries until it wins), never per read. `agents secrets stop` removes it; `agents secrets status` shows whether it's installed.
8
+ - `unlock` and the auto-cache worker now install/kickstart this service automatically via `ensureAgentRunning`, falling back to the old one-off detached spawn only if the service path is unavailable. So the persistent broker is set up on first use with no extra step.
9
+ - macOS only. Security model unchanged: in-memory only, per-bundle TTL, wiped on screen-lock/sleep.
10
+
11
+ **Fix: secrets-agent auto-cache now survives a slow broker cold-start under load**
12
+
13
+ - `secrets.agent.auto` (auto-cache on first read of a `session`-tier bundle) used a fire-and-forget inline loader that gave up connecting to the broker after 3s. But the broker it spawns is itself a full CLI cold-starting; under heavy load (many concurrent agents) that can exceed 3s, so the loader quit before the broker bound and the cache silently never populated — every read kept prompting. The auto-load now runs through a detached `secrets _agent-load` worker that reuses the robust `ensureAgentRunning` path (spawn-then-ping, 20s budget) and loads synchronously, so it reliably populates even when the broker is slow to start. Manual `agents secrets unlock` was always reliable and is unchanged. (secret values still travel over stdin, never argv.)
14
+
5
15
  **`agents secrets unlock`: a secrets-agent that ends Touch ID prompt spam (macOS)**
6
16
 
7
17
  - macOS pops a Touch ID prompt **per bundle, per process** — the biometry assertion is process-local and macOS refuses to cache `kSecAccessControl`+biometry items, so running several agents at once (`agents teams`, parallel `agents run --secrets`) re-prompts once per process. New `agents secrets unlock <bundle>` reads the bundle once (one prompt) and holds the resolved env in a local broker; every later resolution — `agents run`, teammates, browser profiles, the routines daemon — is served from memory over a user-only Unix socket (`~/.agents/.cache/helpers/secrets-agent/`, `0700`) with no prompt. `agents secrets lock` wipes it; `agents secrets status` shows what's held and when it locks. The hold also ends on TTL expiry (default 24h, `--ttl`) and on screen-lock / sleep.
@@ -8,10 +8,10 @@
8
8
  import chalk from 'chalk';
9
9
  import * as fs from 'fs';
10
10
  import { spawnSync } from 'child_process';
11
- import { bundleExists, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
12
- import { deleteKeychainToken, getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
11
+ import { bundleExists, bundleItemStore, bundleTier, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
12
+ import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
13
13
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
14
- import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, runSecretsAgent, } from '../lib/secrets/agent.js';
14
+ import { DEFAULT_TTL_MS, agentLoad, agentLock, agentStatus, ensureAgentRunning, installSecretsAgentService, runAgentLoadFromStdin, runSecretsAgent, secretsAgentServiceInstalled, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
15
15
  import { parseDuration } from '../lib/hooks/cache.js';
16
16
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
17
17
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -235,7 +235,11 @@ function renderBundleRow(b) {
235
235
  `${padVisible(created, 9)} ` +
236
236
  `${padVisible(updated, 9)} ` +
237
237
  `${padVisible(used, 7)}`;
238
- return b.description ? `${head} ${chalk.gray(safePrint(b.description))}` : head.trimEnd();
238
+ // Mark file-backed bundles so `list` distinguishes them from keychain ones.
239
+ const tag = b.backend === 'file' ? chalk.magenta('[file] ') : '';
240
+ const desc = b.description ? chalk.gray(safePrint(b.description)) : '';
241
+ const trailer = `${tag}${desc}`.trimEnd();
242
+ return trailer ? `${head} ${trailer}` : head.trimEnd();
239
243
  }
240
244
  /** Colorize a variable source kind (literal, keychain, env, file, exec). */
241
245
  function kindLabel(kind) {
@@ -412,7 +416,7 @@ export function registerSecretsCommands(program) {
412
416
  registerCommandGroups(cmd, [
413
417
  { title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
414
418
  { title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
415
- { title: 'Agent commands', names: ['unlock', 'lock', 'status', 'tier'] },
419
+ { title: 'Agent commands', names: ['start', 'stop', 'unlock', 'lock', 'status', 'tier'] },
416
420
  { title: 'Raw item commands', names: ['get', 'set'] },
417
421
  { title: 'Sync commands', names: ['push', 'pull', 'remote-list'] },
418
422
  { title: 'Utilities', names: ['exec', 'generate', 'migrate-acl'] },
@@ -449,6 +453,8 @@ export function registerSecretsCommands(program) {
449
453
  console.log(chalk.gray(safePrint(bundle.description)));
450
454
  if (bundle.allow_exec)
451
455
  console.log(chalk.yellow('allow_exec: true'));
456
+ if (bundle.backend === 'file')
457
+ console.log(chalk.gray('backend: file (passphrase-encrypted; reads need AGENTS_SECRETS_PASSPHRASE, no Touch ID)'));
452
458
  if (bundleTier(bundle) === 'session')
453
459
  console.log(chalk.gray('tier: session (secrets-agent eligible)'));
454
460
  if (bundle.created_at)
@@ -573,12 +579,14 @@ export function registerSecretsCommands(program) {
573
579
  .option('--description <text>', 'Free-form description')
574
580
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
575
581
  .option('--tier <tier>', 'secrets-agent tier: biometry (default) or session', 'biometry')
582
+ .option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
576
583
  .option('--force', 'Overwrite an existing bundle')
577
584
  .action(async (name, opts) => {
578
585
  try {
579
586
  const resolvedName = name ?? (await promptBundleName());
580
587
  validateBundleName(resolvedName);
581
588
  const tier = parseTierOpt(opts.tier);
589
+ const backend = parseBackendOpt(opts.backend);
582
590
  if (bundleExists(resolvedName) && !opts.force) {
583
591
  console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
584
592
  process.exit(1);
@@ -587,11 +595,16 @@ export function registerSecretsCommands(program) {
587
595
  name: resolvedName,
588
596
  description: opts.description,
589
597
  allow_exec: opts.allowExec,
598
+ backend: backend === 'file' ? 'file' : undefined,
590
599
  tier,
591
600
  vars: {},
592
601
  };
593
602
  writeBundle(bundle);
594
- console.log(chalk.green(`Bundle '${resolvedName}' created${tier === 'session' ? ' (tier: session)' : ''}.`));
603
+ const tags = [tier === 'session' ? 'tier: session' : null, backend === 'file' ? 'backend: file' : null].filter(Boolean);
604
+ console.log(chalk.green(`Bundle '${resolvedName}' created${tags.length ? ` (${tags.join(', ')})` : ''}.`));
605
+ if (backend === 'file') {
606
+ console.log(chalk.gray('File-backed: items are AES-256-GCM encrypted under AGENTS_SECRETS_PASSPHRASE (no Touch ID).'));
607
+ }
595
608
  console.log(chalk.gray(`Try: agents secrets add ${resolvedName} MY_KEY`));
596
609
  }
597
610
  catch (err) {
@@ -711,7 +724,7 @@ export function registerSecretsCommands(program) {
711
724
  console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} = <literal>`));
712
725
  return;
713
726
  }
714
- // Default path: keychain-backed.
727
+ // Default path: stored in the bundle's backend (keychain or file).
715
728
  let secretValue;
716
729
  if (opts.valueStdin) {
717
730
  secretValue = readStdinSync();
@@ -722,11 +735,12 @@ export function registerSecretsCommands(program) {
722
735
  secretValue = await promptForSecret(`Enter value for ${resolvedBundleName}.${resolvedKey}`);
723
736
  }
724
737
  const item = secretsKeychainItem(resolvedBundleName, resolvedKey);
725
- setKeychainToken(item, secretValue);
738
+ bundleItemStore(bundle.backend).set(item, secretValue);
726
739
  bundle.vars[resolvedKey] = keychainRef(resolvedKey);
727
740
  applyMeta();
728
741
  writeBundle(bundle);
729
- console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} stored in keychain (${item}).`));
742
+ const where = bundle.backend === 'file' ? 'encrypted file store' : 'keychain';
743
+ console.log(chalk.green(`${resolvedBundleName}.${resolvedKey} stored in ${where} (${item}).`));
730
744
  }
731
745
  catch (err) {
732
746
  if (isPromptCancelled(err))
@@ -831,9 +845,10 @@ Examples:
831
845
  writeBundle(bundle);
832
846
  if (willPurge) {
833
847
  const item = secretsKeychainItem(resolvedBundleName, raw.slice('keychain:'.length));
834
- const removed = deleteKeychainToken(item);
848
+ const removed = bundleItemStore(bundle.backend).delete(item);
835
849
  if (removed) {
836
- console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey} and purged keychain item.`));
850
+ const where = bundle.backend === 'file' ? 'encrypted file item' : 'keychain item';
851
+ console.log(chalk.green(`Removed ${resolvedBundleName}.${resolvedKey} and purged ${where}.`));
837
852
  return;
838
853
  }
839
854
  }
@@ -875,8 +890,9 @@ Examples:
875
890
  }
876
891
  }
877
892
  if (!opts.keepSecrets) {
893
+ const store = bundleItemStore(bundle.backend);
878
894
  for (const { item } of keychainItemsForBundle(bundle)) {
879
- deleteKeychainToken(item);
895
+ store.delete(item);
880
896
  }
881
897
  }
882
898
  const existed = deleteBundle(resolvedName);
@@ -929,11 +945,12 @@ Examples:
929
945
  });
930
946
  cmd
931
947
  .command('import [bundle]')
932
- .description('Import keys from a .env file or a 1Password vault into a bundle. By default every key is stored in keychain.')
948
+ .description('Import keys from a .env file or a 1Password vault into a bundle. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
933
949
  .option('--from <path>', 'Path to a .env file')
934
950
  .option('--from-1password', 'Import secrets from a 1Password vault (requires the op CLI)')
935
951
  .option('--vault <name>', '1Password vault name (used with --from-1password)')
936
952
  .option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
953
+ .option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted, headless-readable)', 'keychain')
937
954
  .option('--force', 'Overwrite an existing key in the bundle')
938
955
  .action(async (bundleName, opts) => {
939
956
  try {
@@ -944,7 +961,27 @@ Examples:
944
961
  throw new Error('--from and --from-1password are mutually exclusive.');
945
962
  }
946
963
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
947
- const bundle = readBundle(resolvedBundleName);
964
+ const requestedBackend = parseBackendOpt(opts.backend);
965
+ // Read the bundle if it exists (inheriting its backend); otherwise
966
+ // create it with the requested backend so a single `import --backend
967
+ // file` works (this is what `export --to-ssh --remote-backend file`
968
+ // drives on the remote).
969
+ let bundle;
970
+ if (bundleExists(resolvedBundleName)) {
971
+ bundle = readBundle(resolvedBundleName);
972
+ if (requestedBackend === 'file' && bundle.backend !== 'file') {
973
+ throw new Error(`Bundle '${resolvedBundleName}' already exists with a keychain backend; ` +
974
+ `--backend file cannot change it. Delete it first to recreate as file-backed.`);
975
+ }
976
+ }
977
+ else {
978
+ bundle = {
979
+ name: resolvedBundleName,
980
+ backend: requestedBackend === 'file' ? 'file' : undefined,
981
+ vars: {},
982
+ };
983
+ }
984
+ const store = bundleItemStore(bundle.backend);
948
985
  let added = 0;
949
986
  let skipped = 0;
950
987
  if (opts.from1password) {
@@ -962,7 +999,7 @@ Examples:
962
999
  }
963
1000
  else {
964
1001
  const item = secretsKeychainItem(resolvedBundleName, envKey);
965
- setKeychainToken(item, value);
1002
+ store.set(item, value);
966
1003
  bundle.vars[envKey] = keychainRef(envKey);
967
1004
  }
968
1005
  added++;
@@ -986,7 +1023,7 @@ Examples:
986
1023
  }
987
1024
  else {
988
1025
  const item = secretsKeychainItem(resolvedBundleName, key);
989
- setKeychainToken(item, value);
1026
+ store.set(item, value);
990
1027
  bundle.vars[key] = keychainRef(key);
991
1028
  }
992
1029
  added++;
@@ -1010,6 +1047,7 @@ Examples:
1010
1047
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
1011
1048
  .option('--to-ssh', 'Push the bundle to remote machine(s) over SSH via their native agents-cli import')
1012
1049
  .option('--host <target...>', 'SSH target(s) for --to-ssh: host alias or user@host (repeatable)')
1050
+ .option('--remote-backend <backend>', 'Backend for the bundle on the remote: keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
1013
1051
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --to-ssh)')
1014
1052
  .action(async (bundleName, opts) => {
1015
1053
  try {
@@ -1022,22 +1060,51 @@ Examples:
1022
1060
  }
1023
1061
  for (const h of hosts)
1024
1062
  assertValidSshTarget(h);
1063
+ const remoteBackend = parseBackendOpt(opts.remoteBackend);
1064
+ // For a file-backed remote bundle the remote must encrypt at rest with
1065
+ // a passphrase. We forward the LOCAL AGENTS_SECRETS_PASSPHRASE — the
1066
+ // operator unlocks it once on this (trusted, biometry-gated) machine —
1067
+ // and ship it as the FIRST stdin line so it never lands in argv / `ps`
1068
+ // / the remote shell history. The remote `read -r` consumes that line;
1069
+ // `agents secrets import --from /dev/stdin` reads the .env remainder.
1070
+ let remotePassphrase = '';
1071
+ if (remoteBackend === 'file') {
1072
+ remotePassphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1073
+ if (!remotePassphrase) {
1074
+ throw new Error('--remote-backend file needs AGENTS_SECRETS_PASSPHRASE set locally to encrypt the ' +
1075
+ 'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
1076
+ }
1077
+ }
1025
1078
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
1026
1079
  const dotenv = bundleEnvToDotenv(env);
1027
1080
  const keyCount = Object.keys(env).length;
1028
- // Drive the remote's own `agents secrets` CLI so values land in its native
1029
- // backend (Keychain on macOS, libsecret / encrypted-file on Linux). Create
1030
- // the bundle if missing, then import the piped .env. `bash -lc` so the login
1031
- // PATH resolves `agents`; the .env flows over ssh stdin and is never parsed
1032
- // by a remote shell.
1081
+ // Drive the remote's own `agents secrets` CLI so values land in its
1082
+ // chosen backend. `bash -lc` so the login PATH resolves `agents`; the
1083
+ // .env (and, for file, the passphrase) flow over ssh stdin and are
1084
+ // never parsed by a remote shell.
1033
1085
  const force = opts.force ? ' --force' : '';
1034
- const remoteAgents = `agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
1035
- `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
1086
+ const backendFlag = remoteBackend === 'file' ? ' --backend file' : '';
1087
+ let remoteAgents;
1088
+ let input;
1089
+ if (remoteBackend === 'file') {
1090
+ // import --backend file auto-creates the file-backed bundle; no
1091
+ // separate `create` needed.
1092
+ remoteAgents =
1093
+ `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
1094
+ `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${backendFlag}${force}`;
1095
+ input = `${remotePassphrase}\n${dotenv}`;
1096
+ }
1097
+ else {
1098
+ remoteAgents =
1099
+ `agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
1100
+ `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
1101
+ input = dotenv;
1102
+ }
1036
1103
  const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
1037
1104
  let failures = 0;
1038
1105
  for (const host of hosts) {
1039
1106
  const res = spawnSync('ssh', ['-o', 'BatchMode=yes', host, remoteCmd], {
1040
- input: dotenv,
1107
+ input,
1041
1108
  stdio: ['pipe', 'pipe', 'pipe'],
1042
1109
  encoding: 'utf-8',
1043
1110
  });
@@ -1296,6 +1363,10 @@ Examples:
1296
1363
  console.log(chalk.gray('secrets-agent is macOS-only.'));
1297
1364
  return;
1298
1365
  }
1366
+ console.log(chalk.gray('service: ') +
1367
+ (secretsAgentServiceInstalled()
1368
+ ? chalk.green('installed (persistent)')
1369
+ : chalk.yellow('not installed — run `agents secrets start` for a persistent broker')));
1299
1370
  const entries = await agentStatus();
1300
1371
  if (entries.length === 0) {
1301
1372
  console.log(chalk.gray('No bundles unlocked. The secrets-agent is idle or not running.'));
@@ -1330,11 +1401,44 @@ Examples:
1330
1401
  process.exit(1);
1331
1402
  }
1332
1403
  });
1404
+ cmd
1405
+ .command('start')
1406
+ .description('Install + start the secrets-agent as a persistent background service (macOS). Survives heavy load; reads connect instantly.')
1407
+ .action(async () => {
1408
+ if (process.platform !== 'darwin') {
1409
+ console.error(chalk.red('secrets-agent service is macOS-only.'));
1410
+ process.exit(1);
1411
+ }
1412
+ process.stdout.write(chalk.gray('Installing launchd service…\n'));
1413
+ if (await installSecretsAgentService()) {
1414
+ console.log(chalk.green('secrets-agent service running.') + chalk.gray(' It stays up across the session; unlock/auto-cache now connect instantly.'));
1415
+ }
1416
+ else {
1417
+ console.error(chalk.red('Service installed but did not become reachable in time (machine may be heavily loaded — launchd will keep retrying).'));
1418
+ process.exit(1);
1419
+ }
1420
+ });
1421
+ cmd
1422
+ .command('stop')
1423
+ .description('Stop + remove the persistent secrets-agent service and wipe what it held.')
1424
+ .action(async () => {
1425
+ if (process.platform !== 'darwin')
1426
+ return;
1427
+ await uninstallSecretsAgentService();
1428
+ console.log(chalk.green('secrets-agent service stopped and removed.'));
1429
+ });
1333
1430
  cmd
1334
1431
  .command('_agent-run', { hidden: true })
1335
1432
  .description('Run the secrets-agent broker in the foreground (internal)')
1433
+ .option('--service', 'run as a persistent launchd service (never idle-exit)')
1434
+ .action(async (opts) => {
1435
+ await runSecretsAgent({ service: Boolean(opts.service) });
1436
+ });
1437
+ cmd
1438
+ .command('_agent-load', { hidden: true })
1439
+ .description('Detached auto-cache worker: load a bundle from stdin into the broker (internal)')
1336
1440
  .action(async () => {
1337
- await runSecretsAgent();
1441
+ await runAgentLoadFromStdin();
1338
1442
  });
1339
1443
  registerSecretsSyncCommands(cmd);
1340
1444
  registerSecretsMigrateAclCommand(cmd);
@@ -1353,6 +1457,14 @@ function parseTierOpt(raw) {
1353
1457
  console.error(chalk.red(`Invalid --tier '${raw}'. Use 'biometry' or 'session'.`));
1354
1458
  process.exit(1);
1355
1459
  }
1460
+ /** Validate a --backend value, exiting with a clear message on a bad one. */
1461
+ function parseBackendOpt(raw) {
1462
+ const v = (raw ?? 'keychain').toLowerCase();
1463
+ if (v === 'keychain' || v === 'file')
1464
+ return v;
1465
+ console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'.`));
1466
+ process.exit(1);
1467
+ }
1356
1468
  /** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
1357
1469
  function humanRemaining(expiresAt) {
1358
1470
  const ms = expiresAt - Date.now();
@@ -105,7 +105,13 @@ async function versionPruneAction(specs, options, commandName) {
105
105
  }
106
106
  const { agent, version } = parsed;
107
107
  const agentConfig = AGENTS[agent];
108
- if (version === 'latest' || version === 'oldest' || !spec.includes('@')) {
108
+ // Script-installed agents (droid, grok) can have a *literal* `latest`
109
+ // version dir on disk when the post-install version probe failed. An
110
+ // explicit `<agent>@latest` should remove that dir directly rather than
111
+ // routing to the interactive picker (which can't run non-interactively),
112
+ // so treat an installed literal `latest` as a concrete pinned version.
113
+ const isLiteralLatestInstalled = version === 'latest' && spec.includes('@') && isVersionInstalled(agent, 'latest');
114
+ if (!isLiteralLatestInstalled && (version === 'latest' || version === 'oldest' || !spec.includes('@'))) {
109
115
  const versions = listInstalledVersions(agent);
110
116
  if (versions.length === 0) {
111
117
  console.log(chalk.gray(`No versions of ${agentLabel(agentConfig.id)} installed`));
@@ -37,6 +37,17 @@ export interface AgentStatusEntry {
37
37
  expiresAt: number;
38
38
  keyCount: number;
39
39
  }
40
+ /** True if the launchd plist for the persistent broker is installed. */
41
+ export declare function secretsAgentServiceInstalled(): boolean;
42
+ /**
43
+ * Install + start the persistent broker as a launchd user service (idempotent).
44
+ * Writes the plist, bootstraps it into the GUI domain, and waits for the socket.
45
+ * `ProcessType: Interactive` asks launchd to schedule it at foreground priority
46
+ * so it can boot even when the machine is loaded. Returns true once reachable.
47
+ */
48
+ export declare function installSecretsAgentService(timeoutMs?: number): Promise<boolean>;
49
+ /** Stop + remove the persistent broker service, and wipe whatever it held. */
50
+ export declare function uninstallSecretsAgentService(): Promise<void>;
40
51
  export type Request = {
41
52
  cmd: 'ping';
42
53
  } | {
@@ -95,7 +106,9 @@ export declare function handleAgentRequest(store: Map<string, StoredBundle>, req
95
106
  * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
96
107
  * sweeps expired entries, wipes on screen-lock/sleep, and self-exits when idle.
97
108
  */
98
- export declare function runSecretsAgent(): Promise<void>;
109
+ export declare function runSecretsAgent(opts?: {
110
+ service?: boolean;
111
+ }): Promise<void>;
99
112
  /** True if a broker socket exists at all. Cheap; gates the sync read so the
100
113
  * never-unlocked path stays a single stat. */
101
114
  export declare function agentSocketExists(): boolean;
@@ -115,10 +128,23 @@ export declare function secretsAgentAutoEnabled(): boolean;
115
128
  * Fire-and-forget: populate the broker with a freshly-resolved bundle so the
116
129
  * NEXT process reads it without a prompt. Used by the auto-cache path after a
117
130
  * real keychain read of a `session`-tier bundle. Adds no latency to the caller
118
- * — it spawns the agent (if needed) and a detached loader, both unref'd, then
119
- * returns immediately. Entirely best-effort; never throws. macOS only.
131
+ * — it spawns a detached `secrets _agent-load` worker (passing the resolved env
132
+ * over stdin, never argv) and returns immediately.
133
+ *
134
+ * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping with a
135
+ * generous budget) rather than a tight inline retry loop: under heavy load the
136
+ * broker is itself a cold-starting full CLI and can take several seconds to bind
137
+ * the socket, so a short fixed budget would give up before it's ready and the
138
+ * cache would silently never populate. Best-effort; never throws. macOS only.
120
139
  */
121
140
  export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
141
+ /**
142
+ * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
143
+ * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
144
+ * budget), and loads the bundle into it. Detached from the originating read, so
145
+ * its latency is invisible — which is why it can afford a long ensure budget.
146
+ */
147
+ export declare function runAgentLoadFromStdin(): Promise<void>;
122
148
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
123
149
  export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): Promise<boolean>;
124
150
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
@@ -127,8 +153,13 @@ export declare function agentLock(name?: string): Promise<number>;
127
153
  /** List currently-unlocked bundles, or [] when no broker is running. */
128
154
  export declare function agentStatus(): Promise<AgentStatusEntry[]>;
129
155
  /**
130
- * Ensure a broker is running and reachable, spawning one detached if not.
131
- * Returns true once the socket answers a ping. On protocol-version skew, kills
132
- * the stale broker and respawns. macOS only.
156
+ * Ensure a broker is running and reachable. Returns true once the socket answers
157
+ * a ping. macOS only.
158
+ *
159
+ * Prefers the persistent launchd service: if it isn't installed we install it
160
+ * (which makes the broker survive for the whole login session, so subsequent
161
+ * reads never cold-start); if it's installed but unreachable we kickstart it.
162
+ * Only when the service path can't be used do we fall back to a one-off detached
163
+ * broker — that's the model that gets starved under heavy load, so it's last.
133
164
  */
134
165
  export declare function ensureAgentRunning(timeoutMs?: number): Promise<boolean>;