@phnx-labs/agents-cli 1.20.56 → 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 +26 -1
- 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.d.ts +3 -2
- package/dist/commands/secrets.js +35 -25
- package/dist/commands/teams.d.ts +20 -1
- package/dist/commands/teams.js +105 -2
- 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 +88 -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/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- 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 +48 -18
- package/dist/lib/secrets/agent.js +288 -165
- 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 +30 -0
- package/dist/lib/teams/agents.js +271 -42
- 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,109 @@ 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
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Host the secrets broker inside the always-on daemon (#416).
|
|
539
|
+
*
|
|
540
|
+
* Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
|
|
541
|
+
* — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
|
|
542
|
+
* keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
|
|
543
|
+
*
|
|
544
|
+
* - no pid-file single-instance guard (the daemon owns the instance);
|
|
545
|
+
* - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
|
|
546
|
+
* (those would kill the daemon — the daemon is the always-on backbone and
|
|
547
|
+
* manages its own version/lifecycle). The sweep only TTL-evicts.
|
|
548
|
+
*
|
|
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).
|
|
554
|
+
*/
|
|
555
|
+
export async function startHostedBroker() {
|
|
556
|
+
if (!onDarwin())
|
|
557
|
+
return null;
|
|
558
|
+
const store = new Map();
|
|
559
|
+
const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
|
|
560
|
+
const handle = (req) => handleAgentRequest(store, req);
|
|
561
|
+
const onConn = (conn) => {
|
|
562
|
+
conn.setEncoding('utf-8');
|
|
563
|
+
let buf = '';
|
|
564
|
+
conn.on('data', (chunk) => {
|
|
565
|
+
buf += chunk;
|
|
566
|
+
let nl;
|
|
567
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
568
|
+
const line = buf.slice(0, nl);
|
|
569
|
+
buf = buf.slice(nl + 1);
|
|
570
|
+
if (!line.trim())
|
|
571
|
+
continue;
|
|
572
|
+
let resp;
|
|
573
|
+
try {
|
|
574
|
+
resp = handle(JSON.parse(line));
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
resp = { ok: false, error: err.message };
|
|
578
|
+
}
|
|
579
|
+
conn.write(JSON.stringify(resp) + '\n');
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
conn.on('error', () => { });
|
|
583
|
+
};
|
|
584
|
+
const server = await bindBrokerSocket(sock, onConn);
|
|
585
|
+
if (!server)
|
|
586
|
+
return null;
|
|
587
|
+
// TTL eviction ONLY. Unlike the standalone broker's sweep, there is no
|
|
588
|
+
// self-heal-exit or idle-exit here — the daemon is always-on and owns the
|
|
589
|
+
// upgrade/lifecycle path; a broker that called process.exit() would take the
|
|
590
|
+
// whole daemon down with it.
|
|
591
|
+
const sweepTimer = setInterval(() => {
|
|
592
|
+
const now = Date.now();
|
|
593
|
+
for (const [name, e] of store)
|
|
594
|
+
if (now >= e.expiresAt)
|
|
595
|
+
store.delete(name);
|
|
596
|
+
}, SWEEP_INTERVAL_MS);
|
|
597
|
+
// Auto-lock on sleep, same as the standalone broker: the signed helper emits
|
|
598
|
+
// LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
|
|
599
|
+
let watcher = null;
|
|
600
|
+
try {
|
|
601
|
+
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
602
|
+
watcher.stdout?.setEncoding('utf-8');
|
|
603
|
+
watcher.stdout?.on('data', (chunk) => {
|
|
604
|
+
if (shouldWipeOnWatchEvent(chunk))
|
|
605
|
+
store.clear();
|
|
606
|
+
});
|
|
607
|
+
watcher.on('error', () => { watcher = null; });
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
watcher = null;
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
close() {
|
|
614
|
+
store.clear();
|
|
615
|
+
clearInterval(sweepTimer);
|
|
616
|
+
try {
|
|
617
|
+
watcher?.kill();
|
|
618
|
+
}
|
|
619
|
+
catch { /* already gone */ }
|
|
620
|
+
try {
|
|
621
|
+
server.close();
|
|
622
|
+
}
|
|
623
|
+
catch { /* not listening */ }
|
|
624
|
+
try {
|
|
625
|
+
fs.unlinkSync(sock);
|
|
626
|
+
}
|
|
627
|
+
catch { /* gone */ }
|
|
628
|
+
},
|
|
629
|
+
};
|
|
502
630
|
}
|
|
503
631
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
504
632
|
/** Open the socket, send one request, resolve the one response. Async path —
|
|
@@ -766,7 +894,7 @@ export async function agentStatus() {
|
|
|
766
894
|
}
|
|
767
895
|
/** Ping result: whether a broker is reachable + speaking our protocol, and the
|
|
768
896
|
* version of the code it's running (for staleness detection). */
|
|
769
|
-
async function agentPing() {
|
|
897
|
+
export async function agentPing() {
|
|
770
898
|
if (!agentSocketExists())
|
|
771
899
|
return { reachable: false };
|
|
772
900
|
const r = await request({ cmd: 'ping' });
|
|
@@ -779,11 +907,12 @@ async function agentPing() {
|
|
|
779
907
|
* Ensure a broker is running and reachable. Returns true once the socket answers
|
|
780
908
|
* a ping. macOS only.
|
|
781
909
|
*
|
|
782
|
-
* Prefers the
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
* Only when the
|
|
786
|
-
*
|
|
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.
|
|
787
916
|
*/
|
|
788
917
|
export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
789
918
|
if (!onDarwin())
|
|
@@ -802,29 +931,29 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
|
802
931
|
return true;
|
|
803
932
|
await teardownStaleBroker();
|
|
804
933
|
}
|
|
805
|
-
//
|
|
806
|
-
//
|
|
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.
|
|
807
943
|
try {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
else {
|
|
813
|
-
const uid = process.getuid?.() ?? 0;
|
|
814
|
-
try {
|
|
815
|
-
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
816
|
-
}
|
|
817
|
-
catch { /* may already be running */ }
|
|
818
|
-
const d = Date.now() + timeoutMs;
|
|
819
|
-
while (Date.now() < d) {
|
|
944
|
+
const { ensureDaemonStarted } = await import('../daemon.js');
|
|
945
|
+
if (ensureDaemonStarted()) {
|
|
946
|
+
const d0 = Date.now() + timeoutMs;
|
|
947
|
+
while (Date.now() < d0) {
|
|
820
948
|
if ((await agentPing()).reachable)
|
|
821
949
|
return true;
|
|
822
|
-
await new Promise((r) => setTimeout(r,
|
|
950
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
823
951
|
}
|
|
824
952
|
}
|
|
825
953
|
}
|
|
826
|
-
catch { /* fall through to the one-off spawn */ }
|
|
827
|
-
// Path
|
|
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.
|
|
828
957
|
const stalePid = (() => {
|
|
829
958
|
try {
|
|
830
959
|
return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
|
|
@@ -859,18 +988,12 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
|
859
988
|
}
|
|
860
989
|
/**
|
|
861
990
|
* Tear down a stale broker (running pre-upgrade code) so a fresh one can take
|
|
862
|
-
* over.
|
|
863
|
-
*
|
|
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.
|
|
864
994
|
*/
|
|
865
995
|
async function teardownStaleBroker() {
|
|
866
|
-
|
|
867
|
-
const uid = process.getuid?.() ?? 0;
|
|
868
|
-
try {
|
|
869
|
-
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
870
|
-
}
|
|
871
|
-
catch { /* best effort */ }
|
|
872
|
-
return; // kickstart -k restarts the service onto current code; socket/pid managed by it
|
|
873
|
-
}
|
|
996
|
+
retireLegacySecretsAgentService();
|
|
874
997
|
const pid = (() => { try {
|
|
875
998
|
return parseInt(fs.readFileSync(pidPath(), 'utf-8').trim(), 10);
|
|
876
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;
|