@estebanforge/pi-antigravity-bridge 1.8.8 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.9.0] - 2026-10-27
6
+
7
+ ### Fixed
8
+
9
+ - **`/agy-stop` no longer discards an answer that already finished computing.** Between the agy process closing and the run settling (conversation discovery + classification, up to ~500ms), a stop settled the run failed and silently dropped the computed answer. The stop handler now consults a finishing guard (the new opt-in `guard` option on pi-ask-shared's `createStopHandler`) and refuses with "already finished; its result is being delivered" while that window is open; the wake then delivers normally.
10
+
11
+ - **`details.stderr` is redacted at the boundary.** Tool-result details are persisted in session files, and raw stderr can carry auth material; the stored field now passes through the bridge's redactor. The blocking answer text stays raw, like every other ask tool. This rides the same change as the background feature, which introduced the background-run stderr wake path that made the asymmetry visible.
12
+
13
+ - **The suppression refcount survives `/reload`.** It lived in module state, so a runtime replacement reset it to zero and the old runtime's late release (its kill path is async) could re-enable the bridge's global entries while the new runtime's delegation was still live. It now lives in a `Symbol.for`-keyed `globalThis` slot, shared by every runtime instance in the process.
14
+
15
+ ### Changed
16
+
17
+ - **Refusal results render with the error glyph.** Refusal paths returned `exitCode: 0`, so pi showed "✓ AskAntigravity" for calls that never ran. They now carry `details.refused`, and `renderResult` flips to ✗. Details only; refusal text is unchanged.
18
+
19
+ ### Added
20
+
21
+ - **`AskAntigravity` supports background runs, 1:1 with the standalone pi-ask-antigravity extension.** The old `background: true` refusal is gone: the call returns a `bg_` runId at once and the full answer arrives as an UNTRUSTED-tagged wake message (customType `ask-antigravity-result`, secret-redacted through the bridge's redactor) when the detached `agy -p` tree settles, carrying the conversationId resume handle. New `/agy-stop [runId]` command kills a run by id prefix, or the only running one with no argument. Shared `pi-ask-shared` machinery gives the same semantics as every other ask tool: concurrency cap 4 with a visible refusal, settle-once so a stop and a natural close can never double-wake, TTL sweep, and `session_shutdown` killing in-flight runs silently (pi's `/new`, `/resume`, `/fork`, `/reload` wipe module state and take the runs with them). Background is refused in print/json mode, where the process exits before the wake could arrive. Two behavior fixes ride along: the MCP-suppression window now spans background runs too (execute() returns while agy is still alive, so the release moved into the run lifecycle and fires exactly once per run), and the per-conversation lock (one run per conversationId, since two runs resuming the same conversation interleave turns inside agy's SQLite state) now also covers blocking calls. The delegation path itself is unchanged and engine-independent: always a fresh one-shot `agy -p`, never the ACP or stream-json turn engine. Review hardening riding in the same change: the detached run now settles on stream close instead of process exit (an exit-time settle could truncate the answer and disarmed the SIGKILL escalation while group members were still dying), stdout/stderr buffers carry valves (1M/64k) so a runaway child cannot grow memory over a days-long timeout, the timeout wake's partial-output slice is redacted before cutting (a sliced secret can fall under the redactor's length thresholds), and the suppression release is exactly-once at the call site, not just inside the callee's refcount guard.
22
+
5
23
  ## [1.8.8] - 2026-10-27
6
24
 
7
25
  ### Fixed
package/README.md CHANGED
@@ -150,6 +150,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
150
150
  /agy engine acp|stream-json switch the turn engine (restart to apply; default acp, stream-json is the fallback; acp runs self-service setup: binary install + auth bootstrap). No arguments opens the engine picker modal (TUI)
151
151
  /agy auth-manual manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
152
152
  /agy clear drop all session bindings (force fresh conversations)
153
+ /agy-stop [runId] stop a background AskAntigravity run (id prefix, or the only running one with no argument)
153
154
  ```
154
155
 
155
156
  ### Permissions
@@ -226,9 +227,9 @@ pi-antigravity-bridge practical risk is low, near zero. But not zero: the "in co
226
227
 
227
228
  This is engineering analysis, not legal advice. Use against your own Antigravity account at your own risk; I am not responsible for any consequence to your account.
228
229
 
229
- ## Delegation tool: background is refused
230
+ ## Delegation tool: background runs
230
231
 
231
- The `AskAntigravity` delegation tool rejects `background: true` by design: a background wake would land in the pi session even when the caller is a nested delegation through the bridge catalog, and long delegations already have the bridge's own async flow (early-ack plus `bridge_poll_result`). Call without `background`; for unattended peer runs launch the CLI directly in a terminal.
232
+ `AskAntigravity` accepts `background: true` (1:1 with the standalone pi-ask-antigravity extension): the call returns a `bg_` runId at once and the full answer arrives later as an UNTRUSTED-tagged message (secret-redacted) carrying the resume handle. `/agy-stop [runId]` stops a run by id prefix, or the only running one with no argument; the concurrency cap is 4. A stop landing after agy already closed but before the result settles (the discovery window) is refused with "already finished; its result is being delivered" instead of dropping the answer. Background is refused in pi's print/json modes (the process exits before the message could arrive), and `/new`, `/resume`, `/fork`, `/reload` kill in-flight runs. Delegation always spawns a one-shot `agy -p` process - never the ACP or stream-json turn engine.
232
233
 
233
234
  ## License
234
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.8.8",
3
+ "version": "1.9.0",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -54,7 +54,7 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@estebanforge/pi-ask-shared": "^0.1.2",
57
+ "@estebanforge/pi-ask-shared": "^0.1.3",
58
58
  "@estebanforge/pi-tui-dialogs": "^0.1.2",
59
59
  "@modelcontextprotocol/sdk": "^1.31.0"
60
60
  }
package/src/ask-tool.ts CHANGED
@@ -21,9 +21,13 @@ import { Text } from "@earendil-works/pi-tui";
21
21
  import { contentText, type ThinkingLevel } from "@earendil-works/pi-ai";
22
22
  import { Type } from "typebox";
23
23
  import {
24
+ BackgroundRunRegistry,
25
+ backgroundFlagText,
24
26
  buildAgyArgs,
25
27
  buildFinalPrompt,
26
28
  buildTimeBudgetNotice,
29
+ createStopHandler,
30
+ createWakeSender,
27
31
  MAX_TIMEOUT_MINUTES,
28
32
  CONVERSATIONS_DIR,
29
33
  CONV_ID_RE,
@@ -33,6 +37,7 @@ import {
33
37
  parseModelLine,
34
38
  resolveAgyModel,
35
39
  snapshotConversations,
40
+ summarizePrompt,
36
41
  type ModelEntry,
37
42
  type Mode,
38
43
  } from "@estebanforge/pi-ask-shared";
@@ -165,6 +170,266 @@ export function stageReviewerAgent(root: string): { name: string; dir: string }
165
170
 
166
171
  const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
167
172
 
173
+ // --- One-shot agy run -------------------------------------------------------
174
+
175
+ type AskLogFn = (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
176
+
177
+ /** Terminal result of one `agy -p` lifecycle. */
178
+ interface AgyOutcome {
179
+ exitCode: number;
180
+ aborted: boolean;
181
+ timedOut: boolean;
182
+ /** Full stdout, trimmed. Empty means the headless auto-deny failure mode. */
183
+ answerText: string;
184
+ }
185
+
186
+ /** Tool-result shape the execute path returns; keeps the background helper's literals narrow. */
187
+ interface AskToolResult {
188
+ content: Array<{ type: "text"; text: string }>;
189
+ details: AgyDetails;
190
+ }
191
+
192
+ /**
193
+ * Spawns `agy -p` and resolves when the process tree closes. Blocking and
194
+ * background runs share this lifecycle. Owns the delegation-suppression
195
+ * window for the WHOLE run: agy watches the global mcp_config and
196
+ * hot-reloads it, so a mid-run re-enable pokes the live delegation
197
+ * (observed live 2026-09-09). Background runs outlive execute()'s return,
198
+ * which is why the window lives here and not in the caller's finally.
199
+ */
200
+ async function runAgyProcess(o: {
201
+ binary: string;
202
+ args: string[];
203
+ cwd: string;
204
+ timeoutMin: number;
205
+ startAt: number;
206
+ /** Mutated in place: conversationId discovery, stderr, outcome fields. */
207
+ details: AgyDetails;
208
+ isContinuation: boolean;
209
+ snapshot: ReturnType<typeof snapshotConversations> | null;
210
+ background: boolean;
211
+ /** Blocking passes pi's per-run signal; background passes none on purpose: a later Esc must not kill a detached run. */
212
+ signal?: AbortSignal;
213
+ /** Blocking only: throttled progress partials. */
214
+ onPartial?: (text: string) => void;
215
+ /** Background only: hands over the tree-kill switch as soon as it exists. */
216
+ registerKill?: (kill: () => void) => void;
217
+ /** Background only: the child closed; discovery/classification still running. Arms the stop handler's finishing guard. */
218
+ onClose?: () => void;
219
+ log?: AskLogFn;
220
+ }): Promise<AgyOutcome> {
221
+ const restoreBridge = acquireBridgeSuppression();
222
+ let bridgeReleased = false;
223
+ const releaseBridge = () => {
224
+ if (bridgeReleased) return;
225
+ bridgeReleased = true;
226
+ restoreBridge();
227
+ };
228
+ let out = "";
229
+ // Local: o.snapshot narrowing does not survive into the bind-poll closure.
230
+ const snapshot = o.snapshot;
231
+ const statusInterval = o.onPartial
232
+ ? setInterval(() => {
233
+ const elapsed = Math.floor((Date.now() - o.startAt) / 1000);
234
+ const tail = out.slice(-STATUS_TAIL_CHARS);
235
+ const text = tail ? `(running ${elapsed}s)\n…${tail}` : `(running ${elapsed}s)`;
236
+ o.onPartial!(text);
237
+ }, STATUS_INTERVAL_MS)
238
+ : null;
239
+
240
+ try {
241
+ // Bind the conversation id DURING the run (agy is alive then) so the
242
+ // pid-based /proc FD resolver can disambiguate when a concurrent agy
243
+ // also drops a new .db. Awaited after the run; the post-exit loop
244
+ // below is the fallback for runs that exit before the poll binds.
245
+ let bindDuringRun: Promise<void> = Promise.resolve();
246
+ const outcome = await new Promise<{ exitCode: number; aborted: boolean; timedOut: boolean }>((resolveP, rejectP) => {
247
+ const proc = spawn(o.binary, o.args, {
248
+ cwd: o.cwd,
249
+ stdio: ["ignore", "pipe", "pipe"],
250
+ shell: false,
251
+ detached: true,
252
+ });
253
+ proc.stdout?.setEncoding("utf8");
254
+ proc.stderr?.setEncoding("utf8");
255
+ // Valve: a runaway child must not grow these without bound over a
256
+ // detached run's lifetime (timeouts can reach days).
257
+ proc.stdout?.on("data", (d: string) => {
258
+ if (out.length < 1_000_000) out += d;
259
+ });
260
+ proc.stderr?.on("data", (d: string) => {
261
+ if (o.details.stderr.length < 65_536) o.details.stderr += d;
262
+ });
263
+
264
+ if (!o.isContinuation && snapshot && proc.pid) {
265
+ bindDuringRun = (async () => {
266
+ for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
267
+ if (o.details.conversationId) return;
268
+ if (proc.exitCode !== null) return; // agy gone: scan useless now
269
+ const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
270
+ pid: proc.pid,
271
+ });
272
+ if (found) {
273
+ o.details.conversationId = found;
274
+ return;
275
+ }
276
+ await sleep(DISCOVERY_POLL_MS);
277
+ }
278
+ })().catch(() => {
279
+ /* best-effort: a bind error must never fail an otherwise-OK turn */
280
+ });
281
+ }
282
+
283
+ let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
284
+ let watchdog: ReturnType<typeof setTimeout> | undefined;
285
+ let settled = false;
286
+ let timedOut = false;
287
+
288
+ const killTree = () => {
289
+ try {
290
+ if (proc.pid) process.kill(-proc.pid, "SIGTERM");
291
+ } catch {
292
+ /* process group already gone */
293
+ }
294
+ if (!sigkillTimer) {
295
+ sigkillTimer = setTimeout(() => {
296
+ try {
297
+ if (proc.pid) process.kill(-proc.pid, "SIGKILL");
298
+ } catch {
299
+ /* give up */
300
+ }
301
+ }, GRACE_AFTER_TIMEOUT_MS);
302
+ }
303
+ };
304
+
305
+ // Hand the tree-kill switch to the background registry before the
306
+ // watchdog arms, so /agy-stop can land the moment the run exists.
307
+ o.registerKill?.(killTree);
308
+
309
+ const cleanup = () => {
310
+ if (watchdog) clearTimeout(watchdog);
311
+ if (sigkillTimer) clearTimeout(sigkillTimer);
312
+ if (o.signal) o.signal.removeEventListener("abort", onAbort);
313
+ releaseBridge();
314
+ o.onClose?.();
315
+ };
316
+ const onAbort = () => killTree();
317
+
318
+ watchdog = setTimeout(() => {
319
+ timedOut = true;
320
+ killTree();
321
+ }, o.timeoutMin * 60_000);
322
+
323
+ if (o.signal) {
324
+ if (o.signal.aborted) killTree();
325
+ else o.signal.addEventListener("abort", onAbort, { once: true });
326
+ }
327
+
328
+ const finish = (code: number | null) => {
329
+ if (settled) return;
330
+ settled = true;
331
+ cleanup();
332
+ resolveP({ exitCode: code ?? 0, aborted: !!o.signal?.aborted, timedOut });
333
+ };
334
+
335
+ proc.on("error", (err) => {
336
+ cleanup();
337
+ rejectP(err);
338
+ });
339
+ // Settle on close, never exit: exit can strand unread stdout
340
+ // (truncated answer) and would clear the SIGKILL escalation while
341
+ // group members are still dying.
342
+ proc.on("close", finish);
343
+ });
344
+
345
+ // Let the during-run bind poll finish (it bails immediately once agy
346
+ // has exited, so this rarely blocks).
347
+ await bindDuringRun;
348
+
349
+ o.details.exitCode = outcome.exitCode;
350
+ o.details.aborted = outcome.aborted;
351
+ o.details.timedOut = outcome.timedOut;
352
+ // details ride the tool result and are persisted in session files;
353
+ // stderr can carry auth material, so it is scrubbed at this boundary.
354
+ o.details.stderr = redactText(o.details.stderr);
355
+ o.details.durationMs = Date.now() - o.startAt;
356
+ o.log?.(
357
+ "ask-end",
358
+ { exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, empty: !out.trim(), background: o.background, durationMs: o.details.durationMs, conversationId: o.details.conversationId },
359
+ outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut || !out.trim() ? "warn" : "info",
360
+ );
361
+
362
+ if (!o.isContinuation && !o.details.conversationId && snapshot) {
363
+ for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
364
+ const found = newConversationId(CONVERSATIONS_DIR, snapshot);
365
+ if (found) {
366
+ o.details.conversationId = found;
367
+ break;
368
+ }
369
+ await sleep(DISCOVERY_POLL_MS);
370
+ }
371
+ }
372
+
373
+ return { ...outcome, answerText: out.trim() };
374
+ } finally {
375
+ if (statusInterval) clearInterval(statusInterval);
376
+ // Belt and braces: cleanup() already releases on close/error; this
377
+ // covers paths that never reached the process (sync spawn throw).
378
+ releaseBridge();
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Builds the model/user-facing answer text for every terminal outcome.
384
+ * Pure except the empty-output marker it stamps on details. Blocking and
385
+ * background share it so both surfaces read identically.
386
+ */
387
+ function shapeFinalText(details: AgyDetails, outcome: AgyOutcome, timeoutMin: number): string {
388
+ const text = outcome.answerText;
389
+
390
+ if (outcome.aborted) {
391
+ return text ? `agy was aborted. Partial output:\n\n${text}` : "agy was aborted before producing output.";
392
+ }
393
+
394
+ if (outcome.timedOut) {
395
+ const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
396
+ return text ? `${text}\n\n[${note}]` : note;
397
+ }
398
+
399
+ if (outcome.exitCode !== 0) {
400
+ // stderr can carry auth material; the note is chat-visible.
401
+ const stderr = redactText(details.stderr.trim());
402
+ const note = stderr
403
+ ? `agy exited with status ${outcome.exitCode}: ${stderr}`
404
+ : `agy exited with status ${outcome.exitCode}`;
405
+ return text ? `${text}\n\n[${note}]` : note;
406
+ }
407
+
408
+ // Exit 0 with nothing on stdout is still a failure for the
409
+ // delegator: headless agy auto-denies a permission-gated tool call
410
+ // (e.g. the command gate in plan mode), prints the reason only to
411
+ // stderr, and ends cleanly. Falling through to the success path here
412
+ // returned just the conversation footer, which read as an empty
413
+ // success (silent-failure bug found 2026-09-25).
414
+ if (!text) {
415
+ details.empty = true;
416
+ const stderr = redactText(details.stderr.trim());
417
+ const note = [
418
+ "agy exited cleanly but produced no output.",
419
+ stderr ? `stderr: ${stderr}` : null,
420
+ "Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
421
+ ]
422
+ .filter(Boolean)
423
+ .join(" ");
424
+ return note;
425
+ }
426
+
427
+ const footer = details.conversationId
428
+ ? `\n\n[agy conversationId: ${details.conversationId} - pass as conversationId to continue this conversation]`
429
+ : "";
430
+ return text + footer;
431
+ }
432
+
168
433
  // --- Registration ----------------------------------------------------------
169
434
 
170
435
  /** Register the AskAntigravity tool. Call once from the extension entry.
@@ -184,6 +449,209 @@ export async function registerAskAntigravityTool(
184
449
  } catch {
185
450
  // a sweep failure must never block registration
186
451
  }
452
+
453
+ // --- Background-run state (one set per registration/load) ----------------
454
+ // Pi wipes module state on /new, /resume, /fork and /reload: an in-flight
455
+ // background run is killed and its result is never delivered. Accepted
456
+ // trade-off, same as the standalone pi-ask-antigravity extension.
457
+ const registry = new BackgroundRunRegistry({ toolName: "ask-antigravity" });
458
+ const kills = new Map<string, () => void>();
459
+ // Staged-artifact cleanup (contextFile + reviewer agent dir), keyed by run:
460
+ // the session_shutdown backstop runs these when a hard exit would skip the
461
+ // close-path cleanup.
462
+ const cleanups = new Map<string, () => void>();
463
+ // One run per conversationId at a time: two runs resuming the same
464
+ // conversation interleave their turns inside agy's SQLite state.
465
+ const busyHandles = new Set<string>();
466
+ // The child closed but the answer is not settled yet (discovery +
467
+ // classification): a stop in this window must refuse instead of settling
468
+ // failed and discarding a computed answer.
469
+ const finishing = new Set<string>();
470
+ // The wake is chat-visible and persisted: run it through the bridge's
471
+ // secret redactor like every other reassembled-output boundary.
472
+ const sendWake = createWakeSender(pi, {
473
+ customType: "ask-antigravity-result",
474
+ isDisposed: () => registry.isDisposed(),
475
+ redact: redactText,
476
+ });
477
+ const stopBackground = createStopHandler(registry, {
478
+ kill: (run) => kills.get(run.runId)?.(),
479
+ onStopped: (run) =>
480
+ sendWake({
481
+ toolLabel: "agy",
482
+ runId: run.runId,
483
+ ok: false,
484
+ elapsedS: Math.round((Date.now() - run.startedAt) / 1000),
485
+ error: "stopped via /agy-stop",
486
+ }),
487
+ // The guard keeps /agy-stop from discarding an answer that already
488
+ // finished computing and is riding discovery to its wake.
489
+ guard: (run) =>
490
+ finishing.has(run.runId) ? `Run ${run.runId} already finished; its result is being delivered.` : null,
491
+ });
492
+ pi.on("session_shutdown", () => {
493
+ // Latch first so close-handler wakes stay silent, then kill children,
494
+ // then sweep staged artifacts (a hard exit would skip the close path).
495
+ for (const run of registry.dispose()) kills.get(run.runId)?.();
496
+ for (const fn of cleanups.values()) fn();
497
+ cleanups.clear();
498
+ kills.clear();
499
+ });
500
+ pi.registerCommand("agy-stop", {
501
+ description: "Stop a background AskAntigravity run (id prefix, or the only running one). Usage: /agy-stop [runId]",
502
+ handler: async (args, ctx) => {
503
+ const message = await stopBackground(args ?? "");
504
+ if (ctx.hasUI) ctx.ui.notify(message, "info");
505
+ },
506
+ });
507
+
508
+ /** Fire-and-forget background run: registry slot, detached agy, wake on
509
+ * settle. Mirrors the standalone pi-ask-antigravity extension call for
510
+ * call; only the suppression window and the redaction are bridge-local. */
511
+ const startBackgroundRun = (o: {
512
+ summary: string;
513
+ binary: string;
514
+ args: string[];
515
+ workdir: string;
516
+ timeoutMin: number;
517
+ startAt: number;
518
+ details: AgyDetails;
519
+ contextFile: string | null;
520
+ reviewerAgent: { name: string; dir: string } | null;
521
+ modelLabel: string;
522
+ isContinuation: boolean;
523
+ snapshot: ReturnType<typeof snapshotConversations> | null;
524
+ conversationId?: string;
525
+ }): AskToolResult => {
526
+ const cleanupStaging = () => {
527
+ // Both staged artifacts must outlive the execute() return in
528
+ // background mode: agy reads them mid-run.
529
+ if (o.contextFile) {
530
+ try {
531
+ fs.unlinkSync(o.contextFile);
532
+ } catch {}
533
+ }
534
+ if (o.reviewerAgent) {
535
+ try {
536
+ fs.rmSync(o.reviewerAgent.dir, { recursive: true, force: true });
537
+ } catch {}
538
+ }
539
+ };
540
+ registry.sweep();
541
+ if (o.conversationId && busyHandles.has(o.conversationId)) {
542
+ cleanupStaging();
543
+ return {
544
+ content: [
545
+ {
546
+ type: "text",
547
+ text: `background refused: conversation ${o.conversationId} already has a background run. Wait for its wake message, or use blocking mode.`,
548
+ },
549
+ ],
550
+ details: { ...o.details, refused: true },
551
+ };
552
+ }
553
+ if (o.conversationId) busyHandles.add(o.conversationId);
554
+ let run;
555
+ try {
556
+ run = registry.start(summarizePrompt(o.summary));
557
+ } catch (err) {
558
+ if (o.conversationId) busyHandles.delete(o.conversationId);
559
+ cleanupStaging();
560
+ const msg = err instanceof Error ? err.message : String(err);
561
+ return {
562
+ content: [
563
+ {
564
+ type: "text",
565
+ text: `background refused: ${msg}. Use blocking (omit background) or free a slot with /agy-stop.`,
566
+ },
567
+ ],
568
+ details: { ...o.details, refused: true },
569
+ };
570
+ }
571
+ const runId = run.runId;
572
+ cleanups.set(runId, cleanupStaging);
573
+ log?.(
574
+ "ask-start",
575
+ { model: o.details.resolvedModel, thinking: o.details.thinking, mode: o.details.mode, digest: o.details.digest, continue: o.isContinuation, timeoutMin: o.timeoutMin, background: true },
576
+ "info",
577
+ );
578
+ void runAgyProcess({
579
+ binary: o.binary,
580
+ args: o.args,
581
+ cwd: o.workdir,
582
+ timeoutMin: o.timeoutMin,
583
+ startAt: o.startAt,
584
+ details: o.details,
585
+ isContinuation: o.isContinuation,
586
+ snapshot: o.snapshot,
587
+ background: true,
588
+ registerKill: (kill) => {
589
+ kills.set(runId, kill);
590
+ },
591
+ onClose: () => finishing.add(runId),
592
+ log,
593
+ })
594
+ .then((outcome) => {
595
+ kills.delete(runId);
596
+ const finalText = shapeFinalText(o.details, outcome, o.timeoutMin);
597
+ const elapsedS = Math.round((Date.now() - o.startAt) / 1000);
598
+ const handle = o.details.conversationId ? `conversationId=${o.details.conversationId}` : undefined;
599
+ const failed = outcome.timedOut || outcome.exitCode !== 0 || !outcome.answerText;
600
+ // The settle latch makes /agy-stop and shutdown win races
601
+ // against this handler: a false return means their wake went out.
602
+ const settled = registry.settle(runId, {
603
+ status: failed ? "failed" : "done",
604
+ output: finalText,
605
+ handle,
606
+ error: outcome.timedOut
607
+ ? `timeout after ${o.timeoutMin}m`
608
+ : outcome.exitCode !== 0
609
+ ? `exit status ${outcome.exitCode}`
610
+ : !outcome.answerText
611
+ ? "agy exited cleanly but produced no output"
612
+ : undefined,
613
+ });
614
+ if (!settled) return;
615
+ if (outcome.timedOut) {
616
+ const partial = outcome.answerText
617
+ ? ` Partial output: ${redactText(outcome.answerText).slice(0, 400)}`
618
+ : "";
619
+ sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: `timeout after ${o.timeoutMin}m.${partial}`, handle });
620
+ } else if (outcome.exitCode !== 0) {
621
+ // stderr can carry auth material; the reason is chat-visible.
622
+ const reason = redactText(o.details.stderr.trim()).slice(0, 400) || `exit status ${outcome.exitCode}`;
623
+ sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: reason, handle });
624
+ } else if (!outcome.answerText) {
625
+ sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: "agy exited cleanly but produced no output (headless auto-deny?)", handle });
626
+ } else {
627
+ sendWake({ toolLabel: "agy", runId, ok: true, elapsedS, handle }, finalText);
628
+ }
629
+ })
630
+ .catch((err) => {
631
+ kills.delete(runId);
632
+ const msg = err instanceof Error ? err.message : String(err);
633
+ // Settle first: an /agy-stop that won the race owns the wake.
634
+ const settled = registry.settle(runId, { status: "failed", error: msg });
635
+ if (!settled) return;
636
+ sendWake({ toolLabel: "agy", runId, ok: false, error: `failed to run agy: ${msg}` });
637
+ })
638
+ .finally(() => {
639
+ cleanups.delete(runId);
640
+ finishing.delete(runId);
641
+ cleanupStaging();
642
+ if (o.conversationId) busyHandles.delete(o.conversationId);
643
+ });
644
+ return {
645
+ content: [
646
+ {
647
+ type: "text",
648
+ text: `Background run ${runId} started (model=${o.modelLabel}). The result arrives as a message when agy finishes; the resume handle (conversationId) comes with it when agy reports one. Do not poll. Continue with other work or end the turn.`,
649
+ },
650
+ ],
651
+ details: { ...o.details },
652
+ };
653
+ };
654
+
187
655
  pi.registerTool({
188
656
  name: "AskAntigravity",
189
657
  label: "Ask Antigravity",
@@ -245,8 +713,8 @@ export async function registerAskAntigravityTool(
245
713
  ),
246
714
  background: Type.Optional(
247
715
  Type.Boolean({
248
- description:
249
- "NOT supported by this tool. The bridge runs agy through its own delegated flow and the answer must return synchronously to the caller. For background runs use the standalone @estebanforge/pi-ask-antigravity extension. Calls with background=true are refused.",
716
+ description: backgroundFlagText("conversationId"),
717
+ default: false,
250
718
  }),
251
719
  ),
252
720
  }),
@@ -277,6 +745,7 @@ export async function registerAskAntigravityTool(
277
745
  if (useDigest) tags.push("digest");
278
746
  if (isContinue) tags.push("continue");
279
747
  if (args.includeContext) tags.push("context=full");
748
+ if (args.background) tags.push("bg");
280
749
 
281
750
  let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
282
751
  text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
@@ -299,7 +768,7 @@ export async function registerAskAntigravityTool(
299
768
 
300
769
  const body = result.content[0]?.type === "text" ? result.content[0].text : "";
301
770
  const errored =
302
- d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut || !!d?.empty;
771
+ !!d?.refused || d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut || !!d?.empty;
303
772
 
304
773
  let text = errored
305
774
  ? theme.fg("error", "✗ AskAntigravity error")
@@ -356,22 +825,6 @@ export async function registerAskAntigravityTool(
356
825
  details: emptyDetails(),
357
826
  };
358
827
  }
359
- // Background refusal (fail-closed): a wake from this tool would land in
360
- // the pi session even when the CALLER is agy (nested delegation via the
361
- // bridge catalog), and the bridge already owns an async delegation flow
362
- // (early-ack + bridge_poll_result). The standalone pi-ask-antigravity
363
- // extension is the background-capable tool.
364
- if (params.background) {
365
- return {
366
- content: [
367
- {
368
- type: "text",
369
- text: 'background is not supported by the bridge\'s AskAntigravity: the answer must return synchronously to the caller. Long delegations are already covered by the bridge\'s own async flow (early-ack + bridge_poll_result); for an unattended peer run, launch agy directly in a terminal instead. Call again without background.',
370
- },
371
- ],
372
- details: emptyDetails(),
373
- };
374
- }
375
828
  const requestedModel = (params.model as string | undefined) ?? config.defaultModel;
376
829
  if (typeof params.model === "string" && params.model.trim().startsWith("-")) {
377
830
  return {
@@ -496,7 +949,7 @@ export async function registerAskAntigravityTool(
496
949
 
497
950
  log?.(
498
951
  "ask-start",
499
- { model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin },
952
+ { model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin, background: false },
500
953
  "info",
501
954
  );
502
955
  // Build argv via the shared peer adapter (see buildAgyArgs there for
@@ -532,246 +985,126 @@ export async function registerAskAntigravityTool(
532
985
  };
533
986
 
534
987
  const binary = process.env.AGY_BIN || "agy";
535
- let out = "";
536
-
537
- // Delegation isolation: any agy on this machine discovers MCP servers
538
- // from the global mcp_config.json, so this spawned `agy -p` would find
539
- // live pi-bridge-* entries and call tools the host bridge cannot serve
540
- // outside a live provider turn ("no active antigravity turn"). agy also
541
- // WATCHES that file (ReloadMcpConfig): re-enabling mid-run pokes the live
542
- // delegation to reconnect. The old 5s grace timer did exactly that -
543
- // observed live 2026-09-09 as a call-tool-fail ~50s into a delegation -
544
- // so the entries now stay hidden for the WHOLE delegated run. The release
545
- // fires only on process close/error (cleanup + finally below); if pi
546
- // itself dies first, session start heals the file
547
- // (healBridgeSuppression in extensions/index.ts, marker-aware). Cross-
548
- // process coordination rides the suppression marker in mcp-registration.
549
- // Refcounted, so overlapping delegations in this process cannot release
550
- // each other's window early. A refused config fail-opens to the status quo.
551
- const restoreBridge = acquireBridgeSuppression();
552
-
553
- const statusInterval = onUpdate
554
- ? setInterval(() => {
555
- const elapsed = Math.floor((Date.now() - start) / 1000);
556
- const tail = out.slice(-STATUS_TAIL_CHARS);
557
- const text = tail ? `(running ${elapsed}s)\n…${tail}` : `(running ${elapsed}s)`;
558
- onUpdate({
559
- content: [{ type: "text", text }],
560
- details: { ...details, durationMs: Date.now() - start },
561
- });
562
- }, STATUS_INTERVAL_MS)
563
- : null;
564
988
 
565
- try {
566
- // Bind the conversation id DURING the run (agy is alive then) so the
567
- // pid-based /proc FD resolver can disambiguate when a concurrent agy
568
- // also drops a new .db. Awaited after the run; the post-exit loop
569
- // below is the fallback for runs that exit before the poll binds.
570
- let bindDuringRun: Promise<void> = Promise.resolve();
571
- const outcome = await new Promise<{
572
- exitCode: number;
573
- aborted: boolean;
574
- timedOut: boolean;
575
- }>((resolveP, rejectP) => {
576
- const proc = spawn(binary, args, {
577
- cwd,
578
- stdio: ["ignore", "pipe", "pipe"],
579
- shell: false,
580
- detached: true,
581
- });
582
- proc.stdout?.setEncoding("utf8");
583
- proc.stderr?.setEncoding("utf8");
584
- proc.stdout?.on("data", (d: string) => (out += d));
585
- proc.stderr?.on("data", (d: string) => (details.stderr += d));
586
-
587
- // Concurrent bind: poll for the new id while agy is alive. The FD
588
- // resolver needs a live process tree, so this stops (and the post-
589
- // exit fallback below takes over) once agy has exited.
590
- if (!isContinuation && snapshot && proc.pid) {
591
- bindDuringRun = (async () => {
592
- for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
593
- if (details.conversationId) return;
594
- if (proc.exitCode !== null) return; // agy gone: scan useless now
595
- const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
596
- pid: proc.pid,
597
- });
598
- if (found) {
599
- details.conversationId = found;
600
- return;
601
- }
602
- await sleep(DISCOVERY_POLL_MS);
603
- }
604
- })().catch(() => {
605
- /* best-effort: a bind error must never fail an otherwise-OK turn */
606
- });
607
- }
608
-
609
- let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
610
- let watchdog: ReturnType<typeof setTimeout> | undefined;
611
- let settled = false;
612
- let timedOut = false;
613
-
614
- const killTree = () => {
615
- try {
616
- if (proc.pid) process.kill(-proc.pid, "SIGTERM");
617
- } catch {
618
- /* process group already gone */
619
- }
620
- if (!sigkillTimer) {
621
- sigkillTimer = setTimeout(() => {
622
- try {
623
- if (proc.pid) process.kill(-proc.pid, "SIGKILL");
624
- } catch {
625
- /* give up */
626
- }
627
- }, GRACE_AFTER_TIMEOUT_MS);
628
- }
629
- };
630
-
631
- const cleanup = () => {
632
- if (watchdog) clearTimeout(watchdog);
633
- if (sigkillTimer) clearTimeout(sigkillTimer);
634
- if (signal) signal.removeEventListener("abort", onAbort);
635
- restoreBridge();
636
- };
637
- const onAbort = () => killTree();
638
-
639
- watchdog = setTimeout(() => {
640
- timedOut = true;
641
- killTree();
642
- }, timeoutMin * 60_000);
643
-
644
- if (signal) {
645
- if (signal.aborted) killTree();
646
- else signal.addEventListener("abort", onAbort, { once: true });
647
- }
648
-
649
- const finish = (code: number | null) => {
650
- if (settled) return;
651
- settled = true;
652
- cleanup();
653
- resolveP({
654
- exitCode: code ?? 0,
655
- aborted: !!signal?.aborted,
656
- timedOut,
657
- });
658
- };
659
-
660
- proc.on("error", (err) => {
661
- cleanup();
662
- rejectP(err);
663
- });
664
- proc.on("close", finish);
665
- proc.on("exit", finish);
666
- });
667
-
668
- if (statusInterval) clearInterval(statusInterval);
669
-
670
- // Let the during-run bind poll finish (it bails immediately once agy
671
- // has exited, so this rarely blocks).
672
- await bindDuringRun;
673
-
674
- details.exitCode = outcome.exitCode;
675
- details.aborted = outcome.aborted;
676
- details.timedOut = outcome.timedOut;
677
- details.durationMs = Date.now() - start;
678
- const text = out.trim();
679
- log?.(
680
- "ask-end",
681
- { exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, empty: !text, durationMs: details.durationMs, conversationId: details.conversationId },
682
- outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut || !text ? "warn" : "info",
683
- );
684
-
685
- if (!isContinuation && !details.conversationId && snapshot) {
686
- for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
687
- const found = newConversationId(CONVERSATIONS_DIR, snapshot);
688
- if (found) {
689
- details.conversationId = found;
690
- break;
691
- }
692
- await sleep(DISCOVERY_POLL_MS);
693
- }
989
+ const cleanupStaging = () => {
990
+ if (contextFile) {
991
+ try {
992
+ fs.unlinkSync(contextFile);
993
+ } catch {}
694
994
  }
995
+ if (reviewerAgent) {
996
+ try {
997
+ fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
998
+ } catch {}
999
+ }
1000
+ };
695
1001
 
696
- if (outcome.aborted) {
1002
+ // Background: hand the run to the registry and return at once. The
1003
+ // result travels back through sendMessage (wake), not this call.
1004
+ if (params.background) {
1005
+ if (ctx.mode === "print" || ctx.mode === "json") {
1006
+ cleanupStaging();
697
1007
  return {
698
1008
  content: [
699
1009
  {
700
1010
  type: "text",
701
- text: text
702
- ? `agy was aborted. Partial output:\n\n${text}`
703
- : "agy was aborted before producing output.",
1011
+ text: "background is not available in print/json mode: the process exits before the result message can arrive. Call again without background.",
704
1012
  },
705
1013
  ],
706
- details,
1014
+ details: { ...details, refused: true },
707
1015
  };
708
1016
  }
1017
+ return startBackgroundRun({
1018
+ summary: params.prompt,
1019
+ binary,
1020
+ args,
1021
+ workdir: cwd,
1022
+ timeoutMin,
1023
+ startAt: start,
1024
+ details,
1025
+ contextFile,
1026
+ reviewerAgent,
1027
+ modelLabel: resolved.model ?? requestedModel,
1028
+ isContinuation,
1029
+ snapshot,
1030
+ conversationId: isContinuation ? (rawConvId as string) : undefined,
1031
+ });
1032
+ }
709
1033
 
710
- if (outcome.timedOut) {
711
- const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
1034
+ // One run per conversationId at a time, blocking included: a blocking
1035
+ // resume and a background resume of the same conversation would
1036
+ // interleave turns inside agy's session state. Declared out here so
1037
+ // the finally can see it; set only when THIS call takes the lock.
1038
+ let lockAcquired = false;
1039
+ try {
1040
+ if (isContinuation && busyHandles.has(rawConvId as string)) {
1041
+ cleanupStaging();
712
1042
  return {
713
- content: [{ type: "text", text: text ? `${text}\n\n[${note}]` : note }],
714
- details,
1043
+ content: [
1044
+ {
1045
+ type: "text",
1046
+ text: `conversation ${rawConvId} already has a run in flight. Wait for its result; if it is a background run, /agy-stop can stop it.`,
1047
+ },
1048
+ ],
1049
+ details: emptyDetails(requestedModel, resolved.model),
715
1050
  };
716
1051
  }
1052
+ lockAcquired = isContinuation;
1053
+ if (lockAcquired) busyHandles.add(rawConvId as string);
1054
+
1055
+ const outcome = await runAgyProcess({
1056
+ binary,
1057
+ args,
1058
+ cwd,
1059
+ timeoutMin,
1060
+ startAt: start,
1061
+ details,
1062
+ isContinuation,
1063
+ snapshot,
1064
+ background: false,
1065
+ signal,
1066
+ onPartial: onUpdate
1067
+ ? (text) => {
1068
+ onUpdate({
1069
+ content: [{ type: "text", text }],
1070
+ details: { ...details, durationMs: Date.now() - start },
1071
+ });
1072
+ }
1073
+ : undefined,
1074
+ log,
1075
+ });
717
1076
 
718
- if (outcome.exitCode !== 0) {
719
- // stderr can carry auth material; the note is chat-visible.
720
- const stderr = redactText(details.stderr.trim());
721
- const note = stderr
722
- ? `agy exited with status ${outcome.exitCode}: ${stderr}`
723
- : `agy exited with status ${outcome.exitCode}`;
724
- return {
725
- content: [{ type: "text", text: text ? `${text}\n\n[${note}]` : note }],
726
- details,
727
- };
1077
+ const finalText = shapeFinalText(details, outcome, timeoutMin);
1078
+
1079
+ if (outcome.aborted || outcome.timedOut || outcome.exitCode !== 0) {
1080
+ return { content: [{ type: "text", text: finalText }], details };
728
1081
  }
729
1082
 
730
- // Exit 0 with nothing on stdout is still a failure for the
731
- // delegator: headless agy auto-denies a permission-gated tool call
732
- // (e.g. the command gate in plan mode), prints the reason only to
733
- // stderr, and ends cleanly. Falling through to the success path here
734
- // returned just the conversation footer, which read as an empty
735
- // success (silent-failure bug found 2026-09-25).
736
- if (!text) {
737
- details.empty = true;
738
- const stderr = redactText(details.stderr.trim());
739
- const note = [
740
- "agy exited cleanly but produced no output.",
741
- stderr ? `stderr: ${stderr}` : null,
742
- "Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
743
- ]
744
- .filter(Boolean)
745
- .join(" ");
746
- return { content: [{ type: "text", text: note }], details };
1083
+ // Exit 0 with no output keeps its dedicated failure result
1084
+ // (headless auto-deny); see shapeFinalText for the note.
1085
+ if (!outcome.answerText) {
1086
+ return { content: [{ type: "text", text: finalText }], details };
747
1087
  }
748
1088
 
749
- onUpdate?.({ content: [{ type: "text", text: "" }], details: { ...details } });
750
- const footer = details.conversationId
751
- ? `\n\n[agy conversationId: ${details.conversationId} - pass as conversationId to continue this conversation]`
752
- : "";
753
- return { content: [{ type: "text", text: text + footer }], details };
1089
+ // Clear the last partial status line (claude-bridge idiom) so the
1090
+ // running-tail preview doesn't linger under the final answer.
1091
+ onUpdate?.({
1092
+ content: [{ type: "text", text: "" }],
1093
+ details: { ...details },
1094
+ });
1095
+
1096
+ return {
1097
+ content: [{ type: "text", text: finalText }],
1098
+ details,
1099
+ };
754
1100
  } catch (err) {
755
- if (statusInterval) clearInterval(statusInterval);
756
1101
  details.durationMs = Date.now() - start;
757
1102
  const msg = err instanceof Error ? err.message : String(err);
758
1103
  log?.("ask-fail", { error: msg, durationMs: details.durationMs }, "error");
759
1104
  return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
760
- }
761
- finally {
762
- // Belt and braces: cleanup() already restores on close/error; this
763
- // covers paths that never reached the process (sync spawn throw).
764
- restoreBridge();
765
- if (contextFile) {
766
- try {
767
- fs.unlinkSync(contextFile);
768
- } catch {}
769
- }
770
- if (reviewerAgent) {
771
- try {
772
- fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
773
- } catch {}
774
- }
1105
+ } finally {
1106
+ if (lockAcquired) busyHandles.delete(rawConvId as string);
1107
+ cleanupStaging();
775
1108
  }
776
1109
  },
777
1110
  });
@@ -857,6 +1190,9 @@ interface AgyDetails {
857
1190
  // Exit 0 with no answer on stdout (the empty-output failure branch).
858
1191
  // renderResult flips to the error glyph on it.
859
1192
  empty?: boolean;
1193
+ // The call was refused before any spawn (validation, lock, cap, kill
1194
+ // switch). renderResult flips to the error glyph on it.
1195
+ refused?: boolean;
860
1196
  durationMs: number;
861
1197
  stderr: string;
862
1198
  }
@@ -880,5 +1216,7 @@ function emptyDetails(
880
1216
  timedOut: false,
881
1217
  durationMs: 0,
882
1218
  stderr: "",
1219
+ // Every emptyDetails caller is a refusal path.
1220
+ refused: true,
883
1221
  };
884
1222
  }
@@ -221,7 +221,12 @@ function bestEffortWriteMarker(file: string, marker: SuppressionMarker): void {
221
221
  }
222
222
  }
223
223
 
224
- const suppressionRefs = new Map<string, number>();
224
+ // Pi replaces the runtime on /new,/resume,/fork,/reload: module state dies
225
+ // but globalThis survives within the process. The refcount must live there,
226
+ // or an old runtime's late release re-enables entries under a new runtime's
227
+ // live delegation.
228
+ const SUPPRESSION_REFS_KEY = Symbol.for("pi-antigravity-bridge.suppression-refs");
229
+ const suppressionRefs: Map<string, number> = ((globalThis as Record<symbol, unknown>)[SUPPRESSION_REFS_KEY] ??= new Map()) as Map<string, number>;
225
230
 
226
231
  /** Reference-counted suppression for a self-spawned agy process (AskAntigravity
227
232
  * delegation). First acquire disables every pi-bridge-* entry AND records