@estebanforge/pi-antigravity-bridge 1.3.1 → 1.3.3

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/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
- }