@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.
@@ -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();