@estebanforge/pi-antigravity-bridge 1.3.0 → 1.3.2

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/protobuf.ts DELETED
@@ -1,184 +0,0 @@
1
- // Hand-rolled protobuf decoder for agy's `step_payload` blobs.
2
- //
3
- // agy writes per-conversation SQLite DBs at ~/.gemini/antigravity-cli/
4
- // conversations/<uuid>.db. The `steps.step_payload` column is a protobuf blob
5
- // with NO published schema. Field numbers below are load-bearing
6
- // reverse-engineered facts (cross-checked against the shindgew/agy-acp and
7
- // shubzkothekar/antigravity-acp decoders, plus real DB inspection on this
8
- // machine, agy v1.1.7).
9
- //
10
- // We hand-roll the varint walker instead of pulling @bufbuild/protobuf or
11
- // generating from .proto. The openab/agy-acp Rust port proves ~94 lines is
12
- // enough for text + tool name extraction. Unknown fields are skipped per
13
- // protobuf wire-format rules, so a future agy that adds fields won't break us.
14
- //
15
- // Layout we care about:
16
- // step_payload:
17
- // field 20 (submessage) = agentText { 1: text }
18
- // field 5 (submessage) = toolRun { 4: toolCall { 2|9: name, 3: inputJson } }
19
- // field 30 (submessage) = titleUpdate { 4: title }
20
- // (fuller map in decodeStepPayload - only the ones we stream to pi.)
21
-
22
- export type ByteSource = Uint8Array | ArrayBufferLike;
23
-
24
- /** Read a base-128 varint starting at offset `i`. Returns [value, nextOffset].
25
- *
26
- * NOTE on precision: accumulation uses bitwise OR and shift, which are
27
- * 32-bit operations in JS. Values needing 5+ continuation bytes (>32 bits)
28
- * are truncated, not decoded correctly. This is acceptable here because agy
29
- * field numbers and payload lengths are always small (well under 2^32). The
30
- * 10-byte cap is a DoS guard (stop a corrupt blob spinning forever), not a
31
- * correctness guarantee for the full 64-bit varint range. */
32
- export function readVarint(buf: Uint8Array, i: number): [number, number] {
33
- let result = 0;
34
- let shift = 0;
35
- let offset = i;
36
- // protobuf caps varints at 10 bytes (64-bit). Cap the loop so a corrupt
37
- // blob can't spin forever.
38
- for (let count = 0; count < 10; count++) {
39
- if (offset >= buf.length) {
40
- throw new RangeError(`varint at ${i} ran past end of buffer`);
41
- }
42
- const byte = buf[offset++];
43
- result |= (byte & 0x7f) << shift;
44
- if ((byte & 0x80) === 0) return [result >>> 0, offset];
45
- shift += 7;
46
- }
47
- throw new RangeError(`varint at ${i} exceeded 10 bytes`);
48
- }
49
-
50
- /** A (fieldNumber, wireType, valueSlice) triple produced by walking one field.
51
- * For length-delimited fields (wire 2), `bytes` is the field payload.
52
- * For varints (wire 0), `varint` holds the value. */
53
- export interface Field {
54
- field: number;
55
- wire: number;
56
- bytes: Uint8Array | null; // wire 2 payload (view into the source buffer)
57
- varint: number | null; // wire 0 value
58
- }
59
-
60
- /** Walk every top-level field in a protobuf message. Returns the fields in
61
- * order. Packed/repeated fields are not collapsed - callers see each
62
- * occurrence. Unknown fields are included so the walker is reusable. */
63
- export function walkFields(buf: Uint8Array): Field[] {
64
- const out: Field[] = [];
65
- let i = 0;
66
- while (i < buf.length) {
67
- const [tag, afterTag] = readVarint(buf, i);
68
- i = afterTag;
69
- const field = tag >>> 3;
70
- const wire = tag & 0x07;
71
- if (wire === 0) {
72
- // varint
73
- const [val, after] = readVarint(buf, i);
74
- i = after;
75
- out.push({ field, wire, bytes: null, varint: val });
76
- } else if (wire === 2) {
77
- // length-delimited
78
- const [len, afterLen] = readVarint(buf, i);
79
- i = afterLen;
80
- if (i + len > buf.length) {
81
- throw new RangeError(`field ${field}: length ${len} runs past buffer end`);
82
- }
83
- // subarray is a VIEW into the same backing buffer (no copy). Safe here
84
- // because the view is decoded and discarded within this poll; do NOT
85
- // retain `Field.bytes` past the current call - the source buffer may
86
- // be reused or collected differently than a retained slice expects.
87
- out.push({ field, wire, bytes: buf.subarray(i, i + len), varint: null });
88
- i += len;
89
- } else if (wire === 5) {
90
- // fixed32
91
- out.push({ field, wire, bytes: null, varint: null });
92
- i += 4;
93
- } else if (wire === 1) {
94
- // fixed64
95
- out.push({ field, wire, bytes: null, varint: null });
96
- i += 8;
97
- } else {
98
- // wire 3/4 (start/end group) are deprecated and agy never emits them.
99
- // Throw rather than silently drop every field after this point - a
100
- // corrupt byte that looks like a group delimiter should fail loudly so
101
- // the caller (pollOnce) can drop the step and retry on the next poll.
102
- throw new RangeError(`unexpected wire type ${wire} at field ${field}`);
103
- }
104
- }
105
- return out;
106
- }
107
-
108
- /** Find the first length-delimited field with the given number, or null.
109
- * Equivalent to agy-acp's readSubmessage + readMessage dispatch for one field. */
110
- export function getField(buf: Uint8Array, target: number): Uint8Array | null {
111
- for (const f of walkFields(buf)) {
112
- if (f.field === target && f.wire === 2 && f.bytes) return f.bytes;
113
- }
114
- return null;
115
- }
116
-
117
- /** Decode a UTF-8 slice to a string. Tolerant: invalid bytes become U+FFFD. */
118
- const utf8 = new TextDecoder("utf-8", { fatal: false });
119
- export function utf8String(bytes: Uint8Array): string {
120
- return utf8.decode(bytes);
121
- }
122
-
123
- export interface AgentText {
124
- text: string;
125
- }
126
-
127
- export interface ToolCallInfo {
128
- /** Primary tool name (field 2 of toolCall). */
129
- name: string;
130
- /** Raw input JSON string (field 3 of toolCall), unparsed. */
131
- inputJson: string;
132
- }
133
-
134
- /** Extract agent text from a step_payload: field 20 -> field 1.
135
- * Returns null if the payload has no agentText field. */
136
- export function extractAgentText(payload: Uint8Array): AgentText | null {
137
- const agentText = getField(payload, 20);
138
- if (!agentText) return null;
139
- const text = getField(agentText, 1);
140
- if (!text) return null;
141
- return { text: utf8String(text) };
142
- }
143
-
144
- /** Extract tool-call info from a step_payload: field 5 (toolRun) -> field 4
145
- * (toolCall) -> fields 2/9 (name) and 3 (inputJson). Returns null if the
146
- * payload has no toolRun.toolCall. */
147
- export function extractToolCall(payload: Uint8Array): ToolCallInfo | null {
148
- const toolRun = getField(payload, 5);
149
- if (!toolRun) return null;
150
- const toolCall = getField(toolRun, 4);
151
- if (!toolCall) return null;
152
- // Name lives at field 2 (namePrimary) or field 9 (nameSecondary).
153
- let name = "";
154
- let inputJson = "";
155
- for (const f of walkFields(toolCall)) {
156
- if (f.field === 2 && f.bytes) name ||= utf8String(f.bytes);
157
- else if (f.field === 9 && f.bytes && !name) name = utf8String(f.bytes);
158
- else if (f.field === 3 && f.bytes) inputJson ||= utf8String(f.bytes);
159
- }
160
- if (!name && !inputJson) return null;
161
- return { name, inputJson };
162
- }
163
-
164
- /** Extract the title from a step_payload: field 30 (titleUpdate) -> field 4.
165
- * Returns null when absent. */
166
- export function extractTitle(payload: Uint8Array): string | null {
167
- const titleUpdate = getField(payload, 30);
168
- if (!titleUpdate) return null;
169
- const title = getField(titleUpdate, 4);
170
- return title ? utf8String(title) : null;
171
- }
172
-
173
- /** Decode a Buffer/Uint8Array-shaped column value to a clean Uint8Array.
174
- * node:sqlite returns Uint8Array for BLOB; better-sqlite3 returns Buffer. */
175
- export function toUint8(v: unknown): Uint8Array {
176
- if (v instanceof Uint8Array) return v;
177
- // Buffer is a Uint8Array subclass; instanceof covers it but be defensive.
178
- if (ArrayBuffer.isView(v)) {
179
- const view = v as Uint8Array;
180
- return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
181
- }
182
- if (v == null) return new Uint8Array(0);
183
- return new Uint8Array(0);
184
- }
package/src/runner.ts DELETED
@@ -1,390 +0,0 @@
1
- // Core agy turn runner: spawn `agy -p`, poll its conversation DB for new steps,
2
- // decode each, and emit structured events via a callback. Shared by the
3
- // standalone CLI (scripts/run-agy.ts) and the pi provider (src/provider.ts).
4
- //
5
- // Streaming contract:
6
- // - agy writes steps to SQLite incrementally as it works.
7
- // - We poll the DB every POLL_INTERVAL_MS (250), decode new rows, emit.
8
- // - After agy exits, TRAILING_POLLS x 100ms catch late flushes.
9
- // - agy does NOT print the conversation id; we bind it by snapshot/diff
10
- // (see discovery.ts).
11
- //
12
- // agy runs its OWN closed tool loop (read/write/edit/exec against --add-dir).
13
- // We cannot bridge those tools to pi. Tool steps surface as "tool" events so
14
- // the UI can show "agy: editing foo.ts" - the edit itself already landed.
15
-
16
- import { spawn, type ChildProcess } from "node:child_process";
17
- import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
18
- import { ConversationPoller, type Step } from "./poller.js";
19
- import {
20
- CONVERSATIONS_DIR,
21
- conversationDbPath,
22
- newConversationId,
23
- snapshotConversations,
24
- } from "./discovery.js";
25
- import { extractAgentText, extractToolCall, extractTitle } from "./protobuf.js";
26
-
27
- // --- tuning -----------------------------------------------------------------
28
-
29
- export const POLL_INTERVAL_MS = 250;
30
- export const TRAILING_POLLS = 3;
31
- export const TRAILING_POLL_MS = 100;
32
- export const DEFAULT_TIMEOUT_MIN = 10;
33
- const GRACE_AFTER_TIMEOUT_MS = 5000;
34
- // Cap on poll-tick retries to bind the conversation id when the snapshot is
35
- // ambiguous (a concurrent agy also created a .db). Each retry can run a full
36
- // /proc FD scan, so we stop after this many ticks (~15s at 250ms) and let the
37
- // turn fail safe rather than scan indefinitely. Generous enough to cover a
38
- // slow agy startup (OAuth refresh + cold init).
39
- const MAX_BIND_ATTEMPTS = 60;
40
-
41
- // agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
42
- // leading-dash value can't misbind on agy's arg parser as the token after
43
- // --conversation (flag injection). Hyphens allowed in the body (real UUIDs).
44
- const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
45
-
46
- // --- events -----------------------------------------------------------------
47
-
48
- export type AgyEvent =
49
- | { kind: "text"; text: string }
50
- | { kind: "thinking"; text: string }
51
- | { kind: "tool"; name: string; inputJson: string }
52
- | { kind: "title"; title: string };
53
-
54
- // step_type values observed in real DBs. Tool steps share the same payload
55
- // layout (field 5 -> toolCall), so we decode them uniformly. Unknown types
56
- // are skipped (return null) - forward-compatible with future agy additions.
57
- const TOOL_STEP_TYPES = new Set([5, 7, 8, 9, 17, 21, 33, 101, 132, 138]);
58
-
59
- /** Map a raw step row to a decoded event, or null when nothing to emit.
60
- * Pure: no I/O, no side effects. Exported for testing. */
61
- export function decodeStep(step: Step): AgyEvent | null {
62
- if (step.stepType === 15) {
63
- const t = extractAgentText(step.payload);
64
- return t ? { kind: "text", text: t.text } : null;
65
- }
66
- if (step.stepType === 14) {
67
- // Thinking steps reuse the agentText layout (field 20.1) in observed DBs.
68
- const t = extractAgentText(step.payload);
69
- return t ? { kind: "thinking", text: t.text } : null;
70
- }
71
- if (step.stepType === 23) {
72
- const title = extractTitle(step.payload);
73
- return title ? { kind: "title", title } : null;
74
- }
75
- if (TOOL_STEP_TYPES.has(step.stepType)) {
76
- const tc = extractToolCall(step.payload);
77
- if (tc?.name) return { kind: "tool", name: tc.name, inputJson: tc.inputJson };
78
- return null;
79
- }
80
- return null;
81
- }
82
-
83
- // --- options + result -------------------------------------------------------
84
-
85
- export interface AgyRunOptions {
86
- /** Workspace root agy operates in (passed as --add-dir). */
87
- cwd: string;
88
- /** Exact agy model string, e.g. "Gemini 3.6 Flash (Medium)". Caller
89
- * resolves aliases; the runner passes this through verbatim. */
90
- model?: string;
91
- /** Reasoning effort passed to agy --effort (low|medium|high). Omit to let
92
- * the model slug's own effort stand (requires agy >= 1.1.5). */
93
- effort?: "low" | "medium" | "high";
94
- /** agy execution mode. accept-edits = agy applies edits; plan = review-only. */
95
- mode?: "accept-edits" | "plan";
96
- /** Pass --dangerously-skip-permissions so commands don't hang on an
97
- * unanswerable y/n prompt in non-interactive `-p` mode. Default true.
98
- * Required for accept-edits to function; harmless under plan mode. */
99
- skipPermissions?: boolean;
100
- /** The prompt. Required. */
101
- prompt: string;
102
- /** Existing conversation id to resume. When set, agy reuses it (no snapshot). */
103
- conversationId?: string | null;
104
- /** Highest step idx already streamed in a prior turn (resume only). The
105
- * poller starts reading AFTER this idx so resumed turns don't replay
106
- * history. Default -1 (read everything). */
107
- baseStepIdx?: number;
108
- /** Hard cap on the run, in minutes. */
109
- timeoutMin?: number;
110
- /** Optional AbortSignal for cancellation. */
111
- signal?: AbortSignal;
112
- /** Conversations dir override (testing / isolation). */
113
- conversationsDir?: string;
114
- /** agy binary path. Defaults to AGY_BIN env or "agy". */
115
- binary?: string;
116
- /** Extra args appended (split from AGY_EXTRA_ARGS by the caller). */
117
- extraArgs?: string[];
118
- }
119
-
120
- export interface AgyRunResult {
121
- exitCode: number;
122
- conversationId: string | null;
123
- lastIdx: number;
124
- aborted: boolean;
125
- timedOut: boolean;
126
- stderr: string;
127
- durationMs: number;
128
- }
129
-
130
- // --- spawn helpers ----------------------------------------------------------
131
-
132
- function resolveBinary(explicit?: string): string {
133
- return explicit || process.env.AGY_BIN || "agy";
134
- }
135
-
136
- function extraArgsFromEnv(): string[] {
137
- const raw = process.env.AGY_EXTRA_ARGS;
138
- return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
139
- }
140
-
141
- const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
142
-
143
- // --- the turn ---------------------------------------------------------------
144
-
145
- /** Spawn agy and stream decoded events until it exits. Calls `onEvent` for
146
- * every decoded step in order. Returns the run outcome (exit code, discovered
147
- * conversation id, last step idx seen). Never throws on agy failure -
148
- * surfaces non-zero exit / timeout / abort in the result. */
149
- export async function runAgyTurn(
150
- opts: AgyRunOptions,
151
- onEvent: (event: AgyEvent) => void,
152
- ): Promise<AgyRunResult> {
153
- const start = Date.now();
154
- const dir = opts.conversationsDir || CONVERSATIONS_DIR;
155
- const mode = opts.mode ?? "accept-edits";
156
- const timeoutMin = opts.timeoutMin ?? DEFAULT_TIMEOUT_MIN;
157
- const binary = resolveBinary(opts.binary);
158
- const extra = [...extraArgsFromEnv(), ...(opts.extraArgs ?? [])];
159
-
160
- const rawConvId = opts.conversationId ?? null;
161
- const isContinuation =
162
- typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
163
- const snapshot = isContinuation ? null : snapshotConversations(dir);
164
- let lastIdx = opts.baseStepIdx ?? -1;
165
-
166
- // Build argv. --add-dir first so agy binds the workspace before anything.
167
- const args = ["--add-dir", opts.cwd, ...extra];
168
- // When the MCP tool bridge is running, add its config dir so this agy
169
- // discovers pi's tools (memory, codegraph, search, ...). agy reads
170
- // .agents/mcp_config.json from --add-dir dirs. AskAntigravity does NOT pass
171
- // this dir, so its agy stays plain (no recursion).
172
- if (bridgeMcpConfigExists()) args.push("--add-dir", bridgeMcpConfigDir());
173
- if (opts.model) args.push("--model", opts.model);
174
- if (opts.effort) args.push("--effort", opts.effort);
175
- args.push("--mode", mode);
176
- // Without this, any run_command triggers an interactive permission prompt
177
- // that hangs forever in non-interactive print mode (no TTY to answer y/n).
178
- // accept-edits only auto-approves file writes, NOT commands. See PLAN.md.
179
- if (opts.skipPermissions !== false) args.push("--dangerously-skip-permissions");
180
- if (isContinuation && rawConvId) args.push("--conversation", rawConvId);
181
- args.push("--print-timeout", `${timeoutMin}m`);
182
- args.push("-p", opts.prompt);
183
-
184
- let stderr = "";
185
- let boundId = isContinuation ? rawConvId : null;
186
- // Counts bind attempts so the /proc FD scan can't run on every tick forever
187
- // when the snapshot stays ambiguous (see MAX_BIND_ATTEMPTS).
188
- let bindAttempts = 0;
189
-
190
- // One poller per turn, lazily opened once we know the conversation id.
191
- let poller: ConversationPoller | null = null;
192
- // agy extends the step it is currently writing in place (same idx, growing
193
- // text). poll() returns only idx > lastIdx, so we re-read the last
194
- // text/thinking step each tick and emit the grown suffix as a delta.
195
- // flushStreamStep also runs BEFORE the loop so a step boundary landing
196
- // inside this tick doesn't drop the outgoing step's final in-place tail
197
- // (the loop may switch streamIdx to a new idx before we'd re-read).
198
- let streamIdx = -1;
199
- let streamKind: "text" | "thinking" | null = null;
200
- let streamEmitted = 0;
201
- const flushStreamStep = (): void => {
202
- if (streamIdx < 0 || !poller || !streamKind) return;
203
- const s = poller.readStepAt(streamIdx);
204
- if (!s) return;
205
- try {
206
- const t = extractAgentText(s.payload);
207
- if (t && t.text.length > streamEmitted) {
208
- onEvent({ kind: streamKind, text: t.text.slice(streamEmitted) });
209
- streamEmitted = t.text.length;
210
- }
211
- } catch {
212
- /* torn re-read; next poll retries */
213
- }
214
- };
215
- const pollOnce = (): boolean => {
216
- // Bind the conversation id on a fresh run (agy doesn't print it).
217
- // pid lets newConversationId disambiguate when a concurrent agy also
218
- // drops a new .db in the dir during our turn (see discovery.ts).
219
- if (!boundId && snapshot && bindAttempts < MAX_BIND_ATTEMPTS) {
220
- boundId = newConversationId(dir, snapshot, {
221
- pid: proc?.pid,
222
- // Only the genuinely-ambiguous case (>1 new DB, unresolved) counts
223
- // against the cap. The ordinary "agy hasn't written its DB yet" wait
224
- // must keep polling for the full turn timeout, not burn a budget
225
- // meant to bound the expensive /proc FD-scan retries.
226
- onAmbiguous: () => { bindAttempts++; },
227
- });
228
- }
229
- if (!boundId) return false;
230
-
231
- if (!poller) {
232
- poller = new ConversationPoller(conversationDbPath(boundId, dir), lastIdx);
233
- }
234
- if (!poller.isOpen && !poller.tryOpen()) return false;
235
-
236
- // Coalesce: one data_version check per tick gates BOTH the in-place
237
- // re-read (flushStreamStep -> readStepAt) and the new-row read. While
238
- // agy is thinking and hasn't committed, hasChanged() is false and we
239
- // skip both SELECTs, so no row read fires on an idle tick.
240
- if (!poller.hasChanged()) return false;
241
-
242
- // Catch the currently-tracked step's final in-place growth BEFORE the
243
- // loop may switch tracking to a new step.
244
- flushStreamStep();
245
- const steps = poller.readNewSteps();
246
- for (const step of steps) {
247
- // A torn read (agy mid-write) can throw RangeError out of the protobuf
248
- // walker. Drop that step rather than aborting the whole turn - the
249
- // row settles on the next poll. (agy-acp database.ts pattern, lifted
250
- // to the decode layer where the throw actually originates.)
251
- try {
252
- const event = decodeStep(step);
253
- if (event) {
254
- onEvent(event);
255
- if (event.kind === "text" || event.kind === "thinking") {
256
- streamIdx = step.idx;
257
- streamKind = event.kind;
258
- streamEmitted = event.text.length;
259
- }
260
- }
261
- } catch {
262
- /* drop undecodable step; lastIdx still advances past it */
263
- }
264
- }
265
- flushStreamStep();
266
- lastIdx = poller.lastIdx;
267
- return steps.length > 0;
268
- };
269
-
270
- const result: AgyRunResult = {
271
- exitCode: 0,
272
- conversationId: null,
273
- lastIdx: -1,
274
- aborted: false,
275
- timedOut: false,
276
- stderr: "",
277
- durationMs: 0,
278
- };
279
-
280
- let proc: ChildProcess | null = null;
281
- let settled = false;
282
- let timedOut = false;
283
- let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
284
- let watchdog: ReturnType<typeof setTimeout> | undefined;
285
-
286
- const killTree = () => {
287
- try {
288
- if (proc?.pid) process.kill(-proc.pid, "SIGTERM");
289
- } catch {
290
- /* process group already gone */
291
- }
292
- if (!sigkillTimer) {
293
- sigkillTimer = setTimeout(() => {
294
- try {
295
- if (proc?.pid) process.kill(-proc.pid, "SIGKILL");
296
- } catch {
297
- /* give up */
298
- }
299
- }, GRACE_AFTER_TIMEOUT_MS);
300
- }
301
- };
302
-
303
- const onAbort = () => killTree();
304
-
305
- try {
306
- await new Promise<void>((resolveP, rejectP) => {
307
- // detached: true so we can signal the whole process group. agy
308
- // spawns its own exec subprocesses in -p mode; a direct kill would
309
- // orphan those grandchildren.
310
- proc = spawn(binary, args, {
311
- cwd: opts.cwd,
312
- stdio: ["ignore", "ignore", "pipe"],
313
- shell: false,
314
- detached: true,
315
- });
316
- proc.stderr?.setEncoding("utf8");
317
- proc.stderr?.on("data", (d: string) => (stderr += d));
318
-
319
- // Drive the DB poll concurrently with the running process. THIS is the
320
- // streaming: without it, no event reaches the caller until agy exits,
321
- // defeating the whole point of the provider. Cleared in cleanup().
322
- const pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
323
- pollTimer.unref?.();
324
-
325
- const cleanup = () => {
326
- clearInterval(pollTimer);
327
- if (watchdog) clearTimeout(watchdog);
328
- if (sigkillTimer) clearTimeout(sigkillTimer);
329
- if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
330
- };
331
-
332
- // Enforce the timeout ourselves (agy's --print-timeout is advisory).
333
- watchdog = setTimeout(() => {
334
- timedOut = true;
335
- killTree();
336
- }, timeoutMin * 60_000);
337
-
338
- if (opts.signal) {
339
- if (opts.signal.aborted) killTree();
340
- else opts.signal.addEventListener("abort", onAbort, { once: true });
341
- }
342
-
343
- const finish = (code: number | null) => {
344
- if (settled) return;
345
- settled = true;
346
- cleanup();
347
- resolveP();
348
- void code; // exit code read off proc below
349
- };
350
-
351
- proc.on("error", (err) => {
352
- cleanup();
353
- rejectP(err);
354
- });
355
- proc.on("close", finish);
356
- proc.on("exit", finish);
357
- });
358
- } catch (err) {
359
- // spawn ENOENT etc. - surface as a non-zero result, don't throw.
360
- stderr += err instanceof Error ? err.message : String(err);
361
- }
362
-
363
- // Capture abort state before the trailing-poll loop. On cancel we skip the
364
- // trailing polls (3 x 100ms) so the stream finalizes promptly with whatever
365
- // was already streamed, instead of stalling ~300ms after agy was killed.
366
- const wasAborted = !!opts.signal?.aborted;
367
-
368
- // Trailing polls: agy may flush a final step moments after exit. The
369
- // agy-acp pattern (3 x 100ms) catches these without adding noticeable latency.
370
- if (!wasAborted) {
371
- for (let i = 0; i < TRAILING_POLLS; i++) {
372
- pollOnce();
373
- await sleep(TRAILING_POLL_MS);
374
- }
375
- }
376
-
377
- // CFA note: poller/proc are assigned inside closures that TS can't track
378
- // through the await, so they narrow to `null` here. Casts break the
379
- // narrowing without lying about the runtime type.
380
- (poller as ConversationPoller | null)?.close();
381
-
382
- result.exitCode = (proc as ChildProcess | null)?.exitCode ?? (stderr ? 1 : 0);
383
- result.aborted = wasAborted;
384
- result.timedOut = timedOut;
385
- result.conversationId = boundId;
386
- result.lastIdx = lastIdx;
387
- result.stderr = stderr;
388
- result.durationMs = Date.now() - start;
389
- return result;
390
- }