@phnx-labs/agents-cli 1.20.73 → 1.20.76

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 (209) hide show
  1. package/CHANGELOG.md +989 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +31 -1
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/export.d.ts +11 -0
  15. package/dist/commands/export.js +215 -0
  16. package/dist/commands/feed.d.ts +7 -1
  17. package/dist/commands/feed.js +47 -4
  18. package/dist/commands/go.d.ts +9 -2
  19. package/dist/commands/go.js +16 -6
  20. package/dist/commands/harness.d.ts +14 -0
  21. package/dist/commands/harness.js +145 -0
  22. package/dist/commands/import.js +87 -9
  23. package/dist/commands/inspect.js +5 -2
  24. package/dist/commands/profiles.d.ts +20 -0
  25. package/dist/commands/profiles.js +68 -21
  26. package/dist/commands/repo.js +14 -0
  27. package/dist/commands/routines.js +15 -2
  28. package/dist/commands/secrets.js +44 -21
  29. package/dist/commands/sessions-browser.js +1 -1
  30. package/dist/commands/sessions-export.js +19 -5
  31. package/dist/commands/sessions-migrate.d.ts +29 -0
  32. package/dist/commands/sessions-migrate.js +596 -0
  33. package/dist/commands/sessions-picker.js +16 -1
  34. package/dist/commands/sessions.d.ts +36 -13
  35. package/dist/commands/sessions.js +210 -40
  36. package/dist/commands/setup-computer.js +2 -2
  37. package/dist/commands/setup.js +6 -1
  38. package/dist/commands/ssh.js +71 -35
  39. package/dist/commands/status.js +10 -2
  40. package/dist/commands/versions.js +51 -11
  41. package/dist/commands/view.d.ts +5 -0
  42. package/dist/commands/view.js +99 -31
  43. package/dist/commands/watchdog.d.ts +7 -2
  44. package/dist/commands/watchdog.js +73 -57
  45. package/dist/index.js +59 -7
  46. package/dist/lib/activity.d.ts +14 -4
  47. package/dist/lib/activity.js +17 -5
  48. package/dist/lib/actor.d.ts +50 -0
  49. package/dist/lib/actor.js +166 -0
  50. package/dist/lib/agent-spec/provider.js +2 -1
  51. package/dist/lib/agent-spec/resolve.js +19 -5
  52. package/dist/lib/agent-spec/types.d.ts +9 -1
  53. package/dist/lib/agents.d.ts +56 -0
  54. package/dist/lib/agents.js +133 -7
  55. package/dist/lib/cloud/codex.d.ts +2 -0
  56. package/dist/lib/cloud/codex.js +14 -3
  57. package/dist/lib/cloud/session-index.d.ts +32 -0
  58. package/dist/lib/cloud/session-index.js +58 -0
  59. package/dist/lib/cloud/store.d.ts +7 -0
  60. package/dist/lib/cloud/store.js +25 -0
  61. package/dist/lib/config-transfer.d.ts +16 -0
  62. package/dist/lib/config-transfer.js +60 -0
  63. package/dist/lib/daemon.js +18 -2
  64. package/dist/lib/devices/connect.d.ts +15 -1
  65. package/dist/lib/devices/connect.js +15 -1
  66. package/dist/lib/devices/fleet.d.ts +21 -0
  67. package/dist/lib/devices/fleet.js +26 -0
  68. package/dist/lib/devices/health-report.d.ts +38 -0
  69. package/dist/lib/devices/health-report.js +214 -0
  70. package/dist/lib/devices/health.js +4 -1
  71. package/dist/lib/devices/reachability.d.ts +31 -0
  72. package/dist/lib/devices/reachability.js +40 -0
  73. package/dist/lib/devices/registry.d.ts +33 -0
  74. package/dist/lib/devices/registry.js +37 -0
  75. package/dist/lib/devices/resolve-target.d.ts +15 -27
  76. package/dist/lib/devices/resolve-target.js +63 -102
  77. package/dist/lib/devices/sync.d.ts +18 -0
  78. package/dist/lib/devices/sync.js +23 -1
  79. package/dist/lib/devices/tailscale.d.ts +3 -0
  80. package/dist/lib/devices/tailscale.js +1 -0
  81. package/dist/lib/events.d.ts +1 -1
  82. package/dist/lib/exec.d.ts +66 -0
  83. package/dist/lib/exec.js +195 -6
  84. package/dist/lib/export.d.ts +72 -0
  85. package/dist/lib/export.js +269 -0
  86. package/dist/lib/feed-post.d.ts +63 -0
  87. package/dist/lib/feed-post.js +204 -0
  88. package/dist/lib/fleet/apply.d.ts +32 -2
  89. package/dist/lib/fleet/apply.js +97 -10
  90. package/dist/lib/fleet/types.d.ts +11 -0
  91. package/dist/lib/fs-walk.d.ts +13 -0
  92. package/dist/lib/fs-walk.js +16 -7
  93. package/dist/lib/hooks.d.ts +1 -0
  94. package/dist/lib/hooks.js +156 -0
  95. package/dist/lib/hosts/dispatch.d.ts +7 -0
  96. package/dist/lib/hosts/dispatch.js +2 -0
  97. package/dist/lib/hosts/passthrough.js +2 -0
  98. package/dist/lib/hosts/reconnect.d.ts +78 -0
  99. package/dist/lib/hosts/reconnect.js +127 -0
  100. package/dist/lib/hosts/registry.d.ts +75 -14
  101. package/dist/lib/hosts/registry.js +205 -30
  102. package/dist/lib/hosts/remote-cmd.js +1 -0
  103. package/dist/lib/hosts/run-target.d.ts +13 -5
  104. package/dist/lib/hosts/run-target.js +26 -10
  105. package/dist/lib/hosts/session-index.d.ts +18 -1
  106. package/dist/lib/hosts/session-index.js +37 -2
  107. package/dist/lib/hosts/session-marker.d.ts +33 -0
  108. package/dist/lib/hosts/session-marker.js +51 -0
  109. package/dist/lib/hosts/tasks.d.ts +8 -4
  110. package/dist/lib/import.d.ts +25 -0
  111. package/dist/lib/import.js +107 -2
  112. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  113. package/dist/lib/isolation-boundary-report.js +35 -0
  114. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  115. package/dist/lib/menubar/install-menubar.js +45 -1
  116. package/dist/lib/migrate.d.ts +16 -0
  117. package/dist/lib/migrate.js +36 -0
  118. package/dist/lib/models.d.ts +27 -0
  119. package/dist/lib/models.js +54 -1
  120. package/dist/lib/overdue.d.ts +7 -2
  121. package/dist/lib/overdue.js +18 -11
  122. package/dist/lib/picker.js +4 -1
  123. package/dist/lib/platform/process.d.ts +17 -0
  124. package/dist/lib/platform/process.js +70 -0
  125. package/dist/lib/profiles-presets.js +9 -7
  126. package/dist/lib/profiles.d.ts +31 -0
  127. package/dist/lib/profiles.js +70 -0
  128. package/dist/lib/project-resources.js +20 -5
  129. package/dist/lib/pty-server.d.ts +2 -10
  130. package/dist/lib/pty-server.js +4 -38
  131. package/dist/lib/registry.d.ts +1 -1
  132. package/dist/lib/registry.js +48 -8
  133. package/dist/lib/rotate.d.ts +18 -0
  134. package/dist/lib/rotate.js +28 -0
  135. package/dist/lib/routines.d.ts +26 -0
  136. package/dist/lib/routines.js +19 -2
  137. package/dist/lib/runner.js +104 -7
  138. package/dist/lib/secrets/agent.d.ts +66 -1
  139. package/dist/lib/secrets/agent.js +154 -90
  140. package/dist/lib/secrets/bundles.d.ts +34 -0
  141. package/dist/lib/secrets/bundles.js +179 -19
  142. package/dist/lib/secrets/index.d.ts +15 -1
  143. package/dist/lib/secrets/index.js +21 -3
  144. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  145. package/dist/lib/secrets/rc-hygiene.js +154 -0
  146. package/dist/lib/secrets/remote.d.ts +7 -4
  147. package/dist/lib/secrets/remote.js +7 -4
  148. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  149. package/dist/lib/secrets/sync-commands.js +21 -0
  150. package/dist/lib/self-update.d.ts +23 -2
  151. package/dist/lib/self-update.js +86 -5
  152. package/dist/lib/session/active.d.ts +114 -7
  153. package/dist/lib/session/active.js +323 -52
  154. package/dist/lib/session/cloud.js +2 -1
  155. package/dist/lib/session/db.d.ts +48 -0
  156. package/dist/lib/session/db.js +238 -14
  157. package/dist/lib/session/detached.d.ts +30 -0
  158. package/dist/lib/session/detached.js +92 -0
  159. package/dist/lib/session/discover.d.ts +465 -2
  160. package/dist/lib/session/discover.js +1648 -468
  161. package/dist/lib/session/fork.js +2 -1
  162. package/dist/lib/session/hook-sessions.d.ts +43 -0
  163. package/dist/lib/session/hook-sessions.js +135 -0
  164. package/dist/lib/session/linear.d.ts +6 -0
  165. package/dist/lib/session/linear.js +55 -0
  166. package/dist/lib/session/migrate-targets.d.ts +65 -0
  167. package/dist/lib/session/migrate-targets.js +94 -0
  168. package/dist/lib/session/migrations.d.ts +37 -0
  169. package/dist/lib/session/migrations.js +60 -0
  170. package/dist/lib/session/parse.d.ts +18 -0
  171. package/dist/lib/session/parse.js +130 -30
  172. package/dist/lib/session/pid-registry.d.ts +34 -2
  173. package/dist/lib/session/pid-registry.js +49 -2
  174. package/dist/lib/session/render.d.ts +7 -2
  175. package/dist/lib/session/render.js +22 -10
  176. package/dist/lib/session/short-id.d.ts +17 -0
  177. package/dist/lib/session/short-id.js +20 -0
  178. package/dist/lib/session/state.js +6 -2
  179. package/dist/lib/shims.d.ts +27 -1
  180. package/dist/lib/shims.js +88 -6
  181. package/dist/lib/sqlite.d.ts +3 -2
  182. package/dist/lib/sqlite.js +27 -4
  183. package/dist/lib/staleness/detectors/commands.js +1 -1
  184. package/dist/lib/staleness/detectors/workflows.js +13 -2
  185. package/dist/lib/staleness/writers/commands.js +7 -7
  186. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  187. package/dist/lib/startup/command-registry.d.ts +2 -0
  188. package/dist/lib/startup/command-registry.js +5 -0
  189. package/dist/lib/state.d.ts +1 -1
  190. package/dist/lib/state.js +15 -36
  191. package/dist/lib/teams/agents.d.ts +19 -10
  192. package/dist/lib/teams/agents.js +40 -39
  193. package/dist/lib/types.d.ts +48 -2
  194. package/dist/lib/uninstall.js +1 -45
  195. package/dist/lib/usage.d.ts +33 -1
  196. package/dist/lib/usage.js +172 -12
  197. package/dist/lib/versions.d.ts +22 -2
  198. package/dist/lib/versions.js +98 -10
  199. package/dist/lib/watchdog/log.d.ts +43 -0
  200. package/dist/lib/watchdog/log.js +69 -0
  201. package/dist/lib/watchdog/routine.d.ts +44 -0
  202. package/dist/lib/watchdog/routine.js +69 -0
  203. package/dist/lib/watchdog/runner.d.ts +51 -7
  204. package/dist/lib/watchdog/runner.js +239 -64
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +31 -16
  207. package/dist/lib/workflows.d.ts +16 -0
  208. package/dist/lib/workflows.js +110 -1
  209. package/package.json +1 -1
