@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.
- package/CHANGELOG.md +20 -0
- package/README.md +34 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/defaults.js +24 -0
- package/dist/commands/exec.js +28 -4
- package/dist/commands/secrets.js +28 -19
- package/dist/commands/versions.js +11 -3
- package/dist/commands/view.js +19 -4
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +28 -4
- package/dist/lib/daemon.d.ts +5 -5
- package/dist/lib/daemon.js +65 -17
- package/dist/lib/git.d.ts +9 -0
- package/dist/lib/git.js +12 -0
- package/dist/lib/hosts/dispatch.d.ts +21 -0
- package/dist/lib/hosts/dispatch.js +88 -5
- package/dist/lib/permissions.d.ts +19 -1
- package/dist/lib/permissions.js +137 -0
- package/dist/lib/project-root.d.ts +65 -0
- package/dist/lib/project-root.js +133 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/secrets/agent.d.ts +26 -23
- package/dist/lib/secrets/agent.js +196 -216
- package/dist/lib/secrets/remote.js +1 -0
- package/dist/lib/session/active.d.ts +3 -0
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +38 -15
- package/dist/lib/session/state.d.ts +4 -1
- package/dist/lib/session/state.js +18 -1
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/staleness/detectors/permissions.js +42 -0
- package/dist/lib/staleness/detectors/subagents.js +30 -0
- package/dist/lib/staleness/writers/subagents.js +13 -1
- package/dist/lib/subagents.d.ts +22 -0
- package/dist/lib/subagents.js +146 -0
- package/dist/lib/teams/agents.d.ts +14 -0
- package/dist/lib/teams/agents.js +158 -22
- package/dist/lib/types.d.ts +13 -0
- package/dist/lib/versions.d.ts +39 -0
- package/dist/lib/versions.js +199 -12
- package/package.json +1 -1
- 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
|
-
// ───
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// on-demand
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
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(
|
|
152
|
+
return path.join(launchAgentsDir(), `${SERVICE_LABEL}.plist`);
|
|
145
153
|
}
|
|
146
|
-
/** True if
|
|
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, '&').replace(/</g, '<')}</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
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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
|
|
192
|
-
if (!onDarwin())
|
|
193
|
-
return
|
|
165
|
+
export function retireLegacySecretsAgentService() {
|
|
166
|
+
if (!onDarwin() || !secretsAgentServiceInstalled())
|
|
167
|
+
return;
|
|
194
168
|
const plist = servicePlistPath();
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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', ['
|
|
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
|
-
|
|
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
|
-
|
|
186
|
+
catch { /* already gone */ }
|
|
220
187
|
}
|
|
221
188
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* and
|
|
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
|
|
241
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`)
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
* Only when the
|
|
912
|
-
*
|
|
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
|
-
//
|
|
932
|
-
// (
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
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
|
|
948
|
-
// Path 1:
|
|
949
|
-
//
|
|
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.
|
|
1006
|
-
*
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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;
|
|
@@ -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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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(
|
|
1580
|
+
events.push(normalizedAttachmentEvent('droid', timestamp, block, source, 'image/png', sizeBytes));
|
|
1558
1581
|
}
|
|
1559
1582
|
}
|
|
1560
1583
|
}
|