@ask-llm/plugin 0.13.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.
- package/.claude-plugin/plugin.json +20 -0
- package/.mcp.json +3 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/agents/antigravity-reviewer.md +139 -0
- package/agents/brainstorm-coordinator.md +305 -0
- package/agents/codex-reviewer.md +194 -0
- package/agents/codex-verifier.md +149 -0
- package/agents/fable-reviewer.md +44 -0
- package/agents/gemini-reviewer.md +130 -0
- package/agents/ollama-reviewer.md +131 -0
- package/agents/sol-reviewer.md +60 -0
- package/codex-pair-defaults.json +4 -0
- package/dist/antigravity-run.d.ts +3 -0
- package/dist/antigravity-run.d.ts.map +1 -0
- package/dist/antigravity-run.js +32 -0
- package/dist/antigravity-run.js.map +1 -0
- package/dist/codex-run.d.ts +3 -0
- package/dist/codex-run.d.ts.map +1 -0
- package/dist/codex-run.js +32 -0
- package/dist/codex-run.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/ollama-run.d.ts +3 -0
- package/dist/ollama-run.d.ts.map +1 -0
- package/dist/ollama-run.js +32 -0
- package/dist/ollama-run.js.map +1 -0
- package/dist/run.d.ts +3 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +32 -0
- package/dist/run.js.map +1 -0
- package/hooks/hooks.json +55 -0
- package/package.json +104 -0
- package/pi/extensions/codex-pair.ts +870 -0
- package/pi/extensions/index.ts +13 -0
- package/pi/extensions/provider-tools.ts +241 -0
- package/pi/tsconfig.json +10 -0
- package/prompts/review.txt +75 -0
- package/scripts/codex-pair-debounce-worker.mjs +103 -0
- package/scripts/codex-pair-log.mjs +271 -0
- package/scripts/codex-pair-prompt-drain.mjs +81 -0
- package/scripts/codex-pair-session.mjs +194 -0
- package/scripts/codex-pair-stop-gate.mjs +271 -0
- package/scripts/codex-pair-watch.mjs +1525 -0
- package/scripts/lib/broker-lifecycle.mjs +575 -0
- package/scripts/lib/broker-rpc.mjs +203 -0
- package/scripts/lib/broker-transport.mjs +407 -0
- package/scripts/lib/broker.mjs +537 -0
- package/scripts/lib/debounce-state.mjs +208 -0
- package/scripts/lib/parser.d.mts +12 -0
- package/scripts/lib/parser.mjs +229 -0
- package/scripts/lib/process.mjs +39 -0
- package/scripts/lib/prompt.d.mts +8 -0
- package/scripts/lib/prompt.mjs +41 -0
- package/scripts/lib/session-registry.mjs +162 -0
- package/scripts/lib/state.d.mts +58 -0
- package/scripts/lib/state.mjs +733 -0
- package/scripts/lib/stop-gate.mjs +134 -0
- package/skills/antigravity-review/SKILL.md +49 -0
- package/skills/brainstorm/SKILL.md +105 -0
- package/skills/brainstorm-all/SKILL.md +43 -0
- package/skills/codex-image/SKILL.md +120 -0
- package/skills/codex-pair/SKILL.md +315 -0
- package/skills/codex-pair-ack/SKILL.md +64 -0
- package/skills/codex-pair-pause/SKILL.md +62 -0
- package/skills/codex-pair-resume/SKILL.md +52 -0
- package/skills/codex-review/SKILL.md +52 -0
- package/skills/codex-verify/SKILL.md +110 -0
- package/skills/compare/SKILL.md +151 -0
- package/skills/fable-review/SKILL.md +42 -0
- package/skills/gemini-review/SKILL.md +40 -0
- package/skills/multi-review/SKILL.md +182 -0
- package/skills/ollama-review/SKILL.md +40 -0
- package/skills/sol-review/SKILL.md +41 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// Broker lifecycle: spawn `codex app-server`, poll readiness, handshake,
|
|
2
|
+
// atomic descriptor write. SessionStart calls `bootstrapBroker`; SessionEnd
|
|
3
|
+
// calls `teardownBroker`. Stale-broker recovery (`clearStaleBrokerState`)
|
|
4
|
+
// lives in `broker.mjs` so the per-edit hook can also use it as a
|
|
5
|
+
// belt-and-suspenders check.
|
|
6
|
+
//
|
|
7
|
+
// Per ADR-090 + ADR-093 + the brainstorm-coordinator's verified findings:
|
|
8
|
+
// the broker uses RFC 6455 WebSocket framing on BOTH `unix://` and `ws://`
|
|
9
|
+
// transports; readiness is `initialize` round-trip success, not socket
|
|
10
|
+
// existence; descriptor must be written ATOMICALLY only after `initialize`
|
|
11
|
+
// succeeds (no partial-broker states observable from the hook side per
|
|
12
|
+
// ADR-077). Wall-clock budget enforced on the whole bootstrap; on
|
|
13
|
+
// exhaustion or any failure path, the spawned child is terminated and
|
|
14
|
+
// the hook exits 0 silently.
|
|
15
|
+
//
|
|
16
|
+
// Pure Node built-ins + relative `./broker-*.mjs` imports per ADR-078.
|
|
17
|
+
|
|
18
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { mkdirSync, openSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
21
|
+
import { rename, unlink, writeFile } from "node:fs/promises";
|
|
22
|
+
import { connect as netConnect } from "node:net";
|
|
23
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { BROKER_PROTOCOL_VERSION, initializeBroker } from "./broker.mjs";
|
|
26
|
+
import { terminateProcessTree, IS_WINDOWS } from "./process.mjs";
|
|
27
|
+
import { stateRoot } from "./state.mjs";
|
|
28
|
+
|
|
29
|
+
// Locks live alongside the broker descriptor. Per-marker-dir isolation is
|
|
30
|
+
// inherent because the parent path is `<markerDir>/.codex-pair/state/`.
|
|
31
|
+
const BROKER_LOCK_DIR = "broker.lock";
|
|
32
|
+
const BROKER_LOG_FILE = "broker.log";
|
|
33
|
+
const BROKER_SOCKET_PREFIX = "codex-pair-broker";
|
|
34
|
+
const BOOTSTRAP_BUDGET_MS_DEFAULT = 5000;
|
|
35
|
+
const SOCKET_POLL_INTERVAL_MS = 100;
|
|
36
|
+
|
|
37
|
+
// Choose the transport URL for this marker directory. POSIX: unix socket
|
|
38
|
+
// under `<markerDir>/.codex-pair/state/`, with sha256-of-markerDir suffix
|
|
39
|
+
// to prevent name collisions across symlinked project trees. Windows:
|
|
40
|
+
// TODO — codex CLI supports `ws://IP:PORT` but cross-platform port
|
|
41
|
+
// reservation has a known race (Brainstorm Risk #3). Punted to a follow-on
|
|
42
|
+
// PR; for Milestone 2 we throw on Windows and the hook treats it as a
|
|
43
|
+
// bootstrap failure (silent exit per ADR-077).
|
|
44
|
+
export function chooseTransport(markerDir) {
|
|
45
|
+
if (IS_WINDOWS) {
|
|
46
|
+
throw new Error("broker-lifecycle: Windows transport not implemented yet (see ADR-090)");
|
|
47
|
+
}
|
|
48
|
+
const hash = createHash("sha256").update(markerDir).digest("hex").slice(0, 8);
|
|
49
|
+
const socketPath = join(stateRoot(markerDir), `${BROKER_SOCKET_PREFIX}.${hash}.sock`);
|
|
50
|
+
return `unix://${socketPath}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Path resolvers for the lifecycle's filesystem state.
|
|
54
|
+
export function brokerLockPath(markerDir) {
|
|
55
|
+
return join(stateRoot(markerDir), BROKER_LOCK_DIR);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function brokerLogPath(markerDir) {
|
|
59
|
+
return join(stateRoot(markerDir), BROKER_LOG_FILE);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Atomic lock via mkdir(2). The mkdir syscall is atomic across all POSIX
|
|
63
|
+
// filesystems we care about (and on Windows). On success, returns the
|
|
64
|
+
// lock path; on EEXIST, returns null (another SessionStart already
|
|
65
|
+
// holding the lock — caller should exit quietly).
|
|
66
|
+
export function acquireBrokerLock(markerDir) {
|
|
67
|
+
const lockPath = brokerLockPath(markerDir);
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(stateRoot(markerDir), { recursive: true });
|
|
70
|
+
mkdirSync(lockPath);
|
|
71
|
+
return lockPath;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err && err.code === "EEXIST") return null;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function releaseBrokerLock(lockPath) {
|
|
79
|
+
if (!lockPath) return;
|
|
80
|
+
try {
|
|
81
|
+
// Lock is a directory created by mkdirSync (so mkdir(2) acted as our
|
|
82
|
+
// atomic primitive). To remove a directory we need recursive:true on
|
|
83
|
+
// rmSync — recursive:false throws even with force:true (force only
|
|
84
|
+
// suppresses ENOENT, not EISDIR).
|
|
85
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
86
|
+
} catch {
|
|
87
|
+
// Best-effort. A stuck lock will be cleared by stale-recovery at
|
|
88
|
+
// next SessionStart (Milestone 2 PR 3 / Milestone 4).
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Poll the transport for reachability. Different probes per scheme:
|
|
93
|
+
// - unix:// — check the socket file exists + try net.connect once
|
|
94
|
+
// - ws:// — try net.connect to host:port
|
|
95
|
+
// Returns true on first reachable response, false after the budget. The
|
|
96
|
+
// caller still has to perform `initialize` separately — reachability is
|
|
97
|
+
// necessary but not sufficient for "broker is healthy" per ADR-093.
|
|
98
|
+
export async function pollSocketReachable(transportUrl, budgetMs) {
|
|
99
|
+
const deadline = Date.now() + budgetMs;
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
const reachable = await probeOnce(transportUrl);
|
|
102
|
+
if (reachable) return true;
|
|
103
|
+
await sleep(SOCKET_POLL_INTERVAL_MS);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function probeOnce(transportUrl) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
let connectOptions;
|
|
111
|
+
if (transportUrl.startsWith("unix://")) {
|
|
112
|
+
const path = transportUrl.slice("unix://".length);
|
|
113
|
+
try {
|
|
114
|
+
statSync(path);
|
|
115
|
+
} catch {
|
|
116
|
+
resolve(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
connectOptions = { path };
|
|
120
|
+
} else if (transportUrl.startsWith("ws://")) {
|
|
121
|
+
const rest = transportUrl.slice("ws://".length);
|
|
122
|
+
const slashIdx = rest.indexOf("/");
|
|
123
|
+
const authority = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
|
|
124
|
+
const colonIdx = authority.lastIndexOf(":");
|
|
125
|
+
const host = colonIdx === -1 ? authority : authority.slice(0, colonIdx);
|
|
126
|
+
const port = colonIdx === -1 ? 80 : Number(authority.slice(colonIdx + 1));
|
|
127
|
+
connectOptions = { host, port };
|
|
128
|
+
} else {
|
|
129
|
+
resolve(false);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const sock = netConnect(connectOptions);
|
|
133
|
+
const settle = (ok) => {
|
|
134
|
+
sock.removeAllListeners();
|
|
135
|
+
try {
|
|
136
|
+
sock.destroy();
|
|
137
|
+
} catch {}
|
|
138
|
+
resolve(ok);
|
|
139
|
+
};
|
|
140
|
+
sock.once("connect", () => settle(true));
|
|
141
|
+
sock.once("error", () => settle(false));
|
|
142
|
+
sock.once("timeout", () => settle(false));
|
|
143
|
+
sock.setTimeout(SOCKET_POLL_INTERVAL_MS);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Sleep helper. Previously unref'd the timer, which codex-pair flagged
|
|
148
|
+
// repeatedly in M2: a unref'd timer lets Node exit before the awaited
|
|
149
|
+
// promise resolves if no other ref holds the event loop open. Result:
|
|
150
|
+
// SessionStart could exit mid-bootstrap, orphaning the partially-spawned
|
|
151
|
+
// codex process. The bootstrap's wall-clock budget is enforced at the
|
|
152
|
+
// deadline-check call sites, NOT by relying on idle-exit semantics.
|
|
153
|
+
function sleep(ms) {
|
|
154
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Spawn `codex app-server --listen <transport>` detached so it outlives
|
|
158
|
+
// SessionStart's process. stdio is redirected to broker.log (open via
|
|
159
|
+
// O_APPEND so multiple writers — unlikely but defensive — don't tear).
|
|
160
|
+
// Returns the spawned ChildProcess; caller is responsible for tracking
|
|
161
|
+
// the pid and writing it to the descriptor only after handshake succeeds.
|
|
162
|
+
export function spawnBroker(markerDir, transportUrl) {
|
|
163
|
+
// Bug #5: the socket path is deterministic (sha256 of markerDir), so an
|
|
164
|
+
// orphaned socket inode — left when a prior broker was killed AFTER
|
|
165
|
+
// binding but BEFORE writing its descriptor — makes the next bind fail
|
|
166
|
+
// with EADDRINUSE permanently (clearStaleBrokerState early-returns
|
|
167
|
+
// "absent" with no descriptor to drive cleanup). Unlink any stale socket
|
|
168
|
+
// at the deterministic path before binding. extractSafeSocketPath gates
|
|
169
|
+
// the unlink to paths strictly under <markerDir>/.codex-pair/state/ so a
|
|
170
|
+
// malformed transportUrl can never delete an arbitrary path.
|
|
171
|
+
const stalePath = extractSafeSocketPath(transportUrl, markerDir);
|
|
172
|
+
if (stalePath !== null) {
|
|
173
|
+
try {
|
|
174
|
+
unlinkSync(stalePath);
|
|
175
|
+
} catch {
|
|
176
|
+
// already gone (the common case) — fine
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const logFd = openSync(brokerLogPath(markerDir), "a");
|
|
180
|
+
const child = spawn("codex", ["app-server", "--listen", transportUrl], {
|
|
181
|
+
detached: true,
|
|
182
|
+
stdio: ["ignore", logFd, logFd],
|
|
183
|
+
});
|
|
184
|
+
// spawn() emits "error" asynchronously for ENOENT (codex not on PATH)
|
|
185
|
+
// and similar dispatch failures. Without a listener, Node treats this
|
|
186
|
+
// as an unhandled error and crashes the hook process — violating
|
|
187
|
+
// ADR-077's silent-on-error contract. Codex-pair flagged this finding
|
|
188
|
+
// repeatedly during M2; attaching a no-op listener catches the error
|
|
189
|
+
// (bootstrapBroker's poll/initialize step will fail subsequently and
|
|
190
|
+
// route through the silent-fallback path).
|
|
191
|
+
child.on("error", () => {
|
|
192
|
+
// best-effort; bootstrap's outer catch handles the resulting
|
|
193
|
+
// poll/initialize failure
|
|
194
|
+
});
|
|
195
|
+
// detached + unref so SessionStart can exit cleanly without waiting
|
|
196
|
+
// for the broker. The broker stays alive as a session-scoped daemon.
|
|
197
|
+
child.unref();
|
|
198
|
+
return child;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Codex version detection. Best-effort: returns the version string or
|
|
202
|
+
// "unknown" if codex isn't on PATH or fails. Used in the descriptor for
|
|
203
|
+
// version-skew detection (stale-broker recovery, Milestone 4).
|
|
204
|
+
export function readCodexVersion() {
|
|
205
|
+
try {
|
|
206
|
+
const out = execFileSync("codex", ["--version"], { timeout: 2000, encoding: "utf-8" });
|
|
207
|
+
return (out || "").trim() || "unknown";
|
|
208
|
+
} catch {
|
|
209
|
+
return "unknown";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Atomic descriptor write via tmp+rename (ADR-086). Caller ensures
|
|
214
|
+
// stateRoot(markerDir) exists (acquireBrokerLock creates it).
|
|
215
|
+
export async function writeBrokerDescriptor(markerDir, descriptor) {
|
|
216
|
+
const finalPath = join(stateRoot(markerDir), "broker.json");
|
|
217
|
+
const tmpPath = `${finalPath}.tmp.${process.pid}`;
|
|
218
|
+
await writeFile(tmpPath, JSON.stringify(descriptor, null, 2));
|
|
219
|
+
await rename(tmpPath, finalPath);
|
|
220
|
+
return finalPath;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function unlinkBrokerDescriptor(markerDir) {
|
|
224
|
+
const finalPath = join(stateRoot(markerDir), "broker.json");
|
|
225
|
+
try {
|
|
226
|
+
await unlink(finalPath);
|
|
227
|
+
} catch {
|
|
228
|
+
// best-effort
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Resolve the plugin version from package.json. Used in clientInfo.title
|
|
233
|
+
// and the descriptor. Falls back to "unknown" if the manifest can't be
|
|
234
|
+
// read (the bundled marketplace install ships package.json adjacent to
|
|
235
|
+
// scripts/).
|
|
236
|
+
let cachedPluginVersion = null;
|
|
237
|
+
export function readPluginVersion() {
|
|
238
|
+
if (cachedPluginVersion) return cachedPluginVersion;
|
|
239
|
+
try {
|
|
240
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
241
|
+
// scripts/lib/*.mjs → packages/claude-plugin/package.json
|
|
242
|
+
const manifest = join(here, "..", "..", "package.json");
|
|
243
|
+
// Use the static ESM import — the original M2 PR 2 code used
|
|
244
|
+
// `require("node:fs")` which is undefined in ESM (.mjs files), so
|
|
245
|
+
// every call to this function threw ReferenceError silently and
|
|
246
|
+
// permanently returned "unknown". Multi-review caught it; the
|
|
247
|
+
// bootstrap-descriptor test now asserts pluginVersion is not
|
|
248
|
+
// "unknown" so this regression can't sneak in again.
|
|
249
|
+
const text = readFileSync(manifest, "utf-8");
|
|
250
|
+
cachedPluginVersion = (JSON.parse(text)?.version || "unknown").trim();
|
|
251
|
+
} catch {
|
|
252
|
+
cachedPluginVersion = "unknown";
|
|
253
|
+
}
|
|
254
|
+
return cachedPluginVersion;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Full bootstrap orchestrator. Acquires lock, spawns broker, polls for
|
|
258
|
+
// socket reachability, performs initialize handshake, writes descriptor
|
|
259
|
+
// atomically. Enforces wall-clock budget. On ANY failure, terminates
|
|
260
|
+
// the spawned child + releases the lock + returns null (caller exits 0
|
|
261
|
+
// per ADR-077). On success, returns the descriptor object that was
|
|
262
|
+
// written + closes the initialize connection (long-lived RPC is the
|
|
263
|
+
// per-edit hook's responsibility, not SessionStart's).
|
|
264
|
+
//
|
|
265
|
+
// Options:
|
|
266
|
+
// - budgetMs (default 5000) — total wall-clock budget for spawn+poll+
|
|
267
|
+
// initialize. Exhaustion = treated as failure.
|
|
268
|
+
// - injectDeps — testing hook to inject mocked spawn / initializeBroker
|
|
269
|
+
// for unit tests. Real production calls leave this undefined.
|
|
270
|
+
export async function bootstrapBroker(markerDir, options = {}) {
|
|
271
|
+
const { budgetMs = BOOTSTRAP_BUDGET_MS_DEFAULT, injectDeps } = options;
|
|
272
|
+
const spawnFn = injectDeps?.spawnBroker ?? spawnBroker;
|
|
273
|
+
const initFn = injectDeps?.initializeBroker ?? initializeBroker;
|
|
274
|
+
const pollFn = injectDeps?.pollSocketReachable ?? pollSocketReachable;
|
|
275
|
+
const versionFn = injectDeps?.readCodexVersion ?? readCodexVersion;
|
|
276
|
+
|
|
277
|
+
const lockPath = acquireBrokerLock(markerDir);
|
|
278
|
+
if (!lockPath) return null; // another SessionStart holds the lock
|
|
279
|
+
|
|
280
|
+
const deadline = Date.now() + budgetMs;
|
|
281
|
+
let child = null;
|
|
282
|
+
let connection = null; // hoisted so the catch block can close on descriptor-write failure
|
|
283
|
+
try {
|
|
284
|
+
const transportUrl = chooseTransport(markerDir);
|
|
285
|
+
child = spawnFn(markerDir, transportUrl);
|
|
286
|
+
|
|
287
|
+
// Strict deadline enforcement. Previously used Math.max(100, ...) and
|
|
288
|
+
// Math.max(500, ...) as floors — codex-pair repeatedly flagged that
|
|
289
|
+
// these floors let bootstrap continue AFTER the wall-clock budget had
|
|
290
|
+
// been exhausted (defeating the silent-fallback contract). The deadline
|
|
291
|
+
// is authoritative; if it's already past, fail fast.
|
|
292
|
+
const pollBudget = deadline - Date.now() - 1000;
|
|
293
|
+
if (pollBudget <= 0) throw new Error("broker bootstrap budget exhausted before poll");
|
|
294
|
+
const reachable = await pollFn(transportUrl, pollBudget);
|
|
295
|
+
if (!reachable) throw new Error("broker did not become reachable within budget");
|
|
296
|
+
|
|
297
|
+
const remaining = deadline - Date.now();
|
|
298
|
+
if (remaining <= 0) throw new Error("broker bootstrap budget exhausted before initialize");
|
|
299
|
+
const clientInfo = {
|
|
300
|
+
name: "codex-pair",
|
|
301
|
+
title: `codex-pair plugin v${readPluginVersion()}`,
|
|
302
|
+
version: readPluginVersion(),
|
|
303
|
+
};
|
|
304
|
+
const initResult = await initFn(transportUrl, clientInfo, {
|
|
305
|
+
handshakeTimeoutMs: remaining,
|
|
306
|
+
initializeTimeoutMs: remaining,
|
|
307
|
+
});
|
|
308
|
+
connection = initResult.connection;
|
|
309
|
+
const initializeResult = initResult.initializeResult;
|
|
310
|
+
|
|
311
|
+
const descriptor = {
|
|
312
|
+
pid: child.pid,
|
|
313
|
+
transportUrl,
|
|
314
|
+
codexVersion: versionFn(),
|
|
315
|
+
codexHome: initializeResult?.codexHome ?? null,
|
|
316
|
+
// Use the constant rather than a hardcoded "v2" — codex-pair flagged
|
|
317
|
+
// the drift risk: if BROKER_PROTOCOL_VERSION changes in broker.mjs
|
|
318
|
+
// but this string isn't updated, stale-recovery would always treat
|
|
319
|
+
// the descriptor as live (matching the literal "v2" string instead
|
|
320
|
+
// of the new constant).
|
|
321
|
+
protocolVersion: BROKER_PROTOCOL_VERSION,
|
|
322
|
+
pluginVersion: readPluginVersion(),
|
|
323
|
+
startedAt: new Date().toISOString(),
|
|
324
|
+
logPath: brokerLogPath(markerDir),
|
|
325
|
+
};
|
|
326
|
+
await writeBrokerDescriptor(markerDir, descriptor);
|
|
327
|
+
|
|
328
|
+
// Close the bootstrap connection — the per-edit hook opens its own
|
|
329
|
+
// long-lived RPC connection (Milestone 4).
|
|
330
|
+
try {
|
|
331
|
+
connection.close(1000, "bootstrap done");
|
|
332
|
+
} catch {
|
|
333
|
+
// best-effort
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return descriptor;
|
|
337
|
+
} catch {
|
|
338
|
+
// ADR-077 silent-on-error. Tear down the child (best-effort) and
|
|
339
|
+
// signal failure to the caller via null return.
|
|
340
|
+
// Close the bootstrap connection if it was opened — codex-pair
|
|
341
|
+
// flagged that a descriptor-write failure would leak the connection
|
|
342
|
+
// because the close-on-success path is BELOW writeBrokerDescriptor
|
|
343
|
+
// but the catch never closed it. Hoisting + close-in-catch fixes the
|
|
344
|
+
// leak.
|
|
345
|
+
if (connection) {
|
|
346
|
+
try {
|
|
347
|
+
connection.close(1011, "bootstrap failed");
|
|
348
|
+
} catch {}
|
|
349
|
+
}
|
|
350
|
+
if (child) {
|
|
351
|
+
try {
|
|
352
|
+
terminateProcessTree(child, "SIGTERM");
|
|
353
|
+
} catch {}
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
} finally {
|
|
357
|
+
releaseBrokerLock(lockPath);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ──── SessionEnd teardown (M2 PR 3) ────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
// Read the broker descriptor synchronously. Returns the parsed object
|
|
364
|
+
// or null on any error (missing, malformed, unreadable). Used by
|
|
365
|
+
// teardownBroker AND by the per-edit hook's readBrokerState lookup.
|
|
366
|
+
export function readBrokerDescriptorSync(markerDir) {
|
|
367
|
+
const descPath = join(stateRoot(markerDir), "broker.json");
|
|
368
|
+
try {
|
|
369
|
+
const text = readFileSync(descPath, "utf-8");
|
|
370
|
+
const parsed = JSON.parse(text);
|
|
371
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
372
|
+
if (typeof parsed.pid !== "number" || typeof parsed.transportUrl !== "string") return null;
|
|
373
|
+
return parsed;
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Best-effort liveness check on a recorded pid. POSIX uses `process.kill(pid, 0)`
|
|
380
|
+
// which sends a no-op signal — succeeds if the pid exists AND we have
|
|
381
|
+
// permission; fails (throws ESRCH) if the process is gone. Windows lacks
|
|
382
|
+
// this — the brainstorm flagged this as a follow-on; for M2 we treat
|
|
383
|
+
// Windows pids as "always live" so we send SIGTERM unconditionally on
|
|
384
|
+
// the Windows path (terminateProcessTree handles the cross-platform kill).
|
|
385
|
+
export function isPidAlive(pid) {
|
|
386
|
+
if (typeof pid !== "number" || pid <= 0) return false;
|
|
387
|
+
if (IS_WINDOWS) return true; // best-effort; rely on terminateProcessTree
|
|
388
|
+
try {
|
|
389
|
+
process.kill(pid, 0);
|
|
390
|
+
return true;
|
|
391
|
+
} catch (err) {
|
|
392
|
+
// ESRCH = no such process. EPERM = process exists but we don't own
|
|
393
|
+
// it (rare for our own-spawned broker but possible across user
|
|
394
|
+
// switches); treat as "live" since we can't safely conclude dead.
|
|
395
|
+
if (err && err.code === "EPERM") return true;
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Send SIGTERM, poll for exit, escalate to terminateProcessTree if the
|
|
401
|
+
// process is still alive after the grace period. Returns boolean (was
|
|
402
|
+
// the pid actually live before we killed it).
|
|
403
|
+
async function killPidGracefully(pid, graceMs) {
|
|
404
|
+
if (!isPidAlive(pid)) return false;
|
|
405
|
+
try {
|
|
406
|
+
if (IS_WINDOWS) {
|
|
407
|
+
// Windows: no graceful SIGTERM equivalent — go straight to taskkill.
|
|
408
|
+
// Pass a minimal ChildProcess-shaped object that terminateProcessTree
|
|
409
|
+
// recognizes.
|
|
410
|
+
terminateProcessTree({ pid, killed: false, exitCode: null }, "SIGTERM");
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
// POSIX: SIGTERM the process group (`-pid` requires the spawn was
|
|
414
|
+
// detached, which bootstrapBroker enforces).
|
|
415
|
+
try {
|
|
416
|
+
process.kill(-pid, "SIGTERM");
|
|
417
|
+
} catch {
|
|
418
|
+
// Group gone — try direct pid signal.
|
|
419
|
+
try {
|
|
420
|
+
process.kill(pid, "SIGTERM");
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Poll for exit
|
|
426
|
+
const deadline = Date.now() + graceMs;
|
|
427
|
+
while (Date.now() < deadline) {
|
|
428
|
+
if (!isPidAlive(pid)) return true;
|
|
429
|
+
await sleep(50);
|
|
430
|
+
}
|
|
431
|
+
// Still alive — escalate to SIGKILL via terminateProcessTree
|
|
432
|
+
terminateProcessTree({ pid, killed: false, exitCode: null }, "SIGKILL");
|
|
433
|
+
return true;
|
|
434
|
+
} catch {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Unlink the unix socket file alongside descriptor + lock. Only meaningful
|
|
440
|
+
// on POSIX; on Windows the WS transport doesn't leave a file. Caller
|
|
441
|
+
// must supply markerDir so we can validate the socket path is rooted
|
|
442
|
+
// under the marker's state directory (defense against a tampered
|
|
443
|
+
// descriptor.json pointing the unlink at an arbitrary path).
|
|
444
|
+
async function unlinkTransportArtifact(transportUrl, markerDir) {
|
|
445
|
+
const safePath = extractSafeSocketPath(transportUrl, markerDir);
|
|
446
|
+
if (safePath === null) return;
|
|
447
|
+
try {
|
|
448
|
+
await unlink(safePath);
|
|
449
|
+
} catch {
|
|
450
|
+
// already gone — fine
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Stale-state cleanup. Reads broker.json; if any "stale" condition holds
|
|
455
|
+
// (pid dead, recorded protocol-version mismatch, unix socket missing),
|
|
456
|
+
// unlinks the descriptor + socket. Returns "absent" | "live" | "stale".
|
|
457
|
+
// SessionStart calls this BEFORE bootstrapBroker to recover from prior
|
|
458
|
+
// crashes; per-edit hook MAY call it as belt-and-suspenders defense.
|
|
459
|
+
// Re-exported from broker.mjs so consumers import one contract surface.
|
|
460
|
+
export function clearStaleBrokerState(markerDir) {
|
|
461
|
+
const descriptor = readBrokerDescriptorSync(markerDir);
|
|
462
|
+
if (!descriptor) return "absent";
|
|
463
|
+
const alive = isPidAlive(descriptor.pid);
|
|
464
|
+
const protoOk = descriptor.protocolVersion === BROKER_PROTOCOL_VERSION;
|
|
465
|
+
// Transport-scheme dispatch — codex-pair flagged that the original code
|
|
466
|
+
// treated UNKNOWN schemes (http://, junk, missing) as live because
|
|
467
|
+
// extractSafeSocketPath returned null which left socketOk = true
|
|
468
|
+
// (initialized). The correct logic distinguishes:
|
|
469
|
+
// - unix:// inside markerDir/state → check socket file exists
|
|
470
|
+
// - unix:// outside markerDir/state → STALE (tampered descriptor)
|
|
471
|
+
// - ws://anything → assume live; per-edit probe validates
|
|
472
|
+
// - unknown / non-string → STALE (junk descriptor)
|
|
473
|
+
let socketOk;
|
|
474
|
+
// Hoist sockPath so the cleanup block can reference it; only the unix
|
|
475
|
+
// branch sets it to a real path, other branches leave it null.
|
|
476
|
+
let sockPath = null;
|
|
477
|
+
if (typeof descriptor.transportUrl !== "string") {
|
|
478
|
+
socketOk = false;
|
|
479
|
+
} else if (descriptor.transportUrl.startsWith("unix://")) {
|
|
480
|
+
sockPath = extractSafeSocketPath(descriptor.transportUrl, markerDir);
|
|
481
|
+
if (sockPath === null) {
|
|
482
|
+
socketOk = false; // unix:// outside bounds — descriptor was tampered
|
|
483
|
+
} else {
|
|
484
|
+
try {
|
|
485
|
+
statSync(sockPath);
|
|
486
|
+
socketOk = true;
|
|
487
|
+
} catch {
|
|
488
|
+
socketOk = false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
} else if (descriptor.transportUrl.startsWith("ws://")) {
|
|
492
|
+
socketOk = true; // assume live; per-edit probeBrokerHealth validates
|
|
493
|
+
} else {
|
|
494
|
+
socketOk = false; // unrecognized scheme
|
|
495
|
+
}
|
|
496
|
+
if (alive && protoOk && socketOk) return "live";
|
|
497
|
+
// Stale — clean up. Best-effort; failures are silent per ADR-077.
|
|
498
|
+
try {
|
|
499
|
+
unlinkSync(join(stateRoot(markerDir), "broker.json"));
|
|
500
|
+
} catch {}
|
|
501
|
+
if (sockPath !== null) {
|
|
502
|
+
try {
|
|
503
|
+
unlinkSync(sockPath);
|
|
504
|
+
} catch {}
|
|
505
|
+
}
|
|
506
|
+
return "stale";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Path-safety: validate that a unix:// socket path resolves under the
|
|
510
|
+
// markerDir's state root before we agree to stat or unlink it. Per the
|
|
511
|
+
// multi-review (Gemini Finding #4), a hostile or stale broker.json could
|
|
512
|
+
// otherwise direct us to unlink arbitrary paths the user has write
|
|
513
|
+
// permission to. Returns the safe socket path or null if invalid /
|
|
514
|
+
// non-unix / outside-bounds.
|
|
515
|
+
function extractSafeSocketPath(transportUrl, markerDir) {
|
|
516
|
+
if (typeof transportUrl !== "string" || !transportUrl.startsWith("unix://")) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
const sockPath = transportUrl.slice("unix://".length);
|
|
520
|
+
if (!sockPath) return null;
|
|
521
|
+
const resolvedSock = resolvePath(sockPath);
|
|
522
|
+
const resolvedRoot = resolvePath(stateRoot(markerDir));
|
|
523
|
+
// Path must be exactly the state root or strictly nested under it.
|
|
524
|
+
// The boundary check guards against `/foo/bar/state-evil/x` matching
|
|
525
|
+
// `/foo/bar/state` via a substring prefix.
|
|
526
|
+
if (resolvedSock === resolvedRoot) return null; // can't unlink the root itself
|
|
527
|
+
if (resolvedSock.startsWith(`${resolvedRoot}/`)) return resolvedSock;
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// SessionEnd orchestrator. Reads the descriptor, signals the broker pid
|
|
532
|
+
// to exit gracefully, terminateProcessTree if it doesn't, and cleans up
|
|
533
|
+
// the descriptor + socket + lock. Always exits successfully — ADR-077.
|
|
534
|
+
//
|
|
535
|
+
// Options:
|
|
536
|
+
// - graceMs (default 1500) — how long to wait for SIGTERM to land
|
|
537
|
+
// before escalating to SIGKILL.
|
|
538
|
+
// - injectDeps — { killPid, unlinkSock } for testing.
|
|
539
|
+
export async function teardownBroker(markerDir, options = {}) {
|
|
540
|
+
const { graceMs = 1500, injectDeps } = options;
|
|
541
|
+
const killFn = injectDeps?.killPid ?? killPidGracefully;
|
|
542
|
+
const unlinkSockFn = injectDeps?.unlinkSock ?? unlinkTransportArtifact;
|
|
543
|
+
|
|
544
|
+
const descriptor = readBrokerDescriptorSync(markerDir);
|
|
545
|
+
if (!descriptor) {
|
|
546
|
+
// No descriptor — nothing to tear down. But still try to clean up
|
|
547
|
+
// any stray lock from a crashed-mid-bootstrap SessionStart.
|
|
548
|
+
releaseBrokerLock(brokerLockPath(markerDir));
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
await killFn(descriptor.pid, graceMs);
|
|
553
|
+
} catch {
|
|
554
|
+
// best-effort
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
await unlinkSockFn(descriptor.transportUrl, markerDir);
|
|
558
|
+
} catch {
|
|
559
|
+
// best-effort
|
|
560
|
+
}
|
|
561
|
+
await unlinkBrokerDescriptor(markerDir);
|
|
562
|
+
releaseBrokerLock(brokerLockPath(markerDir));
|
|
563
|
+
return descriptor;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Test-only exports
|
|
567
|
+
export const __testing__ = {
|
|
568
|
+
BROKER_LOCK_DIR,
|
|
569
|
+
BROKER_LOG_FILE,
|
|
570
|
+
BROKER_SOCKET_PREFIX,
|
|
571
|
+
BOOTSTRAP_BUDGET_MS_DEFAULT,
|
|
572
|
+
isPidAlive,
|
|
573
|
+
killPidGracefully,
|
|
574
|
+
unlinkTransportArtifact,
|
|
575
|
+
};
|