@estebanforge/pi-antigravity-bridge 1.2.5 → 1.3.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/src/driver.ts ADDED
@@ -0,0 +1,644 @@
1
+ // Persistent agy stream-json driver.
2
+ //
3
+ // One long-lived `agy --input-format stream-json --output-format stream-json`
4
+ // process per provider instance. Turns are serialized through the driver
5
+ // queue; a turn that parks mid-flight (pi toolUse round-trip) keeps the agy
6
+ // process running and is re-entered via reentry() instead of spawning again.
7
+ //
8
+ // Recycle semantics: the child is killed and respawned when the next turn's
9
+ // process profile (model / effort / mode / cwd / conversation) drifts from the
10
+ // running one, mirroring tianzuo/pi-antigravity's driver. Stats and a bounded
11
+ // lifecycle log feed /agy doctor.
12
+ //
13
+ // The driver never talks to the MCP bridge directly: the provider owns the
14
+ // toolUse round-trips and injects bridge_call activities via
15
+ // handle.pushExternal(). This keeps the driver testable with a fake child.
16
+
17
+ import { spawn, type ChildProcess } from "node:child_process";
18
+ import { randomUUID } from "node:crypto";
19
+ import { parseAgyLine, type AgyUsage } from "./stream-events.js";
20
+ import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
21
+
22
+ export type DriverState = "idle" | "starting" | "ready" | "running" | "dead";
23
+
24
+ export interface DriverProfile {
25
+ cwd: string;
26
+ model: string;
27
+ effort?: string;
28
+ mode: string;
29
+ skipPermissions: boolean;
30
+ }
31
+
32
+ export interface DriverTurnRequest extends DriverProfile {
33
+ /** Existing agy conversation to resume (`--conversation`). */
34
+ conversationId?: string | null;
35
+ prompt: string;
36
+ signal?: AbortSignal;
37
+ /** Overall turn cap in minutes (default 10). */
38
+ timeoutMin?: number;
39
+ /** Stdout inactivity cap in minutes (default 5). */
40
+ inactivityMin?: number;
41
+ }
42
+
43
+ export type DriverActivity =
44
+ | { type: "text"; delta: string }
45
+ | { type: "thought"; tokens: number }
46
+ | { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
47
+ | {
48
+ type: "tool_done";
49
+ stepId?: number;
50
+ name: string;
51
+ args: Record<string, unknown>;
52
+ output?: string;
53
+ durationSeconds?: number;
54
+ }
55
+ | { type: "tool_error"; stepId?: number; name: string; message: string }
56
+ | { type: "usage"; usage: AgyUsage }
57
+ /** Synthetic: injected by the provider when the MCP bridge receives a call. */
58
+ | { type: "bridge_call"; callId: string; name: string; args: Record<string, unknown> };
59
+
60
+ export interface TurnOutcome {
61
+ conversationId?: string;
62
+ status: "OK" | "ERROR" | "UNKNOWN";
63
+ response: string;
64
+ error?: string;
65
+ usage?: AgyUsage;
66
+ finished: boolean;
67
+ aborted: boolean;
68
+ }
69
+
70
+ export interface TurnHandle {
71
+ id: string;
72
+ /** Resolves when the turn settles (result event, exit, abort, recycle). */
73
+ outcome: Promise<TurnOutcome>;
74
+ /** Pull the next activity. Resolves null once the activity stream closes. */
75
+ next(): Promise<DriverActivity | null>;
76
+ /** Inject a synthetic activity (bridge inbox). No-op after settle. */
77
+ pushExternal(activity: DriverActivity): void;
78
+ }
79
+
80
+ export interface DriverSnapshot {
81
+ state: DriverState;
82
+ pid?: number;
83
+ conversationId?: string;
84
+ stats: {
85
+ spawns: number;
86
+ turns: number;
87
+ reused: number;
88
+ recycles: number;
89
+ lastRecycleReason?: string;
90
+ recycleReasons: Record<string, number>;
91
+ };
92
+ lifecycle: string[];
93
+ }
94
+
95
+ const LIFECYCLE_LIMIT = 24;
96
+ const THINKING_TOKEN_FLOOR = 64;
97
+
98
+ interface ActiveTurn {
99
+ id: string;
100
+ request: DriverTurnRequest;
101
+ /** Activities not yet pulled by a consumer. */
102
+ buffer: DriverActivity[];
103
+ /** Wake waiting next() callers. */
104
+ wake: (() => void)[];
105
+ closed: boolean;
106
+ resolve: (o: TurnOutcome) => void;
107
+ outcome: Promise<TurnOutcome>;
108
+ overallTimer?: NodeJS.Timeout;
109
+ idleTimer?: NodeJS.Timeout;
110
+ onAbort?: () => void;
111
+ response: string;
112
+ usage?: AgyUsage;
113
+ conversationId?: string;
114
+ sawResult: boolean;
115
+ /** Text-dedupe guard state (delta vs cumulative response_text). */
116
+ cumulativeText: boolean | undefined;
117
+ /** Open bridge parks. Each one suspends the stdout idle timer: agy is
118
+ * blocked waiting on the MCP HTTP response and produces no output, so
119
+ * inactivity is EXPECTED while parked. */
120
+ parks: number;
121
+ }
122
+
123
+ function emit(turn: ActiveTurn, activity: DriverActivity): void {
124
+ if (turn.closed) return;
125
+ if (turn.wake.length > 0) turn.wake.shift()!();
126
+ turn.buffer.push(activity);
127
+ }
128
+
129
+ async function nextActivity(turn: ActiveTurn): Promise<DriverActivity | null> {
130
+ for (;;) {
131
+ if (turn.buffer.length > 0) return turn.buffer.shift()!;
132
+ if (turn.closed) return null;
133
+ await new Promise<void>((r) => turn.wake.push(r));
134
+ }
135
+ }
136
+
137
+ function makeHandle(turn: ActiveTurn): TurnHandle {
138
+ return {
139
+ id: turn.id,
140
+ outcome: turn.outcome,
141
+ next: () => nextActivity(turn),
142
+ pushExternal: (activity) => {
143
+ if (activity.type === "bridge_call") {
144
+ turn.parks += 1;
145
+ if (turn.idleTimer) {
146
+ clearTimeout(turn.idleTimer);
147
+ turn.idleTimer = undefined;
148
+ }
149
+ }
150
+ emit(turn, activity);
151
+ },
152
+ };
153
+ }
154
+
155
+ function nowIso(): string {
156
+ return new Date().toISOString().slice(11, 19);
157
+ }
158
+
159
+ /** True when `next` is a cumulative resend of `accumulated` (it repeats every
160
+ * byte already streamed) rather than a fresh delta. Exported for tests. */
161
+ export function isCumulativeResend(accumulated: string, next: string): boolean {
162
+ // Nothing accumulated yet: no resend is possible (first chunk).
163
+ return accumulated.length > 0 && next.length > accumulated.length && next.startsWith(accumulated);
164
+ }
165
+
166
+ export class AgyDriver {
167
+ #state: DriverState = "idle";
168
+ #child: ChildProcess | undefined;
169
+ #generation = 0;
170
+ #profile: DriverProfile | undefined;
171
+ #boundConversation: string | undefined;
172
+ #active: ActiveTurn | undefined;
173
+ #queueTail: Promise<void> = Promise.resolve();
174
+ #shutdown = false;
175
+ #stderrTail = "";
176
+ #lifecycle: string[] = [];
177
+ #onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
178
+ #stats = {
179
+ spawns: 0,
180
+ turns: 0,
181
+ reused: 0,
182
+ recycles: 0,
183
+ lastRecycleReason: undefined as string | undefined,
184
+ recycleReasons: {} as Record<string, number>,
185
+ };
186
+
187
+ get state(): DriverState {
188
+ return this.#state;
189
+ }
190
+
191
+ get activeHandle(): TurnHandle | null {
192
+ return this.#active && !this.#active.closed ? makeHandle(this.#active) : null;
193
+ }
194
+
195
+ /** Called when a parked bridge call resolves or fails. Rearms the idle
196
+ * timer once no parks remain. */
197
+ kickIdle(): void {
198
+ const turn = this.#active;
199
+ if (!turn || turn.closed) return;
200
+ if (turn.parks > 0) turn.parks -= 1;
201
+ if (turn.parks === 0 && !turn.idleTimer) {
202
+ const idleMin = turn.request.inactivityMin ?? 5;
203
+ turn.idleTimer = setTimeout(() => {
204
+ if (turn.closed) return;
205
+ this.#log(`stall:${turn.id}`);
206
+ this.#killChild();
207
+ this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
208
+ }, idleMin * 60_000);
209
+ }
210
+ }
211
+
212
+ /** Hook: invoked with the outcome whenever a turn settles. The provider
213
+ * uses it to fail round-trips parked against a dead turn. */
214
+ set onTurnEnd(fn: ((outcome: TurnOutcome) => void) | undefined) {
215
+ this.#onTurnEnd = fn;
216
+ }
217
+
218
+ snapshot(): DriverSnapshot {
219
+ return {
220
+ state: this.#state,
221
+ pid: this.#child?.pid,
222
+ conversationId: this.#boundConversation,
223
+ stats: { ...this.#stats, recycleReasons: { ...this.#stats.recycleReasons } },
224
+ lifecycle: [...this.#lifecycle],
225
+ };
226
+ }
227
+
228
+ /** Run one turn. Turn LIFETIMES are serialized: release fires only when
229
+ * the dispatched turn settles, so a second run() can never overlap an
230
+ * open turn (which would orphan the first). A turn parked on a pi toolUse
231
+ * round-trip stays open; the continuation path uses reentry(), which does
232
+ * not queue, so parking cannot deadlock the queue. */
233
+ run(request: DriverTurnRequest): Promise<TurnHandle> {
234
+ let release!: () => void;
235
+ const prev = this.#queueTail;
236
+ this.#queueTail = new Promise<void>((r) => (release = r));
237
+ return prev
238
+ .then(() => this.#runExclusive(request))
239
+ .then((handle) => {
240
+ void handle.outcome.catch(() => {}).then(() => release());
241
+ return handle;
242
+ })
243
+ .catch((err) => {
244
+ release();
245
+ throw err;
246
+ });
247
+ }
248
+
249
+ /** Re-attach to the active turn (pi toolUse continuation). */
250
+ reentry(): TurnHandle | null {
251
+ return this.activeHandle;
252
+ }
253
+
254
+ async #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
255
+ if (this.#shutdown) throw new Error("agy driver is shut down.");
256
+ if (request.signal?.aborted) throw new Error("aborted before start");
257
+
258
+ const cause = this.#recycleCause(request);
259
+ if (cause) await this.close("recycle", cause);
260
+ else if (this.#child) this.#stats.reused += 1;
261
+ if (!this.#child) this.#start(request);
262
+
263
+ const turn = this.#createTurn(request);
264
+ this.#active = turn;
265
+ this.#state = "running";
266
+ this.#stats.turns += 1;
267
+ this.#armTimers(turn);
268
+
269
+ const line = `${JSON.stringify({
270
+ event: "user",
271
+ message: { role: "user", content: request.prompt },
272
+ })}\n`;
273
+ const stdin = this.#child?.stdin;
274
+ try {
275
+ if (!stdin) throw new Error("agy driver stdin unavailable");
276
+ stdin.write(line);
277
+ } catch (err) {
278
+ this.#failTurn(
279
+ turn,
280
+ `failed to write to agy driver: ${err instanceof Error ? err.message : String(err)}`,
281
+ );
282
+ }
283
+ return makeHandle(turn);
284
+ }
285
+
286
+ #createTurn(request: DriverTurnRequest): ActiveTurn {
287
+ let resolve!: (o: TurnOutcome) => void;
288
+ const outcome = new Promise<TurnOutcome>((r) => (resolve = r));
289
+ const turn: ActiveTurn = {
290
+ id: randomUUID().slice(0, 8),
291
+ request,
292
+ buffer: [],
293
+ wake: [],
294
+ closed: false,
295
+ resolve,
296
+ outcome,
297
+ response: "",
298
+ sawResult: false,
299
+ cumulativeText: undefined,
300
+ parks: 0,
301
+ };
302
+ if (request.signal) {
303
+ turn.onAbort = () => {
304
+ if (turn.closed) return;
305
+ this.#log(`abort:${turn.id}`);
306
+ this.#killChild();
307
+ this.#settle(turn, {
308
+ conversationId: turn.conversationId,
309
+ status: "ERROR",
310
+ response: turn.response,
311
+ error: "aborted",
312
+ usage: turn.usage,
313
+ finished: true,
314
+ aborted: true,
315
+ });
316
+ };
317
+ request.signal.addEventListener("abort", turn.onAbort, { once: true });
318
+ }
319
+ return turn;
320
+ }
321
+
322
+ #armTimers(turn: ActiveTurn): void {
323
+ const totalMin = turn.request.timeoutMin ?? 10;
324
+ turn.overallTimer = setTimeout(() => {
325
+ if (turn.closed) return;
326
+ this.#log(`timeout:${turn.id}`);
327
+ this.#killChild();
328
+ this.#failTurn(turn, `agy exceeded the ${totalMin}m turn timeout`);
329
+ }, totalMin * 60_000);
330
+ const idleMin = turn.request.inactivityMin ?? 5;
331
+ turn.idleTimer = setTimeout(() => {
332
+ if (turn.closed) return;
333
+ this.#log(`stall:${turn.id}`);
334
+ this.#killChild();
335
+ this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
336
+ }, idleMin * 60_000);
337
+ }
338
+
339
+ #start(request: DriverTurnRequest): void {
340
+ this.#state = "starting";
341
+ this.#generation += 1;
342
+ const generation = this.#generation;
343
+ this.#profile = {
344
+ cwd: request.cwd,
345
+ model: request.model,
346
+ effort: request.effort,
347
+ mode: request.mode,
348
+ skipPermissions: request.skipPermissions,
349
+ };
350
+ this.#boundConversation = request.conversationId ?? undefined;
351
+ this.#stderrTail = "";
352
+
353
+ const args: string[] = ["--add-dir", request.cwd];
354
+ if (bridgeMcpConfigExists()) args.push("--add-dir", bridgeMcpConfigDir());
355
+ args.push("--model", request.model);
356
+ if (request.effort) args.push("--effort", request.effort);
357
+ args.push("--mode", request.mode);
358
+ if (request.skipPermissions) args.push("--dangerously-skip-permissions");
359
+ if (request.conversationId) args.push("--conversation", request.conversationId);
360
+ args.push(
361
+ "--input-format",
362
+ "stream-json",
363
+ "--output-format",
364
+ "stream-json",
365
+ // Skills and slash commands are bridged/owned by pi, not expanded by agy.
366
+ "--disable-slash-commands",
367
+ );
368
+
369
+ const child = spawn("agy", args, {
370
+ cwd: request.cwd,
371
+ stdio: ["pipe", "pipe", "pipe"],
372
+ detached: process.platform !== "win32",
373
+ windowsHide: true,
374
+ });
375
+ this.#child = child;
376
+ this.#stats.spawns += 1;
377
+ this.#log(`spawn:${child.pid ?? "?"}:${request.conversationId ? "resume" : "fresh"}`);
378
+ this.#state = "ready";
379
+
380
+ child.stdout!.setEncoding("utf8");
381
+ child.stdout!.on("data", (chunk: string) => {
382
+ if (generation !== this.#generation) return;
383
+ this.#onStdout(chunk);
384
+ });
385
+ child.stderr!.setEncoding("utf8");
386
+ child.stderr!.on("data", (chunk: string) => {
387
+ this.#stderrTail = (this.#stderrTail + chunk).slice(-8192);
388
+ });
389
+ child.on("exit", (code) => {
390
+ if (generation !== this.#generation) return;
391
+ const turn = this.#active;
392
+ this.#child = undefined;
393
+ this.#state = "dead";
394
+ this.#log(`exit:${code ?? "signal"}`);
395
+ if (turn && !turn.closed) {
396
+ if (turn.sawResult) {
397
+ this.#settle(turn, {
398
+ conversationId: turn.conversationId,
399
+ status: turn.usage || turn.response ? "OK" : "UNKNOWN",
400
+ response: turn.response,
401
+ usage: turn.usage,
402
+ finished: true,
403
+ aborted: false,
404
+ });
405
+ } else {
406
+ this.#failTurn(
407
+ turn,
408
+ this.#stderrTail.trim() || `agy exited with status ${code ?? "signal"}`,
409
+ );
410
+ }
411
+ }
412
+ });
413
+ child.on("error", (err) => {
414
+ if (generation !== this.#generation) return;
415
+ const turn = this.#active;
416
+ this.#child = undefined;
417
+ this.#state = "dead";
418
+ if (turn && !turn.closed) this.#failTurn(turn, `agy spawn failed: ${err.message}`);
419
+ });
420
+ }
421
+
422
+ #onStdout(chunk: string): void {
423
+ const turn = this.#active;
424
+ if (!turn || turn.closed) return;
425
+ if (turn.idleTimer) turn.idleTimer.refresh();
426
+ for (const line of chunk.split("\n")) {
427
+ if (!line.trim()) continue;
428
+ this.#applyParsed(turn, parseAgyLine(line));
429
+ if (turn.closed) return;
430
+ }
431
+ }
432
+
433
+ #applyParsed(turn: ActiveTurn, parsed: ReturnType<typeof parseAgyLine>): void {
434
+ switch (parsed.kind) {
435
+ case "init": {
436
+ if (parsed.conversationId) {
437
+ turn.conversationId = parsed.conversationId;
438
+ this.#boundConversation = parsed.conversationId;
439
+ }
440
+ if (parsed.usage) {
441
+ turn.usage = parsed.usage;
442
+ emit(turn, { type: "usage", usage: parsed.usage });
443
+ }
444
+ break;
445
+ }
446
+ case "step": {
447
+ const s = parsed.step;
448
+ if (s.conversation_id && !turn.conversationId) {
449
+ turn.conversationId = s.conversation_id;
450
+ this.#boundConversation = s.conversation_id;
451
+ }
452
+ if (s.usage) {
453
+ turn.usage = s.usage;
454
+ emit(turn, { type: "usage", usage: s.usage });
455
+ }
456
+ if (s.step_type === "agent_response") {
457
+ const text =
458
+ typeof s.text_delta === "string"
459
+ ? s.text_delta
460
+ : typeof s.response_text === "string"
461
+ ? s.response_text
462
+ : "";
463
+ if (text) this.#appendAgentText(turn, text);
464
+ if (typeof s.thinking_tokens === "number" && s.thinking_tokens >= THINKING_TOKEN_FLOOR) {
465
+ emit(turn, { type: "thought", tokens: s.thinking_tokens });
466
+ }
467
+ break;
468
+ }
469
+ if (s.step_type === "tool") {
470
+ const name = s.tool_name ?? s.tool_info?.name ?? "tool";
471
+ const args =
472
+ s.tool_info?.parameters && typeof s.tool_info.parameters === "object"
473
+ ? (s.tool_info.parameters as Record<string, unknown>)
474
+ : {};
475
+ if (s.state === "ACTIVE") {
476
+ emit(turn, { type: "tool_start", stepId: s.step_index, name, args });
477
+ } else if (s.state === "DONE") {
478
+ emit(turn, {
479
+ type: "tool_done",
480
+ stepId: s.step_index,
481
+ name,
482
+ args,
483
+ output: typeof s.response_text === "string" ? s.response_text : undefined,
484
+ durationSeconds: s.duration_seconds,
485
+ });
486
+ } else if (s.state === "ERROR") {
487
+ emit(turn, {
488
+ type: "tool_error",
489
+ stepId: s.step_index,
490
+ name,
491
+ message: s.error_message ?? "tool error",
492
+ });
493
+ }
494
+ }
495
+ // user_input / checkpoint: no provider-facing activity.
496
+ break;
497
+ }
498
+ case "result": {
499
+ turn.sawResult = true;
500
+ const r = parsed.result;
501
+ if (r.conversation_id) turn.conversationId = r.conversation_id;
502
+ if (r.usage) turn.usage = r.usage;
503
+ // agy reports SUCCESS on live stream-json runs (OK seen in older builds).
504
+ const ok = r.status === "OK" || r.status === "SUCCESS";
505
+ const status = ok ? "OK" : "ERROR";
506
+ // Prefer the streamed accumulation, fall back to the result body.
507
+ const response = turn.response || (typeof r.response === "string" ? r.response : "");
508
+ turn.response = response;
509
+ this.#settle(turn, {
510
+ conversationId: turn.conversationId,
511
+ status,
512
+ response,
513
+ error: status === "ERROR" ? (r.error ?? "agy reported an error") : undefined,
514
+ usage: r.usage ?? turn.usage,
515
+ finished: true,
516
+ aborted: false,
517
+ });
518
+ break;
519
+ }
520
+ default:
521
+ break;
522
+ }
523
+ }
524
+
525
+ #appendAgentText(turn: ActiveTurn, text: string): void {
526
+ // response_text is observed as a delta stream; guard against builds that
527
+ // resend the full text. A cumulative sender's second chunk CONTAINS
528
+ // everything accumulated so far as a prefix.
529
+ if (turn.cumulativeText === undefined) {
530
+ turn.cumulativeText = false;
531
+ } else if (!turn.cumulativeText && isCumulativeResend(turn.response, text)) {
532
+ turn.cumulativeText = true;
533
+ }
534
+ if (turn.cumulativeText) {
535
+ if (text.length > turn.response.length) {
536
+ const delta = text.slice(turn.response.length);
537
+ turn.response = text;
538
+ emit(turn, { type: "text", delta });
539
+ }
540
+ return;
541
+ }
542
+ turn.response += text;
543
+ emit(turn, { type: "text", delta: text });
544
+ }
545
+
546
+ #settle(turn: ActiveTurn, outcome: TurnOutcome): void {
547
+ if (turn.closed) return;
548
+ turn.closed = true;
549
+ if (turn.overallTimer) clearTimeout(turn.overallTimer);
550
+ if (turn.idleTimer) clearTimeout(turn.idleTimer);
551
+ if (turn.onAbort && turn.request.signal) {
552
+ turn.request.signal.removeEventListener("abort", turn.onAbort);
553
+ }
554
+ this.#active = undefined;
555
+ this.#state = this.#child ? "ready" : "dead";
556
+ for (const wake of turn.wake) wake();
557
+ turn.wake = [];
558
+ turn.resolve(outcome);
559
+ try {
560
+ this.#onTurnEnd?.(outcome);
561
+ } catch {
562
+ /* listener errors must not break settling */
563
+ }
564
+ }
565
+
566
+ #failTurn(turn: ActiveTurn, message: string): void {
567
+ this.#settle(turn, {
568
+ conversationId: turn.conversationId,
569
+ status: "ERROR",
570
+ response: turn.response,
571
+ error: message,
572
+ usage: turn.usage,
573
+ finished: true,
574
+ aborted: false,
575
+ });
576
+ }
577
+
578
+ #recycleCause(next: DriverTurnRequest): string | undefined {
579
+ const cur = this.#profile;
580
+ if (!cur) return undefined;
581
+ if (cur.cwd !== next.cwd) return "cwd";
582
+ if (cur.model !== next.model) return "model";
583
+ if (cur.effort !== next.effort) return "effort";
584
+ if (cur.mode !== next.mode) return "mode";
585
+ if (cur.skipPermissions !== next.skipPermissions) return "permissions";
586
+ if (!next.conversationId) return this.#boundConversation ? "conversation-reset" : undefined;
587
+ return next.conversationId === this.#boundConversation ? undefined : "conversation";
588
+ }
589
+
590
+ async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
591
+ if (reason === "shutdown") this.#shutdown = true;
592
+ const child = this.#child;
593
+ if (!child) {
594
+ this.#state = reason === "shutdown" ? "dead" : "idle";
595
+ return;
596
+ }
597
+ if (reason === "recycle" && cause) {
598
+ this.#stats.recycles += 1;
599
+ this.#stats.lastRecycleReason = cause;
600
+ this.#stats.recycleReasons[cause] = (this.#stats.recycleReasons[cause] ?? 0) + 1;
601
+ }
602
+ this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
603
+ const turn = this.#active;
604
+ if (turn && !turn.closed) {
605
+ this.#failTurn(turn, `agy driver ${reason}ed mid-turn${cause ? ` (${cause})` : ""}`);
606
+ }
607
+ this.#killChild();
608
+ this.#state = reason === "shutdown" ? "dead" : "idle";
609
+ }
610
+
611
+ #killChild(): void {
612
+ const child = this.#child;
613
+ if (!child) return;
614
+ this.#child = undefined;
615
+ this.#generation += 1;
616
+ try {
617
+ child.stdout?.removeAllListeners();
618
+ child.stderr?.removeAllListeners();
619
+ } catch {
620
+ /* already gone */
621
+ }
622
+ try {
623
+ if (process.platform !== "win32" && child.pid) {
624
+ process.kill(-child.pid, "SIGTERM");
625
+ setTimeout(() => {
626
+ try {
627
+ if (child.pid) process.kill(-child.pid, "SIGKILL");
628
+ } catch {
629
+ /* already dead */
630
+ }
631
+ }, 750);
632
+ } else {
633
+ child.kill("SIGTERM");
634
+ }
635
+ } catch {
636
+ /* already dead */
637
+ }
638
+ }
639
+
640
+ #log(msg: string): void {
641
+ this.#lifecycle.push(`${nowIso()} ${msg}`);
642
+ if (this.#lifecycle.length > LIFECYCLE_LIMIT) this.#lifecycle.shift();
643
+ }
644
+ }