@estebanforge/pi-antigravity-bridge 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,19 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [1.5.2] - 2026-09-15
5
+ ## [1.6.0] - 2026-09-14
6
+
7
+ ### Added
8
+
9
+ - Turn-cap knobs (`turnTimeoutMin`, `inactivityTimeoutMin`) with TTY-aware defaults. The "ACP turn exceeded the 10m deadline" error was the bridge's own overall turn cap - not a Google server limit: the ACP server binary exposes no timeout flag (`--helpfull` lists only debug/notices), and the stream-json engine carried the identical 10m cap ("agy exceeded the 10m turn timeout"). The cap never refreshed on activity, so healthy long turns died mid-task at exactly 10m. The default is now `0` (no cap) on an interactive pi - the user aborts with Esc and is the better backstop - and `20` minutes headless, where nobody can abort and one runaway turn blocks the drivers' serialized turn queue. Opt into a timed gate with 1-1440 minutes: free type via `config.json` or the new `/agy timeout <1-1440|off>` subcommand, or pick a preset (0/1/5/10/15/30/60/120/360/720/1440) in the `/agy` settings picker; `0` disables explicitly; garbage, negative, or >1440 falls back to the TTY-aware default. The 5m inactivity stall guard is unchanged, so a silent hung server still dies. Env `AGY_TURN_TIMEOUT_MIN` / `AGY_INACTIVITY_TIMEOUT_MIN` win over the file.
10
+
11
+ ## [1.5.3] - 2026-09-10
12
+
13
+ ### Fixed
14
+
15
+ - **Parallel agy tool approvals no longer clobber each other.** The shadow-tool `GatePolicy` asked through pi's `ui.confirm`, but pi's TUI shows ONE extension dialog at a time and an overlapping call replaces the live dialog without settling it: with several agy bash/write/edit calls in flight, approvals were silently lost (the park timeout eventually resolved them as declines). The confirm now holds the same shared cross-extension dialog lock as the pi-*-me gates (`withDialogLock`, `Symbol.for("pi-me.dialog-lock")`), so approvals queue and render in turn; the park timeout still bounds each dialog and is held inside the lock. New `src/dialog-lock.ts` module plus contract tests (FIFO order, throw-release, shared key).
16
+
17
+ ## [1.5.2] - 2026-09-09
6
18
 
7
19
  ### Fixed
8
20
 
package/README.md CHANGED
@@ -123,8 +123,10 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
123
123
  | `bridgeTools` | `none` (bridge off), `all` (every non-builtin tool, incl. other `Ask*` delegations), `mcp` (pi-mcp-adapter tools + skills bridge only) | `all` |
124
124
  | `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` |
125
125
  | `systemPrompt` | `on` (prepend 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) or `off` (agy-native behavior) | `on` |
