@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -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 +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -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
@@ -68,12 +69,14 @@ export interface StoredBundle {
68
69
  env: Record<string, string>;
69
70
  /** epoch ms; the entry is gone once Date.now() passes this. */
70
71
  expiresAt: number;
72
+ harness: string;
71
73
  }
72
74
  /** One unlocked bundle as reported by `status`. */
73
75
  export interface AgentStatusEntry {
74
76
  name: string;
75
77
  expiresAt: number;
76
78
  keyCount: number;
79
+ harness: string;
77
80
  }
78
81
  /**
79
82
  * Read the current broker capability token, or null if none is present. Clients
@@ -105,10 +108,12 @@ export type Request = {
105
108
  } | {
106
109
  cmd: 'get';
107
110
  name: string;
111
+ harness?: string;
108
112
  token?: string;
109
113
  } | {
110
114
  cmd: 'load';
111
115
  name: string;
116
+ harness?: string;
112
117
  bundle: SecretsBundle;
113
118
  env: Record<string, string>;
114
119
  ttlMs: number;
@@ -165,6 +170,7 @@ export type Response = {
165
170
  * exiting. Pure + exported for unit testing.
166
171
  */
167
172
  export declare function realBundleCount(store: Map<string, StoredBundle>): number;
173
+ export declare function scopedBundleKey(name: string, harness: string): string;
168
174
  export declare function handleAgentRequest(store: Map<string, StoredBundle>, req: Request, now?: number): Response;
169
175
  /**
170
176
  * Decide whether a `watch-lock` helper line should wipe the in-memory store.
@@ -232,15 +238,61 @@ export declare function startHostedBroker(): Promise<{
232
238
  /** True if a broker socket exists at all. Cheap; gates the sync read so the
233
239
  * never-unlocked path stays a single stat. */
234
240
  export declare function agentSocketExists(): boolean;
241
+ /**
242
+ * Spawn one of the `__secrets-*` sync clients and return its result.
243
+ *
244
+ * These three call sites used to hand-roll `spawnSync(process.execPath, ['-e',
245
+ * <inline node program>, …])`. That is correct only for a JS install, where
246
+ * `process.execPath` is `node`. Since 1.20.53 the shipped macOS `agents` is a
247
+ * **bun-compiled Mach-O**, where `process.execPath` is the CLI binary itself —
248
+ * so the spawn became `agents -e <program> …`, which commander rejects with
249
+ * `error: unknown option '-e'` and a non-zero exit. Every sync client then took
250
+ * its own failure path (`agentGetSync` → null, `agentReachableSync` → false,
251
+ * `agentEvictSync` → no-op), which reads as "broker down" and falls through to
252
+ * a real keychain read. Net effect on the standalone binary: the broker cache
253
+ * was never hit, so the `daily` policy's one-prompt-per-7d never applied and
254
+ * every bundle read re-popped Touch ID.
255
+ *
256
+ * Same defect class as the broker-launch bug fixed in 1.20.56 (see cliSpawn's
257
+ * doc comment); these three sites were simply never converted. Routing them
258
+ * through `getCliLaunch` fixes both install shapes at once and keeps a single
259
+ * code path.
260
+ *
261
+ * The subcommands are top-level `__secrets-*` tokens intercepted in index.ts
262
+ * BEFORE commander and before the CLI's startup work, exactly like
263
+ * `__daemon-run` and `__vault-age-helper`. That is load-bearing, not cosmetic:
264
+ * a normal command path runs `checkForUpdates()` and `spawnDetachedSync()` on
265
+ * every invocation (index.ts), so registering these as ordinary hidden
266
+ * subcommands would fire an update check and spawn a detached background sync
267
+ * on every cache hit — turning the hot read path into a process fork storm.
268
+ */
269
+ export declare function syncClientLaunch(sub: string[], agentsBin?: string): {
270
+ command: string;
271
+ args: string[];
272
+ };
235
273
  /**
236
274
  * Synchronous read for the hot path. Returns the cached resolved bundle, or
237
275
  * null if the agent isn't running / doesn't hold this bundle / anything fails
238
276
  * (soft — caller falls through to the real keychain). macOS only.
239
277
  */