@@ -22,13 +22,16 @@ import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
24
24
  import { createTimer, maybeRotate, redactPrompt } from './events.js';
25
- import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, } from './exec.js';
25
+ import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, detectAuthFailure, isAuthFailureFromLog, authFailureReason, } from './exec.js';
26
26
  import { loadTask as loadHostTask } from './hosts/tasks.js';
27
27
  import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
28
28
  import { backgroundSpawnOptions } from './platform/process.js';
29
29
  import { walkForFiles } from './fs-walk.js';
30
- import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
31
- import { getConfiguredRunStrategy, resolveRunVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
30
+ import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
31
+ import { claudeHomeHasOwnCredential } from './agents.js';
32
+ import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
33
+ import { readAuthHealth, isDeadVerdict } from './auth-health.js';
34
+ import { machineId } from './machine-id.js';
32
35
  /** CLI command templates per agent, with {prompt} as a placeholder. */
33
36
  const AGENT_COMMANDS = {
34
37
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -287,6 +290,19 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
287
290
  pinned: true,
288
291
  };
289
292
  }
293
+ // Account pin: resolve the login identity to the version slot that holds it and
294
+ // run pinned — no rotation, no failover onto other accounts — so concurrent
295
+ // unattended routines never share (and mutually revoke) one single-use OAuth
296
+ // credential (RUSH-1957). Falls through to the strategy only when the account
297
+ // is not signed in on this box, with a loud warning rather than a silent stall.
298
+ if (config.account) {
299
+ const version = await resolveAccountVersion(agent, config.account);
300
+ if (version) {
301
+ return { chain: [{ agent, version }], rotation: null, pinned: true };
302
+ }
303
+ process.stderr.write(`[agents] routine ${config.name}: account '${config.account}' is not signed in for ${agent}; ` +
304
+ `falling back to strategy — pin a signed-in account to stop OAuth-rotation revocation\n`);
305
+ }
290
306
  const strategy = getConfiguredRunStrategy(agent, cwd);