126
+ | `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 |
127
+ | `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` |
126
128
 
127
- Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_ASK_TOOL`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`. 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.
129
+ Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_ASK_TOOL`, `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.
128
130
 
129
131
  The `activate_skill` catalog mirrors pi's directory-based skill discovery: the two global dirs plus project dirs, the latter only when pi has trusted the project (same gate pi itself applies). Pi's other skill sources - the `skills` settings array, `package.json` entries, and `--skill` CLI paths - are not mirrored and won't appear in the catalog.
130
132
 
@@ -65,7 +65,7 @@ import { runAcpAuth } from "../src/acp/auth.js";
65
65
  import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
66
66
  import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
67
67
  import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
68
- import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
68
+ import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
69
69
  import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
70
70
  import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
71
71
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
@@ -88,6 +88,7 @@ import {
88
88
  import { mapAgyToolToNative } from "../src/native-tools.js";
89
89
  import { Type } from "typebox";
90
90
  import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
91
+ import { withDialogLock } from "../src/dialog-lock.js";
91
92
 
92
93
  // Last UI seen (session_start / /agy commands). The ACP login URL arrives
93
94
  // via the driver log sink, which has no command context; the stash lets that
@@ -808,7 +809,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
808
809
  : typeof params.path === "string"
809
810
  ? params.path
810
811
  : JSON.stringify(stripMarkerFields(params)).slice(0, 200);
811
- const ok = await extCtx.ui.confirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS });
812
+ // Dialog lock: parallel agy tool approvals queue up instead of
813
+ // clobbering the live dialog (which silently loses the approval).
814
+ // Capture the narrowed method: TS drops the guard's narrowing
815
+ // inside the deferred closure.
816
+ const uiConfirm = extCtx.ui.confirm.bind(extCtx.ui);
817
+ const ok = await withDialogLock(() =>
818
+ uiConfirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS }),
819
+ );
812
820
  return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
813
821
  };
814
822
  const handle = r.handle;
@@ -897,6 +905,7 @@ interface PendingConfig {
897
905
  skipPermissions?: boolean;
898
906
  defaultModel?: string;
899
907
  defaultThinking?: ThinkingTier;
908
+ turnTimeoutMin?: number;
900
909
  askTool?: boolean;
901
910
  bridgeTools?: BridgeTools;
902
911
  digest?: boolean;
@@ -933,7 +942,7 @@ function statusText(ctx: AgyCommandCtx): string {
933
942
  function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
934
943
  pi.registerCommand("agy", {
935
944
  description:
936
- "Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|digest on|off|system-prompt on|off|acp-bin <path|auto>|patch-cleanup|clear]",
945
+ "Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
937
946
  handler: async (args, cmdCtx: ExtensionCommandContext) => {
938
947
  const ui = cmdCtx.ui;
939
948
  if (ui) activeUi = ui;
@@ -968,6 +977,33 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
968
977
  );
969
978
  return;
970
979
  }
980
+ if (sub === "timeout") {
981
+ // Free-type entry point for the turn cap (the TUI picker only offers
982
+ // presets). Same sanity rules as loadConfig: 1..MAX valid, off/0
983
+ // disables, anything else rejected with the valid range.
984
+ const live = loadConfig();
985
+ const cur = live.turnTimeoutMin;
986
+ if (!val) {
987
+ ui?.notify(
988
+ `Turn time cap: ${cur === 0 ? "off (no cap)" : `${cur}m`}. Usage: /agy timeout <1-${MAX_TURN_CAP_MIN}|off>. Default: off on an interactive pi, 20m headless.`,
989
+ "info",
990
+ );
991
+ return;
992
+ }
993
+ const parsed = val === "off" ? 0 : parseCapMinutes(val, Number.NaN);
994
+ if (Number.isNaN(parsed)) {
995
+ ui?.notify(`Invalid turn cap "${val}". Use 1-${MAX_TURN_CAP_MIN} minutes, or off/0 to disable.`, "error");
996
+ return;
997
+ }
998
+ saveConfig({ turnTimeoutMin: parsed });
999
+ ui?.notify(
1000
+ parsed === 0
1001
+ ? `Turn time cap disabled. The inactivity stall guard (${live.inactivityTimeoutMin}m silence) still applies.`
1002
+ : `Turn time cap set to ${parsed}m. Takes effect next turn.`,
1003
+ "info",
1004
+ );
1005
+ return;
1006
+ }
971
1007
  if (sub === "engine") {
972
1008
  if (val === "acp" || val === "stream-json") {
973
1009
  if (val === "acp" && loadConfig().mode === "plan") {
@@ -1346,6 +1382,14 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1346
1382
  currentValue: config.bridgeTools,
1347
1383
  values: ["all", "mcp", "none"],
1348
1384
  },
1385
+ {
1386
+ id: "turn-cap",
1387
+ label: "Turn time cap",
1388
+ description:
1389
+ "Max minutes for ONE agy turn before the bridge kills it. Default: no cap on an interactive pi (abort with Esc), 20 headless. 0 disables; 1-1440 enables the gate. Free type via /agy timeout <minutes|off> or config.json. Takes effect next turn.",
1390
+ currentValue: String(config.turnTimeoutMin),
1391
+ values: ["0", "1", "5", "10", "15", "30", "60", "120", "360", "720", "1440"],
1392
+ },
1349
1393
  {
1350
1394
  id: "digest",
1351
1395
  label: "Context digest",
@@ -1386,6 +1430,8 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1386
1430
  pending.askTool = newValue === "on";
1387
1431
  } else if (id === "bridge") {
1388
1432
  pending.bridgeTools = newValue as BridgeTools;
1433
+ } else if (id === "turn-cap") {
1434
+ pending.turnTimeoutMin = Number(newValue);
1389
1435
  } else if (id === "digest") {
1390
1436
  pending.digest = newValue === "on";
1391
1437
  } else if (id === "system-prompt") {
@@ -1433,6 +1479,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1433
1479
  pending.bridgeTools !== undefined ? `bridge=${next.bridgeTools}` : null,
1434
1480
  pending.digest !== undefined ? `digest=${next.digest ? "on" : "off"}` : null,
1435
1481
  pending.systemPrompt !== undefined ? `system-prompt=${next.systemPrompt ? "on" : "off"}` : null,
1482
+ pending.turnTimeoutMin !== undefined ? `turn cap=${next.turnTimeoutMin === 0 ? "off" : `${next.turnTimeoutMin}m`}` : null,
1436
1483
  ]
1437
1484
  .filter(Boolean)
1438
1485
  .join(", ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/acp/driver.ts CHANGED
@@ -611,6 +611,9 @@ export class AcpDriver implements TurnDriver {
611
611
  }
612
612
 
613
613
  #startOverallTimer(turn: ActiveTurn, ms: number): void {
614
+ // 0 disables the deadline (config turnTimeoutMin: 0). setTimeout(fn, 0)
615
+ // would fire instantly and kill every turn, guard or not.
616
+ if (ms <= 0) return;
614
617
  // The deadline lives HERE, not just in the callers: the running branch
615
618
  // of #armOverall never assigns it, and #pauseOverall keys off
616
619
  // `deadline !== null` to do anything at all. Without this line every
@@ -649,13 +652,16 @@ export class AcpDriver implements TurnDriver {
649
652
  #armIdle(turn: ActiveTurn): void {
650
653
  if (turn.idleTimer) clearTimeout(turn.idleTimer);
651
654
  if (turn.parks > 0) return; // parked: idle timer resumes on unpark
655
+ const idleMs = this.#idleBudgetMs(turn);
656
+ // 0 disables the stall guard (config inactivityTimeoutMin: 0).
657
+ if (idleMs <= 0) return;
652
658
  turn.idleTimer = setTimeout(() => {
653
659
  if (turn.closed) return;
654
660
  this.#log("stall", { sessionId: turn.sessionId });
655
661
  this.#conn?.abortAll("idle stall");
656
662
  this.#conn?.kill();
657
- this.#failTurn(turn, `ACP stalled for ${(this.#idleBudgetMs(turn) / 60_000) | 0}m with no output`);
658
- }, this.#idleBudgetMs(turn));
663
+ this.#failTurn(turn, `ACP stalled for ${(idleMs / 60_000) | 0}m with no output`);
664
+ }, idleMs);
659
665
  }
