@ask-llm/plugin 0.17.0 → 0.19.0

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 (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.cursor-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +36 -0
  4. package/README.md +8 -8
  5. package/agents/brainstorm-coordinator.md +17 -16
  6. package/agents/codex-reviewer.md +1 -1
  7. package/agents/sol-reviewer.md +5 -5
  8. package/codex-pair-defaults.json +1 -1
  9. package/dist/brainstorm-panel.d.ts +1 -1
  10. package/dist/brainstorm-panel.d.ts.map +1 -1
  11. package/dist/brainstorm-panel.js +8 -8
  12. package/dist/brainstorm-panel.js.map +1 -1
  13. package/dist/brainstorm-run.js +1 -1
  14. package/dist/brainstorm-run.js.map +1 -1
  15. package/package.json +11 -10
  16. package/pi/extensions/codex-pair.ts +2 -1
  17. package/pi/extensions/provider-tools.ts +1 -1
  18. package/scripts/codex-pair-debounce-worker.mjs +60 -88
  19. package/scripts/codex-pair-prompt-drain.mjs +50 -64
  20. package/scripts/codex-pair-session.mjs +129 -168
  21. package/scripts/codex-pair-stop-gate.mjs +183 -233
  22. package/scripts/codex-pair-watch.mjs +1018 -1371
  23. package/scripts/lib/broker-lifecycle.mjs +677 -0
  24. package/scripts/lib/broker-rpc.mjs +173 -0
  25. package/scripts/lib/broker-transport.mjs +327 -0
  26. package/scripts/lib/broker.mjs +327 -0
  27. package/scripts/lib/debounce-state.mjs +206 -0
  28. package/scripts/lib/frontmatter.mjs +57 -0
  29. package/scripts/lib/parser.mjs +229 -0
  30. package/scripts/lib/process.mjs +56 -0
  31. package/scripts/lib/prompt.mjs +32 -0
  32. package/scripts/lib/session-registry.mjs +161 -0
  33. package/scripts/lib/state.mjs +720 -0
  34. package/scripts/lib/stop-gate.mjs +134 -0
  35. package/scripts/sol-review-transport.mjs +1 -1
  36. package/skills/brainstorm/SKILL.md +9 -9
  37. package/skills/codex-image/SKILL.md +2 -2
  38. package/skills/codex-pair/SKILL.md +5 -4
  39. package/skills/codex-review/SKILL.md +1 -1
  40. package/skills/grok-pair/SKILL.md +3 -3
  41. package/skills/sol-review/SKILL.md +5 -5
@@ -0,0 +1,677 @@
1
+ // Source of truth: broker-lifecycle.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
2
+ // Broker lifecycle (ADR-090, ADR-093, ADR-168): the descriptor is written only after `initialize` succeeds.
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
5
+ import { rename, unlink, writeFile } from "node:fs/promises";
6
+ import { connect as netConnect } from "node:net";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { basename, dirname, join, resolve as resolvePath, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { BROKER_PROTOCOL_VERSION, initializeBroker } from "./broker.mjs";
11
+ import { IS_WINDOWS, terminateProcessTree } from "./process.mjs";
12
+ import { stateRoot } from "./state.mjs";
13
+ const BROKER_LOCK_DIR = "broker.lock";
14
+ const LOCK_OWNER_FILE = "owner.json";
15
+ const LOCK_SPAWN_FILE = "spawn.json";
16
+ // Far longer than a bootstrap (5s budget) or teardown (1.5s grace) can hold the lock.
17
+ const LOCK_STALE_MS = 30_000;
18
+ const BROKER_LOG_FILE = "broker.log";
19
+ const BROKER_SOCKET_FILE = "broker.sock";
20
+ // macOS sun_path is 104 bytes including the terminator; Linux allows 108.
21
+ const MAX_UNIX_SOCKET_PATH_BYTES = 103;
22
+ const BOOTSTRAP_BUDGET_MS_DEFAULT = 5000;
23
+ const SOCKET_POLL_INTERVAL_MS = 100;
24
+ const ISOLATED_HOME_PREFIX = "codex-pair-broker-";
25
+ const BROKER_OWNER_TTL_MS = 24 * 60 * 60 * 1000;
26
+ function sourceCodexHome(sourceHome) {
27
+ return sourceHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex");
28
+ }
29
+ export function hasCurrentBrokerAuth(isolatedHome, sourceHome) {
30
+ if (!isolatedHome)
31
+ return false;
32
+ const auth = join(sourceCodexHome(sourceHome), "auth.json");
33
+ try {
34
+ return existsSync(auth) && readlinkSync(join(isolatedHome, "auth.json")) === auth;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ // Link auth.json so token refreshes update the source credentials.
41
+ export function createIsolatedBrokerHome(options = {}) {
42
+ const sourceHome = sourceCodexHome(options.sourceHome);
43
+ const home = mkdtempSync(join(options.tempRoot ?? tmpdir(), ISOLATED_HOME_PREFIX));
44
+ try {
45
+ chmodSync(home, 0o700);
46
+ const auth = join(sourceHome, "auth.json");
47
+ if (existsSync(auth))
48
+ symlinkSync(auth, join(home, "auth.json"));
49
+ writeFileSync(join(home, "config.toml"), "[features]\napps = false\n", { mode: 0o600 });
50
+ return home;
51
+ }
52
+ catch (error) {
53
+ rmSync(home, { recursive: true, force: true });
54
+ throw error;
55
+ }
56
+ }
57
+ export function isIsolatedBrokerHome(home) {
58
+ if (typeof home !== "string")
59
+ return false;
60
+ try {
61
+ const actual = realpathSync(home);
62
+ const tempRoot = realpathSync(tmpdir());
63
+ return (basename(actual).startsWith(ISOLATED_HOME_PREFIX) &&
64
+ actual.startsWith(`${tempRoot}${sep}`) &&
65
+ (statSync(actual).mode & 0o777) === 0o700);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ export function removeIsolatedBrokerHome(home) {
72
+ if (isIsolatedBrokerHome(home))
73
+ rmSync(home, { recursive: true, force: true });
74
+ }
75
+ // The socket lives in the private broker home so its path stays short however deep the project is.
76
+ export function chooseTransport(isolatedHome) {
77
+ if (IS_WINDOWS) {
78
+ throw new Error("broker-lifecycle: Windows transport not implemented yet (see ADR-090)");
79
+ }
80
+ const socketPath = join(isolatedHome, BROKER_SOCKET_FILE);
81
+ if (Buffer.byteLength(socketPath) > MAX_UNIX_SOCKET_PATH_BYTES) {
82
+ throw new Error(`broker-lifecycle: socket path exceeds ${MAX_UNIX_SOCKET_PATH_BYTES} bytes`);
83
+ }
84
+ return `unix://${socketPath}`;
85
+ }
86
+ // Path resolvers for the lifecycle's filesystem state.
87
+ export function brokerLockPath(markerDir) {
88
+ return join(stateRoot(markerDir), BROKER_LOCK_DIR);
89
+ }
90
+ export function brokerLogPath(markerDir) {
91
+ return join(stateRoot(markerDir), BROKER_LOG_FILE);
92
+ }
93
+ // Atomic lock creation lets the next start reclaim a dead holder.
94
+ export function acquireBrokerLock(markerDir) {
95
+ const lockPath = brokerLockPath(markerDir);
96
+ mkdirSync(stateRoot(markerDir), { recursive: true });
97
+ for (let attempt = 0; attempt < 2; attempt++) {
98
+ try {
99
+ mkdirSync(lockPath);
100
+ writeFileSync(join(lockPath, LOCK_OWNER_FILE), JSON.stringify({ pid: process.pid, at: Date.now() }));
101
+ return lockPath;
102
+ }
103
+ catch (err) {
104
+ if (err?.code !== "EEXIST")
105
+ throw err;
106
+ if (attempt > 0 || !reclaimAbandonedLock(lockPath))
107
+ return null;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ function readLockFile(lockPath, file) {
113
+ try {
114
+ return readFileSync(join(lockPath, file), "utf-8");
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ // Return a lock if another reclaimer replaced it after our stale check.
121
+ function reclaimAbandonedLock(lockPath) {
122
+ const owner = readLockFile(lockPath, LOCK_OWNER_FILE);
123
+ if (!isAbandonedLock(lockPath, owner))
124
+ return false;
125
+ // Look the orphan up before claiming, so an unknown ps leaves the lock and its record in place.
126
+ const orphan = findAbandonedBroker(lockPath);
127
+ if (!orphan)
128
+ return false;
129
+ const claimed = `${lockPath}.abandoned.${process.pid}.${Date.now()}`;
130
+ try {
131
+ renameSync(lockPath, claimed);
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ if (readLockFile(claimed, LOCK_OWNER_FILE) !== owner) {
137
+ try {
138
+ renameSync(claimed, lockPath);
139
+ }
140
+ catch { }
141
+ return false;
142
+ }
143
+ stopAbandonedBroker(orphan);
144
+ rmSync(claimed, { recursive: true, force: true });
145
+ return true;
146
+ }
147
+ function isAbandonedLock(lockPath, owner) {
148
+ try {
149
+ if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS)
150
+ return true;
151
+ return owner !== null && !isPidAlive(JSON.parse(owner)?.pid);
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ // The transport is recorded before spawning, so a broker orphaned at any point is found by it; null means ps failed.
158
+ function findAbandonedBroker(lockPath) {
159
+ let spawned;
160
+ try {
161
+ const record = readLockFile(lockPath, LOCK_SPAWN_FILE) ?? readLockFile(lockPath, `${LOCK_SPAWN_FILE}.tmp`);
162
+ spawned = JSON.parse(record ?? "");
163
+ if (!isIsolatedBrokerHome(spawned.isolatedHome))
164
+ return { pids: [] };
165
+ if (spawned.transportUrl !== chooseTransport(spawned.isolatedHome))
166
+ return { pids: [] };
167
+ }
168
+ catch {
169
+ return { pids: [] };
170
+ }
171
+ const pids = findBrokerPids(spawned.transportUrl);
172
+ return pids && { pids, isolatedHome: spawned.isolatedHome };
173
+ }
174
+ function stopAbandonedBroker({ pids, isolatedHome }) {
175
+ for (const pid of pids) {
176
+ try {
177
+ process.kill(-pid, "SIGKILL");
178
+ }
179
+ catch {
180
+ try {
181
+ process.kill(pid, "SIGKILL");
182
+ }
183
+ catch { }
184
+ }
185
+ }
186
+ removeIsolatedBrokerHome(isolatedHome);
187
+ }
188
+ function recordBootstrapSpawn(lockPath, spawned) {
189
+ const file = join(lockPath, LOCK_SPAWN_FILE);
190
+ writeFileSync(`${file}.tmp`, JSON.stringify(spawned));
191
+ renameSync(`${file}.tmp`, file);
192
+ }
193
+ export function releaseBrokerLock(lockPath) {
194
+ if (!lockPath)
195
+ return;
196
+ try {
197
+ rmSync(lockPath, { recursive: true, force: true });
198
+ }
199
+ catch {
200
+ // Best-effort; an abandoned lock is reclaimed by the next acquireBrokerLock.
201
+ }
202
+ }
203
+ // Socket reachability is only a precondition; initialize still proves broker health.
204
+ export async function pollSocketReachable(transportUrl, budgetMs) {
205
+ const deadline = Date.now() + budgetMs;
206
+ while (Date.now() < deadline) {
207
+ const reachable = await probeOnce(transportUrl);
208
+ if (reachable)
209
+ return true;
210
+ await sleep(SOCKET_POLL_INTERVAL_MS);
211
+ }
212
+ return false;
213
+ }
214
+ function probeOnce(transportUrl) {
215
+ return new Promise((resolve) => {
216
+ let connectOptions;
217
+ if (transportUrl.startsWith("unix://")) {
218
+ const path = transportUrl.slice("unix://".length);
219
+ try {
220
+ statSync(path);
221
+ }
222
+ catch {
223
+ resolve(false);
224
+ return;
225
+ }
226
+ connectOptions = { path };
227
+ }
228
+ else if (transportUrl.startsWith("ws://")) {
229
+ const rest = transportUrl.slice("ws://".length);
230
+ const slashIdx = rest.indexOf("/");
231
+ const authority = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
232
+ const colonIdx = authority.lastIndexOf(":");
233
+ const host = colonIdx === -1 ? authority : authority.slice(0, colonIdx);
234
+ const port = colonIdx === -1 ? 80 : Number(authority.slice(colonIdx + 1));
235
+ connectOptions = { host, port };
236
+ }
237
+ else {
238
+ resolve(false);
239
+ return;
240
+ }
241
+ const sock = netConnect(connectOptions);
242
+ const settle = (ok) => {
243
+ sock.removeAllListeners();
244
+ try {
245
+ sock.destroy();
246
+ }
247
+ catch { }
248
+ resolve(ok);
249
+ };
250
+ sock.once("connect", () => settle(true));
251
+ sock.once("error", () => settle(false));
252
+ sock.once("timeout", () => settle(false));
253
+ sock.setTimeout(SOCKET_POLL_INTERVAL_MS);
254
+ });
255
+ }
256
+ // Keep this timer referenced so SessionStart cannot exit mid-bootstrap and orphan the broker.
257
+ function sleep(ms) {
258
+ return new Promise((resolve) => setTimeout(resolve, ms));
259
+ }
260
+ // Detach app-server across SessionStart exit; persist its descriptor only after handshake.
261
+ export function spawnBroker(markerDir, transportUrl, isolatedHome) {
262
+ if (!isIsolatedBrokerHome(isolatedHome))
263
+ throw new Error("broker requires an isolated Codex home");
264
+ const logFd = openSync(brokerLogPath(markerDir), "a");
265
+ const child = spawn("codex", ["app-server", "--listen", transportUrl], {
266
+ detached: true,
267
+ env: { ...process.env, CODEX_HOME: isolatedHome },
268
+ stdio: ["ignore", logFd, logFd],
269
+ });
270
+ // Handle asynchronous spawn errors so a missing Codex binary cannot crash the hook.
271
+ child.on("error", () => { });
272
+ // Detach so the broker outlives SessionStart.
273
+ child.unref();
274
+ return child;
275
+ }
276
+ export function readCodexVersion() {
277
+ try {
278
+ const out = execFileSync("codex", ["--version"], { timeout: 2000, encoding: "utf-8" });
279
+ return (out || "").trim() || "unknown";
280
+ }
281
+ catch {
282
+ return "unknown";
283
+ }
284
+ }
285
+ // Rename atomically after the lock creates the state root.
286
+ export async function writeBrokerDescriptor(markerDir, descriptor) {
287
+ const finalPath = join(stateRoot(markerDir), "broker.json");
288
+ const tmpPath = `${finalPath}.tmp.${process.pid}`;
289
+ await writeFile(tmpPath, JSON.stringify(descriptor, null, 2));
290
+ await rename(tmpPath, finalPath);
291
+ return finalPath;
292
+ }
293
+ export async function unlinkBrokerDescriptor(markerDir) {
294
+ const finalPath = join(stateRoot(markerDir), "broker.json");
295
+ try {
296
+ await unlink(finalPath);
297
+ }
298
+ catch {
299
+ // best-effort
300
+ }
301
+ }
302
+ let cachedPluginVersion = null;
303
+ export function readPluginVersion() {
304
+ if (cachedPluginVersion)
305
+ return cachedPluginVersion;
306
+ try {
307
+ const here = dirname(fileURLToPath(import.meta.url));
308
+ // scripts/lib/*.mjs → packages/claude-plugin/package.json
309
+ const manifest = join(here, "..", "..", "package.json");
310
+ // Use the static ESM import; require is unavailable in generated .mjs.
311
+ const text = readFileSync(manifest, "utf-8");
312
+ cachedPluginVersion = (JSON.parse(text)?.version || "unknown").trim();
313
+ }
314
+ catch {
315
+ cachedPluginVersion = "unknown";
316
+ }
317
+ return cachedPluginVersion;
318
+ }
319
+ // Any failure terminates the spawned child and returns null so the session keeps direct reviews (ADR-077).
320
+ export async function bootstrapBroker(markerDir, options = {}) {
321
+ const { budgetMs = BOOTSTRAP_BUDGET_MS_DEFAULT, injectDeps } = options;
322
+ const spawnFn = injectDeps?.spawnBroker ?? spawnBroker;
323
+ const initFn = injectDeps?.initializeBroker ?? initializeBroker;
324
+ const pollFn = injectDeps?.pollSocketReachable ?? pollSocketReachable;
325
+ const versionFn = injectDeps?.readCodexVersion ?? readCodexVersion;
326
+ // Without shared file credentials (keyring or environment keys) a broker could only fail its turns.
327
+ const hasCredentials = existsSync(join(sourceCodexHome(options.sourceHome), "auth.json"));
328
+ const lockPath = acquireBrokerLock(markerDir);
329
+ if (!lockPath)
330
+ return null; // another SessionStart holds the lock
331
+ const deadline = Date.now() + budgetMs;
332
+ let child = null;
333
+ let isolatedHome = null;
334
+ let connection = null; // hoisted so the catch block can close on descriptor-write failure
335
+ try {
336
+ const previous = readBrokerDescriptorSync(markerDir);
337
+ if (previous) {
338
+ const startedAt = Date.parse(previous.startedAt ?? "");
339
+ const ageMs = Date.now() - startedAt;
340
+ const expired = !Number.isFinite(ageMs) || ageMs >= BROKER_OWNER_TTL_MS || ageMs < -5 * 60 * 1000;
341
+ const liveness = expired ? null : (injectDeps?.brokerLiveness ?? brokerLiveness)(previous);
342
+ // Unverifiable liveness keeps the recorded broker and starts no second one; edits fall back to direct if it fails.
343
+ if (liveness === "unknown")
344
+ return null;
345
+ const live = liveness === "live";
346
+ if (hasCredentials && live && hasCurrentBrokerAuth(previous.isolatedHome, options.sourceHome)) {
347
+ return previous;
348
+ }
349
+ const retired = await teardownBroker(markerDir, {
350
+ lockHeld: true,
351
+ injectDeps,
352
+ onlyIfCredentialMissing: !hasCredentials && live,
353
+ });
354
+ if (!retired)
355
+ return null;
356
+ }
357
+ if (!hasCredentials)
358
+ return null;
359
+ isolatedHome = createIsolatedBrokerHome({ sourceHome: options.sourceHome });
360
+ const transportUrl = chooseTransport(isolatedHome);
361
+ recordBootstrapSpawn(lockPath, { isolatedHome, transportUrl });
362
+ child = spawnFn(markerDir, transportUrl, isolatedHome);
363
+ // Never extend the bootstrap deadline with a minimum timeout floor.
364
+ const pollBudget = deadline - Date.now() - 1000;
365
+ if (pollBudget <= 0)
366
+ throw new Error("broker bootstrap budget exhausted before poll");
367
+ const reachable = await pollFn(transportUrl, pollBudget);
368
+ if (!reachable)
369
+ throw new Error("broker did not become reachable within budget");
370
+ const remaining = deadline - Date.now();
371
+ if (remaining <= 0)
372
+ throw new Error("broker bootstrap budget exhausted before initialize");
373
+ const clientInfo = {
374
+ name: "codex-pair",
375
+ title: `codex-pair plugin v${readPluginVersion()}`,
376
+ version: readPluginVersion(),
377
+ };
378
+ const initResult = await initFn(transportUrl, clientInfo, {
379
+ handshakeTimeoutMs: remaining,
380
+ initializeTimeoutMs: remaining,
381
+ });
382
+ connection = initResult.connection;
383
+ const initializeResult = initResult.initializeResult;
384
+ if (typeof initializeResult?.codexHome !== "string" ||
385
+ realpathSync(initializeResult.codexHome) !== realpathSync(isolatedHome)) {
386
+ throw new Error("broker reported an unexpected Codex home");
387
+ }
388
+ const descriptor = {
389
+ pid: child.pid,
390
+ transportUrl,
391
+ codexVersion: versionFn(),
392
+ codexHome: initializeResult?.codexHome ?? null,
393
+ isolatedHome,
394
+ sessionId: options.sessionId ?? null,
395
+ // Record the shared protocol constant so stale detection cannot drift.
396
+ protocolVersion: BROKER_PROTOCOL_VERSION,
397
+ pluginVersion: readPluginVersion(),
398
+ startedAt: new Date().toISOString(),
399
+ logPath: brokerLogPath(markerDir),
400
+ };
401
+ await writeBrokerDescriptor(markerDir, descriptor);
402
+ // Per-edit hooks open their own RPC connections.
403
+ try {
404
+ connection.close(1000, "bootstrap done");
405
+ }
406
+ catch {
407
+ // best-effort
408
+ }
409
+ return descriptor;
410
+ }
411
+ catch {
412
+ // Close a partially initialized connection and remove the child on any bootstrap failure.
413
+ if (connection) {
414
+ try {
415
+ connection.close(1011, "bootstrap failed");
416
+ }
417
+ catch { }
418
+ }
419
+ if (child) {
420
+ try {
421
+ terminateProcessTree(child, "SIGTERM");
422
+ }
423
+ catch { }
424
+ }
425
+ if (isolatedHome)
426
+ removeIsolatedBrokerHome(isolatedHome);
427
+ return null;
428
+ }
429
+ finally {
430
+ releaseBrokerLock(lockPath);
431
+ }
432
+ }
433
+ // ──── SessionEnd teardown (M2 PR 3) ────────────────────────────────────
434
+ export function readBrokerDescriptorSync(markerDir) {
435
+ const descPath = join(stateRoot(markerDir), "broker.json");
436
+ try {
437
+ const text = readFileSync(descPath, "utf-8");
438
+ const parsed = JSON.parse(text);
439
+ if (!parsed || typeof parsed !== "object")
440
+ return null;
441
+ if (typeof parsed.pid !== "number" || typeof parsed.transportUrl !== "string")
442
+ return null;
443
+ return parsed;
444
+ }
445
+ catch {
446
+ return null;
447
+ }
448
+ }
449
+ // PID liveness is only a hint; Windows relies on process-tree termination.
450
+ export function isPidAlive(pid) {
451
+ if (typeof pid !== "number" || pid <= 0)
452
+ return false;
453
+ if (IS_WINDOWS)
454
+ return true; // best-effort; rely on terminateProcessTree
455
+ try {
456
+ process.kill(pid, 0);
457
+ return true;
458
+ }
459
+ catch (err) {
460
+ // EPERM still means the process may be alive; only ESRCH proves absence.
461
+ if (err?.code === "EPERM")
462
+ return true;
463
+ return false;
464
+ }
465
+ }
466
+ // Descriptors outlive crashes and reboots, so a recorded pid may since belong to an unrelated process.
467
+ export function brokerLiveness(descriptor) {
468
+ if (IS_WINDOWS || !isPidAlive(descriptor.pid) || typeof descriptor.transportUrl !== "string")
469
+ return "dead";
470
+ try {
471
+ const args = execFileSync("ps", ["-ww", "-o", "args=", "-p", String(descriptor.pid)], {
472
+ encoding: "utf-8",
473
+ timeout: 2000,
474
+ });
475
+ return isBrokerCommand(args, descriptor.transportUrl) ? "live" : "dead";
476
+ }
477
+ catch {
478
+ // ps also exits non-zero when the pid exited after the liveness probe.
479
+ return isPidAlive(descriptor.pid) ? "unknown" : "dead";
480
+ }
481
+ }
482
+ function isBrokerCommand(args, transportUrl) {
483
+ const argv = args.trim().split(/\s+/);
484
+ const at = argv.indexOf("app-server");
485
+ return (at > 0 &&
486
+ /^codex(\.\w+)?$/.test(basename(argv[at - 1])) &&
487
+ argv.slice(at + 1).join(" ") === `--listen ${transportUrl}`);
488
+ }
489
+ function findBrokerPids(transportUrl) {
490
+ if (IS_WINDOWS)
491
+ return [];
492
+ try {
493
+ const table = execFileSync("ps", ["-ax", "-ww", "-o", "pid=,args="], { encoding: "utf-8", timeout: 2000 });
494
+ return table.split("\n").flatMap((line) => {
495
+ const row = line.match(/^\s*(\d+)\s+(.*)$/);
496
+ return row && isBrokerCommand(row[2], transportUrl) ? [Number(row[1])] : [];
497
+ });
498
+ }
499
+ catch {
500
+ return null;
501
+ }
502
+ }
503
+ async function killPidGracefully(pid, graceMs) {
504
+ if (!isPidAlive(pid))
505
+ return false;
506
+ try {
507
+ if (IS_WINDOWS) {
508
+ // Windows has no graceful SIGTERM path.
509
+ terminateProcessTree({ pid, killed: false, exitCode: null }, "SIGTERM");
510
+ return true;
511
+ }
512
+ // The detached POSIX process group receives SIGTERM.
513
+ try {
514
+ process.kill(-pid, "SIGTERM");
515
+ }
516
+ catch {
517
+ // Group gone — try direct pid signal.
518
+ try {
519
+ process.kill(pid, "SIGTERM");
520
+ }
521
+ catch {
522
+ return false;
523
+ }
524
+ }
525
+ // Poll for exit
526
+ const deadline = Date.now() + graceMs;
527
+ while (Date.now() < deadline) {
528
+ if (!isPidAlive(pid))
529
+ return true;
530
+ await sleep(50);
531
+ }
532
+ // Still alive — escalate to SIGKILL via terminateProcessTree
533
+ terminateProcessTree({ pid, killed: false, exitCode: null }, "SIGKILL");
534
+ return true;
535
+ }
536
+ catch {
537
+ return false;
538
+ }
539
+ }
540
+ // Validate socket ancestry before unlinking a descriptor-provided path.
541
+ async function unlinkTransportArtifact(transportUrl, markerDir) {
542
+ const safePath = extractSafeSocketPath(transportUrl, markerDir);
543
+ if (safePath === null)
544
+ return;
545
+ try {
546
+ await unlink(safePath);
547
+ }
548
+ catch {
549
+ // already gone — fine
550
+ }
551
+ }
552
+ // Reclaim stale descriptors after dead PIDs, protocol changes, or missing sockets.
553
+ export function clearStaleBrokerState(markerDir) {
554
+ const descriptor = readBrokerDescriptorSync(markerDir);
555
+ if (!descriptor)
556
+ return "absent";
557
+ const alive = isPidAlive(descriptor.pid);
558
+ const protoOk = descriptor.protocolVersion === BROKER_PROTOCOL_VERSION;
559
+ // Reject unknown transports and Unix sockets outside the recorded state boundary.
560
+ let socketOk;
561
+ let sockPath = null;
562
+ if (typeof descriptor.transportUrl !== "string") {
563
+ socketOk = false;
564
+ }
565
+ else if (descriptor.transportUrl.startsWith("unix://")) {
566
+ sockPath = extractSafeSocketPath(descriptor.transportUrl, markerDir, descriptor.isolatedHome);
567
+ if (sockPath === null) {
568
+ socketOk = false; // unix:// outside bounds — descriptor was tampered
569
+ }
570
+ else {
571
+ try {
572
+ statSync(sockPath);
573
+ socketOk = true;
574
+ }
575
+ catch {
576
+ socketOk = false;
577
+ }
578
+ }
579
+ }
580
+ else if (descriptor.transportUrl.startsWith("ws://")) {
581
+ socketOk = true; // assume live; per-edit probeBrokerHealth validates
582
+ }
583
+ else {
584
+ socketOk = false; // unrecognized scheme
585
+ }
586
+ if (alive && protoOk && socketOk)
587
+ return "live";
588
+ // Stale — clean up. Best-effort; failures are silent per ADR-077.
589
+ try {
590
+ unlinkSync(join(stateRoot(markerDir), "broker.json"));
591
+ }
592
+ catch { }
593
+ if (sockPath !== null) {
594
+ try {
595
+ unlinkSync(sockPath);
596
+ }
597
+ catch { }
598
+ }
599
+ return "stale";
600
+ }
601
+ // Only unlink sockets under the isolated home or legacy marker state root.
602
+ function extractSafeSocketPath(transportUrl, markerDir, isolatedHome) {
603
+ if (typeof transportUrl !== "string" || !transportUrl.startsWith("unix://")) {
604
+ return null;
605
+ }
606
+ const sockPath = transportUrl.slice("unix://".length);
607
+ if (!sockPath)
608
+ return null;
609
+ const resolvedSock = resolvePath(sockPath);
610
+ const roots = [resolvePath(stateRoot(markerDir))];
611
+ if (isIsolatedBrokerHome(isolatedHome))
612
+ roots.push(resolvePath(isolatedHome));
613
+ return roots.some((root) => resolvedSock.startsWith(`${root}/`)) ? resolvedSock : null;
614
+ }
615
+ // SessionEnd teardown is best-effort and must not fail the hook.
616
+ export async function teardownBroker(markerDir, options = {}) {
617
+ const { graceMs = 1500, injectDeps } = options;
618
+ const unlinkSockFn = injectDeps?.unlinkSock ?? unlinkTransportArtifact;
619
+ const lockPath = options.lockHeld ? brokerLockPath(markerDir) : acquireBrokerLock(markerDir);
620
+ if (!lockPath)
621
+ return null;
622
+ try {
623
+ const descriptor = readBrokerDescriptorSync(markerDir);
624
+ if (!descriptor || ("sessionId" in options && (!options.sessionId || descriptor.sessionId !== options.sessionId))) {
625
+ return null;
626
+ }
627
+ if (options.onlyIfCredentialMissing) {
628
+ if (!isIsolatedBrokerHome(descriptor.isolatedHome))
629
+ return null;
630
+ let source = null;
631
+ try {
632
+ source = readlinkSync(join(descriptor.isolatedHome, "auth.json"));
633
+ statSync(resolvePath(descriptor.isolatedHome, source));
634
+ return null;
635
+ }
636
+ catch (error) {
637
+ if (!source || error.code !== "ENOENT")
638
+ return null;
639
+ }
640
+ }
641
+ // An unverified pid is never signaled, and its state is kept so the broker is not orphaned.
642
+ const liveness = (injectDeps?.brokerLiveness ?? brokerLiveness)(descriptor);
643
+ if (liveness === "unknown")
644
+ return null;
645
+ try {
646
+ if (liveness === "live")
647
+ await (injectDeps?.killPid ?? killPidGracefully)(descriptor.pid, graceMs);
648
+ }
649
+ catch {
650
+ // best-effort
651
+ }
652
+ try {
653
+ await unlinkSockFn(descriptor.transportUrl, markerDir);
654
+ }
655
+ catch {
656
+ // best-effort
657
+ }
658
+ await unlinkBrokerDescriptor(markerDir);
659
+ removeIsolatedBrokerHome(descriptor.isolatedHome);
660
+ return descriptor;
661
+ }
662
+ finally {
663
+ if (!options.lockHeld)
664
+ releaseBrokerLock(lockPath);
665
+ }
666
+ }
667
+ // Test-only exports
668
+ export const __testing__ = {
669
+ BROKER_LOCK_DIR,
670
+ BROKER_LOG_FILE,
671
+ BROKER_SOCKET_FILE,
672
+ MAX_UNIX_SOCKET_PATH_BYTES,
673
+ BOOTSTRAP_BUDGET_MS_DEFAULT,
674
+ isPidAlive,
675
+ killPidGracefully,
676
+ unlinkTransportArtifact,
677
+ };