291
307
  let version;
292
308
  let rotation = null;
@@ -383,6 +399,19 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
383
399
  if (v !== undefined)
384
400
  out[k] = v;
385
401
  }
402
+ // RUSH-1979: the daemon injects one ambient CLAUDE_CODE_OAUTH_TOKEN (RUSH-1759)
403
+ // so a token-less default account still authenticates. When balanced rotation
404
+ // pins THIS spawn to a specific account's CLAUDE_CONFIG_DIR that holds its own
405
+ // credential, that ambient token would shadow the account (Claude and the Linux
406
+ // shim both prefer the env var) — making the pool inert and 401ing on the one
407
+ // stale token. Drop it here so the pinned account authenticates itself; keep it
408
+ // when the account has no on-disk credential (the RUSH-1759 default).
409
+ if (agent === 'claude' &&
410
+ version &&
411
+ out.CLAUDE_CONFIG_DIR &&
412
+ claudeHomeHasOwnCredential(getVersionHomePath('claude', version))) {
413
+ delete out.CLAUDE_CODE_OAUTH_TOKEN;
414
+ }
386
415
  if (timezone)
387
416
  out.TZ = timezone;
388
417
  return out;
@@ -536,6 +565,25 @@ export async function executeJob(config, deps) {
536
565
  exitCode: null,
537
566
  };
538
567
  writeRunMeta(meta);
568
+ // Auth preflight: if the last live probe rejected this (agent, version)'s
569
+ // token (verdict `revoked`), the run is guaranteed to fail auth — fail fast
570
+ // before spawning instead of producing a doomed run + poisoned report. Cache-
571
+ // only (the daemon refreshes it periodically); fail OPEN on any non-revoked or
572
+ // missing verdict so a stale/absent probe or a network blip never blocks a
573
+ // run, and agents with no live probe (codex/gemini/grok) are never blocked.
574
+ const preflightVersion = launch.chain[0]?.version;
575
+ if (preflightVersion) {
576
+ const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
577
+ if (health && isDeadVerdict(health.verdict)) {
578
+ const reason = `auth_preflight: ${health.verdict}`;
579
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
580
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
581
+ writeRunMeta(meta);
582
+ timer.end({ status: 'failed', exitCode: 1, runId, error: reason });
583
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
584
+ return { meta, reportPath: null };
585
+ }
586
+ }
539
587
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
540
588
  // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
541
589
  if (config.loop) {
@@ -616,6 +664,21 @@ export async function executeJob(config, deps) {
616
664
  return { meta, reportPath };
617
665
  }
618
666
  if (attempt.status === 'completed') {
667
+ // Exit code alone is unreliable for auth: a logged-out Claude can exit 0
668
+ // with a `result` event carrying is_error:true (terminal_reason
669
+ // "completed"). Consult the SAME structural signal the detached path uses
670
+ // so both paths agree — raw text is deliberately NOT used here, so a
671
+ // genuinely-completed run that merely mentions an auth phrase stays a
672
+ // success (processFailed:false).
673
+ if (isAuthFailureFromLog(attempt.logText, effectiveAgent, { processFailed: false })) {
674
+ const reason = authFailureReason(attempt.logText) ?? 'authentication_failed';
675
+ finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, { errorMessage: `auth_failed: ${reason}` });
676
+ writeRunMeta(meta);
677
+ timer.end({ status: 'failed', exitCode: meta.exitCode ?? undefined, runId, error: `auth_failed: ${reason}` });
678
+ // Never persist the login-error text as the report.
679
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
680
+ return { meta, reportPath: null };
681
+ }
619
682
  finalizeRunMeta(meta, 'completed', 0);
620
683
  writeRunMeta(meta);
621
684
  timer.end({ status: 'completed', exitCode: 0, runId });
@@ -633,18 +696,36 @@ export async function executeJob(config, deps) {
633
696
  fs.appendFileSync(stdoutPath, `\n[agents] ${label} hit rate/usage limit — failover → ${nextLabel}\n`);
634
697
  continue;
635
698
  }
699
+ // Auth failure — the agent is logged out / token revoked. Unlike a rate
700
+ // limit it is not self-healing by failover (every chain entry on the same
701
+ // account fails identically), so rate-limit is classified first (above) and
702
+ // auth only when NOT rate-limited. Classified so the failure is visible and,
703
+ // critically, so the login-error text is never persisted as the report.
704
+ const authFailed = !rateLimited && (isAuthFailureFromLog(attempt.logText, effectiveAgent, { processFailed: true }) ||
705
+ (attempt.error ? detectAuthFailure(attempt.error) : false));
636
706
  if (attempt.error) {
637
707
  process.stderr.write(`[agents] routine ${config.name}: spawn failed for ${label}: ${attempt.error}\n`);
638
708
  }
639
- finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, attempt.error ? { errorMessage: attempt.error } : undefined);
709
+ const authReason = authFailed
710
+ ? (authFailureReason(attempt.logText)
711
+ ?? (attempt.error ? authFailureReason(attempt.error) : null)
712
+ ?? 'authentication_failed')
713
+ : null;
714
+ const failureErrorMessage = authReason
715
+ ? `auth_failed: ${authReason}`
716
+ : (attempt.error ?? undefined);
717
+ finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, failureErrorMessage ? { errorMessage: failureErrorMessage } : undefined);
640
718
  writeRunMeta(meta);