660
666
 
661
667
  #clearIdle(turn: ActiveTurn): void {
package/src/config.ts CHANGED
@@ -141,6 +141,26 @@ export interface AgyConfig {
141
141
  * digest (per-turn) is not. Turn off for agy-native behavior (agy's own
142
142
  * system prompt only). */
143
143
  systemPrompt: boolean;
144
+ /** Overall cap on ONE agy turn in minutes, both engines. When it fires, the
145
+ * bridge kills the agy process mid-task ("ACP turn exceeded the Xm
146
+ * deadline" / "agy exceeded the Xm turn timeout"). The ACP server binary
147
+ * exposes no timeout flag of its own (--helpfull: debug/notices only), so
148
+ * this bridge cap is the only knob.
149
+ *
150
+ * Default is TTY-aware: 0 (no cap) on an interactive pi - the user aborts
151
+ * with Esc and is the better backstop - and 20 on headless runs, where
152
+ * nobody can abort and one runaway turn blocks the drivers' serialized
153
+ * turn queue. Explicit values opt into the gate: 1..1440 valid, 0
154
+ * disables, anything else (garbage, negative, >1440) falls back to the
155
+ * TTY-aware default. The inactivity stall guard (5m silence) still bounds
156
+ * a hung server either way. Next-turn effect. Env AGY_TURN_TIMEOUT_MIN
157
+ * wins over the file. */
158
+ turnTimeoutMin: number;
159
+ /** Silence cap in minutes, both engines: no stream-json stdout / no ACP
160
+ * session/update for this long fails the turn as a stall. Default 5;
161
+ * 0 disables the guard. Next-turn effect.
162
+ * Env AGY_INACTIVITY_TIMEOUT_MIN wins over the file. */
163
+ inactivityTimeoutMin: number;
144
164
  /** Approval gate over agy NATIVE tool calls (create_file, run_command,
145
165
  * ...). pi tools agy calls already pass through pi's gates via the G9
146
166
  * round-trip; this covers the rest. Default auto (off until a
@@ -158,10 +178,34 @@ const DEFAULTS: AgyConfig = {
158
178
  bridgeTools: "all",
159
179
  digest: false,
160
180
  systemPrompt: true,
181
+ turnTimeoutMin: 0, // placeholder: the real default is resolved per loadConfig call (defaultTurnTimeoutMin)
182
+ inactivityTimeoutMin: 5,
161
183
  approvals: { gateMode: "auto", mode: "ask" },
162
184
  acp: { bin: "", permissions: "auto", usageEstimate: "estimate" },
163
185
  };
164
186
 
187
+ /** Hard ceiling for a free-typed turn cap: one day, in minutes. */
188
+ export const MAX_TURN_CAP_MIN = 1440;
189
+
190
+ /** TTY-aware default for the overall turn cap. An interactive user aborts
191
+ * with Esc and is the better backstop; the wall clock only earns its keep
192
+ * where nobody can abort (headless runs: one runaway turn also blocks the
193
+ * drivers' serialized turn queue). */
194
+ export function defaultTurnTimeoutMin(): number {
195
+ return process.stdout.isTTY || process.stdin.isTTY ? 0 : 20;
196
+ }
197
+
198
+ /** Explicit cap minutes: 0 keeps (disable), 1..MAX_TURN_CAP_MIN keeps;
199
+ * garbage, negative, or out-of-range falls back to the caller's default.
200
+ * Pass Number.NaN as the fallback to detect rejection via isNaN. */
201
+ export function parseCapMinutes(raw: string | number | undefined, fallback: number): number {
202
+ if (raw === undefined || String(raw).trim() === "") return fallback;
203
+ const n = typeof raw === "number" ? raw : Number(String(raw).trim());
204
+ if (!Number.isFinite(n)) return fallback;
205
+ if (n === 0) return 0;
206
+ return n >= 1 && n <= MAX_TURN_CAP_MIN ? n : fallback;
207
+ }
208
+
165
209
  /** Load config merged over defaults. Env vars override the file when set. */
166
210
  export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
167
211
  let file: Partial<AgyConfig> = {};
@@ -228,6 +272,16 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
228
272
  ? ["1", "true", "on"].includes(envSys.toLowerCase())
229
273
  : file.systemPrompt ?? DEFAULTS.systemPrompt;
230
274
 
275
+ // Turn caps, minutes. Env wins over the file (same pattern as mode).
276
+ const turnTimeoutMin = parseCapMinutes(
277
+ process.env.AGY_TURN_TIMEOUT_MIN ?? file.turnTimeoutMin,
278
+ defaultTurnTimeoutMin(),
279
+ );
280
+ const inactivityTimeoutMin = parseCapMinutes(
281
+ process.env.AGY_INACTIVITY_TIMEOUT_MIN ?? file.inactivityTimeoutMin,
282
+ DEFAULTS.inactivityTimeoutMin,
283
+ );
284
+
231
285
  // Approval gate (docs/TODO.md 2.5). Unknown values fall back to "auto"
232
286
  // so a typo can never silently force the gate on.
233
287
  const gateRaw = (process.env.AGY_APPROVALS ?? file.approvals?.gateMode ?? DEFAULTS.approvals.gateMode).toLowerCase();
@@ -268,6 +322,8 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
268
322
  bridgeTools,
269
323
  digest,
270
324
  systemPrompt,
325
+ turnTimeoutMin,
326
+ inactivityTimeoutMin,
271
327
  approvals: { gateMode, mode: gateAskMode },
272
328
  patchCleanupNotified: file.patchCleanupNotified === true,
273
329
  };