240
- export declare function agentGetSync(name: string): {
278
+ export declare function agentGetSync(name: string, harness?: string): {
241
279
  bundle: SecretsBundle;
242
280
  env: Record<string, string>;
243
281
  } | null;
282
+ /**
283
+ * Last non-empty line of a child's stdout — the payload line.
284
+ *
285
+ * The inline `node -e` client this replaced was a bare node process that could
286
+ * only ever emit its own JSON. The replacement boots the real CLI, so anything
287
+ * the CLI prints on the way up shares that stream. Today the known chatter (the
288
+ * `~/.agents/ is N commits behind` notice) correctly goes to stderr, but a
289
+ * single future stdout write anywhere in startup would make `JSON.parse` throw
290
+ * and turn every cache hit back into a silent miss — i.e. quietly reintroduce
291
+ * the Touch-ID-on-every-read bug this fix closes, with no failing test to catch
292
+ * it. Anchoring on the last line makes the payload the terminator of the
293
+ * stream rather than the whole of it, so preceding chatter is inert.
294
+ */
295
+ export declare function lastLine(stdout: string): string;
244
296
  /**
245
297
  * Synchronous liveness check: is a broker actually LISTENING and answering (not
246
298
  * just a lingering socket file)? Used to decide whether the auto-cache may take
@@ -259,6 +311,25 @@ export declare function agentReachableSync(): boolean;
259
311
  * macOS only.
260
312
  */
261
313
  export declare function agentEvictSync(name: string): void;
314
+ /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
315
+ * cache hit. Exit 0 = hit, 3 = miss or broker down. */
316
+ export declare function runAgentGetSync(name: string, harness?: string): Promise<number>;
317
+ /** Body of `__secrets-ping`. Exit 0 = a broker is listening and speaking
318
+ * our protocol, 3 = nothing there. Deliberately does NOT gate on
319
+ * PROTOCOL_VERSION: this only decides whether the auto-cache may take the
320
+ * synchronous warm path, and a version-skewed broker still answers reads. That
321
+ * matches the inline program this replaced. */
322
+ export declare function runAgentPingSync(): Promise<number>;
323
+ /** Body of `__secrets-lock <name>`. Best-effort evict; exit 0 even when no
324
+ * broker answers, so a missing broker never fails the mutating write that
325
+ * triggered it (agentEvictSync discards this either way).
326
+ *
327
+ * An empty name is refused rather than forwarded: the broker treats a nameless
328
+ * lock as lock-ALL (handleAgentRequest), so a bare `__secrets-lock` would wipe
329
+ * every held bundle and re-prompt Touch ID for each. That was unreachable while
330
+ * this was an inline `node -e` string; as a top-level argv token it is one typo
331
+ * away, and agentEvictSync only ever locks by name. */
332
+ export declare function runAgentLockSync(name: string): Promise<number>;
262
333
  /**
263
334
  * Read the cached `secrets list` metadata snapshot for the given keychain
264
335
  * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
@@ -321,7 +392,7 @@ export declare function clampHoldMs(v: unknown): number;
321
392
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
322
393
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
323
394
  */
324
- export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): void;
395
+ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): void;
325
396
  /**
326
397
  * Body of the hidden `secrets _agent-load` worker. Reads one `{name, bundle,
327
398
  * env, ttlMs}` payload from stdin, ensures the broker is up (robust, generous
@@ -336,7 +407,7 @@ export declare function agentAutoLoadSync(name: string, bundle: SecretsBundle, e
336
407
  */
337
408
  export declare function runAgentLoadFromStdin(): Promise<void>;
338
409
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
339
- export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number): Promise<boolean>;
410
+ export declare function agentLoad(name: string, bundle: SecretsBundle, env: Record<string, string>, ttlMs: number, harness?: string): Promise<boolean>;
340
411
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count
341
412
  * wiped, or 0 when no broker is running. */
