@estebanforge/pi-antigravity-bridge 1.9.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.9.2] - 2026-11-02
6
+
7
+ ### Fixed
8
+ - The AskAntigravity tool's expand hint now comes from the render context pi injects (`context.keyHint`) instead of the package import. pi 1.5 blinds an imported `keyHint` to the host keybindings (app.* ids format as ""), so the hint rendered as "to expand" with no key. Peer floor moves to `^1.5.0` accordingly.
9
+
10
+ ## [1.9.1] - 2026-10-29
11
+
12
+ ### Fixed
13
+
14
+ - **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).
15
+
16
+ - **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.
17
+
18
+ ### Changed
19
+
20
+ - **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.
21
+
22
+ ### Known limits
23
+
24
+ - **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.
25
+
5
26
  ## [1.9.0] - 2026-10-27
6
27
 
7
28
  ### 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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",
@@ -46,7 +46,7 @@
46
46
  ]
47
47
  },
48
48
  "peerDependencies": {
49
- "@earendil-works/pi-coding-agent": "^1.0.0"
49
+ "@earendil-works/pi-coding-agent": "^1.5.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@earendil-works/pi-coding-agent": {
package/src/ask-tool.ts CHANGED
@@ -16,7 +16,7 @@ import { randomBytes } from "node:crypto";
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
- import { buildSessionContext, getAgentDir, keyHint } from "@earendil-works/pi-coding-agent";
19
+ import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
20
20
  import { Text } from "@earendil-works/pi-tui";
21
21
  import { contentText, type ThinkingLevel } from "@earendil-works/pi-ai";
22
22
  import { Type } from "typebox";
@@ -759,7 +759,7 @@ export async function registerAskAntigravityTool(
759
759
  }
760
760
  return new Text(text, 0, 0);
761
761
  },
762
- renderResult(result, { expanded, isPartial }, theme) {
762
+ renderResult(result, { expanded, isPartial }, theme, context) {
763
763
  const d = result.details as AgyDetails | undefined;
764
764
  if (isPartial) {
765
765
  const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
@@ -789,7 +789,7 @@ export async function registerAskAntigravityTool(
789
789
  const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
790
790
  if (lines.length) text += `\n${theme.fg("toolOutput", lines.join("\n"))}`;
791
791
  if (body.length > PREVIEW_MAX_CHARS || body.split("\n").length > PREVIEW_MAX_LINES) {
792
- text += `\n${theme.fg("dim", `… (${keyHint("app.tools.expand", "to expand")})`)}`;
792
+ text += `\n${theme.fg("dim", `… (${context.keyHint("app.tools.expand", "to expand")})`)}`;
793
793
  }
794
794
  }
795
795
  return new Text(text, 0, 0);
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;
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) {