@phnx-labs/agents-cli 1.20.57 → 1.20.58

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 (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +34 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +28 -4
  6. package/dist/commands/secrets.js +28 -19
  7. package/dist/commands/versions.js +11 -3
  8. package/dist/commands/view.js +19 -4
  9. package/dist/lib/agents.d.ts +21 -0
  10. package/dist/lib/agents.js +28 -4
  11. package/dist/lib/daemon.d.ts +5 -5
  12. package/dist/lib/daemon.js +65 -17
  13. package/dist/lib/git.d.ts +9 -0
  14. package/dist/lib/git.js +12 -0
  15. package/dist/lib/hosts/dispatch.d.ts +21 -0
  16. package/dist/lib/hosts/dispatch.js +88 -5
  17. package/dist/lib/permissions.d.ts +19 -1
  18. package/dist/lib/permissions.js +137 -0
  19. package/dist/lib/project-root.d.ts +65 -0
  20. package/dist/lib/project-root.js +133 -0
  21. package/dist/lib/resources/permissions.js +2 -0
  22. package/dist/lib/resources/types.d.ts +1 -1
  23. package/dist/lib/secrets/agent.d.ts +26 -23
  24. package/dist/lib/secrets/agent.js +196 -216
  25. package/dist/lib/secrets/remote.js +1 -0
  26. package/dist/lib/session/active.d.ts +3 -0
  27. package/dist/lib/session/active.js +1 -0
  28. package/dist/lib/session/parse.js +38 -15
  29. package/dist/lib/session/state.d.ts +4 -1
  30. package/dist/lib/session/state.js +18 -1
  31. package/dist/lib/session/types.d.ts +8 -0
  32. package/dist/lib/staleness/detectors/permissions.js +42 -0
  33. package/dist/lib/staleness/detectors/subagents.js +30 -0
  34. package/dist/lib/staleness/writers/subagents.js +13 -1
  35. package/dist/lib/subagents.d.ts +22 -0
  36. package/dist/lib/subagents.js +146 -0
  37. package/dist/lib/teams/agents.d.ts +14 -0
  38. package/dist/lib/teams/agents.js +158 -22
  39. package/dist/lib/types.d.ts +13 -0
  40. package/dist/lib/versions.d.ts +39 -0
  41. package/dist/lib/versions.js +199 -12
  42. package/package.json +1 -1
  43. package/scripts/postinstall.js +26 -11
@@ -132,127 +132,71 @@ function cliSpawn(sub) {
132
132
  function brokerSpawn() {
133
133
  return cliSpawn(['secrets', '_agent-run']);
134
134
  }
135
- // ─── Persistent launchd service ──────────────────────────────────────────────
136
- // On a heavily-loaded machine a freshly-spawned broker (a full CLI cold start)
137
- // can't get scheduled enough CPU to finish booting and bind its socket — so the
138
- // on-demand model fails exactly when there are many agents (the case we care
139
- // about). The fix is to run the broker as a launchd user service: started once
140
- // with RunAtLoad + KeepAlive, it stays up, and every read just connects. The
141
- // cold start happens once (and launchd retries until it wins), never per-read.
135
+ // ─── Legacy standalone launchd service (retired, #416 step 2) ────────────────
136
+ // Earlier versions ran the broker as its own launchd user service
137
+ // (com.phnx-labs.agents-secrets-agent, shipped in 1.20.20) so a heavily-loaded
138
+ // machine couldn't starve an on-demand cold start. That role now belongs to the
139
+ // always-on daemon, which hosts the broker socket-first (#416 step 1) one
140
+ // supervised backbone instead of a second service. The functions below no
141
+ // longer INSTALL the standalone service; they only DETECT and RETIRE a plist
142
+ // left by an older version so the daemon can take over the socket. The upgrade
143
+ // migration (postinstall) and `ensureAgentRunning` both drive the retire path.
142
144
  const SERVICE_LABEL = 'com.phnx-labs.agents-secrets-agent';
145
+ // LaunchAgents dir is relocatable for tests via AGENTS_SECRETS_LAUNCHAGENTS_DIR.
146
+ // A relocated dir is NOT launchd-managed (launchd only bootstraps plists from
147
+ // the real ~/Library/LaunchAgents), so retirement there is a pure file removal.
148
+ function launchAgentsDir() {
149
+ return process.env.AGENTS_SECRETS_LAUNCHAGENTS_DIR || path.join(os.homedir(), 'Library', 'LaunchAgents');
150
+ }
143
151
  function servicePlistPath() {
144
- return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
152
+ return path.join(launchAgentsDir(), `${SERVICE_LABEL}.plist`);
145
153
  }
146
- /** True if the launchd plist for the persistent broker is installed. */
154
+ /** True if a legacy standalone-broker launchd plist is still installed. */
147
155
  export function secretsAgentServiceInstalled() {
148
156
  return onDarwin() && fs.existsSync(servicePlistPath());
149
157
  }
150
- function generateServicePlist() {
151
- const { cmd, args } = cliSpawn(['secrets', '_agent-run', '--service']);
152
- const progArgs = [cmd, ...args]
153
- .map((a) => ` <string>${a.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</string>`)
154
- .join('\n');
155
- const logPath = path.join(agentDir(), 'service.log');
156
- const home = os.homedir();
157
- return `<?xml version="1.0" encoding="UTF-8"?>
158
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
159
- <plist version="1.0">
160
- <dict>
161
- <key>Label</key>
162
- <string>${SERVICE_LABEL}</string>
163
- <key>ProgramArguments</key>
164
- <array>
165
- ${progArgs}
166
- </array>
167
- <key>RunAtLoad</key>
168
- <true/>
169
- <key>KeepAlive</key>
170
- <true/>
171
- <key>ProcessType</key>
172
- <string>Interactive</string>
173
- <key>StandardOutPath</key>
174
- <string>${logPath}</string>
175
- <key>StandardErrorPath</key>
176
- <string>${logPath}</string>
177
- <key>EnvironmentVariables</key>
178
- <dict>
179
- <key>PATH</key>
180
- <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${home}/.bun/bin</string>
181
- </dict>
182
- </dict>
183
- </plist>`;
184
- }
185
158
  /**
186
- * Install + start the persistent broker as a launchd user service (idempotent).
187
- * Writes the plist, bootstraps it into the GUI domain, and waits for the socket.
188
- * `ProcessType: Interactive` asks launchd to schedule it at foreground priority
189
- * so it can boot even when the machine is loaded. Returns true once reachable.
159
+ * Retire the legacy standalone secrets-agent launchd service: bootout the job
160
+ * (falling back to the legacy `unload`) and remove its plist so the always-on
161
+ * daemon owns the broker socket. Idempotent and best-effort a no-op when no
162
+ * legacy plist is present. Does NOT wipe held bundles: the booted-out process's
163
+ * memory is gone anyway, and the daemon-hosted broker starts fresh.
190
164
  */
191
- export async function installSecretsAgentService(timeoutMs = 30000) {
192
- if (!onDarwin())
193
- return false;
165
+ export function retireLegacySecretsAgentService() {
166
+ if (!onDarwin() || !secretsAgentServiceInstalled())
167
+ return;
194
168
  const plist = servicePlistPath();
195
- fs.mkdirSync(path.dirname(plist), { recursive: true });
196
- fs.writeFileSync(plist, generateServicePlist());
197
- const uid = process.getuid?.() ?? 0;
198
- // bootstrap is the modern API; fall back to legacy load. Both idempotent-ish.
199
- try {
200
- execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { stdio: ['ignore', 'ignore', 'ignore'] });
201
- }
202
- catch {
169
+ // Only the real LaunchAgents dir is launchd-managed; a relocated (test) dir
170
+ // has no bootstrapped job, so skip launchctl and just remove the plist.
171
+ if (!process.env.AGENTS_SECRETS_LAUNCHAGENTS_DIR) {
172
+ const uid = process.getuid?.() ?? 0;
203
173
  try {
204
- execFileSync('launchctl', ['load', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
174
+ execFileSync('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
175
+ }
176
+ catch {
177
+ try {
178
+ execFileSync('launchctl', ['unload', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
179
+ }
180
+ catch { /* not loaded */ }
205
181
  }
206
- catch { /* may already be loaded */ }
207
182
  }
208
- // kickstart to force an immediate start even if already bootstrapped.
209
183
  try {
210
- execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
211
- }
212
- catch { /* best effort */ }
213
- const deadline = Date.now() + timeoutMs;
214
- while (Date.now() < deadline) {
215
- if ((await agentPing()).reachable)
216
- return true;
217
- await new Promise((r) => setTimeout(r, 200));
184
+ fs.unlinkSync(plist);
218
185
  }
219
- return false;
186
+ catch { /* already gone */ }
220
187
  }
221
188
  /**
222
- * Kickstart the already-installed persistent broker so launchd relaunches it
223
- * onto the current on-disk code. Used by postinstall heal-on-upgrade. No-op if
224
- * the service isn't installed; never rewrites the plist or waits, so it's safe
225
- * and fast to call from an installer.
189
+ * Stop the persistent broker for `agents secrets stop`: wipe whatever the broker
190
+ * holds (forces Touch ID again on the next read), then retire any legacy
191
+ * standalone service. The daemon-hosted broker itself is left running it is
192
+ * the always-on backbone, and stopping it would take down unrelated background
193
+ * work (routines, browser IPC, session-sync).
226
194
  */
227
- export function kickstartSecretsAgentService() {
228
- if (!onDarwin() || !secretsAgentServiceInstalled())
229
- return;
230
- const uid = process.getuid?.() ?? 0;
231
- try {
232
- execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
233
- }
234
- catch { /* best effort */ }
235
- }
236
- /** Stop + remove the persistent broker service, and wipe whatever it held. */
237
195
  export async function uninstallSecretsAgentService() {
238
196
  if (!onDarwin())
239
197
  return;
240
- await agentLock(); // wipe the in-memory store before tearing down
241
- const plist = servicePlistPath();
242
- const uid = process.getuid?.() ?? 0;
243
- try {
244
- execFileSync('launchctl', ['bootout', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
245
- }
246
- catch {
247
- try {
248
- execFileSync('launchctl', ['unload', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
249
- }
250
- catch { /* not loaded */ }
251
- }
252
- try {
253
- fs.unlinkSync(plist);
254
- }
255
- catch { /* already gone */ }
198
+ await agentLock(); // wipe the in-memory store before retiring the legacy service
199
+ retireLegacySecretsAgentService();
256
200
  }
257
201
  // ─── Broker server (runs in the detached `secrets _agent-run` process) ───────
258
202
  /**
@@ -328,6 +272,46 @@ export function handleAgentRequest(store, req, now = Date.now()) {
328
272
  export function shouldWipeOnWatchEvent(chunk) {
329
273
  return /\bSLEEP\b/.test(chunk);
330
274
  }
275
+ /**
276
+ * Bind the shared broker socket without stealing it from another live owner.
277
+ * Both the standalone service and daemon-hosted broker use this single path so
278
+ * either startup order is safe: a reachable owner wins, while an unreachable
279
+ * stale socket is reclaimed once.
280
+ */
281
+ async function bindBrokerSocket(sock, onConnection) {
282
+ const listenOnce = () => new Promise((resolve, reject) => {
283
+ const server = net.createServer(onConnection);
284
+ const onError = (err) => {
285
+ if (err.code === 'EADDRINUSE')
286
+ resolve('inuse');
287
+ else
288
+ reject(err);
289
+ };
290
+ server.once('error', onError);
291
+ server.listen(sock, () => {
292
+ try {
293
+ fs.chmodSync(sock, 0o600);
294
+ }
295
+ catch { /* dir 0700 already gates it */ }
296
+ resolve(server);
297
+ });
298
+ });
299
+ let bound = await listenOnce();
300
+ if (bound !== 'inuse')
301
+ return bound;
302
+ if ((await agentPing()).reachable)
303
+ return null;
304
+ try {
305
+ fs.unlinkSync(sock);
306
+ }
307
+ catch { /* disappeared between probe and reclaim */ }
308
+ bound = await listenOnce();
309
+ if (bound !== 'inuse')
310
+ return bound;
311
+ if ((await agentPing()).reachable)
312
+ return null;
313
+ throw new Error(`Secrets broker socket is in use but unreachable: ${sock}`);
314
+ }
331
315
  /**
332
316
  * Run the broker in the foreground. Spawned detached by ensureAgentRunning via
333
317
  * `agents secrets _agent-run`. Holds the store in memory, serves the socket,
@@ -335,7 +319,7 @@ export function shouldWipeOnWatchEvent(chunk) {
335
319
  */
336
320
  export async function runSecretsAgent(opts = {}) {
337
321
  if (!onDarwin())
338
- return; // nothing to broker without biometry prompts
322
+ return null; // nothing to broker without biometry prompts
339
323
  // When launchd keeps us alive as a persistent service, never idle-exit:
340
324
  // exiting would just make launchd cold-start us again, reintroducing the
341
325
  // startup-under-load fragility the service exists to avoid.
@@ -352,7 +336,7 @@ export async function runSecretsAgent(opts = {}) {
352
336
  if (err?.code === 'EEXIST') {
353
337
  const holder = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
354
338
  if (!isNaN(holder) && isAlive(holder))
355
- return; // another broker is live
339
+ return null; // another broker is live
356
340
  // Stale pid — reclaim it.
357
341
  try {
358
342
  fs.unlinkSync(pidFile);
@@ -369,10 +353,42 @@ export async function runSecretsAgent(opts = {}) {
369
353
  // the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
370
354
  let emptySince = Date.now();
371
355
  const sock = socketPath();
372
- try {
373
- fs.unlinkSync(sock);
374
- }
375
- catch { /* no stale socket */ }
356
+ const releasePid = () => {
357
+ try {
358
+ if (parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) === process.pid) {
359
+ fs.unlinkSync(pidFile);
360
+ }
361
+ }
362
+ catch { /* gone or no longer ours */ }
363
+ };
364
+ // Register lifecycle handlers before socket arbitration. A persistent
365
+ // launchd service may spend its whole lifetime as the standby loser, and a
366
+ // kickstart/bootout during that wait must still release its pid-file lease.
367
+ let standbyTimer = null;
368
+ let cleanupActive = null;
369
+ let shuttingDown = false;
370
+ const onSigterm = () => shutdown(0);
371
+ const onSigint = () => shutdown(0);
372
+ const detachSignals = () => {
373
+ process.off('SIGTERM', onSigterm);
374
+ process.off('SIGINT', onSigint);
375
+ };
376
+ const shutdown = (code) => {
377
+ if (shuttingDown)
378
+ return;
379
+ shuttingDown = true;
380
+ if (standbyTimer) {
381
+ clearTimeout(standbyTimer);
382
+ standbyTimer = null;
383
+ }
384
+ if (cleanupActive)
385
+ cleanupActive();
386
+ else
387
+ releasePid();
388
+ process.exit(code);
389
+ };
390
+ process.on('SIGTERM', onSigterm);
391
+ process.on('SIGINT', onSigint);
376
392
  // Capture the version of the code we're running so the sweep can detect when
377
393
  // an in-place upgrade has landed and self-heal onto it. getCliVersion caches
378
394
  // this value for the process lifetime; getCliVersionFresh re-reads on disk.
@@ -412,7 +428,7 @@ export async function runSecretsAgent(opts = {}) {
412
428
  emptySince = Date.now();
413
429
  return resp;
414
430
  };
415
- const server = net.createServer((conn) => {
431
+ const onConnection = (conn) => {
416
432
  conn.setEncoding('utf-8');
417
433
  let buf = '';
418
434
  conn.on('data', (chunk) => {
@@ -434,14 +450,40 @@ export async function runSecretsAgent(opts = {}) {
434
450
  }
435
451
  });
436
452
  conn.on('error', () => { });
437
- });
453
+ };
454
+ let server = null;
455
+ do {
456
+ try {
457
+ server = await bindBrokerSocket(sock, onConnection);
458
+ }
459
+ catch (err) {
460
+ detachSignals();
461
+ releasePid();
462
+ throw err;
463
+ }
464
+ if (!server && persistent) {
465
+ // launchd KeepAlive would immediately relaunch a persistent loser if it
466
+ // returned here. Stay quiescent instead, then claim the socket if the
467
+ // daemon-hosted owner goes away. The pid file keeps launchd/manual starts
468
+ // from creating additional waiters while this process is standing by.
469
+ do {
470
+ await new Promise((resolve) => {
471
+ standbyTimer = setTimeout(() => {
472
+ standbyTimer = null;
473
+ resolve();
474
+ }, 1000);
475
+ });
476
+ } while ((await agentPing()).reachable);
477
+ }
478
+ } while (!server && persistent);
479
+ if (!server) {
480
+ detachSignals();
481
+ releasePid();
482
+ return null;
483
+ }
438
484
  let watcher = null;
439
485
  let sweepTimer = null;
440
- let shuttingDown = false;
441
- const shutdown = (code) => {
442
- if (shuttingDown)
443
- return;
444
- shuttingDown = true;
486
+ cleanupActive = () => {
445
487
  store.clear();
446
488
  if (sweepTimer)
447
489
  clearInterval(sweepTimer);
@@ -457,25 +499,8 @@ export async function runSecretsAgent(opts = {}) {
457
499
  fs.unlinkSync(sock);
458
500
  }
459
501
  catch { /* gone */ }
460
- try {
461
- if (parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10) === process.pid)
462
- fs.unlinkSync(pidFile);
463
- }
464
- catch { /* gone */ }
465
- process.exit(code);
502
+ releasePid();
466
503
  };
467
- process.on('SIGTERM', () => shutdown(0));
468
- process.on('SIGINT', () => shutdown(0));
469
- await new Promise((resolve, reject) => {
470
- server.once('error', reject);
471
- server.listen(sock, () => {
472
- try {
473
- fs.chmodSync(sock, 0o600);
474
- }
475
- catch { /* dir 0700 already gates it */ }
476
- resolve();
477
- });
478
- });
479
504
  sweepTimer = setInterval(sweep, SWEEP_INTERVAL_MS);
480
505
  // Auto-lock on sleep. The signed helper emits LOCK / SLEEP lines; we wipe
481
506
  // everything on SLEEP (and, implicitly, logout — that tears down the launchd
@@ -499,6 +524,15 @@ export async function runSecretsAgent(opts = {}) {
499
524
  catch {
500
525
  watcher = null;
501
526
  }
527
+ return {
528
+ close() {
529
+ if (shuttingDown)
530
+ return;
531
+ shuttingDown = true;
532
+ detachSignals();
533
+ cleanupActive?.();
534
+ },
535
+ };
502
536
  }
503
537
  /**
504
538
  * Host the secrets broker inside the always-on daemon (#416).
@@ -512,11 +546,11 @@ export async function runSecretsAgent(opts = {}) {
512
546
  * (those would kill the daemon — the daemon is the always-on backbone and
513
547
  * manages its own version/lifecycle). The sweep only TTL-evicts.
514
548
  *
515
- * The caller (`runDaemon`) must only invoke this when NO broker is already
516
- * reachable (ping first) this function clears a stale socket before binding,
517
- * so calling it while a live standalone broker holds the socket would orphan
518
- * that broker's clients. Returns a handle the daemon closes on shutdown, or
519
- * null off-darwin (nothing to broker without biometry).
549
+ * The caller (`runDaemon`) normally invokes this only when no broker answers
550
+ * its initial ping. Binding still arbitrates ownership through the same shared
551
+ * path as the standalone service: a live owner wins, while only an unreachable
552
+ * stale socket is reclaimed. Returns a handle the daemon closes on shutdown,
553
+ * or null off-darwin (nothing to broker without biometry).
520
554
  */
521
555
  export async function startHostedBroker() {
522
556
  if (!onDarwin())
@@ -547,41 +581,9 @@ export async function startHostedBroker() {
547
581
  });
548
582
  conn.on('error', () => { });
549
583
  };
550
- // Bind race-safely: never clobber a LIVE standalone broker. Try to listen; if
551
- // the socket is already bound, a broker raced us after runDaemon's ping — if
552
- // it answers, back off and let it serve (return null); if it's a stale socket
553
- // file with no live listener, reclaim it and retry once. (Unconditionally
554
- // unlinking before bind — as an earlier draft did — could remove a live
555
- // broker's socket in the sub-ms window after runDaemon's reachability check.)
556
- const listenOnce = () => new Promise((resolve, reject) => {
557
- const s = net.createServer(onConn);
558
- s.once('error', (err) => {
559
- if (err.code === 'EADDRINUSE')
560
- resolve('inuse');
561
- else
562
- reject(err);
563
- });
564
- s.listen(sock, () => {
565
- try {
566
- fs.chmodSync(sock, 0o600);
567
- }
568
- catch { /* dir 0700 already gates it */ }
569
- resolve(s);
570
- });
571
- });
572
- let bound = await listenOnce();
573
- if (bound === 'inuse') {
574
- if ((await agentPing()).reachable)
575
- return null; // a live broker holds it — don't clobber
576
- try {
577
- fs.unlinkSync(sock);
578
- }
579
- catch { /* gone */ }
580
- bound = await listenOnce();
581
- if (bound === 'inuse')
582
- return null; // still contended — the standalone fallback covers it
583
- }
584
- const server = bound;
584
+ const server = await bindBrokerSocket(sock, onConn);
585
+ if (!server)
586
+ return null;
585
587
  // TTL eviction ONLY. Unlike the standalone broker's sweep, there is no
586
588
  // self-heal-exit or idle-exit here — the daemon is always-on and owns the
587
589
  // upgrade/lifecycle path; a broker that called process.exit() would take the
@@ -905,11 +907,12 @@ export async function agentPing() {
905
907
  * Ensure a broker is running and reachable. Returns true once the socket answers
906
908
  * a ping. macOS only.
907
909
  *
908
- * Prefers the persistent launchd service: if it isn't installed we install it
909
- * (which makes the broker survive for the whole login session, so subsequent
910
- * reads never cold-start); if it's installed but unreachable we kickstart it.
911
- * Only when the service path can't be used do we fall back to a one-off detached
912
- * broker that's the model that gets starved under heavy load, so it's last.
910
+ * Prefers the always-on daemon, which hosts the broker socket (#416): retire any
911
+ * legacy standalone launchd service so the daemon owns the socket, then bring the
912
+ * daemon up (Path 0) one supervised backbone that survives the whole login
913
+ * session, so subsequent reads never cold-start. Only when the daemon can't be
914
+ * used do we fall back to a one-off detached broker (Path 1) the model that
915
+ * gets starved under heavy load, so it's last.
913
916
  */
914
917
  export async function ensureAgentRunning(timeoutMs = 5000) {
915
918
  if (!onDarwin())
@@ -928,11 +931,15 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
928
931
  return true;
929
932
  await teardownStaleBroker();
930
933
  }
931
- // Path 0 (#416): prefer the always-on daemon it hosts the broker socket
932
- // (one supervised backbone rather than a separate launchd service). If
933
- // bringing the daemon up makes the broker answer, we're done. Fall through to
934
- // the standalone-service paths below when the daemon path isn't available
935
- // (kept as a fallback until the standalone service is retired, #416 step 2).
934
+ // A legacy standalone secrets-agent service may still be installed from an
935
+ // older version. Retire it (#416 step 2) so the always-on daemon owns the
936
+ // broker socket rather than racing a launchd job for it. No-op when no legacy
937
+ // plist is present. We only reach here when nothing is already reachable, so
938
+ // retiring never disrupts a warm broker.
939
+ retireLegacySecretsAgentService();
940
+ // Path 0 (#416): the always-on daemon hosts the broker socket — one supervised
941
+ // backbone rather than a separate launchd service. If bringing the daemon up
942
+ // makes the broker answer, we're done.
936
943
  try {
937
944
  const { ensureDaemonStarted } = await import('../daemon.js');
938
945
  if (ensureDaemonStarted()) {
@@ -944,30 +951,9 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
944
951
  }
945
952
  }
946
953
  }
947
- catch { /* daemon path unavailable — fall through to the standalone service */ }
948
- // Path 1: the persistent service. installSecretsAgentService is idempotent and
949
- // waits for the socket; for an already-installed service we kickstart and wait.
950
- try {
951
- if (!secretsAgentServiceInstalled()) {
952
- if (await installSecretsAgentService(Math.max(timeoutMs, 20000)))
953
- return true;
954
- }
955
- else {
956
- const uid = process.getuid?.() ?? 0;
957
- try {
958
- execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
959
- }
960
- catch { /* may already be running */ }
961
- const d = Date.now() + timeoutMs;
962
- while (Date.now() < d) {
963
- if ((await agentPing()).reachable)
964
- return true;
965
- await new Promise((r) => setTimeout(r, 150));
966
- }
967
- }
968
- }
969
- catch { /* fall through to the one-off spawn */ }
970
- // Path 2 (fallback): one-off detached broker. Clear a stale socket/pid first.
954
+ catch { /* daemon path unavailable — fall through to the one-off spawn */ }
955
+ // Path 1 (fallback): one-off detached broker when the daemon can't host it.
956
+ // Clear a stale socket/pid first.
971
957
  const stalePid = (() => {
972
958
  try {
973
959
  return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
@@ -1002,18 +988,12 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
1002
988
  }
1003
989
  /**
1004
990
  * Tear down a stale broker (running pre-upgrade code) so a fresh one can take
1005
- * over. If the persistent service is installed, bootout makes launchd relaunch
1006
- * it on the new code; otherwise kill the process and clear its socket/pid.
991
+ * over. Retire any legacy standalone service first (#416 step 2) so the daemon —
992
+ * not the old launchd job — hosts the fresh broker, then kill the process and
993
+ * clear its socket/pid. The caller then brings the daemon-hosted broker up.
1007
994
  */
1008
995
  async function teardownStaleBroker() {
1009
- if (secretsAgentServiceInstalled()) {
1010
- const uid = process.getuid?.() ?? 0;
1011
- try {
1012
- execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
1013
- }
1014
- catch { /* best effort */ }
1015
- return; // kickstart -k restarts the service onto current code; socket/pid managed by it
1016
- }
996
+ retireLegacySecretsAgentService();
1017
997
  const pid = (() => { try {
1018
998
  return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
1019
999
  }
@@ -91,6 +91,7 @@ export function remoteSecretsRaw(target, args, opts = {}) {
91
91
  timeoutMs: REMOTE_TIMEOUT_MS,
92
92
  input: opts.input,
93
93
  extraSshArgs: opts.tty ? ['-tt'] : undefined,
94
+ multiplex: opts.tty ? false : undefined,
94
95
  });
95
96
  }
96
97
  /**
@@ -1,6 +1,7 @@
1
1
  import type { CloudTaskStatus } from '../cloud/types.js';
2
2
  import { type PidSessionEntry } from './pid-registry.js';
3
3
  import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
4
+ import type { SessionAttachment } from './types.js';
4
5
  import { type SessionProvenance } from './provenance.js';
5
6
  /**
6
7
  * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
@@ -59,6 +60,8 @@ export interface ActiveSession {
59
60
  createdTickets?: string[];
60
61
  /** Team name the session SPAWNED via `agents teams create/add`. */
61
62
  spawnedTeam?: string;
63
+ /** Files/screenshots attached to the session prompt. */
64
+ attachments?: SessionAttachment[];
62
65
  sessionFile?: string;
63
66
  startedAtMs?: number;
64
67
  status: ActiveStatus;
@@ -275,6 +275,7 @@ function applyState(base, state, fallbackFile) {
275
275
  ticket: state.ticket,
276
276
  createdTickets: state.createdTickets,
277
277
  spawnedTeam: state.spawnedTeam,
278
+ attachments: state.attachments,
278
279
  rateLimited: state.rateLimited,
279
280
  };
280
281
  }
@@ -55,6 +55,8 @@ function sanitizeEvent(e) {
55
55
  e.command = sanitizeForTerminal(e.command);
56
56
  if (e.path)
57
57
  e.path = sanitizeForTerminal(e.path);
58
+ if (e.name)
59
+ e.name = sanitizeForTerminal(e.name);
58
60
  if (e.output)
59
61
  e.output = sanitizeForTerminal(e.output);
60
62
  if (e.tool)
@@ -66,6 +68,36 @@ function sanitizeEvent(e) {
66
68
  if (e.args)
67
69
  e.args = sanitizeArgsDeep(e.args);
68
70
  }
71
+ function firstString(...values) {
72
+ for (const value of values) {
73
+ if (typeof value === 'string' && value.trim())
74
+ return value.trim();
75
+ }
76
+ return undefined;
77
+ }
78
+ function attachmentPath(block, source) {
79
+ return firstString(source?.path, source?.file_path, source?.filePath, source?.url, source?.ref, block?.path, block?.file_path, block?.filePath, block?.ref);
80
+ }
81
+ function attachmentName(block, source, filePath) {
82
+ return firstString(block?.name, block?.title, source?.name, source?.filename, source?.file_name, source?.fileName, filePath ? path.basename(filePath) : undefined);
83
+ }
84
+ function normalizedAttachmentEvent(agent, timestamp, block, source, defaultMediaType, sizeBytes) {
85
+ const filePath = attachmentPath(block, source);
86
+ const name = attachmentName(block, source, filePath);
87
+ const explicitSize = typeof source?.sizeBytes === 'number' ? source.sizeBytes :
88
+ typeof source?.size === 'number' ? source.size :
89
+ typeof block?.sizeBytes === 'number' ? block.sizeBytes :
90
+ undefined;
91
+ return {
92
+ type: 'attachment',
93
+ agent,
94
+ timestamp,
95
+ path: filePath,
96
+ name,
97
+ mediaType: firstString(source?.media_type, source?.mediaType, block?.media_type, block?.mediaType) || defaultMediaType,
98
+ sizeBytes: sizeBytes || explicitSize || 0,
99
+ };
100
+ }
69
101
  /**
70
102
  * Read a session file, refusing files above maxBytes. Bounded read protects
71
103
  * against multi-GB session blobs that would OOM the CLI or exceed V8's
@@ -351,24 +383,15 @@ export function parseClaudeContent(content) {
351
383
  const source = block.source || {};
352
384
  if (source.type === 'base64') {
353
385
  const sizeBytes = Math.ceil((source.data?.length || 0) * 0.75);
354
- events.push({
355
- type: 'attachment',
356
- agent: 'claude',
357
- timestamp,
358
- mediaType: source.media_type || 'image/png',
359
- sizeBytes,
360
- });
386
+ events.push(normalizedAttachmentEvent('claude', timestamp, block, source, 'image/png', sizeBytes));
387
+ }
388
+ else {
389
+ events.push(normalizedAttachmentEvent('claude', timestamp, block, source, 'image/png', 0));
361
390
  }
362
391
  }
363
392
  else if (block.type === 'document') {
364
393
  const source = block.source || {};
365
- events.push({
366
- type: 'attachment',
367
- agent: 'claude',
368
- timestamp,
369
- mediaType: source.media_type || 'application/pdf',
370
- sizeBytes: 0,
371
- });
394
+ events.push(normalizedAttachmentEvent('claude', timestamp, block, source, 'application/pdf', 0));
372
395
  }
373
396
  else if (block.type === 'tool_result') {
374
397
  const toolId = block.tool_use_id;
@@ -1554,7 +1577,7 @@ export function parseDroid(filePath) {
1554
1577
  else if (block.type === 'image') {
1555
1578
  const source = block.source || {};
1556
1579
  const sizeBytes = source.type === 'base64' ? Math.ceil((source.data?.length || 0) * 0.75) : 0;
1557
- events.push({ type: 'attachment', agent: 'droid', timestamp, mediaType: source.media_type || 'image/png', sizeBytes });
1580
+ events.push(normalizedAttachmentEvent('droid', timestamp, block, source, 'image/png', sizeBytes));
1558
1581
  }
1559
1582
  }
1560
1583
  }