342
413
  export declare function agentLock(name?: string): Promise<number>;
@@ -364,4 +435,3 @@ export declare function agentPing(): Promise<{
364
435
  * gets starved under heavy load, so it's last.
365
436
  */
366
437
  export declare function ensureAgentRunning(timeoutMs?: number): Promise<boolean>;
367
- export {};
@@ -35,9 +35,10 @@ 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
- const PROTOCOL_VERSION = 1;
41
+ const PROTOCOL_VERSION = 2;
41
42
  /** Default lifetime of an unlocked bundle when `--ttl` is not given. */
42
43
  export const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
43
44
  /**
@@ -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
@@ -104,7 +133,8 @@ function onDarwin() {
104
133
  function rehydrateStore(now = Date.now()) {
105
134
  const store = new Map();
106
135
  for (const { name, entry } of rehydrateSessions(now)) {
107
- store.set(name, { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt });
136
+ const harness = entry.harness || 'cli';
137
+ store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
108
138
  }
109
139
  return store;
110
140
  }
@@ -262,11 +292,14 @@ export async function uninstallSecretsAgentService() {
262
292
  */
263
293
  export function realBundleCount(store) {
264
294
  let n = 0;
265
- for (const name of store.keys())
266
- if (!name.startsWith(META_CACHE_PREFIX))
295
+ for (const e of store.values())
296
+ if (!e.bundle.name.startsWith(META_CACHE_PREFIX))
267
297
  n++;
268
298
  return n;
269
299
  }
300
+ export function scopedBundleKey(name, harness) {
301
+ return `${harness}:${name}`;
302
+ }
270
303
  export function handleAgentRequest(store, req, now = Date.now()) {
271
304
  switch (req.cmd) {
272
305
  case 'ping':
@@ -276,20 +309,26 @@ export function handleAgentRequest(store, req, now = Date.now()) {
276
309
  // the broker is running pre-upgrade code and should be restarted.
277
310
  return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
278
311
  case 'get': {
279
- const e = store.get(req.name);
312
+ const key = scopedBundleKey(req.name, req.harness || 'cli');
313
+ const e = store.get(key);
280
314
  if (!e || now >= e.expiresAt) {
281
315
  if (e)
282
- store.delete(req.name); // drop expired on read
316
+ store.delete(key); // drop expired on read
283
317
  return { ok: true, cmd: 'get', hit: false };
284
318
  }
285
319
  return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
286
320
  }
287
321
  case 'load':
288
- store.set(req.name, { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs });
322
+ const harness = req.harness || 'cli';
323
+ store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
289
324
  return { ok: true, cmd: 'load' };
290
325
  case 'lock': {
291
326
  if (req.name) {
292
- return { ok: true, cmd: 'lock', wiped: store.delete(req.name) ? 1 : 0 };
327
+ let wiped = 0;
328
+ for (const [key, entry] of store)
329
+ if (entry.bundle.name === req.name && store.delete(key))
330
+ wiped++;
331
+ return { ok: true, cmd: 'lock', wiped };
293
332
  }
294
333
  const wiped = store.size;
295
334
  store.clear();
@@ -300,9 +339,9 @@ export function handleAgentRequest(store, req, now = Date.now()) {
300
339
  for (const [name, e] of store) {
301
340
  if (now >= e.expiresAt)
302
341
  continue;
303
- if (name.startsWith(META_CACHE_PREFIX))
304
- continue; // internal list cache, not a user bundle
305
- entries.push({ name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length });
342
+ if (e.bundle.name.startsWith(META_CACHE_PREFIX))
343
+ continue; // internal list cache
344
+ entries.push({ name: e.bundle.name, expiresAt: e.expiresAt, keyCount: Object.keys(e.env).length, harness: e.harness });
306
345
  }
307
346
  return { ok: true, cmd: 'status', entries };
308
347
  }
@@ -744,51 +783,65 @@ export function agentSocketExists() {
744
783
  return onDarwin() && fs.existsSync(socketPath());
745
784
  }
746
785
  /**
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>].
786
+ * Spawn one of the `__secrets-*` sync clients and return its result.
787
+ *
788
+ * These three call sites used to hand-roll `spawnSync(process.execPath, ['-e',
789
+ * <inline node program>, …])`. That is correct only for a JS install, where
790
+ * `process.execPath` is `node`. Since 1.20.53 the shipped macOS `agents` is a
791
+ * **bun-compiled Mach-O**, where `process.execPath` is the CLI binary itself —
792
+ * so the spawn became `agents -e <program> …`, which commander rejects with
793
+ * `error: unknown option '-e'` and a non-zero exit. Every sync client then took
794
+ * its own failure path (`agentGetSync` → null, `agentReachableSync` → false,
795
+ * `agentEvictSync` → no-op), which reads as "broker down" and falls through to
796
+ * a real keychain read. Net effect on the standalone binary: the broker cache
797
+ * was never hit, so the `daily` policy's one-prompt-per-7d never applied and
798
+ * every bundle read re-popped Touch ID.
799
+ *
800
+ * Same defect class as the broker-launch bug fixed in 1.20.56 (see cliSpawn's
801
+ * doc comment); these three sites were simply never converted. Routing them
802
+ * through `getCliLaunch` fixes both install shapes at once and keeps a single
803
+ * code path.
804
+ *
805
+ * The subcommands are top-level `__secrets-*` tokens intercepted in index.ts
806
+ * BEFORE commander and before the CLI's startup work, exactly like
807
+ * `__daemon-run` and `__vault-age-helper`. That is load-bearing, not cosmetic:
808
+ * a normal command path runs `checkForUpdates()` and `spawnDetachedSync()` on
809
+ * every invocation (index.ts), so registering these as ordinary hidden
810
+ * subcommands would fire an update check and spawn a detached background sync
811
+ * on every cache hit — turning the hot read path into a process fork storm.
753
812
  */
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
- `;
813
+ export function syncClientLaunch(sub, agentsBin) {
814
+ return agentsBin ? getCliLaunch(sub, agentsBin) : getCliLaunch(sub);
815
+ }
816
+ function syncClient(sub, timeout) {
817
+ // Soft on every failure: bundles.ts's fast path promises "any failure falls
818
+ // through to the real keychain read below". spawnSync reports most failures
819
+ // via r.error rather than throwing, but getCliLaunch/getAgentsBinPath DO
820
+ // throw on a broken install — a bunfs entry whose execPath is gone, or a
821
+ // browser/computer shim with no sibling index.js. Without this catch those
822
+ // turn a graceful fallback into a crash, and only on already-degraded
823
+ // installs, which is the worst time for it.
824
+ try {
825
+ const { command, args } = syncClientLaunch(sub);
826
+ return spawnSync(command, args, { encoding: 'utf-8', timeout });
827
+ }
828
+ catch {
829
+ return null;
830
+ }
831
+ }
776
832
  /**
777
833
  * Synchronous read for the hot path. Returns the cached resolved bundle, or
778
834
  * null if the agent isn't running / doesn't hold this bundle / anything fails
779
835
  * (soft — caller falls through to the real keychain). macOS only.
780
836
  */
781
- export function agentGetSync(name) {
837
+ export function agentGetSync(name, harness = 'cli') {
782
838
  if (!agentSocketExists())
783
839
  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)
840
+ const r = syncClient([SYNC_GET_CMD, name, harness], SYNC_GET_TIMEOUT_MS);
841
+ if (!r || r.status !== 0 || !r.stdout)
789
842
  return null;
790
843
  try {
791
- const o = JSON.parse(r.stdout);
844
+ const o = JSON.parse(lastLine(r.stdout));
792
845
  if (!o || typeof o !== 'object' || !o.env)
793
846
  return null;
794
847
  return { bundle: o.bundle, env: o.env };
@@ -797,30 +850,28 @@ export function agentGetSync(name) {
797
850
  return null;
798
851
  }
799
852
  }
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
- `;
853
+ /**
854
+ * Last non-empty line of a child's stdout the payload line.
855
+ *
856
+ * The inline `node -e` client this replaced was a bare node process that could
857
+ * only ever emit its own JSON. The replacement boots the real CLI, so anything
858
+ * the CLI prints on the way up shares that stream. Today the known chatter (the
859
+ * `~/.agents/ is N commits behind` notice) correctly goes to stderr, but a
860
+ * single future stdout write anywhere in startup would make `JSON.parse` throw
861
+ * and turn every cache hit back into a silent miss — i.e. quietly reintroduce
862
+ * the Touch-ID-on-every-read bug this fix closes, with no failing test to catch
863
+ * it. Anchoring on the last line makes the payload the terminator of the
864
+ * stream rather than the whole of it, so preceding chatter is inert.
865
+ */
866
+ export function lastLine(stdout) {
867
+ const lines = stdout.split('\n');
868
+ for (let i = lines.length - 1; i >= 0; i--) {
869
+ const t = lines[i].trim();
870
+ if (t)
871
+ return t;
872
+ }
873
+ return '';
874
+ }
824
875
  /**
825
876
  * Synchronous liveness check: is a broker actually LISTENING and answering (not
826
877
  * just a lingering socket file)? Used to decide whether the auto-cache may take
@@ -834,36 +885,9 @@ export function agentReachableSync() {
834
885
  return false;
835
886
  if (!agentSocketExists())
836
887
  return false;
837
- const r = spawnSync(process.execPath, ['-e', SYNC_PING_PROGRAM, socketPath()], { timeout: 1500 });
838
- return r.status === 0 && !r.error;
888
+ const r = syncClient([SYNC_PING_CMD], SYNC_PING_TIMEOUT_MS);
889
+ return r !== null && r.status === 0 && !r.error;
839
890
  }
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
891
  /**
868
892
  * Synchronously evict one bundle from the broker. Called after a mutating
869
893
  * keychain write (add / rotate / remove / rename / delete) so the broker never
@@ -877,10 +901,60 @@ export function agentEvictSync(name) {
877
901
  return;
878
902
  if (!agentSocketExists())
879
903
  return;
880
- try {
881
- spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name, tokenPath()], { timeout: 3000 });
904
+ // No try/catch: syncClient swallows its own failures and returns null.
905
+ syncClient([SYNC_LOCK_CMD, name], SYNC_LOCK_TIMEOUT_MS);
906
+ }
907
+ // ─── Top-level `__secrets-*` sync-client entrypoints ────────────────────────
908
+ // The bodies behind the three spawns above. Each does one socket round-trip and
909
+ // signals the parent through its exit code, mirroring the inline `node -e`
910
+ // programs these replaced: 0 = hit/alive, 3 = miss/down. Lock deviates twice:
911
+ // it reports 0 even when no broker answers, and 3 for an empty name, which it
912
+ // refuses rather than forwarding as a lock-ALL (see runAgentLockSync).
913
+ // Dispatched from index.ts BEFORE commander and before the startup statements,
914
+ // so a cache hit never runs checkForUpdates() or forks a detached sync.
915
+ /** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
916
+ * cache hit. Exit 0 = hit, 3 = miss or broker down. */
917
+ export async function runAgentGetSync(name, harness = 'cli') {
918
+ const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
919
+ if (r?.ok === true && r.cmd === 'get' && r.hit) {
920
+ // Trailing newline: the parent reads the LAST line (see lastLine), so the
921
+ // payload must terminate the stream even if anything ever precedes it.
922
+ //
923
+ // Awaited on the write callback, not fire-and-forget: stdout is a pipe here
924
+ // (the parent captures it), pipe writes are asynchronous, and the caller
925
+ // exits the process the moment this resolves. An unflushed write would be
926
+ // truncated on exit — the parent would see a partial or empty payload, treat
927
+ // it as a miss, and fall through to a keychain read, silently costing the
928
+ // Touch ID prompt this whole path exists to avoid.
929
+ const payload = JSON.stringify({ bundle: r.bundle, env: r.env }) + '\n';
930
+ await new Promise((resolve) => { process.stdout.write(payload, () => resolve()); });
931
+ return 0;
882
932
  }
883
- catch { /* best-effort */ }
933
+ return 3;
934
+ }
935
+ /** Body of `__secrets-ping`. Exit 0 = a broker is listening and speaking
936
+ * our protocol, 3 = nothing there. Deliberately does NOT gate on
937
+ * PROTOCOL_VERSION: this only decides whether the auto-cache may take the
938
+ * synchronous warm path, and a version-skewed broker still answers reads. That
939
+ * matches the inline program this replaced. */
940
+ export async function runAgentPingSync() {
941
+ const r = await request({ cmd: 'ping' }, SOCKET_PING_TIMEOUT_MS);
942
+ return r?.ok === true && r.cmd === 'ping' ? 0 : 3;
943
+ }
944
+ /** Body of `__secrets-lock <name>`. Best-effort evict; exit 0 even when no
945
+ * broker answers, so a missing broker never fails the mutating write that
946
+ * triggered it (agentEvictSync discards this either way).
947
+ *
948
+ * An empty name is refused rather than forwarded: the broker treats a nameless
949
+ * lock as lock-ALL (handleAgentRequest), so a bare `__secrets-lock` would wipe
950
+ * every held bundle and re-prompt Touch ID for each. That was unreachable while
951
+ * this was an inline `node -e` string; as a top-level argv token it is one typo
952
+ * away, and agentEvictSync only ever locks by name. */
953
+ export async function runAgentLockSync(name) {
954
+ if (!name)
955
+ return 3;
956
+ await request({ cmd: 'lock', name }, SOCKET_LOCK_TIMEOUT_MS);
957
+ return 0;
884
958
  }
885
959
  // Key inside the cached entry's env that holds the JSON metadata snapshot.
886
960
  const META_SNAPSHOT_KEY = '__snapshot__';
@@ -992,10 +1066,10 @@ export function clampHoldMs(v) {
992
1066
  * The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
993
1067
  * than a tight inline retry loop. Best-effort; never throws. macOS only.
994
1068
  */
995
- export function agentAutoLoadSync(name, bundle, env, ttlMs) {
1069
+ export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = 'cli') {
996
1070
  if (!onDarwin())
997
1071
  return;
998
- const payload = JSON.stringify({ name, bundle, env, ttlMs });
1072
+ const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
999
1073
  // Broker actually LISTENING → deterministic synchronous warm (bounded; the read
1000
1074
  // already paid a Touch ID, so <1s here is invisible). We gate on a real liveness
1001
1075
  // ping, NOT mere socket-file existence: a broker that died leaving its socket
@@ -1062,13 +1136,13 @@ export async function runAgentLoadFromStdin() {
1062
1136
  process.exitCode = 1; // broker couldn't be brought up — did NOT load
1063
1137
  return;
1064
1138
  }
1065
- const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS);
1139
+ const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? 'cli');
1066
1140
  if (!loaded)
1067
1141
  process.exitCode = 1; // transport failed — did NOT load
1068
1142
  }
1069
1143
  /** Store a resolved bundle in the broker. Returns false on transport failure. */
1070
- export async function agentLoad(name, bundle, env, ttlMs) {
1071
- const r = await request({ cmd: 'load', name, bundle, env, ttlMs });
1144
+ export async function agentLoad(name, bundle, env, ttlMs, harness = 'cli') {
1145
+ const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
1072
1146
  return r?.ok === true && r.cmd === 'load';
1073
1147
  }
1074
1148
  /** Wipe one bundle (or all if name omitted) from the broker. Returns the count