@@ -0,0 +1,48 @@
1
+ // Process-wide FIFO lock for human-facing dialogs (confirm/editor/select).
2
+ //
3
+ // pi's interactive UI shows ONE extension dialog at a time; an overlapping
4
+ // ctx.ui.* dialog call REPLACES the live dialog and the replaced promise
5
+ // never settles, so parallel gated tool calls hang forever (or silently lose
6
+ // their prompt). Holding this lock around each dialog serializes them: the
7
+ // first renders, the rest appear in turn as each is answered.
8
+ //
9
+ // The lock is keyed via Symbol.for because ALL pi-* extensions run in the
10
+ // SAME pi process and must share one queue: a per-module lock would still let
11
+ // a dialog from one extension clobber a dialog from another in the same
12
+ // parallel tool-call batch.
13
+ const DIALOG_LOCK: unique symbol = Symbol.for("pi-me.dialog-lock");
14
+
15
+ interface DialogQueue {
16
+ tail: Promise<void>;
17
+ }
18
+
19
+ function dialogQueue(): DialogQueue {
20
+ const host = globalThis as typeof globalThis & Record<symbol, unknown>;
21
+ const existing = host[DIALOG_LOCK] as DialogQueue | undefined;
22
+ if (existing) return existing;
23
+ const created: DialogQueue = { tail: Promise.resolve() };
24
+ host[DIALOG_LOCK] = created;
25
+ return created;
26
+ }
27
+
28
+ /**
29
+ * Run `run` while holding the cross-extension dialog lock. FIFO: each caller
30
+ * chains onto the queue tail synchronously (before its first await), so call
31
+ * order is the order the dialogs appear. Released in a finally block, so one
32
+ * throwing dialog can never wedge the queue for the callers behind it.
33
+ * NOT reentrant: calling it inside a held `run` self-deadlocks.
34
+ */
35
+ export async function withDialogLock<T>(run: () => Promise<T>): Promise<T> {
36
+ const queue = dialogQueue();
37
+ const prev = queue.tail;
38
+ let release!: () => void;
39
+ queue.tail = new Promise<void>((resolve) => {
40
+ release = resolve;
41
+ });
42
+ await prev;
43
+ try {
44
+ return await run();
45
+ } finally {
46
+ release();
47
+ }
48
+ }
@@ -36,9 +36,9 @@ export interface DriverTurnRequest extends DriverProfile {
36
36
  contextBlock?: { uri: string; text: string };
37
37
  signal?: AbortSignal;
38
38
  /** Overall turn cap in minutes (default 10). Fractional values are valid
39
- * (tests use sub-minute caps). */
39
+ * (tests use sub-minute caps). 0 disables the cap. */
40
40
  timeoutMin?: number;
41
- /** Stdout-inactivity cap in minutes (default 5). */
41
+ /** Stdout-inactivity cap in minutes (default 5). 0 disables the cap. */
42
42
  inactivityMin?: number;
43
43
  }
