@estebanforge/pi-antigravity-bridge 1.3.2 → 1.4.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.
@@ -0,0 +1,719 @@
1
+ // AcpDriver: the ACP turn engine. Implements the same TurnDriver surface as
2
+ // the legacy stream-json driver (see src/driver-types.ts) so provider.ts and
3
+ // the G9 round-trip store work unchanged.
4
+ //
5
+ // Engine differences vs legacy, all verified live (docs/ACP-PROTOCOL-REFERENCE.md):
6
+ // - no process recycle on profile drift: one server process, sessions
7
+ // selected per turn via session/new / session/load
8
+ // - model/effort via session/set_config_option (configId "model", FULL slug
9
+ // with the effort tier baked in); mode via configId "mode"
10
+ // - config does NOT persist across server restarts: re-applied every turn
11
+ // - session/load replays history as full-text notification pairs BEFORE its
12
+ // response; the connection suppresses updates while loading
13
+ // - session/cancel is unimplemented on RC01 (-32601): abort = abortAll()
14
+ // teardown + kill, then session/load on the next turn. The method is
15
+ // probed once per connection; when upstream ships it, abort goes graceful
16
+ // - overall-timer pause uses remaining-budget semantics on G9 parks (never a
17
+ // fresh cap); every park carries its own timeout (BRIDGE_TIMEOUT_MS)
18
+ // - single `auto` permission policy: request_permission answered
19
+ // in-connection (plan §9.3); no provider involvement
20
+
21
+ import { randomUUID } from "node:crypto";
22
+ import { AcpConnection, resolveAcpBinary, type AcpMcpServer } from "./connection.js";
23
+ import { mapStopReason, mapUpdate, TextAccumulator, type AcpEditDiff } from "./events.js";
24
+ import type {
25
+ DriverActivity,
26
+ DriverSnapshot,
27
+ DriverState,
28
+ DriverTurnRequest,
29
+ TurnDriver,
30
+ TurnHandle,
31
+ TurnOutcome,
32
+ } from "../driver-types.js";
33
+
34
+ const LIFECYCLE_LIMIT = 24;
35
+
36
+ export interface AcpDriverOptions {
37
+ /** Config acp.bin value (may be empty). Env AGY_ACP_BIN wins. */
38
+ bin: string;
39
+ /** Extra argv for the binary (tests: node + fake-server script). */
40
+ binArgs?: string[];
41
+ extraEnv?: Record<string, string>;
42
+ /** Bridge registration for session/new AND session/load. */
43
+ mcpServers?: () => AcpMcpServer[];
44
+ log?: (msg: string, data?: unknown) => void;
45
+ }
46
+
47
+ interface ActiveTurn {
48
+ id: string;
49
+ request: DriverTurnRequest;
50
+ sessionId: string;
51
+ buffer: DriverActivity[];
52
+ wake: (() => void)[];
53
+ closed: boolean;
54
+ resolve: (o: TurnOutcome) => void;
55
+ outcome: Promise<TurnOutcome>;
56
+ response: TextAccumulator;
57
+ sawResult: boolean;
58
+ /** True once the prompt RPC was issued. Abort before this point has
59
+ * nothing to cancel: probing would risk a success-as-noop answer from a
60
+ * future cancel-capable server stranding the turn in the safety-net
61
+ * wait, so the driver tears down instead. */
62
+ promptStarted: boolean;
63
+ aborted: boolean;
64
+ abortedBy: "signal" | "timer" | null;
65
+ parks: number;
66
+ /** Wall-clock deadline of the overall timer; null while paused. */
67
+ overallDeadline: number | null;
68
+ overallRemainingMs: number | null;
69
+ overallTimer?: ReturnType<typeof setTimeout>;
70
+ idleTimer?: ReturnType<typeof setTimeout>;
71
+ /** toolCallId → tool name + args + optional native diff (diff rides on
72
+ * the pending tool_call frame; updates don't repeat it). */
73
+ toolCalls: Map<string, { name: string; args: Record<string, unknown>; diff?: AcpEditDiff }>;
74
+ /** Last pending native tool seen. The supersede quirk (run 6, finding 7)
75
+ * means the executing call can arrive under a DIFFERENT id than the
76
+ * approved one; unknown-id updates adopt this so the diff and name are
77
+ * not lost. */
78
+ lastNativeTool?: { name: string; args: Record<string, unknown>; diff?: AcpEditDiff };
79
+ }
80
+
81
+ /** Recombine the provider's (base slug, effort) into the FULL ACP model slug.
82
+ * Fixed families (no effort) pass through unchanged. Verified against the
83
+ * run-5 catalog: session/set_config_option wants "gemini-3.8-flash-low"-style
84
+ * full slugs. */
85
+ export function acpModelSlug(model: string, effort?: string): string {
86
+ if (!effort) return model;
87
+ if (/(?:^|-)(?:high|medium|low)$/.test(model)) return model;
88
+ return `${model}-${effort}`;
89
+ }
90
+
91
+ /** Map our config knobs onto ACP session modes. Under the single `auto`
92
+ * policy the practical effect of the modes converges (everything is
93
+ * approved); the mapping keeps the server-side counters honest.
94
+ * Known gap: the CLI's `--mode plan` has no ACP equivalent (review 4,
95
+ * finding 4) — plan delegations stay on the legacy path. */
96
+ export function acpMode(mode: string, skipPermissions: boolean): string {
97
+ if (skipPermissions) return "yolo";
98
+ return mode === "plan" ? "default" : "auto_edit";
99
+ }
100
+
101
+ export class AcpDriver implements TurnDriver {
102
+ #opts: AcpDriverOptions;
103
+ #state: DriverState = "idle";
104
+ #conn: AcpConnection | undefined;
105
+ #generation = 0;
106
+ #active: ActiveTurn | undefined;
107
+ #queueTail: Promise<void> = Promise.resolve();
108
+ #shutdown = false;
109
+ #lifecycle: string[] = [];
110
+ #onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
111
+ #stats = {
112
+ spawns: 0,
113
+ turns: 0,
114
+ sessionsCreated: 0,
115
+ sessionsLoaded: 0,
116
+ kills: 0,
117
+ };
118
+ #serverVersion: string | undefined;
119
+ #lastSessionId: string | undefined;
120
+ #lastCancelSupported: boolean | null = null;
121
+ #agentInfo: { name?: string; title?: string } | undefined;
122
+
123
+ constructor(opts: AcpDriverOptions) {
124
+ this.#opts = opts;
125
+ this.#log("driver-created", { bin: resolveAcpBinary(opts.bin) });
126
+ }
127
+
128
+ get state(): DriverState {
129
+ return this.#state;
130
+ }
131
+
132
+ get activeHandle(): TurnHandle | null {
133
+ const t = this.#active;
134
+ return t && !t.closed ? this.#makeHandle(t) : null;
135
+ }
136
+
137
+ set onTurnEnd(fn: ((outcome: TurnOutcome) => void) | undefined) {
138
+ this.#onTurnEnd = fn;
139
+ }
140
+
141
+ /** Inject a synthetic activity into the live turn (bridge inbox). Parks the
142
+ * turn: suspends the idle timer and pauses the overall deadline. */
143
+ pushExternal(activity: DriverActivity): void {
144
+ const t = this.#active;
145
+ if (!t || t.closed) return;
146
+ if (activity.type === "bridge_call") {
147
+ t.parks += 1;
148
+ this.#clearIdle(t);
149
+ this.#pauseOverall(t);
150
+ }
151
+ this.#emit(t, activity);
152
+ }
153
+
154
+ kickIdle(): void {
155
+ const t = this.#active;
156
+ if (!t || t.closed) return;
157
+ if (t.parks > 0) t.parks -= 1;
158
+ if (t.parks === 0) {
159
+ this.#armIdle(t);
160
+ this.#resumeOverall(t);
161
+ this.#log("unparked");
162
+ }
163
+ }
164
+
165
+ /** Turns are serialized; a parked turn stays open and the continuation
166
+ * path uses reentry() (same contract as the legacy driver). */
167
+ run(request: DriverTurnRequest): Promise<TurnHandle> {
168
+ let release!: () => void;
169
+ const prev = this.#queueTail;
170
+ this.#queueTail = new Promise<void>((r) => (release = r));
171
+ return prev
172
+ .then(() => this.#runExclusive(request))
173
+ .then((handle) => {
174
+ void handle.outcome.catch(() => {}).then(() => release());
175
+ return handle;
176
+ })
177
+ .catch((err) => {
178
+ release();
179
+ throw err;
180
+ });
181
+ }
182
+
183
+ reentry(): TurnHandle | null {
184
+ return this.activeHandle;
185
+ }
186
+
187
+ #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
188
+ if (this.#shutdown) return Promise.reject(new Error("ACP driver is shut down."));
189
+ if (request.signal?.aborted) return Promise.reject(new Error("aborted before start"));
190
+
191
+ const turn = this.#createTurn(request);
192
+ this.#active = turn;
193
+ this.#state = "running";
194
+ this.#stats.turns += 1;
195
+
196
+ // Abort wiring first: a kill during session setup must still settle the
197
+ // turn (Gate D teardown applies from the first request).
198
+ if (request.signal) {
199
+ const onAbort = () => void this.#abortTurn(turn);
200
+ if (request.signal.aborted) {
201
+ onAbort();
202
+ } else {
203
+ request.signal.addEventListener("abort", onAbort, { once: true });
204
+ }
205
+ }
206
+
207
+ // Execute asynchronously: the handle returns as soon as the prompt is
208
+ // dispatched, and activities stream through next() (legacy contract).
209
+ void this.#executeTurn(turn).catch((err: unknown) => {
210
+ this.#failTurn(turn, `ACP turn failed: ${describe(err)}`);
211
+ });
212
+ return Promise.resolve(this.#makeHandle(turn));
213
+ }
214
+
215
+ async #executeTurn(turn: ActiveTurn): Promise<void> {
216
+ const request = turn.request;
217
+ const conn = await this.#ensureConnection(request);
218
+
219
+ // Session: load (resume) or create. Load failures fall back to a fresh
220
+ // session — a missing conversation must not fail the turn (9.4).
221
+ try {
222
+ if (request.conversationId) {
223
+ this.#log("session-load", { sessionId: request.conversationId });
224
+ this.#stats.sessionsLoaded += 1;
225
+ await conn.loadSession(request.conversationId, request.cwd);
226
+ turn.sessionId = request.conversationId;
227
+ } else {
228
+ const created = await conn.newSession(request.cwd);
229
+ turn.sessionId = created.sessionId;
230
+ this.#stats.sessionsCreated += 1;
231
+ this.#log("session-new", { sessionId: created.sessionId });
232
+ }
233
+ } catch (err) {
234
+ if (turn.aborted) {
235
+ this.#settle(turn, {
236
+ conversationId: turn.sessionId,
237
+ status: "OK",
238
+ response: turn.response.text,
239
+ finished: true,
240
+ aborted: true,
241
+ });
242
+ return;
243
+ }
244
+ if (request.conversationId) {
245
+ this.#log("session-load-failed-creating-fresh", {
246
+ sessionId: request.conversationId,
247
+ message: err instanceof Error ? err.message : String(err),
248
+ });
249
+ try {
250
+ const created = await conn.newSession(request.cwd);
251
+ turn.sessionId = created.sessionId;
252
+ this.#stats.sessionsCreated += 1;
253
+ } catch (err2) {
254
+ this.#failTurn(turn, `ACP session failed: ${describe(err2)}`);
255
+ return;
256
+ }
257
+ } else {
258
+ this.#failTurn(turn, `ACP session failed: ${describe(err)}`);
259
+ return;
260
+ }
261
+ }
262
+ if (turn.closed) return;
263
+
264
+ // Config: model + mode. Model failure fails the turn (wrong-model turns
265
+ // are a parity break); mode failure is best-effort (auto policy makes
266
+ // the modes converge anyway).
267
+ try {
268
+ await conn.setConfigOption(turn.sessionId, "model", acpModelSlug(request.model, request.effort));
269
+ } catch (err) {
270
+ this.#failTurn(turn, `ACP model selection failed: ${describe(err)}`);
271
+ return;
272
+ }
273
+ try {
274
+ await conn.setConfigOption(turn.sessionId, "mode", acpMode(request.mode, request.skipPermissions));
275
+ } catch (err) {
276
+ this.#log("mode-apply-failed", { message: describe(err) });
277
+ }
278
+ if (turn.closed) return;
279
+
280
+ // Timers: overall (turn deadline, pause-aware) + idle (inactivity).
281
+ this.#armOverall(turn);
282
+ this.#armIdle(turn);
283
+
284
+ // Prompt. Updates stream through the connection's onUpdate callback.
285
+ try {
286
+ // Nothing to cancel before the prompt RPC exists; see promptStarted.
287
+ turn.promptStarted = true;
288
+ const result = await conn.prompt(turn.sessionId, request.prompt, request.images, request.contextBlock);
289
+ if (turn.closed) return;
290
+ turn.sawResult = true;
291
+ const mapped = mapStopReason(result.stopReason);
292
+ this.#settle(turn, {
293
+ conversationId: turn.sessionId,
294
+ status: mapped.status,
295
+ response: turn.response.text,
296
+ error: mapped.error,
297
+ finished: true,
298
+ aborted: mapped.aborted,
299
+ });
300
+ } catch (err) {
301
+ if (turn.closed) return;
302
+ const aborted = turn.aborted;
303
+ this.#settle(turn, {
304
+ conversationId: turn.sessionId,
305
+ status: aborted ? "OK" : "ERROR",
306
+ response: turn.response.text,
307
+ error: aborted ? undefined : `ACP prompt failed: ${describe(err)}`,
308
+ finished: true,
309
+ aborted,
310
+ });
311
+ }
312
+ }
313
+
314
+ #makeHandle(turn: ActiveTurn): TurnHandle {
315
+ return {
316
+ id: turn.id,
317
+ outcome: turn.outcome,
318
+ next: () => this.#nextActivity(turn),
319
+ pushExternal: (activity) => this.pushExternal(activity),
320
+ };
321
+ }
322
+
323
+ async #nextActivity(turn: ActiveTurn): Promise<DriverActivity | null> {
324
+ for (;;) {
325
+ if (turn.buffer.length > 0) return turn.buffer.shift() ?? null;
326
+ if (turn.closed) return null;
327
+ await new Promise<void>((r) => turn.wake.push(r));
328
+ }
329
+ }
330
+
331
+ #emit(turn: ActiveTurn, activity: DriverActivity): void {
332
+ if (turn.closed) return;
333
+ if (turn.wake.length > 0) turn.wake.shift()!();
334
+ turn.buffer.push(activity);
335
+ }
336
+
337
+ #onConnectionUpdate(sessionId: string | null, update: unknown): void {
338
+ const turn = this.#active;
339
+ if (!turn || turn.closed) return;
340
+ if (sessionId !== null && sessionId !== turn.sessionId) return;
341
+ if (turn.idleTimer) turn.idleTimer.refresh();
342
+ const mapped = mapUpdate(update);
343
+ if (!mapped) return;
344
+ switch (mapped.kind) {
345
+ case "text": {
346
+ const emit = turn.response.append(mapped.delta);
347
+ if (emit) this.#emit(turn, { type: "text", delta: emit });
348
+ return;
349
+ }
350
+ case "thought": {
351
+ this.#emit(turn, { type: "thought", delta: mapped.delta });
352
+ return;
353
+ }
354
+ case "tool_start": {
355
+ const entry = { name: mapped.name, args: mapped.args, diff: mapped.diff };
356
+ turn.toolCalls.set(mapped.toolCallId, entry);
357
+ turn.lastNativeTool = { ...entry };
358
+ this.#emit(turn, { type: "tool_start", name: mapped.name, args: mapped.args });
359
+ return;
360
+ }
361
+ case "tool_done": {
362
+ let entry = turn.toolCalls.get(mapped.toolCallId);
363
+ if (!entry && turn.lastNativeTool) {
364
+ // Unknown id with a recent native tool: adopt it (supersede).
365
+ entry = { ...turn.lastNativeTool };
366
+ turn.toolCalls.set(mapped.toolCallId, entry);
367
+ turn.lastNativeTool = undefined;
368
+ }
369
+ const name = entry?.name ?? "tool";
370
+ const args = entry?.args ?? {};
371
+ // Native diff from the stored tool_call frame; the update's own
372
+ // diff (future builds) wins when present.
373
+ this.#emit(turn, { type: "tool_done", name, args, output: mapped.output, diff: mapped.diff ?? entry?.diff });
374
+ return;
375
+ }
376
+ case "tool_error": {
377
+ const entry = turn.toolCalls.get(mapped.toolCallId);
378
+ const name = entry?.name ?? "tool";
379
+ this.#emit(turn, { type: "tool_error", name, message: mapped.message });
380
+ return;
381
+ }
382
+ case "replay_user":
383
+ return; // load replay: history, never live text
384
+ }
385
+ }
386
+
387
+ #onConnectionExit(conn: AcpConnection, info: { stderrTail: string }): void {
388
+ if (this.#conn !== conn) {
389
+ // A replaced connection reporting its death late (RC01's signal
390
+ // handler intercepts SIGTERM and can outlive its replacement by
391
+ // seconds): its turn is long gone and the new connection owns the
392
+ // driver state. Clobbering #conn here would orphan the live one.
393
+ this.#log("stale-connection-exited", { tail: info.stderrTail.slice(-200) });
394
+ return;
395
+ }
396
+ const turn = this.#active;
397
+ this.#conn = undefined;
398
+ this.#state = "dead";
399
+ this.#log("connection-exited", { tail: info.stderrTail.slice(-200) });
400
+ if (!turn || turn.closed) return;
401
+ if (turn.aborted || turn.sawResult) {
402
+ this.#settle(turn, {
403
+ conversationId: turn.sessionId,
404
+ status: turn.response.text.length > 0 || turn.sawResult ? "OK" : "UNKNOWN",
405
+ response: turn.response.text,
406
+ finished: true,
407
+ aborted: turn.aborted,
408
+ });
409
+ return;
410
+ }
411
+ this.#failTurn(turn, info.stderrTail.trim() || "ACP server exited mid-turn");
412
+ }
413
+
414
+ #ensureConnection(request: DriverTurnRequest): Promise<AcpConnection> {
415
+ if (this.#conn?.alive) return Promise.resolve(this.#conn);
416
+ this.#generation += 1;
417
+ this.#state = "starting";
418
+ this.#stats.spawns += 1;
419
+ const conn = new AcpConnection({
420
+ bin: resolveAcpBinary(this.#opts.bin),
421
+ binArgs: this.#opts.binArgs,
422
+ extraEnv: this.#opts.extraEnv,
423
+ cwd: request.cwd,
424
+ mcpServers: this.#opts.mcpServers,
425
+ log: (msg, data) => this.#log(msg, data),
426
+ onUpdate: (sessionId, update) => this.#onConnectionUpdate(sessionId, update),
427
+ onExit: (info) => this.#onConnectionExit(conn, info),
428
+ });
429
+ this.#conn = conn;
430
+ this.#log("spawn", { bin: resolveAcpBinary(this.#opts.bin) });
431
+ return conn
432
+ .start()
433
+ .then(() => {
434
+ this.#serverVersion = conn.serverVersion();
435
+ const info = conn.agentInfo as { name?: unknown; title?: unknown } | undefined;
436
+ this.#agentInfo = {
437
+ name: typeof info?.name === "string" ? info.name : undefined,
438
+ title: typeof info?.title === "string" ? info.title : undefined,
439
+ };
440
+ this.#state = "ready";
441
+ return conn;
442
+ })
443
+ .catch((err) => {
444
+ // A server that spawned but failed the handshake (init timeout,
445
+ // auth hang) must not leak: it is detached, so it outlives pi.
446
+ this.#state = "dead";
447
+ this.#conn = undefined;
448
+ conn.kill();
449
+ this.#log("start-failed", { message: describe(err) });
450
+ throw err;
451
+ });
452
+ }
453
+
454
+ /** Gate D abort. RC01 has no session/cancel: probe it once per connection,
455
+ * then either wait for the cancelled result or tear down. */
456
+ async #abortTurn(turn: ActiveTurn): Promise<void> {
457
+ if (turn.closed) return;
458
+ turn.aborted = true;
459
+ turn.abortedBy = "signal";
460
+ const conn = this.#conn;
461
+ // No session yet (killed during setup), no live connection, or a server
462
+ // already known not to implement cancel: teardown directly.
463
+ if (!conn?.alive || turn.sessionId === "" || !turn.promptStarted || this.#cancelUnsupported()) {
464
+ this.#teardownAbort(turn);
465
+ return;
466
+ }
467
+ try {
468
+ const probe = await conn.cancel(turn.sessionId);
469
+ conn.cancelSupported = probe.supported;
470
+ this.#lastCancelSupported = probe.supported;
471
+ if (!probe.supported) {
472
+ this.#log("cancel-unsupported", { build: this.#serverVersion });
473
+ this.#teardownAbort(turn);
474
+ return;
475
+ }
476
+ // Cancel accepted: the prompt result (stopReason cancelled) settles
477
+ // the turn through the normal path. Safety net below in case the
478
+ // server never answers.
479
+ const started = this.#nowMs();
480
+ const check = setInterval(() => {
481
+ if (turn.closed) {
482
+ clearInterval(check);
483
+ return;
484
+ }
485
+ if (this.#nowMs() - started > 10_000) {
486
+ clearInterval(check);
487
+ this.#teardownAbort(turn);
488
+ }
489
+ }, 250);
490
+ } catch (err) {
491
+ this.#log("cancel-failed", { message: describe(err) });
492
+ this.#teardownAbort(turn);
493
+ }
494
+ }
495
+
496
+ #cancelUnsupported(): boolean {
497
+ return this.#conn?.cancelSupported === false;
498
+ }
499
+
500
+ /** Gate D teardown: reject everything pending, kill the process. The turn
501
+ * settles through the connection-exit path as aborted. */
502
+ #teardownAbort(turn: ActiveTurn): void {
503
+ this.#stats.kills += 1;
504
+ this.#log("teardown-abort", { sessionId: turn.sessionId });
505
+ turn.aborted = true;
506
+ this.#conn?.abortAll("abort: connection torn down");
507
+ this.#conn?.kill();
508
+ }
509
+
510
+ #createTurn(request: DriverTurnRequest): ActiveTurn {
511
+ let resolve!: (o: TurnOutcome) => void;
512
+ const outcome = new Promise<TurnOutcome>((r) => (resolve = r));
513
+ const turn: ActiveTurn = {
514
+ id: randomUUID().slice(0, 8),
515
+ request,
516
+ sessionId: "",
517
+ buffer: [],
518
+ wake: [],
519
+ closed: false,
520
+ resolve,
521
+ outcome,
522
+ response: new TextAccumulator(),
523
+ sawResult: false,
524
+ promptStarted: false,
525
+ aborted: false,
526
+ abortedBy: null,
527
+ parks: 0,
528
+ overallDeadline: null,
529
+ overallRemainingMs: null,
530
+ toolCalls: new Map(),
531
+ };
532
+ return turn;
533
+ }
534
+
535
+ // --- timers ----------------------------------------------------------------
536
+
537
+ #overallBudgetMs(turn: ActiveTurn): number {
538
+ return (turn.request.timeoutMin ?? 10) * 60_000;
539
+ }
540
+
541
+ #idleBudgetMs(turn: ActiveTurn): number {
542
+ return (turn.request.inactivityMin ?? 5) * 60_000;
543
+ }
544
+
545
+ #nowMs(): number {
546
+ return Date.now();
547
+ }
548
+
549
+ #armOverall(turn: ActiveTurn): void {
550
+ const budget = this.#overallBudgetMs(turn);
551
+ if (turn.overallTimer) clearTimeout(turn.overallTimer);
552
+ // Parked before timers armed (setup-time park): the turn is PAUSED from
553
+ // birth. Keep the pause invariant (deadline === null) and store the full
554
+ // budget; kickIdle() resumes the timer on unpark. A stale non-null
555
+ // deadline here would make the next #pauseOverall recompute the
556
+ // remaining budget against a wall-clock instant that never ran.
557
+ if (turn.parks > 0) {
558
+ turn.overallDeadline = null;
559
+ turn.overallRemainingMs = budget;
560
+ return;
561
+ }
562
+ turn.overallRemainingMs = null;
563
+ this.#startOverallTimer(turn, budget);
564
+ }
565
+
566
+ #startOverallTimer(turn: ActiveTurn, ms: number): void {
567
+ // The deadline lives HERE, not just in the callers: the running branch
568
+ // of #armOverall never assigns it, and #pauseOverall keys off
569
+ // `deadline !== null` to do anything at all. Without this line every
570
+ // post-arm park is a silent no-op and the timer ticks through the park.
571
+ turn.overallDeadline = this.#nowMs() + ms;
572
+ turn.overallTimer = setTimeout(() => {
573
+ if (turn.closed) return;
574
+ turn.abortedBy = "timer";
575
+ this.#log("timeout", { sessionId: turn.sessionId });
576
+ this.#conn?.abortAll("turn deadline");
577
+ this.#conn?.kill();
578
+ this.#failTurn(turn, `ACP turn exceeded the ${(this.#overallBudgetMs(turn) / 60_000) | 0}m deadline`);
579
+ }, ms);
580
+ }
581
+
582
+ /** Pause WITHOUT resetting the deadline (remaining-budget semantics: the
583
+ * overall timer is a turn deadline, not an inactivity guard). */
584
+ #pauseOverall(turn: ActiveTurn): void {
585
+ if (turn.overallDeadline === null) return;
586
+ if (turn.overallTimer) clearTimeout(turn.overallTimer);
587
+ turn.overallTimer = undefined;
588
+ turn.overallRemainingMs = Math.max(0, turn.overallDeadline - this.#nowMs());
589
+ // Null the deadline: a nested park (parks > 1) must not recompute the
590
+ // remaining budget against a stale wall-clock instant — parked time does
591
+ // not consume budget.
592
+ turn.overallDeadline = null;
593
+ }
594
+
595
+ #resumeOverall(turn: ActiveTurn): void {
596
+ if (turn.overallRemainingMs === null) return;
597
+ const remaining = turn.overallRemainingMs;
598
+ turn.overallRemainingMs = null;
599
+ this.#startOverallTimer(turn, remaining);
600
+ }
601
+
602
+ #armIdle(turn: ActiveTurn): void {
603
+ if (turn.idleTimer) clearTimeout(turn.idleTimer);
604
+ if (turn.parks > 0) return; // parked: idle timer resumes on unpark
605
+ turn.idleTimer = setTimeout(() => {
606
+ if (turn.closed) return;
607
+ this.#log("stall", { sessionId: turn.sessionId });
608
+ this.#conn?.abortAll("idle stall");
609
+ this.#conn?.kill();
610
+ this.#failTurn(turn, `ACP stalled for ${(this.#idleBudgetMs(turn) / 60_000) | 0}m with no output`);
611
+ }, this.#idleBudgetMs(turn));
612
+ }
613
+
614
+ #clearIdle(turn: ActiveTurn): void {
615
+ if (turn.idleTimer) clearTimeout(turn.idleTimer);
616
+ turn.idleTimer = undefined;
617
+ }
618
+
619
+ // --- settling ----------------------------------------------------------------
620
+
621
+ #settle(turn: ActiveTurn, outcome: TurnOutcome): void {
622
+ if (turn.closed) return;
623
+ turn.closed = true;
624
+ if (turn.overallTimer) clearTimeout(turn.overallTimer);
625
+ if (turn.idleTimer) clearTimeout(turn.idleTimer);
626
+ this.#active = undefined;
627
+ this.#state = this.#conn?.alive ? "ready" : "dead";
628
+ if (outcome.conversationId) this.#lastSessionId = outcome.conversationId;
629
+ for (const wake of turn.wake) wake();
630
+ turn.wake = [];
631
+ if (outcome.aborted && turn.abortedBy === null) turn.abortedBy = "signal";
632
+ turn.resolve(outcome);
633
+ try {
634
+ this.#onTurnEnd?.(outcome);
635
+ } catch {
636
+ /* listener errors must not break settling */
637
+ }
638
+ }
639
+
640
+ #failTurn(turn: ActiveTurn, message: string): void {
641
+ this.#settle(turn, {
642
+ conversationId: turn.sessionId,
643
+ status: "ERROR",
644
+ response: turn.response.text,
645
+ error: message,
646
+ finished: true,
647
+ aborted: turn.aborted,
648
+ });
649
+ }
650
+
651
+ #log(msg: string, data?: unknown): void {
652
+ const line = `${new Date().toISOString().slice(11, 19)} ${msg}${data !== undefined ? ` ${JSON.stringify(data)}` : ""}`;
653
+ this.#lifecycle.push(line);
654
+ if (this.#lifecycle.length > LIFECYCLE_LIMIT) this.#lifecycle.shift();
655
+ this.#opts.log?.(msg, data);
656
+ }
657
+
658
+ // --- TurnDriver surface ----------------------------------------------------
659
+
660
+ async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
661
+ if (reason === "shutdown") this.#shutdown = true;
662
+ this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
663
+ const turn = this.#active;
664
+ if (turn && !turn.closed) {
665
+ this.#settle(turn, {
666
+ conversationId: turn.sessionId,
667
+ status: "ERROR",
668
+ response: turn.response.text,
669
+ error: `ACP driver ${reason}ed mid-turn${cause ? ` (${cause})` : ""}`,
670
+ finished: true,
671
+ aborted: false,
672
+ });
673
+ }
674
+ this.#conn?.abortAll(`driver ${reason}`);
675
+ this.#conn?.kill();
676
+ this.#conn = undefined;
677
+ this.#state = "dead";
678
+ }
679
+
680
+ snapshot(): DriverSnapshot {
681
+ return {
682
+ state: this.#state,
683
+ pid: this.#conn?.pid,
684
+ conversationId: this.#active?.sessionId ?? this.#lastSessionId,
685
+ stats: {
686
+ spawns: this.#stats.spawns,
687
+ turns: this.#stats.turns,
688
+ reused: 0,
689
+ recycles: this.#stats.kills,
690
+ lastRecycleReason: undefined,
691
+ recycleReasons: {},
692
+ },
693
+ lifecycle: [...this.#lifecycle],
694
+ engine: "acp",
695
+ acp: {
696
+ sessionId: this.#active?.sessionId ?? this.#lastSessionId,
697
+ prompts: this.#stats.turns,
698
+ sessionsCreated: this.#stats.sessionsCreated,
699
+ sessionsLoaded: this.#stats.sessionsLoaded,
700
+ kills: this.#stats.kills,
701
+ cancelSupported: this.#conn?.cancelSupported ?? this.#lastCancelSupported,
702
+ serverVersion: this.#serverVersion,
703
+ // Connections beyond the first are server restarts (Gate D kills,
704
+ // stale-exit replacements) = reconnects.
705
+ reconnects: Math.max(0, this.#stats.spawns - 1),
706
+ agentName: this.#agentInfo?.name,
707
+ agentTitle: this.#agentInfo?.title,
708
+ },
709
+ };
710
+ }
711
+ }
712
+
713
+ function describe(err: unknown): string {
714
+ if (err instanceof Error) return err.message;
715
+ return String(err);
716
+ }
717
+
718
+ // re-exported for the extension's doctor (server version display)
719
+ export type { AcpMcpServer } from "./connection.js";