641
719
  timer.end({
642
720
  status: 'failed',
643
721
  exitCode: meta.exitCode ?? undefined,
644
722
  runId,
645
- ...(attempt.error ? { error: attempt.error } : {}),
723
+ ...(failureErrorMessage ? { error: failureErrorMessage } : {}),
646
724
  });
647
- const reportPath = extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
725
+ // On auth failure the last assistant text IS the login error — never persist
726
+ // it as report.md; it would otherwise be injected into the next run's prompt
727
+ // via {last_report}.
728
+ const reportPath = authFailed ? null : extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
648
729
  archiveRoutineTranscripts(meta, runDir, overlayHome);
649
730
  return { meta, reportPath };
650
731
  }
@@ -876,10 +957,26 @@ export async function executeJobDetached(config) {
876
957
  finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
877
958
  writeRunMeta(meta);
878
959
  archiveRoutineTranscripts(meta, runDir, overlayHome);
879
- if (status !== 'timeout')
960
+ // Never persist the report on an auth failure — the last assistant text is
961
+ // the login error, which would poison the next run's {last_report} prompt.
962
+ const isAuthFailure = !!errorMessage && errorMessage.startsWith('auth_failed:');
963
+ if (status !== 'timeout' && !isAuthFailure)
880
964
  extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
881
965
  };
