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