@ask-llm/plugin 0.15.0 → 0.16.3

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 (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.cursor-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +979 -0
  4. package/README.md +2 -0
  5. package/agents/brainstorm-coordinator.md +1 -1
  6. package/agents/gemini-reviewer.md +1 -1
  7. package/dist/antigravity-run.js +0 -0
  8. package/dist/brainstorm-run.js +0 -0
  9. package/dist/codex-run.js +0 -0
  10. package/dist/grok-run.js +0 -0
  11. package/dist/ollama-run.js +0 -0
  12. package/dist/run.js +0 -0
  13. package/package.json +14 -14
  14. package/pi/extensions/provider-tools.ts +1 -1
  15. package/scripts/benchmark/README.md +114 -0
  16. package/scripts/benchmark/fixtures/README.md +29 -0
  17. package/scripts/codex-pair-debounce-worker.mjs +0 -0
  18. package/scripts/codex-pair-log.mjs +4 -13
  19. package/scripts/codex-pair-prompt-drain.mjs +1 -1
  20. package/scripts/codex-pair-session.mjs +2 -2
  21. package/scripts/codex-pair-stop-gate.mjs +8 -8
  22. package/scripts/codex-pair-watch.mjs +20 -39
  23. package/skills/gemini-review/SKILL.md +1 -1
  24. package/scripts/lib/broker-lifecycle.mjs +0 -575
  25. package/scripts/lib/broker-rpc.mjs +0 -203
  26. package/scripts/lib/broker-transport.mjs +0 -407
  27. package/scripts/lib/broker.mjs +0 -537
  28. package/scripts/lib/debounce-state.mjs +0 -208
  29. package/scripts/lib/parser.d.mts +0 -12
  30. package/scripts/lib/parser.mjs +0 -229
  31. package/scripts/lib/process.mjs +0 -56
  32. package/scripts/lib/prompt.d.mts +0 -8
  33. package/scripts/lib/prompt.mjs +0 -41
  34. package/scripts/lib/session-registry.mjs +0 -162
  35. package/scripts/lib/state.d.mts +0 -58
  36. package/scripts/lib/state.mjs +0 -733
  37. package/scripts/lib/stop-gate.mjs +0 -134
@@ -1,203 +0,0 @@
1
- // JSON-RPC 2.0 client layered on a `broker-transport.mjs` connection
2
- // (ADR-090 + ADR-093 Milestone 2 PR 1). The transport emits WebSocket
3
- // TEXT frames whose payloads are JSON-RPC envelopes; this module handles
4
- // request/response correlation by `id`, per-request timeouts, server-
5
- // pushed notifications, and graceful close.
6
- //
7
- // **Tolerance.** Brainstorm probing of the real `codex app-server`
8
- // (codex-cli 0.130.0) found that responses lack the `"jsonrpc":"2.0"`
9
- // discriminator (ADR-093 protocol note). This client accepts envelopes
10
- // with OR without that field; it dispatches purely on the `id` and
11
- // `method` properties.
12
-
13
- // Auto-incrementing request id source. JSON-RPC permits any unique
14
- // non-null id; integers are simplest. Not cryptographic — exposing the
15
- // counter doesn't leak anything.
16
- let nextId = 1;
17
- function takeNextId() {
18
- const id = nextId;
19
- nextId = (nextId + 1) | 0; // wrap at 2^31 (effectively never)
20
- if (nextId <= 0) nextId = 1;
21
- return id;
22
- }
23
-
24
- // Create a JSON-RPC client over a connected `broker-transport.mjs`
25
- // WebSocketConnection. The caller is responsible for `connection.close()`.
26
- // This client manages the protocol layer on top, not the socket lifetime.
27
- //
28
- // Options:
29
- // - defaultTimeoutMs (number, default 30000): per-request budget.
30
- // - onNotification (function, default no-op): called with
31
- // `{ method, params }` for server-pushed notifications (envelopes
32
- // lacking an `id`).
33
- // - onProtocolError (function, default no-op): called when an inbound
34
- // text frame can't be parsed as JSON or has neither id nor method.
35
- export function createRpcClient(connection, options = {}) {
36
- const { defaultTimeoutMs = 30000, onNotification = () => {}, onProtocolError = () => {} } = options;
37
- const pending = new Map(); // id -> { resolve, reject, timer }
38
- // Subscriber list for waitFor — each entry { method, predicate, resolve, reject, timer }.
39
- // M3 needs to attach a notification listener BEFORE dispatching `turn/start`
40
- // (race-safe per brainstorm Risk #1: server can emit `turn/completed` between
41
- // request-send and listener-install if registered after the send).
42
- const notificationSubscribers = new Set();
43
- let closed = false;
44
-
45
- connection.on("message", (text) => {
46
- let env;
47
- try {
48
- env = JSON.parse(text);
49
- } catch (err) {
50
- onProtocolError(new Error(`broker-rpc: malformed JSON from server: ${err?.message ?? String(err)}`));
51
- return;
52
- }
53
- if (env && typeof env === "object" && "id" in env && env.id != null) {
54
- const entry = pending.get(env.id);
55
- if (!entry) {
56
- // Late response after timeout, or an id we didn't send. Ignore;
57
- // surface as a soft protocol error for diagnostics.
58
- onProtocolError(new Error(`broker-rpc: response for unknown id ${env.id}`));
59
- return;
60
- }
61
- pending.delete(env.id);
62
- clearTimeout(entry.timer);
63
- if (env.error) {
64
- const err = new Error(env.error.message ?? `JSON-RPC error ${env.error.code ?? "?"}`);
65
- err.code = env.error.code;
66
- err.data = env.error.data;
67
- entry.reject(err);
68
- } else {
69
- entry.resolve(env.result);
70
- }
71
- return;
72
- }
73
- if (env && typeof env === "object" && typeof env.method === "string") {
74
- // Server-pushed notification (or a server-initiated request, which
75
- // codex-pair refuses since approvalPolicy:"never" — but pass it up
76
- // either way and let the caller decide).
77
- const notification = { method: env.method, params: env.params, id: env.id };
78
- // Dispatch to waitFor subscribers first — they capture by method+predicate.
79
- // Iterate a snapshot since resolved subscribers self-remove during dispatch.
80
- for (const sub of [...notificationSubscribers]) {
81
- if (sub.method === env.method) {
82
- try {
83
- if (!sub.predicate || sub.predicate(notification)) {
84
- notificationSubscribers.delete(sub);
85
- clearTimeout(sub.timer);
86
- sub.resolve(notification);
87
- }
88
- } catch (err) {
89
- notificationSubscribers.delete(sub);
90
- clearTimeout(sub.timer);
91
- sub.reject(err);
92
- }
93
- }
94
- }
95
- onNotification(notification);
96
- return;
97
- }
98
- onProtocolError(new Error(`broker-rpc: envelope has neither id nor method: ${text.slice(0, 120)}`));
99
- });
100
-
101
- connection.on("close", () => {
102
- closed = true;
103
- for (const [id, entry] of pending.entries()) {
104
- clearTimeout(entry.timer);
105
- entry.reject(new Error("broker-rpc: connection closed before response"));
106
- pending.delete(id);
107
- }
108
- // Also reject any notification waiters — they'll never fire post-close.
109
- for (const sub of notificationSubscribers) {
110
- clearTimeout(sub.timer);
111
- sub.reject(new Error("broker-rpc: connection closed before notification"));
112
- }
113
- notificationSubscribers.clear();
114
- });
115
-
116
- connection.on("error", (err) => {
117
- // Pending requests still time out via their own timers, but surface
118
- // transport errors immediately too.
119
- for (const [id, entry] of pending.entries()) {
120
- clearTimeout(entry.timer);
121
- entry.reject(err);
122
- pending.delete(id);
123
- }
124
- // Multi-review M3 hotfix: also reject notification waiters — some
125
- // Node transports emit "error" without a following "close", so the
126
- // close-handler cleanup wouldn't run otherwise and waitFor() would
127
- // hang until its own timeout. Surface the real transport error
128
- // immediately for diagnostics instead of a generic timeout.
129
- for (const sub of notificationSubscribers) {
130
- clearTimeout(sub.timer);
131
- sub.reject(err);
132
- }
133
- notificationSubscribers.clear();
134
- });
135
-
136
- return {
137
- request(method, params, opts = {}) {
138
- if (closed) return Promise.reject(new Error("broker-rpc: client is closed"));
139
- const id = takeNextId();
140
- const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
141
- const envelope = JSON.stringify({ jsonrpc: "2.0", id, method, params });
142
- return new Promise((resolve, reject) => {
143
- const timer = setTimeout(() => {
144
- pending.delete(id);
145
- reject(new Error(`broker-rpc: timeout after ${timeoutMs}ms (method=${method}, id=${id})`));
146
- }, timeoutMs);
147
- timer.unref?.();
148
- pending.set(id, { resolve, reject, timer });
149
- try {
150
- connection.sendText(envelope);
151
- } catch (err) {
152
- pending.delete(id);
153
- clearTimeout(timer);
154
- reject(err);
155
- }
156
- });
157
- },
158
- notify(method, params) {
159
- // Notifications have no id and expect no response.
160
- if (closed) throw new Error("broker-rpc: client is closed");
161
- connection.sendText(JSON.stringify({ jsonrpc: "2.0", method, params }));
162
- },
163
- // Register a notification listener with a predicate. Returns a Promise
164
- // resolving to the matched notification, OR rejecting on timeout / close.
165
- // CRITICAL: call this BEFORE the request that triggers the notification
166
- // (per brainstorm Risk #1 — server can emit `turn/completed` between
167
- // request-send and listener-install if registered after the send).
168
- //
169
- // Usage in M3 submitReview:
170
- // const waiter = rpc.waitFor("turn/completed", n => n.params?.threadId === ourThreadId, timeoutMs);
171
- // await rpc.request("turn/start", { ... });
172
- // const completion = await waiter;
173
- waitFor(method, predicate, timeoutMs) {
174
- if (closed) return Promise.reject(new Error("broker-rpc: client is closed"));
175
- return new Promise((resolve, reject) => {
176
- const sub = { method, predicate, resolve, reject, timer: null };
177
- sub.timer = setTimeout(() => {
178
- notificationSubscribers.delete(sub);
179
- // Multi-review M3 hotfix: attach a structured `.timeout = true`
180
- // marker so callers don't have to regex-match the message.
181
- const err = new Error(`broker-rpc: waitFor(${method}) timed out after ${timeoutMs}ms`);
182
- err.timeout = true;
183
- reject(err);
184
- }, timeoutMs);
185
- sub.timer.unref?.();
186
- notificationSubscribers.add(sub);
187
- });
188
- },
189
- get pendingCount() {
190
- return pending.size;
191
- },
192
- get closed() {
193
- return closed;
194
- },
195
- };
196
- }
197
-
198
- // Exports for tests
199
- export const __testing__ = {
200
- resetIdCounter() {
201
- nextId = 1;
202
- },
203
- };
@@ -1,407 +0,0 @@
1
- // WebSocket-over-net transport for the codex-pair broker (ADR-090 + ADR-093
2
- // Milestone 2 PR 1). Minimal hand-rolled RFC 6455 client supporting both
3
- // unix-domain-socket (`unix://`) and TCP (`ws://`) transports through one
4
- // code path. Pure Node built-ins per ADR-078 — no `ws` package, no
5
- // workspace imports.
6
- //
7
- // **Scope discipline.** This is the smallest subset of RFC 6455 that
8
- // satisfies codex `app-server`'s usage:
9
- // - Single client connection, no multiplexing.
10
- // - TEXT opcode (0x1) for JSON-RPC frames, no BINARY, no fragmentation.
11
- // - Client masks all frames (RFC 6455 §5.3). Server does not.
12
- // - Auto-respond to server PING with PONG mirroring the payload.
13
- // - Send CLOSE (0x8) on `close()`, handle inbound CLOSE.
14
- // - No permessage-deflate or other extensions.
15
- // - No subprotocols.
16
- //
17
- // **Tolerance.** codex app-server's JSON-RPC responses observed in the
18
- // wild lack the `"jsonrpc":"2.0"` discriminator (verified by brainstorm
19
- // probing — see ADR-093 protocol notes). The JSON parsing here passes
20
- // raw text up; the RPC layer in `broker-rpc.mjs` does the tolerant
21
- // matching by id.
22
-
23
- import { Buffer } from "node:buffer";
24
- import { createHash, randomBytes } from "node:crypto";
25
- import { connect } from "node:net";
26
-
27
- // RFC 6455 frame opcodes we recognize.
28
- const OPCODE_CONTINUATION = 0x0;
29
- const OPCODE_TEXT = 0x1;
30
- const OPCODE_BINARY = 0x2;
31
- const OPCODE_CLOSE = 0x8;
32
- const OPCODE_PING = 0x9;
33
- const OPCODE_PONG = 0xa;
34
-
35
- // RFC 6455 magic GUID for the Sec-WebSocket-Accept derivation (§4.2.2).
36
- const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
37
-
38
- // Parse a transport URL into `net.connect` options. Supports:
39
- // unix:///absolute/path/to/socket
40
- // unix://relative/from/cwd
41
- // ws://host:port — also accepts host without port (defaults 80)
42
- // Returns `{ connectOptions, host, isUnix }`.
43
- export function parseTransportUrl(url) {
44
- if (typeof url !== "string") {
45
- throw new TypeError(`broker-transport: transport URL must be string, got ${typeof url}`);
46
- }
47
- if (url.startsWith("unix://")) {
48
- const path = url.slice("unix://".length);
49
- if (!path) throw new Error(`broker-transport: unix:// URL has empty path`);
50
- return { connectOptions: { path }, host: "localhost", isUnix: true };
51
- }
52
- if (url.startsWith("ws://")) {
53
- const rest = url.slice("ws://".length);
54
- const slashIdx = rest.indexOf("/");
55
- const authority = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
56
- const colonIdx = authority.lastIndexOf(":");
57
- const host = colonIdx === -1 ? authority : authority.slice(0, colonIdx);
58
- const port = colonIdx === -1 ? 80 : Number(authority.slice(colonIdx + 1));
59
- if (!Number.isFinite(port) || port <= 0) {
60
- throw new Error(`broker-transport: ws:// URL has invalid port: ${authority}`);
61
- }
62
- return { connectOptions: { host, port }, host: authority, isUnix: false };
63
- }
64
- throw new Error(`broker-transport: unsupported transport URL scheme: ${url} (need unix:// or ws://)`);
65
- }
66
-
67
- // Build the HTTP/1.1 upgrade request bytes for a given parsed URL. The
68
- // `Host:` header is required for the HTTP/1.1 framing even when the
69
- // transport is a unix socket (it's not used for routing but the codex
70
- // server's upgrade parser requires it). We send a fixed "localhost" for
71
- // unix sockets and the authority for TCP.
72
- function buildUpgradeRequest(host, secKey) {
73
- return [
74
- `GET / HTTP/1.1`,
75
- `Host: ${host}`,
76
- `Upgrade: websocket`,
77
- `Connection: Upgrade`,
78
- `Sec-WebSocket-Key: ${secKey}`,
79
- `Sec-WebSocket-Version: 13`,
80
- ``,
81
- ``,
82
- ].join("\r\n");
83
- }
84
-
85
- // Validate the server's upgrade response (RFC 6455 §4.1 step 4). The
86
- // 101 status + Sec-WebSocket-Accept header derived from our Sec-WebSocket-Key
87
- // are the mandatory checks. We ignore optional fields.
88
- function validateUpgradeResponse(headerText, sentKey) {
89
- const lines = headerText.split("\r\n");
90
- if (!lines[0] || !/^HTTP\/1\.[01]\s+101\b/.test(lines[0])) {
91
- throw new Error(`broker-transport: upgrade rejected, status line: ${lines[0] ?? "(empty)"}`);
92
- }
93
- let accept = null;
94
- for (let i = 1; i < lines.length; i++) {
95
- const colon = lines[i].indexOf(":");
96
- if (colon === -1) continue;
97
- const name = lines[i].slice(0, colon).trim().toLowerCase();
98
- if (name === "sec-websocket-accept") {
99
- accept = lines[i].slice(colon + 1).trim();
100
- break;
101
- }
102
- }
103
- const expected = createHash("sha1").update(sentKey + WS_GUID).digest("base64");
104
- if (accept !== expected) {
105
- throw new Error(
106
- `broker-transport: Sec-WebSocket-Accept mismatch (got ${accept ?? "(missing)"}, expected ${expected})`,
107
- );
108
- }
109
- }
110
-
111
- // Encode a TEXT frame. Client frames MUST be masked per RFC 6455 §5.3.
112
- // Returns a Buffer ready to write to the socket.
113
- function encodeTextFrame(text) {
114
- const payload = Buffer.from(text, "utf-8");
115
- const mask = randomBytes(4);
116
- const masked = Buffer.allocUnsafe(payload.length);
117
- for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4];
118
-
119
- let lenBytes;
120
- if (payload.length < 126) {
121
- lenBytes = Buffer.from([0x80 | payload.length]); // MASK bit + 7-bit length
122
- } else if (payload.length < 0x10000) {
123
- lenBytes = Buffer.allocUnsafe(3);
124
- lenBytes[0] = 0x80 | 126;
125
- lenBytes.writeUInt16BE(payload.length, 1);
126
- } else {
127
- lenBytes = Buffer.allocUnsafe(9);
128
- lenBytes[0] = 0x80 | 127;
129
- // 64-bit big-endian. JS BigInt for >32-bit lengths; we cap at Node's
130
- // Buffer.allocUnsafe practical limit anyway.
131
- lenBytes.writeBigUInt64BE(BigInt(payload.length), 1);
132
- }
133
-
134
- return Buffer.concat([
135
- Buffer.from([0x80 | OPCODE_TEXT]), // FIN bit + TEXT opcode
136
- lenBytes,
137
- mask,
138
- masked,
139
- ]);
140
- }
141
-
142
- // Maximum control-frame payload per RFC 6455 §5.5 ("All control frames
143
- // MUST have a payload length of 125 bytes or less"). The 7-bit length
144
- // field in byte[1] would otherwise overflow into the extended-length
145
- // encoding flags (126, 127). Both encoders below truncate to this cap
146
- // to guarantee on-wire correctness.
147
- const MAX_CONTROL_FRAME_PAYLOAD = 125;
148
-
149
- // Encode a CLOSE frame with optional status code + reason. Client-masked.
150
- // Reason is truncated as needed to keep total payload ≤ 125 bytes per
151
- // RFC 6455 §5.5 (multi-review Finding #3 — previously silently corrupted
152
- // frames if reason was ≥ 124 bytes).
153
- function encodeCloseFrame(code = 1000, reason = "") {
154
- let reasonBuf = Buffer.from(reason, "utf-8");
155
- // 2 bytes for the status code + reason. Truncate reason if combined
156
- // would exceed the control-frame cap.
157
- if (2 + reasonBuf.length > MAX_CONTROL_FRAME_PAYLOAD) {
158
- reasonBuf = reasonBuf.slice(0, MAX_CONTROL_FRAME_PAYLOAD - 2);
159
- }
160
- const payload = Buffer.allocUnsafe(2 + reasonBuf.length);
161
- payload.writeUInt16BE(code, 0);
162
- reasonBuf.copy(payload, 2);
163
- const mask = randomBytes(4);
164
- const masked = Buffer.allocUnsafe(payload.length);
165
- for (let i = 0; i < payload.length; i++) masked[i] = payload[i] ^ mask[i % 4];
166
- return Buffer.concat([Buffer.from([0x80 | OPCODE_CLOSE, 0x80 | payload.length]), mask, masked]);
167
- }
168
-
169
- // Encode a PONG frame echoing the server's PING payload. Client-masked.
170
- // PING payload is truncated to 125 bytes (RFC 6455 §5.5) — a hostile or
171
- // buggy server sending a > 125-byte PING would otherwise scramble our
172
- // outgoing frame.
173
- function encodePongFrame(payload) {
174
- const capped = payload.length > MAX_CONTROL_FRAME_PAYLOAD ? payload.slice(0, MAX_CONTROL_FRAME_PAYLOAD) : payload;
175
- const mask = randomBytes(4);
176
- const masked = Buffer.allocUnsafe(capped.length);
177
- for (let i = 0; i < capped.length; i++) masked[i] = capped[i] ^ mask[i % 4];
178
- return Buffer.concat([Buffer.from([0x80 | OPCODE_PONG, 0x80 | capped.length]), mask, masked]);
179
- }
180
-
181
- // Stateful frame parser. Accumulates incoming bytes and emits whole frames
182
- // via `onFrame({ opcode, payload })`. Caller drains via `feed(chunk)`.
183
- // Server→client frames are NOT masked per RFC 6455 §5.3, so we ignore
184
- // the MASK bit on parse. On fatal protocol violations (fragmentation we
185
- // don't support, illegal frame shapes) the parser flips into a "corrupted"
186
- // state — no further frames are emitted, and onError is called once.
187
- // The caller (connectWebSocket) destroys the socket so pending RPC
188
- // requests reject via the close handler. This was a multi-review finding
189
- // — the previous code did `continue` after fragmentation and the buffer
190
- // state corrupted forever.
191
- function createFrameParser(onFrame, onError) {
192
- let buf = Buffer.alloc(0);
193
- let corrupted = false;
194
- return (chunk) => {
195
- if (corrupted) return; // already reported fatal — discard further bytes
196
- buf = Buffer.concat([buf, chunk]);
197
- while (buf.length >= 2) {
198
- const first = buf[0];
199
- const second = buf[1];
200
- const fin = (first & 0x80) !== 0;
201
- const opcode = first & 0x0f;
202
- let len = second & 0x7f;
203
- let offset = 2;
204
- if (len === 126) {
205
- if (buf.length < 4) return; // need more
206
- len = buf.readUInt16BE(2);
207
- offset = 4;
208
- } else if (len === 127) {
209
- if (buf.length < 10) return;
210
- // Cast BigInt to Number — control frames have len<126 anyway, and
211
- // for data frames we cap at safe-integer range. 2GB payload is far
212
- // larger than any conceivable RPC response.
213
- len = Number(buf.readBigUInt64BE(2));
214
- offset = 10;
215
- }
216
- // Per RFC 6455, server→client frames must NOT be masked (we'd see
217
- // bit 0x80 set on byte[1]). If we see it, the peer is buggy; just
218
- // skip the mask bytes if present.
219
- const maskBit = (second & 0x80) !== 0;
220
- if (maskBit) offset += 4;
221
- if (buf.length < offset + len) return; // need more
222
- if (!fin && opcode !== OPCODE_CONTINUATION) {
223
- // Fragmentation is not supported. Marking corrupted so subsequent
224
- // bytes are ignored; the connect handler destroys the socket.
225
- // codex doesn't fragment JSON-RPC frames in practice, so this
226
- // path is defensive against a buggy or hostile server.
227
- corrupted = true;
228
- buf = Buffer.alloc(0);
229
- onError(
230
- new Error(`broker-transport: fragmentation not supported (opcode=${opcode}) — connection terminating`),
231
- );
232
- return;
233
- }
234
- const payload = buf.slice(offset, offset + len);
235
- buf = buf.slice(offset + len);
236
- onFrame({ opcode, payload });
237
- }
238
- };
239
- }
240
-
241
- // Top-level connector. Returns a Promise<WebSocketConnection> after a
242
- // successful HTTP upgrade. The connection exposes `sendText(s)`,
243
- // `close(code?, reason?)`, and event listeners `on("message", cb)` /
244
- // `on("close", cb)` / `on("error", cb)`.
245
- //
246
- // **Timeout semantics.** Connect + upgrade must complete within
247
- // `handshakeTimeoutMs` (default 5000). On timeout, the underlying socket
248
- // is destroyed and the promise rejects. After upgrade success, the caller
249
- // owns the connection's lifetime.
250
- export async function connectWebSocket(transportUrl, options = {}) {
251
- const { handshakeTimeoutMs = 5000 } = options;
252
- const { connectOptions, host } = parseTransportUrl(transportUrl);
253
-
254
- return new Promise((resolve, reject) => {
255
- const socket = connect(connectOptions);
256
- const listeners = { message: [], close: [], error: [] };
257
- let upgraded = false;
258
- let headerBuf = Buffer.alloc(0);
259
- let parser = null;
260
-
261
- const timer = setTimeout(() => {
262
- if (!upgraded) {
263
- socket.destroy();
264
- reject(new Error(`broker-transport: handshake timeout after ${handshakeTimeoutMs}ms`));
265
- }
266
- }, handshakeTimeoutMs);
267
- timer.unref?.();
268
-
269
- const secKey = randomBytes(16).toString("base64");
270
-
271
- // Use `.on` not `.once` — codex-pair flagged that `.once` removes the
272
- // only error listener after the first emission. If a second error
273
- // fires post-upgrade (e.g., RST after CLOSE, or a TCP-level failure
274
- // mid-stream), it becomes unhandled and crashes the process. The
275
- // `if (!upgraded)` guard makes the pre-upgrade reject() idempotent
276
- // (reject is a no-op after the promise settles).
277
- socket.on("error", (err) => {
278
- if (!upgraded) {
279
- clearTimeout(timer);
280
- reject(err);
281
- } else {
282
- for (const cb of listeners.error) cb(err);
283
- }
284
- });
285
-
286
- // Send the HTTP/1.1 upgrade request once the TCP/UDS connection is
287
- // established. This was missing in the original M2 PR 1 implementation
288
- // — flagged by the multi-review (both Codex + Gemini caught it). Unit
289
- // tests mocked around connectWebSocket so the missing write was
290
- // invisible. The fixture-server test added in this hotfix exercises
291
- // the real upgrade path so this class of bug can't recur silently.
292
- socket.once("connect", () => {
293
- try {
294
- socket.write(buildUpgradeRequest(host, secKey));
295
- } catch (err) {
296
- if (!upgraded) {
297
- clearTimeout(timer);
298
- reject(err);
299
- }
300
- }
301
- });
302
-
303
- socket.once("close", () => {
304
- clearTimeout(timer);
305
- if (!upgraded) reject(new Error("broker-transport: socket closed before upgrade"));
306
- else for (const cb of listeners.close) cb();
307
- });
308
-
309
- socket.on("data", (chunk) => {
310
- if (upgraded) {
311
- parser(chunk);
312
- return;
313
- }
314
- headerBuf = Buffer.concat([headerBuf, chunk]);
315
- const end = headerBuf.indexOf("\r\n\r\n");
316
- if (end === -1) return;
317
- const headerText = headerBuf.slice(0, end).toString("utf-8");
318
- const tail = headerBuf.slice(end + 4);
319
- try {
320
- validateUpgradeResponse(headerText, secKey);
321
- } catch (err) {
322
- socket.destroy();
323
- clearTimeout(timer);
324
- reject(err);
325
- return;
326
- }
327
- upgraded = true;
328
- clearTimeout(timer);
329
- parser = createFrameParser(
330
- ({ opcode, payload }) => {
331
- if (opcode === OPCODE_TEXT) {
332
- const text = payload.toString("utf-8");
333
- for (const cb of listeners.message) cb(text);
334
- } else if (opcode === OPCODE_PING) {
335
- // Auto-respond with PONG mirroring the payload (RFC §5.5.2).
336
- socket.write(encodePongFrame(payload));
337
- } else if (opcode === OPCODE_CLOSE) {
338
- // Echo close + half-close (RFC §5.5.1).
339
- try {
340
- socket.write(encodeCloseFrame(1000, ""));
341
- } catch {
342
- // best-effort
343
- }
344
- socket.end();
345
- } else if (opcode === OPCODE_BINARY) {
346
- // codex doesn't send binary for JSON-RPC; ignore silently.
347
- }
348
- // PONG and CONTINUATION are no-ops here.
349
- },
350
- (err) => {
351
- for (const cb of listeners.error) cb(err);
352
- // Parser-level errors signal unrecoverable protocol corruption
353
- // (fragmentation, malformed framing). Destroy the socket so
354
- // pending RPC requests reject via the close handler. Multi-
355
- // review finding #4: previously the parser silently corrupted
356
- // its buffer state and kept "running" against garbage.
357
- try {
358
- socket.destroy();
359
- } catch {
360
- // best-effort
361
- }
362
- },
363
- );
364
-
365
- const conn = {
366
- sendText(text) {
367
- socket.write(encodeTextFrame(text));
368
- },
369
- close(code = 1000, reason = "") {
370
- try {
371
- socket.write(encodeCloseFrame(code, reason));
372
- } catch {
373
- // best-effort — caller treats close as fire-and-forget
374
- }
375
- socket.end();
376
- },
377
- on(event, cb) {
378
- if (!listeners[event]) throw new Error(`broker-transport: unknown event ${event}`);
379
- listeners[event].push(cb);
380
- },
381
- get destroyed() {
382
- return socket.destroyed;
383
- },
384
- // For tests / diagnostics
385
- _underlyingSocket() {
386
- return socket;
387
- },
388
- };
389
-
390
- // If the upgrade response had body bytes already buffered, feed them.
391
- if (tail.length > 0) parser(tail);
392
-
393
- resolve(conn);
394
- });
395
- });
396
- }
397
-
398
- // Exports for tests
399
- export const __testing__ = {
400
- encodeTextFrame,
401
- encodeCloseFrame,
402
- encodePongFrame,
403
- createFrameParser,
404
- validateUpgradeResponse,
405
- buildUpgradeRequest,
406
- WS_GUID,
407
- };