@get-bb/plugin-sdk 0.4.15 → 0.4.16
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/README.md +3 -3
- package/bundled-types/bb-plugin-sdk-ai-services.d.ts +141 -0
- package/bundled-types/bb-plugin-sdk-app.d.ts +144 -21
- package/bundled-types/bb-plugin-sdk-host.d.ts +479 -2
- package/bundled-types/bb-plugin-sdk-internal-composer-customization-validation.d.ts +10 -1
- package/bundled-types/bb-plugin-sdk-internal-host-policy.d.ts +339 -32
- package/bundled-types/bb-plugin-sdk-internal-plugin-app-collector.d.ts +2 -1
- package/bundled-types/bb-plugin-sdk-provider-bridge-acp.d.ts +807 -0
- package/bundled-types/bb-plugin-sdk-provider-bridge-testing.d.ts +1742 -211
- package/bundled-types/bb-plugin-sdk-provider-bridge.d.ts +2985 -2442
- package/bundled-types/bb-plugin-sdk-testing-app.d.ts +4 -3
- package/bundled-types/bb-plugin-sdk-testing.d.ts +551 -9
- package/bundled-types/bb-plugin-sdk.d.ts +1331 -1350
- package/dist/ai-services.js +91 -0
- package/dist/app.js +2 -2
- package/dist/host.js +14547 -1
- package/dist/internal/composer-customization-validation.js +11 -0
- package/dist/internal/host-policy.js +656 -91
- package/dist/internal/plugin-app-collector.js +58 -14
- package/dist/provider-bridge-acp.js +9753 -0
- package/dist/provider-bridge-testing.js +3355 -1771
- package/dist/provider-bridge-worker-entry.mjs +917 -0
- package/dist/provider-bridge.js +2255 -3718
- package/dist/replay-provider-child.mjs +650 -0
- package/dist/testing/app.js +63 -19
- package/dist/testing/index.js +803 -115
- package/package.json +14 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* A fake provider child that replays a bridge recording's provider lanes.
|
|
4
|
+
*
|
|
5
|
+
* node replay-provider-child.mjs --recording <dir> --dialect <json-rpc|claude-cli|pi-rpc> --state <dir>
|
|
6
|
+
*
|
|
7
|
+
* The bridge under test spawns this instead of the real CLI (`codex
|
|
8
|
+
* app-server`, an ACP agent, the `claude` binary, `pi --mode rpc`). It plays the recorded
|
|
9
|
+
* `provider→bridge` lines back on stdout, gated on the bridge's own writes:
|
|
10
|
+
* the recording's `bridge→provider` entries are *expectations*, and the script
|
|
11
|
+
* does not advance past one until the live bridge has written a matching line
|
|
12
|
+
* (same method or control subtype for requests and notifications, same id for
|
|
13
|
+
* responses). Ids the bridge mints for its requests are mapped to the recorded
|
|
14
|
+
* ones so the recorded responses answer the live requests; ids the provider
|
|
15
|
+
* minted are replayed verbatim, so the bridge's answers match by id. This is
|
|
16
|
+
* what makes the fake generic: the recording IS the script, and the same
|
|
17
|
+
* program serves every JSON-RPC provider and the Claude CLI control protocol.
|
|
18
|
+
*
|
|
19
|
+
* One recording can span several children (a new child per session, per
|
|
20
|
+
* resume, per bridge restart). The lanes are cut into segments at each
|
|
21
|
+
* bridge-originated `initialize`, and every spawned child claims the next
|
|
22
|
+
* unclaimed segment through the shared `--state` directory. A child whose
|
|
23
|
+
* first session-level request does not match its segment's (a maintenance
|
|
24
|
+
* child the thread recording never saw, such as codex's unarchive) releases
|
|
25
|
+
* the segment for the next child and answers generically.
|
|
26
|
+
*
|
|
27
|
+
* The harness paces the replay through the `cursor` file in the state
|
|
28
|
+
* directory: provider lines are emitted only up to the recorded position of
|
|
29
|
+
* the next runtime request, so a steer or an interrupt lands between the same
|
|
30
|
+
* two provider lines it did live.
|
|
31
|
+
*
|
|
32
|
+
* Divergence never hangs the bridge: a live request that matches nothing for
|
|
33
|
+
* STALL_MS is answered with a generic success, and a child past the end of its
|
|
34
|
+
* segment answers everything generically. Both are logged on stderr.
|
|
35
|
+
*/
|
|
36
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, rmdirSync } from "node:fs";
|
|
37
|
+
import { Socket } from "node:net";
|
|
38
|
+
import { StringDecoder } from "node:string_decoder";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
|
|
41
|
+
const STALL_MS = 5_000;
|
|
42
|
+
/**
|
|
43
|
+
* How long an unmatched live line waits for the in-order expectation before
|
|
44
|
+
* the script skips ahead to a later expectation it does match. Long enough
|
|
45
|
+
* for a bridge to send two requests in the other order; short enough that a
|
|
46
|
+
* bridge version which simply never sends a recorded request costs little.
|
|
47
|
+
*/
|
|
48
|
+
const LOOKAHEAD_MS = 750;
|
|
49
|
+
const CURSOR_POLL_MS = 5;
|
|
50
|
+
/**
|
|
51
|
+
* Gap between two emitted provider lines. A real provider never delivers a
|
|
52
|
+
* response and the notification after it in one read; the bridge's response
|
|
53
|
+
* handlers (which emit the steer's ack, say) must get the event loop between
|
|
54
|
+
* them, or the replay reorders what the recording had in order.
|
|
55
|
+
*/
|
|
56
|
+
const EMIT_GAP_MS = 2;
|
|
57
|
+
/**
|
|
58
|
+
* Gap after a response. The bridge continues its request's continuation in
|
|
59
|
+
* a microtask once the line loop yields; a notification read in the same
|
|
60
|
+
* chunk is handled first, so under load two milliseconds let a steer's ack
|
|
61
|
+
* (emitted after `await request("turn/steer")`) land after the next
|
|
62
|
+
* notification instead of before it, as the recording had it. A response
|
|
63
|
+
* is rare, so the longer gap costs nothing measurable.
|
|
64
|
+
*/
|
|
65
|
+
const RESPONSE_GAP_MS = 50;
|
|
66
|
+
/** A request that opens or addresses a provider session; see segment release. */
|
|
67
|
+
const SESSION_DEFINING_KEY =
|
|
68
|
+
/^(thread|session)\/(start|resume|fork|new|load|archive|unarchive|name\/set)$/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Only the replay flags are read; anything else on argv (the Agent SDK's
|
|
72
|
+
* `--output-format stream-json …` when this plays the Claude CLI) is ignored.
|
|
73
|
+
*/
|
|
74
|
+
function parseArgs(argv) {
|
|
75
|
+
const args = {};
|
|
76
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
77
|
+
const key = argv[i];
|
|
78
|
+
if (key === "--recording" || key === "--dialect" || key === "--state") {
|
|
79
|
+
args[key.slice(2)] = argv[i + 1];
|
|
80
|
+
i += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!args.recording || !args.dialect || !args.state) {
|
|
84
|
+
throw new Error("usage: --recording <dir> --dialect <json-rpc|claude-cli|pi-rpc> --state <dir>");
|
|
85
|
+
}
|
|
86
|
+
return args;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readLane(dir, direction) {
|
|
90
|
+
const file = join(dir, `${direction}.ndjson`);
|
|
91
|
+
if (!existsSync(file)) return [];
|
|
92
|
+
return readFileSync(file, "utf8")
|
|
93
|
+
.split("\n")
|
|
94
|
+
.filter((line) => line.length > 0)
|
|
95
|
+
.map((line) => JSON.parse(line))
|
|
96
|
+
.map((entry) => ({ ...entry, run: typeof entry.run === "number" ? entry.run : 0 }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Dialects: classify a line into request / response / notification
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const DIALECTS = {
|
|
104
|
+
"json-rpc": {
|
|
105
|
+
classify(message) {
|
|
106
|
+
const hasId = typeof message.id === "string" || typeof message.id === "number";
|
|
107
|
+
if (hasId && typeof message.method === "string") {
|
|
108
|
+
return { kind: "request", id: message.id, key: message.method };
|
|
109
|
+
}
|
|
110
|
+
if (hasId) {
|
|
111
|
+
return { kind: "response", id: message.id, key: "response" };
|
|
112
|
+
}
|
|
113
|
+
if (typeof message.method === "string") {
|
|
114
|
+
return { kind: "notification", key: message.method };
|
|
115
|
+
}
|
|
116
|
+
return { kind: "notification", key: "?" };
|
|
117
|
+
},
|
|
118
|
+
isInitialize(classified) {
|
|
119
|
+
return classified.kind === "request" && classified.key === "initialize";
|
|
120
|
+
},
|
|
121
|
+
withResponseId(message, id) {
|
|
122
|
+
return { ...message, id };
|
|
123
|
+
},
|
|
124
|
+
genericResponse(id) {
|
|
125
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
"claude-cli": {
|
|
129
|
+
classify(message) {
|
|
130
|
+
if (message.type === "control_request") {
|
|
131
|
+
return {
|
|
132
|
+
kind: "request",
|
|
133
|
+
id: message.request_id,
|
|
134
|
+
key: `control_request:${message.request?.subtype ?? "?"}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (message.type === "control_response") {
|
|
138
|
+
return { kind: "response", id: message.response?.request_id, key: "control_response" };
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
kind: "notification",
|
|
142
|
+
key: `${message.type ?? "?"}${message.subtype ? `:${message.subtype}` : ""}`,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
isInitialize(classified) {
|
|
146
|
+
return classified.kind === "request" && classified.key === "control_request:initialize";
|
|
147
|
+
},
|
|
148
|
+
withResponseId(message, id) {
|
|
149
|
+
return { ...message, response: { ...message.response, request_id: id } };
|
|
150
|
+
},
|
|
151
|
+
genericResponse(id) {
|
|
152
|
+
return {
|
|
153
|
+
type: "control_response",
|
|
154
|
+
response: { subtype: "success", request_id: id, response: {} },
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
/**
|
|
159
|
+
* `pi --mode rpc`: commands carry `{ id, type }`, responses are
|
|
160
|
+
* `{ id, type: "response", command, success }`, and every other line is a
|
|
161
|
+
* raw AgentSessionEvent (or an `extension_ui_request`). The bb extension's
|
|
162
|
+
* channel (fd 3 child → bridge, fd 4 bridge → child) is recorded on the
|
|
163
|
+
* same lanes wrapped as `{ bbChannel: <message> }`; this dialect routes
|
|
164
|
+
* those back onto the channel fds.
|
|
165
|
+
*/
|
|
166
|
+
"pi-rpc": {
|
|
167
|
+
channel: {
|
|
168
|
+
key: "bbChannel",
|
|
169
|
+
childToBridgeFd: 3,
|
|
170
|
+
bridgeToChildFd: 4,
|
|
171
|
+
},
|
|
172
|
+
classify(message) {
|
|
173
|
+
const channel = message.bbChannel;
|
|
174
|
+
if (typeof channel === "object" && channel !== null) {
|
|
175
|
+
// The extension mints tool-call ids; the bridge mints request ids
|
|
176
|
+
// (`cr-N`), disjoint from its stdin ids (`bb-N`).
|
|
177
|
+
if (channel.kind === "tool-call" || channel.kind === "request") {
|
|
178
|
+
return {
|
|
179
|
+
kind: "request",
|
|
180
|
+
id: channel.id,
|
|
181
|
+
key: `channel:${channel.kind}${channel.method ? `:${channel.method}` : ""}`,
|
|
182
|
+
channel: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (channel.kind === "tool-result" || channel.kind === "reply") {
|
|
186
|
+
return { kind: "response", id: channel.id, key: "channel:response", channel: true };
|
|
187
|
+
}
|
|
188
|
+
return { kind: "notification", key: `channel:${channel.kind ?? "?"}`, channel: true };
|
|
189
|
+
}
|
|
190
|
+
if (message.type === "response") {
|
|
191
|
+
return { kind: "response", id: message.id, key: "response" };
|
|
192
|
+
}
|
|
193
|
+
if (typeof message.type === "string" && typeof message.id === "string") {
|
|
194
|
+
return { kind: "request", id: message.id, key: message.type };
|
|
195
|
+
}
|
|
196
|
+
return { kind: "notification", key: `event:${message.type ?? "?"}` };
|
|
197
|
+
},
|
|
198
|
+
isInitialize(classified) {
|
|
199
|
+
// Every pi child the bridge spawns (session, catalog, fork helper)
|
|
200
|
+
// opens with `get_state`, and the bridge numbers its requests per
|
|
201
|
+
// child from `bb-1`; later `get_state` probes (compaction guard, steer
|
|
202
|
+
// settlement) carry higher ids and do not start a segment.
|
|
203
|
+
return (
|
|
204
|
+
classified.kind === "request" &&
|
|
205
|
+
classified.key === "get_state" &&
|
|
206
|
+
classified.id === "bb-1"
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
withResponseId(message, id) {
|
|
210
|
+
if (typeof message.bbChannel === "object" && message.bbChannel !== null) {
|
|
211
|
+
return { ...message, bbChannel: { ...message.bbChannel, id } };
|
|
212
|
+
}
|
|
213
|
+
return { ...message, id };
|
|
214
|
+
},
|
|
215
|
+
genericResponse(id, classified) {
|
|
216
|
+
if (classified?.channel) {
|
|
217
|
+
return { bbChannel: { kind: "reply", id, result: {} } };
|
|
218
|
+
}
|
|
219
|
+
return { id, type: "response", command: "?", success: true, data: {} };
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Segments: one per spawned child, cut at each bridge-originated initialize
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function parseLine(line) {
|
|
229
|
+
try {
|
|
230
|
+
const parsed = JSON.parse(line);
|
|
231
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildSegments(entries, dialect) {
|
|
238
|
+
const segments = [];
|
|
239
|
+
let current = null;
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
const message = parseLine(entry.line);
|
|
242
|
+
const classified = message === null ? { kind: "raw", key: "raw" } : dialect.classify(message);
|
|
243
|
+
const startsSegment = entry.dir === "bridge→provider" && message !== null && dialect.isInitialize(classified);
|
|
244
|
+
if (current === null || startsSegment) {
|
|
245
|
+
current = [];
|
|
246
|
+
segments.push(current);
|
|
247
|
+
}
|
|
248
|
+
current.push({ ...entry, message, classified });
|
|
249
|
+
}
|
|
250
|
+
return segments;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function claimSegmentIndex(stateDir) {
|
|
254
|
+
mkdirSync(stateDir, { recursive: true });
|
|
255
|
+
for (let index = 0; index < 10_000; index += 1) {
|
|
256
|
+
try {
|
|
257
|
+
mkdirSync(join(stateDir, `segment-${index}`));
|
|
258
|
+
return index;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error && error.code === "EEXIST") continue;
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
throw new Error("too many replay children");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function releaseSegmentIndex(stateDir, index) {
|
|
268
|
+
try {
|
|
269
|
+
rmdirSync(join(stateDir, `segment-${index}`));
|
|
270
|
+
} catch {
|
|
271
|
+
// Already gone; nothing to release.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The harness's pacing cursor: `"<run> <seq>"` (play recorded lines up to and
|
|
277
|
+
* excluding that position), `"end"` (play everything), or absent (play
|
|
278
|
+
* everything — a harness that does not pace).
|
|
279
|
+
*/
|
|
280
|
+
function readCursor(stateDir) {
|
|
281
|
+
let text;
|
|
282
|
+
try {
|
|
283
|
+
text = readFileSync(join(stateDir, "cursor"), "utf8").trim();
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
if (text === "end" || text === "") return null;
|
|
288
|
+
const [run, seq] = text.split(" ").map(Number);
|
|
289
|
+
return { run, seq };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function cursorAllows(cursor, entry) {
|
|
293
|
+
if (cursor === null) return true;
|
|
294
|
+
return entry.run < cursor.run || (entry.run === cursor.run && entry.seq < cursor.seq);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function firstSessionDefiningKey(script) {
|
|
298
|
+
for (const step of script) {
|
|
299
|
+
if (
|
|
300
|
+
step.dir === "bridge→provider" &&
|
|
301
|
+
step.classified.kind === "request" &&
|
|
302
|
+
SESSION_DEFINING_KEY.test(step.classified.key)
|
|
303
|
+
) {
|
|
304
|
+
return step.classified.key;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Claude hook callback ids: the SDK numbers hooks per process; align the
|
|
312
|
+
// recorded registration with the live one by event name and position.
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function hookCallbackIdMap(recordedInitialize, liveInitialize) {
|
|
316
|
+
const map = new Map();
|
|
317
|
+
const recordedHooks = recordedInitialize?.request?.hooks ?? {};
|
|
318
|
+
const liveHooks = liveInitialize?.request?.hooks ?? {};
|
|
319
|
+
for (const [event, recordedMatchers] of Object.entries(recordedHooks)) {
|
|
320
|
+
const liveMatchers = liveHooks[event] ?? [];
|
|
321
|
+
recordedMatchers.forEach((recordedMatcher, matcherIndex) => {
|
|
322
|
+
const liveMatcher = liveMatchers[matcherIndex];
|
|
323
|
+
(recordedMatcher.hookCallbackIds ?? []).forEach((recordedId, idIndex) => {
|
|
324
|
+
const liveId = liveMatcher?.hookCallbackIds?.[idIndex];
|
|
325
|
+
if (liveId !== undefined) map.set(recordedId, liveId);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return map;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** `\n`-terminated lines (CR stripped); never readline, which also splits on U+2028/U+2029. */
|
|
333
|
+
function readNewlineDelimitedLines(input, onLine) {
|
|
334
|
+
const decoder = new StringDecoder("utf8");
|
|
335
|
+
let pending = "";
|
|
336
|
+
input.on("data", (chunk) => {
|
|
337
|
+
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
338
|
+
let start = 0;
|
|
339
|
+
for (;;) {
|
|
340
|
+
const index = text.indexOf("\n", start);
|
|
341
|
+
if (index === -1) {
|
|
342
|
+
pending += text.slice(start);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const line = pending + text.slice(start, index);
|
|
346
|
+
pending = "";
|
|
347
|
+
start = index + 1;
|
|
348
|
+
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Player
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
function main() {
|
|
358
|
+
// A bridge's install gate may probe `<cli> --version` through the replay
|
|
359
|
+
// command; answer like a CLI instead of claiming a segment and waiting.
|
|
360
|
+
if (process.argv.includes("--version")) {
|
|
361
|
+
process.stdout.write("0.0.0-replay\n");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const args = parseArgs(process.argv.slice(2));
|
|
365
|
+
const dialect = DIALECTS[args.dialect];
|
|
366
|
+
if (!dialect) throw new Error(`unknown dialect ${args.dialect}`);
|
|
367
|
+
|
|
368
|
+
const entries = [
|
|
369
|
+
...readLane(args.recording, "provider→bridge"),
|
|
370
|
+
...readLane(args.recording, "bridge→provider"),
|
|
371
|
+
].sort((left, right) => left.run - right.run || left.seq - right.seq);
|
|
372
|
+
const segments = buildSegments(entries, dialect);
|
|
373
|
+
const segmentIndex = claimSegmentIndex(args.state);
|
|
374
|
+
let script = segments[segmentIndex] ?? [];
|
|
375
|
+
const log = (text) => process.stderr.write(`[replay-child #${segmentIndex}] ${text}\n`);
|
|
376
|
+
if (script.length === 0) {
|
|
377
|
+
log(`no recorded segment ${segmentIndex} (recording has ${segments.length}); answering generically`);
|
|
378
|
+
}
|
|
379
|
+
const segmentSessionKey = firstSessionDefiningKey(script);
|
|
380
|
+
let sawSessionDefiningRequest = false;
|
|
381
|
+
let cursorWait = null;
|
|
382
|
+
|
|
383
|
+
let position = 0;
|
|
384
|
+
const pendingLive = [];
|
|
385
|
+
/** recorded bridge request id → live bridge request id */
|
|
386
|
+
const liveIdByRecordedId = new Map();
|
|
387
|
+
/** recorded bridge request ids this bridge never sent; their responses are dropped */
|
|
388
|
+
const skippedRecordedIds = new Set();
|
|
389
|
+
let hookIds = new Map();
|
|
390
|
+
let stallTimer = null;
|
|
391
|
+
let lookaheadTimer = null;
|
|
392
|
+
let emitTimer = null;
|
|
393
|
+
|
|
394
|
+
const channel = dialect.channel ?? null;
|
|
395
|
+
const channelOut = channel ? createWriteStream(null, { fd: channel.childToBridgeFd }) : null;
|
|
396
|
+
function emit(message) {
|
|
397
|
+
if (channel && typeof message[channel.key] === "object" && message[channel.key] !== null) {
|
|
398
|
+
channelOut.write(`${JSON.stringify(message[channel.key])}\n`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function emitRecorded(step) {
|
|
405
|
+
const { message, classified } = step;
|
|
406
|
+
if (classified.kind === "response") {
|
|
407
|
+
const liveId = liveIdByRecordedId.get(String(classified.id));
|
|
408
|
+
emit(dialect.withResponseId(message, liveId === undefined ? classified.id : liveId));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (
|
|
412
|
+
classified.kind === "request" &&
|
|
413
|
+
classified.key === "control_request:hook_callback" &&
|
|
414
|
+
message.request &&
|
|
415
|
+
hookIds.has(message.request.callback_id)
|
|
416
|
+
) {
|
|
417
|
+
emit({
|
|
418
|
+
...message,
|
|
419
|
+
request: { ...message.request, callback_id: hookIds.get(message.request.callback_id) },
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
emit(message);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function takeMatchingLive(expected) {
|
|
427
|
+
for (let index = 0; index < pendingLive.length; index += 1) {
|
|
428
|
+
const live = pendingLive[index];
|
|
429
|
+
const { classified } = live;
|
|
430
|
+
if (classified.kind !== expected.classified.kind) continue;
|
|
431
|
+
const matches =
|
|
432
|
+
classified.kind === "response"
|
|
433
|
+
? String(classified.id) === String(expected.classified.id)
|
|
434
|
+
: classified.key === expected.classified.key;
|
|
435
|
+
if (matches) {
|
|
436
|
+
pendingLive.splice(index, 1);
|
|
437
|
+
return live;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function scheduleAdvance(gapMs = EMIT_GAP_MS) {
|
|
444
|
+
if (emitTimer !== null) return;
|
|
445
|
+
emitTimer = setTimeout(() => {
|
|
446
|
+
emitTimer = null;
|
|
447
|
+
advance();
|
|
448
|
+
}, gapMs);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function advance() {
|
|
452
|
+
if (emitTimer !== null) {
|
|
453
|
+
// A line just went out; the next one waits its gap.
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
while (position < script.length) {
|
|
457
|
+
const step = script[position];
|
|
458
|
+
if (step.dir === "provider→bridge") {
|
|
459
|
+
if (
|
|
460
|
+
step.classified.kind !== "response" &&
|
|
461
|
+
!cursorAllows(readCursor(args.state), step)
|
|
462
|
+
) {
|
|
463
|
+
// Paced by the harness: the next runtime request comes first. Only
|
|
464
|
+
// spontaneous lines wait; a response answers a request the bridge
|
|
465
|
+
// already made, and holding it would deadlock a child that is
|
|
466
|
+
// replaying a later segment (codex's maintenance child).
|
|
467
|
+
if (cursorWait === null) {
|
|
468
|
+
cursorWait = setTimeout(() => {
|
|
469
|
+
cursorWait = null;
|
|
470
|
+
advance();
|
|
471
|
+
}, CURSOR_POLL_MS);
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (step.message === null) {
|
|
476
|
+
process.stdout.write(`${step.line}\n`);
|
|
477
|
+
position += 1;
|
|
478
|
+
scheduleAdvance();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (step.classified.kind === "response") {
|
|
482
|
+
if (skippedRecordedIds.has(String(step.classified.id))) {
|
|
483
|
+
position += 1;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (!liveIdByRecordedId.has(String(step.classified.id))) {
|
|
487
|
+
// The response to a bridge request the live bridge has not sent yet.
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
emitRecorded(step);
|
|
492
|
+
position += 1;
|
|
493
|
+
scheduleAdvance(
|
|
494
|
+
step.classified.kind === "response" ? RESPONSE_GAP_MS : EMIT_GAP_MS,
|
|
495
|
+
);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// An expectation of what the bridge writes.
|
|
499
|
+
const live = takeMatchingLive(step);
|
|
500
|
+
if (live === null) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (step.classified.kind === "request") {
|
|
504
|
+
liveIdByRecordedId.set(String(step.classified.id), live.classified.id);
|
|
505
|
+
if (dialect.isInitialize(step.classified) && args.dialect === "claude-cli") {
|
|
506
|
+
hookIds = hookCallbackIdMap(step.message, live.message);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
position += 1;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* A live line that matches no current expectation but does match a later
|
|
515
|
+
* one: this bridge version skipped what the recording has in between. Drop
|
|
516
|
+
* those expectations (and the responses to skipped requests), keep emitting
|
|
517
|
+
* the provider lines in between, and resume at the match.
|
|
518
|
+
*/
|
|
519
|
+
function lookAhead() {
|
|
520
|
+
lookaheadTimer = null;
|
|
521
|
+
for (const live of pendingLive) {
|
|
522
|
+
for (let index = position + 1; index < script.length; index += 1) {
|
|
523
|
+
const step = script[index];
|
|
524
|
+
if (step.dir !== "bridge→provider") continue;
|
|
525
|
+
const same =
|
|
526
|
+
step.classified.kind === live.classified.kind &&
|
|
527
|
+
(step.classified.kind === "response"
|
|
528
|
+
? String(step.classified.id) === String(live.classified.id)
|
|
529
|
+
: step.classified.key === live.classified.key);
|
|
530
|
+
if (!same) continue;
|
|
531
|
+
const skipped = [];
|
|
532
|
+
for (let cursor = position; cursor < index; cursor += 1) {
|
|
533
|
+
const between = script[cursor];
|
|
534
|
+
if (between.dir === "bridge→provider") {
|
|
535
|
+
if (between.classified.kind === "request") {
|
|
536
|
+
skippedRecordedIds.add(String(between.classified.id));
|
|
537
|
+
}
|
|
538
|
+
skipped.push(between.classified.key);
|
|
539
|
+
} else if (between.message === null) {
|
|
540
|
+
process.stdout.write(`${between.line}\n`);
|
|
541
|
+
} else if (
|
|
542
|
+
between.classified.kind !== "response" ||
|
|
543
|
+
liveIdByRecordedId.has(String(between.classified.id))
|
|
544
|
+
) {
|
|
545
|
+
emitRecorded(between);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
log(`bridge skipped recorded ${skipped.join(", ")}; resuming at ${live.classified.key}`);
|
|
549
|
+
position = index;
|
|
550
|
+
advance();
|
|
551
|
+
armStall();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function answerGenerically(live, reason) {
|
|
558
|
+
if (live.classified.kind === "request") {
|
|
559
|
+
log(`${reason}: answering ${live.classified.key} (${String(live.classified.id)}) generically`);
|
|
560
|
+
emit(dialect.genericResponse(live.classified.id, live.classified));
|
|
561
|
+
} else {
|
|
562
|
+
log(`${reason}: dropping unmatched ${live.classified.kind} ${live.classified.key}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function onStall() {
|
|
567
|
+
stallTimer = null;
|
|
568
|
+
if (pendingLive.length === 0) return;
|
|
569
|
+
const expected = script[position];
|
|
570
|
+
log(
|
|
571
|
+
`stalled for ${STALL_MS}ms at step ${position}/${script.length}` +
|
|
572
|
+
(expected ? ` (expecting ${expected.dir} ${expected.classified.key})` : ""),
|
|
573
|
+
);
|
|
574
|
+
for (const live of pendingLive.splice(0)) {
|
|
575
|
+
answerGenerically(live, "stall");
|
|
576
|
+
}
|
|
577
|
+
advance();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function armStall() {
|
|
581
|
+
if (stallTimer !== null) clearTimeout(stallTimer);
|
|
582
|
+
if (lookaheadTimer !== null) clearTimeout(lookaheadTimer);
|
|
583
|
+
stallTimer = pendingLive.length > 0 ? setTimeout(onStall, STALL_MS) : null;
|
|
584
|
+
lookaheadTimer = pendingLive.length > 0 ? setTimeout(lookAhead, LOOKAHEAD_MS) : null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* This child is not the one the segment was recorded from: hand the
|
|
589
|
+
* segment back for the next spawn and serve this bridge generically.
|
|
590
|
+
*/
|
|
591
|
+
function releaseSegment(live) {
|
|
592
|
+
log(
|
|
593
|
+
`first session request ${live.classified.key} does not match the segment's ${segmentSessionKey}; releasing segment ${segmentIndex}`,
|
|
594
|
+
);
|
|
595
|
+
releaseSegmentIndex(args.state, segmentIndex);
|
|
596
|
+
script = [];
|
|
597
|
+
position = 0;
|
|
598
|
+
for (const pending of pendingLive.splice(0)) {
|
|
599
|
+
answerGenerically(pending, "released segment");
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function onLiveMessage(message) {
|
|
604
|
+
const live = { message, classified: dialect.classify(message) };
|
|
605
|
+
if (
|
|
606
|
+
!sawSessionDefiningRequest &&
|
|
607
|
+
live.classified.kind === "request" &&
|
|
608
|
+
SESSION_DEFINING_KEY.test(live.classified.key)
|
|
609
|
+
) {
|
|
610
|
+
sawSessionDefiningRequest = true;
|
|
611
|
+
if (segmentSessionKey !== null && live.classified.key !== segmentSessionKey) {
|
|
612
|
+
releaseSegment(live);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (position >= script.length) {
|
|
616
|
+
answerGenerically(live, "past end of segment");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
pendingLive.push(live);
|
|
620
|
+
advance();
|
|
621
|
+
armStall();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Newline-only framing, like the bridges' own readers: a recorded line
|
|
625
|
+
// with U+2028/U+2029 inside a JSON string must replay as one line.
|
|
626
|
+
readNewlineDelimitedLines(process.stdin, (line) => {
|
|
627
|
+
const message = parseLine(line);
|
|
628
|
+
if (message !== null) onLiveMessage(message);
|
|
629
|
+
});
|
|
630
|
+
process.stdin.on("end", () => {
|
|
631
|
+
process.exit(0);
|
|
632
|
+
});
|
|
633
|
+
if (channel) {
|
|
634
|
+
// The bridge's channel writes (tool results, fork requests) arrive on
|
|
635
|
+
// their own fd; wrap them the way the recorder did so they match. A
|
|
636
|
+
// net.Socket reads the pipe non-blockingly, as the real extension does.
|
|
637
|
+
const channelIn = new Socket({ fd: channel.bridgeToChildFd, readable: true, writable: false });
|
|
638
|
+
channelIn.on("error", () => {});
|
|
639
|
+
channelIn.unref();
|
|
640
|
+
readNewlineDelimitedLines(channelIn, (line) => {
|
|
641
|
+
const message = parseLine(line);
|
|
642
|
+
if (message !== null) onLiveMessage({ [channel.key]: message });
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
process.on("SIGTERM", () => process.exit(0));
|
|
646
|
+
|
|
647
|
+
advance();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
main();
|