882
966
  child.on('exit', (code) => {
967
+ let logText = '';
968
+ try {
969
+ logText = fs.readFileSync(stdoutPath, 'utf-8');
970
+ }
971
+ catch { /* log unreadable */ }
972
+ // processFailed gates the raw-text fallback so a COMPLETED run (exit 0) that
973
+ // merely mentions an auth phrase is never misclassified; the structural
974
+ // marker still catches an exit-0 auth failure on its own.
975
+ if (isAuthFailureFromLog(logText, effectiveAgent, { processFailed: (code ?? 1) !== 0 })) {
976
+ const reason = authFailureReason(logText) ?? 'authentication_failed';
977
+ settle('failed', code ?? 1, `auth_failed: ${reason}`);
978
+ return;
979
+ }
883
980
  const inferred = inferFinalStatusFromLog(stdoutPath, effectiveAgent);
884
981
  if (inferred) {
885
982
  settle(inferred.status, inferred.exitCode);
@@ -40,6 +40,7 @@ export declare const DEFAULT_TTL_MS: number;
40
40
  * spawnSync argv (unlike a NUL byte); `status` hides these entries.
41
41
  */
42
42
  export declare const META_CACHE_PREFIX = "!meta:";
43
+ export { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
43
44
  /**
44
45
  * Decide whether a persistent broker should self-heal onto freshly-installed
45
46
  * code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
@@ -232,6 +233,38 @@ export declare function startHostedBroker(): Promise<{
232
233
  /** True if a broker socket exists at all. Cheap; gates the sync read so the
233
234
  * never-unlocked path stays a single stat. */
234
235
  export declare function agentSocketExists(): boolean;
236
+ /**
237
+ * Spawn one of the `__secrets-*` sync clients and return its result.
238
+ *
239
+ * These three call sites used to hand-roll `spawnSync(process.execPath, ['-e',
240
+ * <inline node program>, …])`. That is correct only for a JS install, where
241
+ * `process.execPath` is `node`. Since 1.20.53 the shipped macOS `agents` is a
242
+ * **bun-compiled Mach-O**, where `process.execPath` is the CLI binary itself —
243
+ * so the spawn became `agents -e <program> …`, which commander rejects with
244
+ * `error: unknown option '-e'` and a non-zero exit. Every sync client then took
245
+ * its own failure path (`agentGetSync` → null, `agentReachableSync` → false,
246
+ * `agentEvictSync` → no-op), which reads as "broker down" and falls through to
247
+ * a real keychain read. Net effect on the standalone binary: the broker cache
248
+ * was never hit, so the `daily` policy's one-prompt-per-7d never applied and
249
+ * every bundle read re-popped Touch ID.
250
+ *
251
+ * Same defect class as the broker-launch bug fixed in 1.20.56 (see cliSpawn's
252
+ * doc comment); these three sites were simply never converted. Routing them
253
+ * through `getCliLaunch` fixes both install shapes at once and keeps a single
254
+ * code path.
255
+ *
256
+ * The subcommands are top-level `__secrets-*` tokens intercepted in index.ts
257
+ * BEFORE commander and before the CLI's startup work, exactly like
258
+ * `__daemon-run` and `__vault-age-helper`. That is load-bearing, not cosmetic:
259
+ * a normal command path runs `checkForUpdates()` and `spawnDetachedSync()` on
260
+ * every invocation (index.ts), so registering these as ordinary hidden
261
+ * subcommands would fire an update check and spawn a detached background sync
262
+ * on every cache hit — turning the hot read path into a process fork storm.
263
+ */
264
+ export declare function syncClientLaunch(sub: string[], agentsBin?: string): {
265
+ command: string;
266
+ args: string[];
267
+ };
235
268
  /**
236
269
  * Synchronous read for the hot path. Returns the cached resolved bundle, or
237
270
  * null if the agent isn't running / doesn't hold this bundle / anything fails
@@ -241,6 +274,20 @@ export declare function agentGetSync(name: string): {
241
274
  bundle: SecretsBundle;
242
275
  env: Record<string, string>;
243
276
  } | null;
277
+ /**
278
+ * Last non-empty line of a child's stdout — the payload line.
279
+ *
280
+ * The inline `node -e` client this replaced was a bare node process that could
281
+ * only ever emit its own JSON. The replacement boots the real CLI, so anything
282
+ * the CLI prints on the way up shares that stream. Today the known chatter (the
283
+ * `~/.agents/ is N commits behind` notice) correctly goes to stderr, but a
284
+ * single future stdout write anywhere in startup would make `JSON.parse` throw
285
+ * and turn every cache hit back into a silent miss — i.e. quietly reintroduce
286
+ * the Touch-ID-on-every-read bug this fix closes, with no failing test to catch
287
+ * it. Anchoring on the last line makes the payload the terminator of the
288
+ * stream rather than the whole of it, so preceding chatter is inert.
289
+ */
290
+ export declare function lastLine(stdout: string): string;
244
291
  /**
245
292
  * Synchronous liveness check: is a broker actually LISTENING and answering (not
246
293
  * just a lingering socket file)? Used to decide whether the auto-cache may take
@@ -259,6 +306,25 @@ export declare function agentReachableSync(): boolean;
259
306
  * macOS only.
260
307
  */
261
308
  export declare function agentEvictSync(name: string): void;
309
+ /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
310
+ * cache hit. Exit 0 = hit, 3 = miss or broker down. */
311
+ export declare function runAgentGetSync(name: string): Promise<number>;
312
+ /** Body of `__secrets-ping`. Exit 0 = a broker is listening and speaking
313
+ * our protocol, 3 = nothing there. Deliberately does NOT gate on
314
+ * PROTOCOL_VERSION: this only decides whether the auto-cache may take the
315
+ * synchronous warm path, and a version-skewed broker still answers reads. That
316
+ * matches the inline program this replaced. */
317
+ export declare function runAgentPingSync(): Promise<number>;
318
+ /** Body of `__secrets-lock <name>`. Best-effort evict; exit 0 even when no
319
+ * broker answers, so a missing broker never fails the mutating write that
320
+ * triggered it (agentEvictSync discards this either way).
321
+ *
322
+ * An empty name is refused rather than forwarded: the broker treats a nameless
323
+ * lock as lock-ALL (handleAgentRequest), so a bare `__secrets-lock` would wipe
324
+ * every held bundle and re-prompt Touch ID for each. That was unreachable while
325
+ * this was an inline `node -e` string; as a top-level argv token it is one typo
326
+ * away, and agentEvictSync only ever locks by name. */
327
+ export declare function runAgentLockSync(name: string): Promise<number>;
262
328
  /**
263
329
  * Read the cached `secrets list` metadata snapshot for the given keychain
264
330
  * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
@@ -364,4 +430,3 @@ export declare function agentPing(): Promise<{
364
430
  * gets starved under heavy load, so it's last.
365
431
  */
366
432
  export declare function ensureAgentRunning(timeoutMs?: number): Promise<boolean>;
367
- export {};
@@ -35,6 +35,7 @@ import { getKeychainHelperPath } from './install-helper.js';
35
35
  import { getCliVersion, getCliVersionFresh } from '../version.js';
36
36
  import { getCliLaunch } from '../cli-entry.js';
37
37
  import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
38
+ import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
38
39
  /** Bumped when the wire protocol changes; a client that pings a mismatched
39
40
  * server kills and respawns it rather than talking a stale dialect. */
40
41
  const PROTOCOL_VERSION = 1;
@@ -58,6 +59,34 @@ export const META_CACHE_PREFIX = '!meta:';
58
59
  const IDLE_EXIT_MS = 5 * 60 * 1000; // 5m
59
60
  /** How often the broker sweeps expired entries. */
60
61
  const SWEEP_INTERVAL_MS = 30 * 1000;
62
+ /**
63
+ * Timeouts for the three synchronous broker clients, split across the process
64
+ * boundary they now straddle.
65
+ *
66
+ * `SOCKET_*` bound the socket round-trip inside the spawned `__secrets-*`
67
+ * child, and carry over unchanged from the inline `node -e` programs these
68
+ * replaced. `SYNC_*` bound the parent's `spawnSync` and must additionally cover
69
+ * the child's boot, so each is its socket budget plus ~2s of headroom. A parent
70
+ * timeout that fired before the child's own timer would report "broker down"
71
+ * for a broker that is merely slow, which costs a Touch ID prompt — so the
72
+ * parent budget is deliberately the looser of the two.
73
+ *
74
+ * Boot cost, measured on the bun-compiled binary on an M-series Mac (median of
75
+ * 15, against a live broker): ~96ms via the index.ts intercept, ~165ms if the
76
+ * intercept is moved below the startup statements, ~44ms for the `node -e` this
77
+ * replaces. The intercept is what keeps the regression modest; see index.ts.
78
+ */
79
+ const SOCKET_GET_TIMEOUT_MS = 2000;
80
+ const SOCKET_PING_TIMEOUT_MS = 700;
81
+ const SOCKET_LOCK_TIMEOUT_MS = 2000;
82
+ const SYNC_GET_TIMEOUT_MS = 4000;
83
+ const SYNC_PING_TIMEOUT_MS = 2500;
84
+ const SYNC_LOCK_TIMEOUT_MS = 4000;
85
+ // The argv tokens live in a leaf module so index.ts can bind the SAME values
86
+ // without importing this one. Re-exported here for callers already reaching for
87
+ // agent.js. See sync-commands.ts for why a shared binding rather than matching
88
+ // literals: the drift it prevents is silent and costs a Touch ID prompt per read.
89
+ export { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
61
90
  /**
62
91
  * Decide whether a persistent broker should self-heal onto freshly-installed
63
92
  * code (exit so launchd relaunches it). Only when the store is EMPTY: exiting
@@ -744,35 +773,52 @@ export function agentSocketExists() {
744
773
  return onDarwin() && fs.existsSync(socketPath());
745
774
  }
746
775
  /**
747
- * Inline node program for the synchronous read fast-path. `readAndResolveBundleEnv`
748
- * is synchronous and called synchronously everywhere, so we can't await a socket
749
- * round-trip but spawning the full CLI to do it would load every command. This
750
- * minimal `node -e` client connects, asks for one bundle, prints the resolved
751
- * {bundle, env} as JSON, and exits 0 (hit) / 3 (miss or agent down). argv after
752
- * -e: [execPath, <socket>, <name>].
776
+ * Spawn one of the `__secrets-*` sync clients and return its result.
777
+ *
778
+ * These three call sites used to hand-roll `spawnSync(process.execPath, ['-e',
779
+ * <inline node program>, …])`. That is correct only for a JS install, where
780
+ * `process.execPath` is `node`. Since 1.20.53 the shipped macOS `agents` is a
781
+ * **bun-compiled Mach-O**, where `process.execPath` is the CLI binary itself —
782
+ * so the spawn became `agents -e <program> …`, which commander rejects with
783
+ * `error: unknown option '-e'` and a non-zero exit. Every sync client then took
784
+ * its own failure path (`agentGetSync` → null, `agentReachableSync` → false,
785
+ * `agentEvictSync` → no-op), which reads as "broker down" and falls through to
786
+ * a real keychain read. Net effect on the standalone binary: the broker cache
787
+ * was never hit, so the `daily` policy's one-prompt-per-7d never applied and
788
+ * every bundle read re-popped Touch ID.
789
+ *
790
+ * Same defect class as the broker-launch bug fixed in 1.20.56 (see cliSpawn's
791
+ * doc comment); these three sites were simply never converted. Routing them
792
+ * through `getCliLaunch` fixes both install shapes at once and keeps a single
793
+ * code path.
794
+ *
795
+ * The subcommands are top-level `__secrets-*` tokens intercepted in index.ts
796
+ * BEFORE commander and before the CLI's startup work, exactly like
797
+ * `__daemon-run` and `__vault-age-helper`. That is load-bearing, not cosmetic:
798
+ * a normal command path runs `checkForUpdates()` and `spawnDetachedSync()` on
799
+ * every invocation (index.ts), so registering these as ordinary hidden
800
+ * subcommands would fire an update check and spawn a detached background sync
801
+ * on every cache hit — turning the hot read path into a process fork storm.
753
802
  */
754
- const SYNC_GET_PROGRAM = `
755
- const net = require('net'), fs = require('fs');
756
- const sock = process.argv[1], name = process.argv[2], tokenPath = process.argv[3];
757
- let token; try { token = fs.readFileSync(tokenPath, 'utf-8').trim() || undefined; } catch (e) {}
758
- const c = net.createConnection(sock);
759
- let buf = '';
760
- const miss = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
761
- const timer = setTimeout(miss, 2000);
762
- c.on('error', miss);
763
- c.on('connect', () => c.write(JSON.stringify({ cmd: 'get', name, token }) + '\\n'));
764
- c.setEncoding('utf-8');
765
- c.on('data', (d) => {
766
- buf += d;
767
- const nl = buf.indexOf('\\n');
768
- if (nl < 0) return;
769
- clearTimeout(timer);
770
- let r; try { r = JSON.parse(buf.slice(0, nl)); } catch (e) { return miss(); }
771
- try { c.destroy(); } catch (e) {}
772
- if (r && r.ok && r.hit) { process.stdout.write(JSON.stringify({ bundle: r.bundle, env: r.env })); process.exit(0); }
773
- process.exit(3);
774
- });
775
- `;
803
+ export function syncClientLaunch(sub, agentsBin) {
804
+ return agentsBin ? getCliLaunch(sub, agentsBin) : getCliLaunch(sub);
805
+ }
806
+ function syncClient(sub, timeout) {
807
+ // Soft on every failure: bundles.ts's fast path promises "any failure falls
808
+ // through to the real keychain read below". spawnSync reports most failures
809
+ // via r.error rather than throwing, but getCliLaunch/getAgentsBinPath DO
810
+ // throw on a broken install — a bunfs entry whose execPath is gone, or a
811
+ // browser/computer shim with no sibling index.js. Without this catch those
812
+ // turn a graceful fallback into a crash, and only on already-degraded
813
+ // installs, which is the worst time for it.
814
+ try {
815
+ const { command, args } = syncClientLaunch(sub);
816
+ return spawnSync(command, args, { encoding: 'utf-8', timeout });
817
+ }
818
+ catch {
819
+ return null;
820
+ }
821
+ }
776
822
  /**
777
823
  * Synchronous read for the hot path. Returns the cached resolved bundle, or
778
824
  * null if the agent isn't running / doesn't hold this bundle / anything fails
@@ -781,14 +827,11 @@ c.on('data', (d) => {
781
827
  export function agentGetSync(name) {
782
828
  if (!agentSocketExists())
783
829
  return null;
784
- const r = spawnSync(process.execPath, ['-e', SYNC_GET_PROGRAM, socketPath(), name, tokenPath()], {
785
- encoding: 'utf-8',
786
- timeout: 3000,
787
- });
788
- if (r.status !== 0 || !r.stdout)
830
+ const r = syncClient([SYNC_GET_CMD, name], SYNC_GET_TIMEOUT_MS);
831
+ if (!r || r.status !== 0 || !r.stdout)
789
832
  return null;
790
833
  try {
791
- const o = JSON.parse(r.stdout);
834
+ const o = JSON.parse(lastLine(r.stdout));
792
835
  if (!o || typeof o !== 'object' || !o.env)
793
836
  return null;
794
837
  return { bundle: o.bundle, env: o.env };
@@ -797,30 +840,28 @@ export function agentGetSync(name) {
797
840
  return null;
798
841
  }
799
842
  }
800
- /** Inline node program for a synchronous liveness ping. Connects, sends one
801
- * `{cmd:'ping'}`, exits 0 iff a valid ping response comes back, else 3. A stale
802
- * socket file with no listener refuses the connection immediately, so this
803
- * fast-fails without riding any cold-start logic. argv after -e: [execPath, <socket>]. */
804
- const SYNC_PING_PROGRAM = `
805
- const net = require('net');
806
- const sock = process.argv[1];
807
- const c = net.createConnection(sock);
808
- let buf = '';
809
- const dead = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
810
- const timer = setTimeout(dead, 700);
811
- c.on('error', dead);
812
- c.on('connect', () => c.write(JSON.stringify({ cmd: 'ping' }) + '\\n'));
813
- c.setEncoding('utf-8');
814
- c.on('data', (d) => {
815
- buf += d;
816
- const nl = buf.indexOf('\\n');
817
- if (nl < 0) return;
818
- clearTimeout(timer);
819
- let r; try { r = JSON.parse(buf.slice(0, nl)); } catch (e) { return dead(); }
820
- try { c.destroy(); } catch (e) {}
821
- process.exit(r && r.ok && r.cmd === 'ping' ? 0 : 3);
822
- });
823
- `;
843
+ /**
844
+ * Last non-empty line of a child's stdout the payload line.
845
+ *
846
+ * The inline `node -e` client this replaced was a bare node process that could
847
+ * only ever emit its own JSON. The replacement boots the real CLI, so anything
848
+ * the CLI prints on the way up shares that stream. Today the known chatter (the
849
+ * `~/.agents/ is N commits behind` notice) correctly goes to stderr, but a
850
+ * single future stdout write anywhere in startup would make `JSON.parse` throw
851
+ * and turn every cache hit back into a silent miss — i.e. quietly reintroduce
852
+ * the Touch-ID-on-every-read bug this fix closes, with no failing test to catch
853
+ * it. Anchoring on the last line makes the payload the terminator of the
854
+ * stream rather than the whole of it, so preceding chatter is inert.
855
+ */
856
+ export function lastLine(stdout) {
857
+ const lines = stdout.split('\n');
858
+ for (let i = lines.length - 1; i >= 0; i--) {
859
+ const t = lines[i].trim();
860
+ if (t)
861
+ return t;
862
+ }
863
+ return '';
864
+ }
824
865
  /**
825
866
  * Synchronous liveness check: is a broker actually LISTENING and answering (not
826
867
  * just a lingering socket file)? Used to decide whether the auto-cache may take
@@ -834,36 +875,9 @@ export function agentReachableSync() {
834
875
  return false;
835
876
  if (!agentSocketExists())
836
877
  return false;
837
- const r = spawnSync(process.execPath, ['-e', SYNC_PING_PROGRAM, socketPath()], { timeout: 1500 });
838
- return r.status === 0 && !r.error;
878
+ const r = syncClient([SYNC_PING_CMD], SYNC_PING_TIMEOUT_MS);
879
+ return r !== null && r.status === 0 && !r.error;
839
880
  }
840
- /**
841
- * Inline node program for the synchronous evict path. Mirrors SYNC_GET_PROGRAM:
842
- * writeBundle is synchronous and called synchronously everywhere, so a stale
843
- * broker entry must be evicted without awaiting a socket round-trip. Sends one
844
- * {cmd:'lock', name} and exits 0 (evicted or nothing held) / 3 (agent down).
845
- * argv after -e: [execPath, <socket>, <name>].
846
- */
847
- const SYNC_LOCK_PROGRAM = `
848
- const net = require('net'), fs = require('fs');
849
- const sock = process.argv[1], name = process.argv[2], tokenPath = process.argv[3];
850
- let token; try { token = fs.readFileSync(tokenPath, 'utf-8').trim() || undefined; } catch (e) {}
851
- const c = net.createConnection(sock);
852
- let buf = '';
853
- const down = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
854
- const timer = setTimeout(down, 2000);
855
- c.on('error', down);
856
- c.on('connect', () => c.write(JSON.stringify({ cmd: 'lock', name, token }) + '\\n'));
857
- c.setEncoding('utf-8');
858
- c.on('data', (d) => {
859
- buf += d;
860
- const nl = buf.indexOf('\\n');
861
- if (nl < 0) return;
862
- clearTimeout(timer);
863
- try { c.destroy(); } catch (e) {}
864
- process.exit(0);
865
- });
866
- `;
867
881
  /**
868
882
  * Synchronously evict one bundle from the broker. Called after a mutating
869
883
  * keychain write (add / rotate / remove / rename / delete) so the broker never
@@ -877,10 +891,60 @@ export function agentEvictSync(name) {
877
891
  return;
878
892
  if (!agentSocketExists())
879
893
  return;
880
- try {
881
- spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name, tokenPath()], { timeout: 3000 });
894
+ // No try/catch: syncClient swallows its own failures and returns null.
895
+ syncClient([SYNC_LOCK_CMD, name], SYNC_LOCK_TIMEOUT_MS);
896
+ }
897
+ // ─── Top-level `__secrets-*` sync-client entrypoints ────────────────────────
898
+ // The bodies behind the three spawns above. Each does one socket round-trip and
899
+ // signals the parent through its exit code, mirroring the inline `node -e`
900
+ // programs these replaced: 0 = hit/alive, 3 = miss/down. Lock deviates twice:
901
+ // it reports 0 even when no broker answers, and 3 for an empty name, which it
902
+ // refuses rather than forwarding as a lock-ALL (see runAgentLockSync).
903
+ // Dispatched from index.ts BEFORE commander and before the startup statements,
904
+ // so a cache hit never runs checkForUpdates() or forks a detached sync.
905
+ /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
906
+ * cache hit. Exit 0 = hit, 3 = miss or broker down. */
907
+ export async function runAgentGetSync(name) {
908
+ const r = await request({ cmd: 'get', name }, SOCKET_GET_TIMEOUT_MS);
909
+ if (r?.ok === true && r.cmd === 'get' && r.hit) {
910
+ // Trailing newline: the parent reads the LAST line (see lastLine), so the
911
+ // payload must terminate the stream even if anything ever precedes it.
912
+ //
913
+ // Awaited on the write callback, not fire-and-forget: stdout is a pipe here
914
+ // (the parent captures it), pipe writes are asynchronous, and the caller
915
+ // exits the process the moment this resolves. An unflushed write would be
916
+ // truncated on exit — the parent would see a partial or empty payload, treat
917
+ // it as a miss, and fall through to a keychain read, silently costing the
918
+ // Touch ID prompt this whole path exists to avoid.
919
+ const payload = JSON.stringify({ bundle: r.bundle, env: r.env }) + '\n';
920
+ await new Promise((resolve) => { process.stdout.write(payload, () => resolve()); });
921
+ return 0;
882
922
  }
883
- catch { /* best-effort */ }
923
+ return 3;
924
+ }
925
+ /** Body of `__secrets-ping`. Exit 0 = a broker is listening and speaking
926
+ * our protocol, 3 = nothing there. Deliberately does NOT gate on
927
+ * PROTOCOL_VERSION: this only decides whether the auto-cache may take the
928
+ * synchronous warm path, and a version-skewed broker still answers reads. That
929
+ * matches the inline program this replaced. */
930
+ export async function runAgentPingSync() {
931
+ const r = await request({ cmd: 'ping' }, SOCKET_PING_TIMEOUT_MS);
932
+ return r?.ok === true && r.cmd === 'ping' ? 0 : 3;
933
+ }
934
+ /** Body of `__secrets-lock <name>`. Best-effort evict; exit 0 even when no
935
+ * broker answers, so a missing broker never fails the mutating write that
936
+ * triggered it (agentEvictSync discards this either way).
937
+ *
938
+ * An empty name is refused rather than forwarded: the broker treats a nameless
939
+ * lock as lock-ALL (handleAgentRequest), so a bare `__secrets-lock` would wipe
940
+ * every held bundle and re-prompt Touch ID for each. That was unreachable while
941
+ * this was an inline `node -e` string; as a top-level argv token it is one typo
942
+ * away, and agentEvictSync only ever locks by name. */
943
+ export async function runAgentLockSync(name) {
944
+ if (!name)
945
+ return 3;
946
+ await request({ cmd: 'lock', name }, SOCKET_LOCK_TIMEOUT_MS);
947
+ return 0;
884
948
  }
885
949
  // Key inside the cached entry's env that holds the JSON metadata snapshot.
886
950
  const META_SNAPSHOT_KEY = '__snapshot__';
@@ -114,6 +114,32 @@ export declare function validateSecretType(t: string): asserts t is SecretType;
114
114
  */
115
115
  export declare function validateExpiresFutureDated(iso: string): void;
116
116
  export declare function bundleExists(name: string): boolean;
117
+ /**
118
+ * Thrown by `readBundle` for the one state `readBundleIfDecryptable` may treat
119
+ * as "present but permanently unreadable": file-store ciphertext on disk that
120
+ * will not decrypt with the passphrase in effect (lost/rotated key or a tampered
121
+ * store). It is deliberately narrow — a bundle that is merely *locked for this
122
+ * run* (headless macOS without `AGENTS_SECRETS_PASSPHRASE`, or a vault that is
123
+ * not logged in) is recoverable and must not be collapsed into this.
124
+ */
125
+ export declare class BundleUndecryptableError extends Error {
126
+ constructor(message: string);
127
+ }
128
+ /**
129
+ * Read a bundle, or return null when its metadata is present but genuinely
130
+ * cannot be decrypted — a lost or rotated file-store passphrase, or a tampered
131
+ * file store (signalled by `BundleUndecryptableError`).
132
+ *
133
+ * Deleting such a bundle is the only way out of that state, and deletion needs
134
+ * no plaintext, so it must not be gated behind a successful decrypt. Every other
135
+ * failure rethrows: a genuinely missing bundle ("not found"), and — critically —
136
+ * a bundle that is only *temporarily locked* for this run (headless macOS with no
137
+ * `AGENTS_SECRETS_PASSPHRASE`, or a not-logged-in vault). Collapsing that
138
+ * recoverable "set the env / log in" state into "unreadable, safe to delete"
139
+ * would let `secrets delete <name> --yes` silently destroy a perfectly healthy
140
+ * bundle from a cron/launchd run that merely forgot to export the passphrase.
141
+ */
142
+ export declare function readBundleIfDecryptable(name: string): SecretsBundle | null;
117
143
  export declare function readBundle(name: string): SecretsBundle;
118
144
  /** The default prompt policy applied to bundles without an explicit per-bundle
119
145
  * policy. Configurable via `secrets.policy` in agents.yaml; `daily` (one Touch
@@ -147,6 +173,14 @@ export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAg
147
173
  export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
148
174
  export declare function writeBundleWithItems(bundle: SecretsBundle, items: Map<string, string>, opts?: WriteBundleOptions): void;
149
175
  export declare function deleteBundle(name: string): boolean;
176
+ /**
177
+ * Re-write already-read keychain bundle metadata items WITHOUT the biometry ACL.
178
+ * `metaJsonByName` maps bundle name → the exact metadata JSON listBundles just
179
+ * batch-read, so writing it back only flips the ACL (via the helper's
180
+ * delete-then-add `set-no-acl`) — contents and updated_at are preserved and no
181
+ * extra keychain read is issued. Exported for tests. Returns the count healed.
182
+ */
183
+ export declare function healKeychainBundleMetadata(metaJsonByName: Map<string, string>): number;
150
184
  export declare function listBundles(): SecretsBundle[];
151
185
  export interface BundleEntryInfo {
152
186
  key: string;