44
44
 
package/src/driver.ts CHANGED
@@ -165,12 +165,15 @@ export class StreamDriver implements TurnDriver {
165
165
  if (turn.parks > 0) turn.parks -= 1;
166
166
  if (turn.parks === 0 && !turn.idleTimer) {
167
167
  const idleMin = turn.request.inactivityMin ?? 5;
168
- turn.idleTimer = setTimeout(() => {
169
- if (turn.closed) return;
170
- this.#log(`stall:${turn.id}`);
171
- this.#killChild();
172
- this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
173
- }, idleMin * 60_000);
168
+ // 0 disables the stall guard (config inactivityTimeoutMin: 0).
169
+ if (idleMin > 0) {
170
+ turn.idleTimer = setTimeout(() => {
171
+ if (turn.closed) return;
172
+ this.#log(`stall:${turn.id}`);
173
+ this.#killChild();
174
+ this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
175
+ }, idleMin * 60_000);
176
+ }
174
177
  }
175
178
  }
176
179
 
@@ -295,20 +298,26 @@ export class StreamDriver implements TurnDriver {
295
298
  }
296
299
 
297
300
  #armTimers(turn: ActiveTurn): void {
301
+ // 0 disables a cap (config turnTimeoutMin / inactivityTimeoutMin: 0):
302
+ // setTimeout(fn, 0) would fire instantly and kill every turn.
298
303
  const totalMin = turn.request.timeoutMin ?? 10;
299
- turn.overallTimer = setTimeout(() => {
300
- if (turn.closed) return;
301
- this.#log(`timeout:${turn.id}`);
302
- this.#killChild();
303
- this.#failTurn(turn, `agy exceeded the ${totalMin}m turn timeout`);
304
- }, totalMin * 60_000);
304
+ if (totalMin > 0) {
305
+ turn.overallTimer = setTimeout(() => {
306
+ if (turn.closed) return;
307
+ this.#log(`timeout:${turn.id}`);
308
+ this.#killChild();
309
+ this.#failTurn(turn, `agy exceeded the ${totalMin}m turn timeout`);
310
+ }, totalMin * 60_000);
311
+ }
305
312
  const idleMin = turn.request.inactivityMin ?? 5;
306
- turn.idleTimer = setTimeout(() => {
307
- if (turn.closed) return;
308
- this.#log(`stall:${turn.id}`);
309
- this.#killChild();
310
- this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
311
- }, idleMin * 60_000);
313
+ if (idleMin > 0) {
314
+ turn.idleTimer = setTimeout(() => {
315
+ if (turn.closed) return;
316
+ this.#log(`stall:${turn.id}`);
317
+ this.#killChild();
318
+ this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
319
+ }, idleMin * 60_000);
320
+ }
312
321
  }
313
322
 
314
323
  #start(request: DriverTurnRequest): void {
package/src/provider.ts CHANGED
@@ -1231,6 +1231,8 @@ async function runTurnDriver(
1231
1231
  effort,
1232
1232
  mode: config.mode,
1233
1233
  skipPermissions: config.skipPermissions,
1234
+ timeoutMin: config.turnTimeoutMin,
1235
+ inactivityMin: config.inactivityTimeoutMin,
1234
1236
  conversationId: existing?.conversationId ?? null,
1235
1237
  prompt: fullPrompt,
1236
1238
  images: images.length > 0 ? images : undefined,