@estebanforge/pi-antigravity-bridge 1.8.8 → 1.9.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 CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.9.1] - 2026-10-29
6
+
7
+ ### Fixed
8
+
9
+ - **A retry-dropped attempt no longer stalls the session with "No user message to send to agy."** When pi retries after a failed turn, pi core drops the failed attempt from the projected context (`_omitRecoveryAttempt`), leaving a `toolResult` or assistant tail with no user prompt, no pending park, and nothing late. The fresh-pass guard used to hard-error there, and pi does not retry that error, so an unattended session stalled for ~6 hours on it (issue #8). With a bound conversation, the provider now re-opens it with a synthetic resume prompt (telling agy the previous attempt was discarded and not to repeat completed actions) instead of erroring. The digest is skipped on resume turns, the guard stays for unbound conversations and empty transcripts, and the `no-user-message` turn error now logs at error level (it was debug, so the failure left no trace in the error-only daily log).
10
+
11
+ - **A first-ever turn that parks now binds its conversation immediately.** The binding used to be written only on a completed OK pass, so a first turn that parked a bridge call and then died (failed continuation, process restart) left no record, and the retry-dropped tail above still hit the hard error. A park is positive evidence that agy received the prompt, so the provider persists the snapshot's conversation id at park time (first binding only, staged agent moving with it). A later ERROR keeps the park-time binding, except the issue #2 signatures that prove the conversation dead (silent drop / deadline kill with no model output seen), which still clear it by design.
12
+
13
+ ### Changed
14
+
15
+ - **The stream-json overall cap now pauses on parks, matching the ACP engine.** `turnTimeoutMin` used to be armed once and cleared only at settle on stream-json, so parked pi-tool time and human-gated escalations consumed the turn cap (with a 15m cap, a 16m escalation died mid-turn). The cap now carries a wall-clock deadline plus a remaining-budget capture, pauses at the first park, and resumes with the remainder on unpark; a 0 cap stays disabled across park cycles. Turn durations on stream-json can now exceed `turnTimeoutMin` by the parked span. This makes the documented "escalated calls get their own 30-minute budget" claim true on both engines.
16
+
17
+ ### Known limits
18
+
19
+ - **A tool result whose tombstone died with the process is lost, not delivered.** Tombstones are in-memory. After a process restart, a retry-dropped tail whose tool result never reached agy resumes the conversation without that output; agy may repeat the call or proceed without it.
20
+
21
+ ## [1.9.0] - 2026-10-27
22
+
23
+ ### Fixed
24
+
25
+ - **`/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.
26
+
27
+ - **`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.
28
+
29
+ - **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.
30
+
31
+ ### Changed
32
+
33
+ - **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.
34
+
35
+ ### Added
36
+
37
+ - **`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.
38
+
5
39
  ## [1.8.8] - 2026-10-27
6
40
 
7
41
  ### Fixed
package/README.md CHANGED
@@ -42,7 +42,7 @@ The bridge starts a localhost MCP server inside pi's process. The exposed catalo
42
42
 
43
43
  **Upstream pi APIs only.** Bridge calls park in the provider's round-trip store; the provider ends the pi assistant message with a `toolUse` stop reason for the real pi tool, pi executes it in its own loop (native cards, permissions, hooks), and the toolResult completes the parked MCP response on the next stream call.
44
44
 
45
- **Long calls don't die.** agy's MCP client abandons a `tools/call` request at a flat ~180s, which used to kill any pi tool that ran longer (a long peer review, a build, a commit preview waiting for you). A call still running after ~20 seconds now settles its HTTP request with a `STILL RUNNING` answer carrying a `callId` while pi keeps executing; agy fetches the result through the bridge-local `bridge_poll_result` tool and polls until it lands. Delegation-shaped tools (pi subagent/delegation tools, e.g. `subagent_run`, `agent_spawn`) hold the synchronous window 120s instead (issue #6: orchestrators expect a plain result across the 30-120s subagent range, and 120s stays under the 180s wall); `escalateLongTools` in config.json adds custom names. Escalated calls get their own 30-minute budget, so human-gated tools can take as long as the human takes. Fast calls stay fully synchronous and never see any of this. If a park does fail (abort, timeout, recycle), the late result is re-routed to agy as a follow-up prompt in the same conversation instead of being lost. Consecutive polls render as ONE ♊︎-marked counted line (`♊︎ bridge_poll_result Poll git_commit (x30)`) instead of one entry per poll, with a live muted status counter while a run is open.
45
+ **Long calls don't die.** agy's MCP client abandons a `tools/call` request at a flat ~180s, which used to kill any pi tool that ran longer (a long peer review, a build, a commit preview waiting for you). A call still running after ~20 seconds now settles its HTTP request with a `STILL RUNNING` answer carrying a `callId` while pi keeps executing; agy fetches the result through the bridge-local `bridge_poll_result` tool and polls until it lands. Delegation-shaped tools (pi subagent/delegation tools, e.g. `subagent_run`, `agent_spawn`) hold the synchronous window 120s instead (issue #6: orchestrators expect a plain result across the 30-120s subagent range, and 120s stays under the 180s wall); `escalateLongTools` in config.json adds custom names. Escalated calls get their own 30-minute budget, so human-gated tools can take as long as the human takes. Fast calls stay fully synchronous and never see any of this. If a park does fail (abort, timeout, recycle), the late result is re-routed to agy as a follow-up prompt in the same conversation instead of being lost. If pi retries a failed turn and drops the attempt from the projected context (pi core `_omitRecoveryAttempt`), leaving a toolResult or assistant tail with no live turn, the provider re-opens the bound conversation with a synthetic resume prompt instead of the unretryable "No user message" hard error (issue #8); a first-ever turn that parks binds its conversation at park time, so a retry-dropped tail has a conversation to resume into unless the park produced no conversation id or the later failure proved the conversation dead. Consecutive polls render as ONE ♊︎-marked counted line (`♊︎ bridge_poll_result Poll git_commit (x30)`) instead of one entry per poll, with a live muted status counter while a run is open.
46
46
 
47
47
  **Recursion safety.** Only the provider's agy receives the extra `--add-dir`. `AskAntigravity` is also filtered from the exposed tool list. Its delegated `agy -p` spawns with just the workspace, and for the whole delegated run the bridge hides its global per-pid entries: disabled before the spawn, released only on process close or error - agy watches the config and hot-reloads MCP servers on file changes, so a mid-run re-enable would poke the live delegation back into pi. A cross-process marker (`suppression.json` in the bridge's extensions-data dir) coordinates concurrent delegations: entries come back only when no live delegation remains, a session starting mid-delegation registers its own entry disabled, and the session-start heal re-enables only once the last delegator is gone (dead entries pruned by pid liveness with a 24h age bound). The inner agy therefore sees no bridge and cannot re-enter pi. Foreign servers in the global config are never touched.
48
48
 
@@ -120,8 +120,8 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
120
120
  | `bridgeTools` | `none` (bridge off), `all` (every non-builtin tool, incl. other `Ask*` delegations), `mcp` (pi-mcp-adapter tools + skills bridge only) | `all` |
121
121
  | `digest` | `off` (stable prompts; agy's prompt cache hits) or `on` (inject a delta of pi-side context - compaction summaries, other-provider turns - into each agy prompt; the delta changes every turn, so agy re-bills the full context). Enable for mixed-provider sessions where agy must see pi-side context | `off` |
122
122
  | `systemPrompt` | `on` (send pi's system prompt - operating instructions plus the global agent-dir `AGENTS.md` and ancestor `AGENTS.md`/`CLAUDE.md` - to the first prompt of each new agy conversation, plus a tool-priority note: Pi Bridge tools win over agy's native interactive ones, which never reach the user. Delivery: stream-json stages it as an agy agent file via `--agent`, off the prompt line - the CLI silently drops single prompts past a ~25KB cap and the dropped turn kills the conversation; the carrier declares `subagent: false` + `agents: []` so the native subagent tools stay masked (ACP carries no agent file, so its natives remain and the tool-priority note steers the model to pi delegation). ACP prepends it to the first prompt) or `off` (agy-native behavior) | `on` |
123
- | `turnTimeoutMin` | Overall cap on ONE agy turn, minutes, both engines. When it fires, the bridge kills the agy process mid-task ("ACP turn exceeded the 10m deadline" / "agy exceeded the 10m turn timeout") - it is a bridge cap, not a Google server limit. Default is TTY-aware: `0` (no cap) on an interactive pi - you are the backstop with Esc; `20` headless, where nobody can abort and one runaway turn blocks the serialized turn queue. Opt into a gate with 1-1440 (free type via `config.json` or `/agy timeout <1-1440|off>`, or the `/agy` picker's preset list); `0` disables explicitly; anything else falls back to the TTY-aware default. The 5m inactivity stall guard always still bounds a hung server | `0` TTY / `20` headless |
124
- | `inactivityTimeoutMin` | Silence cap, minutes, both engines: no stream-json stdout / no ACP session/update for this long fails the turn as a stall. `0` disables the guard | `5` |
123
+ | `turnTimeoutMin` | Overall cap on ONE agy turn, minutes, both engines. Parked time (pi-tool round-trips, escalated calls, human-gated approvals) does not count against it on either engine: the cap pauses with remaining-budget semantics and resumes on unpark. When it fires, the bridge kills the agy process mid-task ("ACP turn exceeded the 10m deadline" / "agy exceeded the 10m turn timeout") - it is a bridge cap, not a Google server limit. Default is TTY-aware: `0` (no cap) on an interactive pi - you are the backstop with Esc; `20` headless, where nobody can abort and one runaway turn blocks the serialized turn queue. Opt into a gate with 1-1440 (free type via `config.json` or `/agy timeout <1-1440|off>`, or the `/agy` picker's preset list); `0` disables explicitly; anything else falls back to the TTY-aware default. The 5m inactivity stall guard always still bounds a hung server | `0` TTY / `20` headless |
124
+ | `inactivityTimeoutMin` | Silence cap, minutes, both engines: no stream-json stdout / no ACP session/update for this long fails the turn as a stall. `0` disables the guard. Parks (bridge calls, escalated calls, ACP permission dialogs) pause it; agy-native long steps such as builds do not. A false kill costs the whole turn, and on stream-json a stall kill with no result frame clears the conversation binding. Keep `5`; use `2`-`3` only when native steps are known to be short; do not use `1` | `5` |
125
125
 
126
126
  Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_ASK_TOOL`, `AGY_WEB_TOOLS`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`, `AGY_TURN_TIMEOUT_MIN`, `AGY_INACTIVITY_TIMEOUT_MIN`. Env wins over the file, so while `AGY_DIGEST` or `AGY_SYSTEM_PROMPT` is set, the matching `/agy digest` or `/agy system-prompt` toggle persists a value that never takes effect.
127
127
 
@@ -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.1",
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
  }
package/src/driver.ts CHANGED
@@ -62,6 +62,12 @@ interface ActiveTurn {
62
62
  resolve: (o: TurnOutcome) => void;
63
63
  outcome: Promise<TurnOutcome>;
64
64
  overallTimer?: NodeJS.Timeout;
65
+ /** Overall-cap bookkeeping for park pauses: a wall-clock deadline while
66
+ * the timer runs, null while parked (parked time must not consume the
67
+ * budget), mirroring the ACP driver's remaining-budget semantics. */
68
+ overallDeadline: number | null;
69
+ /** Remaining budget captured at park time; null while the timer runs. */
70
+ overallRemainingMs: number | null;
65
71
  idleTimer?: NodeJS.Timeout;
66
72
  onAbort?: () => void;
67
73
  response: string;
@@ -117,6 +123,17 @@ function makeHandle(turn: ActiveTurn): TurnHandle {
117
123
  pushExternal: (activity) => {
118
124
  if (activity.type === "bridge_call") {
119
125
  turn.parks += 1;
126
+ // A park suspends both caps: agy is blocked waiting on the MCP
127
+ // HTTP response, so parked time consumes neither the stall guard
128
+ // nor the turn deadline (pi-tool and human-gated time stay free).
129
+ if (turn.overallTimer) {
130
+ clearTimeout(turn.overallTimer);
131
+ turn.overallTimer = undefined;
132
+ }
133
+ if (turn.overallDeadline !== null) {
134
+ turn.overallRemainingMs = Math.max(0, turn.overallDeadline - Date.now());
135
+ turn.overallDeadline = null;
136
+ }
120
137
  if (turn.idleTimer) {
121
138
  clearTimeout(turn.idleTimer);
122
139
  turn.idleTimer = undefined;
@@ -204,13 +221,20 @@ export class StreamDriver implements TurnDriver {
204
221
  const turn = this.#active;
205
222
  if (!turn || turn.closed) return;
206
223
  if (turn.parks > 0) turn.parks -= 1;
207
- if (turn.parks === 0 && !turn.idleTimer) {
208
- const idleMin = turn.request.inactivityMin ?? 5;
209
- // 0 disables the stall guard (config inactivityTimeoutMin: 0).
210
- if (idleMin > 0) {
211
- turn.idleTimer = setTimeout(() => {
212
- void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
213
- }, idleMin * 60_000);
224
+ if (turn.parks === 0) {
225
+ if (turn.overallRemainingMs !== null) {
226
+ const remaining = turn.overallRemainingMs;
227
+ turn.overallRemainingMs = null;
228
+ this.#startOverallTimer(turn, remaining);
229
+ }
230
+ if (!turn.idleTimer) {
231
+ const idleMin = turn.request.inactivityMin ?? 5;
232
+ // 0 disables the stall guard (config inactivityTimeoutMin: 0).
233
+ if (idleMin > 0) {
234
+ turn.idleTimer = setTimeout(() => {
235
+ void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
236
+ }, idleMin * 60_000);
237
+ }
214
238
  }
215
239
  }
216
240
  }
@@ -312,6 +336,8 @@ export class StreamDriver implements TurnDriver {
312
336
  outcome,
313
337
  response: "",
314
338
  rawResponse: "",
339
+ overallDeadline: null,
340
+ overallRemainingMs: null,
315
341
  sanitizer: new StreamSanitizer(),
316
342
  sawResult: false,
317
343
  sawModelStep: false,
@@ -343,11 +369,7 @@ export class StreamDriver implements TurnDriver {
343
369
  // 0 disables a cap (config turnTimeoutMin / inactivityTimeoutMin: 0):
344
370
  // setTimeout(fn, 0) would fire instantly and kill every turn.
345
371
  const totalMin = turn.request.timeoutMin ?? 10;
346
- if (totalMin > 0) {
347
- turn.overallTimer = setTimeout(() => {
348
- void this.#turnDeadlineGuard(turn, "timeout", `agy exceeded the ${totalMin}m turn timeout`);
349
- }, totalMin * 60_000);
350
- }
372
+ if (totalMin > 0) this.#startOverallTimer(turn, totalMin * 60_000);
351
373
  const idleMin = turn.request.inactivityMin ?? 5;
352
374
  if (idleMin > 0) {
353
375
  turn.idleTimer = setTimeout(() => {
@@ -356,6 +378,18 @@ export class StreamDriver implements TurnDriver {
356
378
  }
357
379
  }
358
380
 
381
+ /** Arm the overall turn cap. A deadline is a wall-clock instant while
382
+ * running; parks capture the remainder and clear it (see
383
+ * ActiveTurn.overallDeadline). A 0ms remainder fires on the next tick,
384
+ * which is correct: the budget is spent. */
385
+ #startOverallTimer(turn: ActiveTurn, ms: number): void {
386
+ const totalMin = turn.request.timeoutMin ?? 10;
387
+ turn.overallDeadline = Date.now() + ms;
388
+ turn.overallTimer = setTimeout(() => {
389
+ void this.#turnDeadlineGuard(turn, "timeout", `agy exceeded the ${totalMin}m turn timeout`);
390
+ }, ms);
391
+ }
392
+
359
393
  #start(request: DriverTurnRequest): void {
360
394
  this.#state = "starting";
361
395
  this.#generation += 1;
@@ -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
package/src/provider.ts CHANGED
@@ -138,7 +138,18 @@ function extractImages(context: TranscriptContext): Array<{ data: string; mimeTy
138
138
 
139
139
  const COMPACTION_MARKER = "compacted into the following summary";
140
140
 
141
- const DIGEST_PREAMBLE =
141
+ /** Synthetic prompt for a resume turn (issue #8): pi's retry dropped the
142
+ * failed attempt from the projected context (_omitRecoveryAttempt), leaving
143
+ * a toolResult or assistant tail with no live agy turn. The bound
144
+ * conversation already holds the task history, so re-open it with this
145
+ * instead of the unretryable "no user message" hard error (which stalled
146
+ * an unattended session for ~6 hours). The wording must stress not
147
+ * repeating actions: the discarded attempt may have half-run with side
148
+ * effects agy saw but pi never recorded. */
149
+ export const RESUME_PROMPT =
150
+ "[The previous attempt of this turn failed and was discarded. Check your conversation history for the last step you completed, then continue the task from there. Do not repeat completed actions.]";
151
+
152
+ export const DIGEST_PREAMBLE =
142
153
  "[The following is context from the broader pi session that this Antigravity turn was not directly spawned for: compaction summaries and turns handled by other providers or pi's own tools. Your own prior turns are already in your conversation history. Use this for continuity only.]";
143
154
 
144
155
  // --- pi system prompt (G10) ----------------------------------------------------
@@ -1453,20 +1464,40 @@ async function runTurnDriver(
1453
1464
  return;
1454
1465
  }
1455
1466
  // Fresh pass, or a stranded escalated result being re-delivered.
1456
- const prompt = extractUserPrompt(context);
1467
+ const userPrompt = extractUserPrompt(context);
1457
1468
  const images = extractImages(context);
1469
+ // Resume pass (issue #8): pi's retry dropped the failed attempt from the
1470
+ // projected context, so the transcript ends in a toolResult or assistant
1471
+ // message and there is no user prompt, no pending park, and nothing
1472
+ // late. With a bound conversation, agy holds the task history: re-open
1473
+ // it with a synthetic resume prompt instead of the hard error below
1474
+ // (pi does not retry that error; it stalls unattended sessions). The
1475
+ // binding is required: "continue" into a brand-new conversation has no
1476
+ // task to continue. Restricted to the two retry tails: an empty
1477
+ // transcript (lastRole undefined) and a user tail both keep their
1478
+ // existing behavior.
1479
+ const lastRole = context.messages[context.messages.length - 1]?.role;
1480
+ const resume =
1481
+ !userPrompt &&
1482
+ images.length === 0 &&
1483
+ late.length === 0 &&
1484
+ Boolean(existing?.conversationId) &&
1485
+ (lastRole === "toolResult" || lastRole === "assistant");
1486
+ const prompt = resume ? RESUME_PROMPT : userPrompt;
1487
+ if (resume) deps.log?.("resume-turn", { conversation: existing?.conversationId }, "error");
1458
1488
  // An image-only message (no text) is valid on the ACP engine; only fail
1459
1489
  // when there is nothing at all to send (no text, no images, no late
1460
1490
  // tool results to deliver).
1461
1491
  if (!prompt && images.length === 0 && late.length === 0) {
1462
- deps.log?.("turn-error", { reason: "no-user-message" }, "debug");
1492
+ deps.log?.("turn-error", { reason: "no-user-message" }, "error");
1463
1493
  finalize(stream, blocks, "error", "No user message to send to agy.");
1464
1494
  return;
1465
1495
  }
1466
1496
  const watermark = existing?.lastMessageCount ?? 0;
1467
- // Late turns re-open the conversation with a synthetic prompt; the digest
1468
- // would re-send context agy already holds, so skip it.
1469
- const digest = config.digest && late.length === 0 ? buildContextDigest(context.messages, watermark) : "";
1497
+ // Late and resume turns re-open the conversation with a synthetic
1498
+ // prompt; the digest would re-send context agy already holds (against a
1499
+ // stale watermark: a failed pass never advances it), so skip it.
1500
+ const digest = config.digest && late.length === 0 && !resume ? buildContextDigest(context.messages, watermark) : "";
1470
1501
  // G1 delivery per engine. stream-json: digest rides inline in the prompt
1471
1502
  // (the CLI has no context channel). ACP: the server advertises
1472
1503
  // `embeddedContext`, so the digest ships as a native resource block
@@ -1575,7 +1606,24 @@ async function runTurnDriver(
1575
1606
  if (consumeActivity(stream, blocks, activity, diffCtx, cwd, feats) === "parked") return "parked";
1576
1607
  }
1577
1608
  };
1578
- if ((await drainTurn(handle)) === "parked") return;
1609
+ if ((await drainTurn(handle)) === "parked") {
1610
+ // A park proves agy got the prompt and is working in this conversation.
1611
+ // Persist a first-turn binding now: otherwise a first-ever turn that
1612
+ // parks and then dies (failed continuation, process restart) leaves no
1613
+ // record, and the retry-dropped tail (issue #8) has nothing to resume
1614
+ // into. Only when no record exists: an unbound run() cannot have
1615
+ // recreated a conversation, so the snapshot id is the real one, and
1616
+ // the staged agent must move with the binding or a stream-json resume
1617
+ // re-runs without its carrier-agent profile.
1618
+ if (!existing) {
1619
+ const parkedConv = deps.driver.snapshot().conversationId;
1620
+ if (parkedConv) {
1621
+ store.set(key, { conversationId: parkedConv, lastStepIdx: -1, lastMessageCount: messageCount });
1622
+ if (stagedAgentName) stagedAgentByConversation.set(parkedConv, stagedAgentName);
1623
+ }
1624
+ }
1625
+ return;
1626
+ }
1579
1627
 
1580
1628
  let outcome = await handle.outcome;
1581
1629
  // Issue #2 recovery paths. agy's stream-json prompt path silently drops an
@@ -1585,12 +1633,17 @@ async function runTurnDriver(
1585
1633
  // settling OK here would persist a dead conversation for every later turn
1586
1634
  // (probe 2026-09-30: resume of a dropped-turn conversation never returns
1587
1635
  // a result frame). stream-json only: the ACP driver leaves the evidence
1588
- // fields undefined.
1636
+ // fields undefined. modelOutputSeen === false keeps deadline kills of a
1637
+ // turn that demonstrably worked (it parked, so a model step arrived) on
1638
+ // the plain retryable error path: "conversation is dead" would be wrong
1639
+ // for a first turn, and deleting a park-time binding (issue #8) would
1640
+ // strip the retry of its conversation.
1589
1641
  const silentDrop = outcome.status === "OK" && outcome.modelOutputSeen === false;
1590
1642
  const poisonedResume =
1591
1643
  outcome.status === "ERROR" &&
1592
1644
  outcome.deadline !== undefined &&
1593
1645
  outcome.sawResult === false &&
1646
+ outcome.modelOutputSeen === false &&
1594
1647
  Boolean(existing?.conversationId);
1595
1648
  if (silentDrop || poisonedResume) {
1596
1649
  if (existing?.conversationId || outcome.conversationId) {