@oh-my-pi/pi-coding-agent 16.2.11 → 16.2.12

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +3005 -2848
  3. package/dist/types/cli/bench-cli.d.ts +0 -5
  4. package/dist/types/cli/dry-balance-cli.d.ts +0 -5
  5. package/dist/types/cli/models-cli.d.ts +1 -1
  6. package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
  7. package/dist/types/config/model-registry.d.ts +1 -23
  8. package/dist/types/config/model-resolver.d.ts +10 -15
  9. package/dist/types/config/models-config-schema.d.ts +0 -6
  10. package/dist/types/config/models-config.d.ts +0 -6
  11. package/dist/types/config/settings-schema.d.ts +31 -1
  12. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +15 -0
  14. package/dist/types/lsp/client.d.ts +9 -3
  15. package/dist/types/modes/controllers/tool-args-reveal.d.ts +0 -5
  16. package/dist/types/task/spawn-policy.d.ts +17 -0
  17. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  18. package/dist/types/task/types.d.ts +8 -2
  19. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  20. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  21. package/dist/types/tools/browser/registry.d.ts +2 -0
  22. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  24. package/dist/types/tools/eval.d.ts +3 -6
  25. package/dist/types/tools/write.d.ts +1 -1
  26. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +1 -1
  29. package/src/auto-thinking/classifier.ts +1 -1
  30. package/src/cli/bench-cli.ts +3 -12
  31. package/src/cli/dry-balance-cli.ts +1 -6
  32. package/src/cli/models-cli.ts +3 -81
  33. package/src/commands/models.ts +1 -2
  34. package/src/commit/model-selection.ts +4 -4
  35. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  36. package/src/config/model-discovery.ts +32 -6
  37. package/src/config/model-registry.ts +11 -225
  38. package/src/config/model-resolver.ts +23 -135
  39. package/src/config/models-config-schema.ts +0 -22
  40. package/src/config/prompt-templates.ts +20 -0
  41. package/src/config/settings-schema.ts +35 -1
  42. package/src/config/settings.ts +60 -24
  43. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  44. package/src/eval/agent-bridge.ts +7 -9
  45. package/src/eval/completion-bridge.ts +1 -1
  46. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  47. package/src/eval/py/runner.py +169 -18
  48. package/src/extensibility/extensions/model-api.ts +1 -3
  49. package/src/extensibility/extensions/runner.ts +62 -5
  50. package/src/lsp/client.ts +162 -45
  51. package/src/lsp/index.ts +31 -21
  52. package/src/main.ts +0 -1
  53. package/src/mcp/transports/http.ts +19 -17
  54. package/src/mcp/transports/stdio.test.ts +51 -1
  55. package/src/mcp/transports/stdio.ts +19 -9
  56. package/src/memories/index.ts +0 -1
  57. package/src/mnemopi/backend.ts +1 -1
  58. package/src/modes/components/model-selector.ts +32 -186
  59. package/src/modes/controllers/event-controller.ts +8 -39
  60. package/src/modes/controllers/selector-controller.ts +0 -1
  61. package/src/modes/controllers/tool-args-reveal.ts +0 -12
  62. package/src/prompts/system/subagent-system-prompt.md +2 -2
  63. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  64. package/src/prompts/tools/eval.md +2 -2
  65. package/src/prompts/tools/task.md +1 -1
  66. package/src/sdk.ts +2 -9
  67. package/src/session/agent-session.ts +89 -8
  68. package/src/session/session-context.ts +2 -1
  69. package/src/session/session-manager.ts +2 -1
  70. package/src/session/unexpected-stop-classifier.ts +1 -1
  71. package/src/task/index.ts +23 -27
  72. package/src/task/spawn-policy.test.ts +63 -0
  73. package/src/task/spawn-policy.ts +58 -0
  74. package/src/task/types.ts +77 -6
  75. package/src/tools/__tests__/eval-description.test.ts +19 -0
  76. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  77. package/src/tools/browser/registry.ts +25 -0
  78. package/src/tools/browser/run-cancellation.ts +47 -0
  79. package/src/tools/browser/tab-supervisor.ts +120 -7
  80. package/src/tools/browser/tab-worker.ts +22 -10
  81. package/src/tools/browser.ts +1 -0
  82. package/src/tools/eval.ts +15 -10
  83. package/src/tools/inspect-image.ts +1 -1
  84. package/src/tools/write.ts +49 -9
  85. package/src/utils/commit-message-generator.ts +0 -1
  86. package/src/utils/image-vision-fallback.ts +2 -3
  87. package/src/utils/title-generator.ts +1 -1
@@ -1,18 +1,19 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { logger, Snowflake } from "@oh-my-pi/pi-utils";
4
+ import { logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
5
5
  import { JsRuntime, type RuntimeHooks } from "../../../eval/js/shared/runtime";
6
6
  import type { JsDisplayOutput } from "../../../eval/js/shared/types";
7
7
  import { callSessionTool } from "../../../eval/js/tool-bridge";
8
- import type { ToolSession } from "../../../sdk";
9
8
  import { resizeImage } from "../../../utils/image-resize";
9
+ import type { ToolSession } from "../../index";
10
10
  import { resolveToCwd } from "../../path-utils";
11
11
  import { formatScreenshot } from "../../render-utils";
12
- import { ToolAbortError, ToolError } from "../../tool-errors";
12
+ import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors";
13
13
  import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-snapshot";
14
14
  import { DEFAULT_VIEWPORT } from "../launch";
15
15
  import { extractReadableFromHtml, type ReadableFormat } from "../readable";
16
+ import { bindBrowserRunFacade, waitForBrowserRun } from "../run-cancellation";
16
17
  import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol";
17
18
  import {
18
19
  type CmuxEvalResult,
@@ -44,7 +45,7 @@ interface RunContext {
44
45
  session: SessionSnapshot;
45
46
  displays: RunResultOk["displays"];
46
47
  screenshots: ScreenshotResult[];
47
- signal?: AbortSignal;
48
+ signal: AbortSignal;
48
49
  timeoutMs: number;
49
50
  }
50
51
 
@@ -535,9 +536,10 @@ export class CmuxTab {
535
536
 
536
537
  async waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise<string> {
537
538
  const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
539
+ const signal = this.#runContext?.signal;
538
540
  if (typeof pattern === "string") {
539
- await this.#request("browser.wait", { url_contains: pattern, timeout_ms: timeoutMs }, timeoutMs);
540
- const result = (await this.#request("browser.url.get", {}, timeoutMs)) as CmuxUrlGetResult;
541
+ await this.#request("browser.wait", { url_contains: pattern, timeout_ms: timeoutMs }, timeoutMs, signal);
542
+ const result = (await this.#request("browser.url.get", {}, timeoutMs, signal)) as CmuxUrlGetResult;
541
543
  if (typeof result.url === "string" && result.url.length > 0) {
542
544
  this.#lastUrl = result.url;
543
545
  }
@@ -545,29 +547,45 @@ export class CmuxTab {
545
547
  }
546
548
  const deadline = Date.now() + timeoutMs;
547
549
  while (Date.now() <= deadline) {
548
- const result = (await this.#request("browser.url.get", {}, Math.min(timeoutMs, 5_000))) as CmuxUrlGetResult;
550
+ const result = (await this.#request(
551
+ "browser.url.get",
552
+ {},
553
+ Math.min(timeoutMs, 5_000),
554
+ signal,
555
+ )) as CmuxUrlGetResult;
549
556
  if (typeof result.url === "string" && result.url.length > 0) {
550
557
  this.#lastUrl = result.url;
551
558
  if (pattern.test(result.url)) return result.url;
552
559
  }
553
- await Bun.sleep(200);
560
+ await untilAborted(signal, () => Bun.sleep(200));
554
561
  }
555
562
  throw new ToolError(`tab.waitForUrl() timed out after ${timeoutMs}ms`);
556
563
  }
557
564
 
558
565
  async waitForNavigation(opts?: { waitUntil?: WaitUntil; timeout?: number }): Promise<null> {
559
566
  const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
567
+ const signal = this.#runContext?.signal;
560
568
  // Cmux has no native "next navigation" wait — snapshot the current URL via a fresh
561
569
  // `browser.url.get` (never the possibly-stale `#lastUrl`), then poll for a change
562
570
  // from it (mirroring headless `page.waitForNavigation` intent) and optionally settle
563
571
  // on the requested load state. Start it BEFORE the click/submit that navigates; after
564
572
  // a completed nav it times out like puppeteer does.
565
- const baseline = (await this.#request("browser.url.get", {}, Math.min(timeoutMs, 5_000))) as CmuxUrlGetResult;
573
+ const baseline = (await this.#request(
574
+ "browser.url.get",
575
+ {},
576
+ Math.min(timeoutMs, 5_000),
577
+ signal,
578
+ )) as CmuxUrlGetResult;
566
579
  const startUrl = typeof baseline.url === "string" && baseline.url.length > 0 ? baseline.url : this.#lastUrl;
567
580
  if (typeof baseline.url === "string" && baseline.url.length > 0) this.#lastUrl = baseline.url;
568
581
  const deadline = Date.now() + timeoutMs;
569
582
  while (Date.now() <= deadline) {
570
- const result = (await this.#request("browser.url.get", {}, Math.min(timeoutMs, 5_000))) as CmuxUrlGetResult;
583
+ const result = (await this.#request(
584
+ "browser.url.get",
585
+ {},
586
+ Math.min(timeoutMs, 5_000),
587
+ signal,
588
+ )) as CmuxUrlGetResult;
571
589
  if (typeof result.url === "string" && result.url.length > 0) {
572
590
  this.#lastUrl = result.url;
573
591
  if (result.url !== startUrl) {
@@ -576,12 +594,13 @@ export class CmuxTab {
576
594
  "browser.wait",
577
595
  { load_state: mapWaitUntil(opts.waitUntil), timeout_ms: timeoutMs },
578
596
  timeoutMs,
597
+ signal,
579
598
  );
580
599
  }
581
600
  return null;
582
601
  }
583
602
  }
584
- await Bun.sleep(200);
603
+ await untilAborted(signal, () => Bun.sleep(200));
585
604
  }
586
605
  throw new ToolError(`tab.waitForNavigation() timed out after ${timeoutMs}ms`);
587
606
  }
@@ -627,6 +646,7 @@ export class CmuxTab {
627
646
  opts?: { timeout?: number },
628
647
  ): Promise<CmuxResponse> {
629
648
  const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
649
+ const signal = this.#runContext?.signal;
630
650
  await this.#installResponseObserver();
631
651
  const startId = await this.#responseCursor();
632
652
  const deadline = Date.now() + timeoutMs;
@@ -640,7 +660,7 @@ export class CmuxTab {
640
660
  return response;
641
661
  }
642
662
  }
643
- await Bun.sleep(100);
663
+ await untilAborted(signal, () => Bun.sleep(100));
644
664
  }
645
665
  throw new ToolError(`tab.waitForResponse() timed out after ${timeoutMs}ms`);
646
666
  }
@@ -665,8 +685,14 @@ export class CmuxTab {
665
685
  method: string,
666
686
  params: Record<string, unknown>,
667
687
  timeoutMs?: number,
688
+ signal: AbortSignal | undefined = this.#runContext?.signal,
668
689
  ): Promise<Record<string, unknown>> {
669
- return await this.#client.request(method, { surface_id: this.#surfaceId, ...params }, { timeoutMs });
690
+ throwIfAborted(signal);
691
+ const result = await untilAborted(signal, () =>
692
+ this.#client.request(method, { surface_id: this.#surfaceId, ...params }, { timeoutMs }),
693
+ );
694
+ throwIfAborted(signal);
695
+ return result;
670
696
  }
671
697
 
672
698
  async #readGeometry(timeoutMs?: number): Promise<CmuxGeometry> {
@@ -717,12 +743,13 @@ export class CmuxTab {
717
743
  ...args: unknown[]
718
744
  ): Promise<unknown> {
719
745
  const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
746
+ const signal = this.#runContext?.signal;
720
747
  const pollingMs = typeof opts?.polling === "number" ? opts.polling : 200;
721
748
  const deadline = Date.now() + timeoutMs;
722
749
  while (Date.now() <= deadline) {
723
750
  const value = typeof fn === "string" ? await this.#evalScript<unknown>(fn) : await this.evaluate(fn, ...args);
724
751
  if (value) return value;
725
- await Bun.sleep(pollingMs);
752
+ await untilAborted(signal, () => Bun.sleep(pollingMs));
726
753
  }
727
754
  throw new ToolError(`page.waitForFunction() timed out after ${timeoutMs}ms`);
728
755
  }
@@ -863,16 +890,17 @@ export class CmuxTab {
863
890
  }
864
891
 
865
892
  async #waitForSelector(selector: string, timeoutMs: number): Promise<void> {
893
+ const signal = this.#runContext?.signal;
866
894
  const spec = this.#selectorSpec(selector);
867
895
  const nativeSelector = this.#nativeSelector(spec);
868
896
  if (nativeSelector) {
869
- await this.#request("browser.wait", { selector: nativeSelector, timeout_ms: timeoutMs }, timeoutMs);
897
+ await this.#request("browser.wait", { selector: nativeSelector, timeout_ms: timeoutMs }, timeoutMs, signal);
870
898
  return;
871
899
  }
872
900
  const deadline = Date.now() + timeoutMs;
873
901
  while (Date.now() <= deadline) {
874
902
  if (await this.#selectorExists(spec)) return;
875
- await Bun.sleep(100);
903
+ await untilAborted(signal, () => Bun.sleep(100));
876
904
  }
877
905
  throw new ToolError(`tab.waitFor(${JSON.stringify(selector)}) timed out after ${timeoutMs}ms`);
878
906
  }
@@ -1251,22 +1279,26 @@ class CmuxBrowserFacade {
1251
1279
  }
1252
1280
 
1253
1281
  export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promise<RunResultOk> {
1282
+ const runAc = new AbortController();
1254
1283
  const timeoutSignal = AbortSignal.timeout(opts.timeoutMs);
1255
- const signal = opts.signal ? AbortSignal.any([timeoutSignal, opts.signal]) : timeoutSignal;
1284
+ const signal = AbortSignal.any(
1285
+ opts.signal ? [timeoutSignal, opts.signal, runAc.signal] : [timeoutSignal, runAc.signal],
1286
+ );
1256
1287
  const displays: RunResultOk["displays"] = [];
1257
1288
  const screenshots: ScreenshotResult[] = [];
1258
1289
  const runId = crypto.randomUUID();
1259
1290
  tab.setRunContext({ session: opts.snapshot, displays, screenshots, signal, timeoutMs: opts.timeoutMs });
1260
1291
  const runtime = tab.ensureRuntime(opts.snapshot);
1261
1292
  runtime.setCwd(opts.snapshot.cwd);
1293
+ const runTab = bindBrowserRunFacade(tab, signal);
1262
1294
  runtime.setRunScope({
1263
- page: tab.page,
1264
- browser: tab.browser,
1265
- tab,
1295
+ page: bindBrowserRunFacade(tab.page, signal),
1296
+ browser: bindBrowserRunFacade(tab.browser, signal),
1297
+ tab: runTab,
1266
1298
  assert: (cond: unknown, text?: string): void => {
1267
1299
  if (!cond) throw new ToolError(text ?? "Assertion failed");
1268
1300
  },
1269
- wait: (ms: number): Promise<void> => Bun.sleep(ms),
1301
+ wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
1270
1302
  });
1271
1303
 
1272
1304
  const { promise: cancelRejection, reject } = Promise.withResolvers<never>();
@@ -1282,9 +1314,18 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1282
1314
 
1283
1315
  try {
1284
1316
  const hooks: RuntimeHooks = {
1285
- onText: chunk => logger.debug(chunk.replace(/\n$/, "")),
1286
- onDisplay: output => pushDisplay(displays, output),
1287
- callTool: (name, args) => callSessionTool(name, args, { session: opts.session, signal }),
1317
+ onText: chunk => {
1318
+ throwIfAborted(signal);
1319
+ logger.debug(chunk.replace(/\n$/, ""));
1320
+ },
1321
+ onDisplay: output => {
1322
+ throwIfAborted(signal);
1323
+ pushDisplay(displays, output);
1324
+ },
1325
+ callTool: (name, args) => {
1326
+ throwIfAborted(signal);
1327
+ return callSessionTool(name, args, { session: opts.session, signal });
1328
+ },
1288
1329
  };
1289
1330
  // Like the inline worker fallback, cmux runs user JS in-process: awaited cmux/tool calls
1290
1331
  // observe this abort signal, but a synchronous infinite loop cannot be interrupted here.
@@ -1295,6 +1336,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1295
1336
  return { displays, returnValue: cloneSafe(returnValue), screenshots };
1296
1337
  } finally {
1297
1338
  signal.removeEventListener("abort", onAbort);
1339
+ runAc.abort(new ToolAbortError("Browser run ended"));
1298
1340
  tab.clearRunContext();
1299
1341
  }
1300
1342
  }
@@ -71,8 +71,28 @@ export async function acquireBrowser(kind: BrowserKind, opts: AcquireBrowserOpti
71
71
  browsers.delete(key);
72
72
  await disposeBrowserHandle(existing, { kill: false });
73
73
  }
74
+ // Short-circuit before launching: the tool wrapper's `untilAborted` only
75
+ // rejects its outer promise on abort; without this check `openBrowserHandle`
76
+ // would still fire and its result would land in `browsers` below.
77
+ if (opts.signal?.aborted) throw new ToolAbortError("Browser open aborted");
74
78
 
75
79
  const handle = await openBrowserHandle(kind, opts);
80
+ // The launch may resolve AFTER the caller has already aborted (the outer
81
+ // `untilAborted` rejects immediately on abort but does not cancel the
82
+ // inner promise, and `launchHeadlessBrowser` does not accept a signal).
83
+ // Without this branch the completed handle sits in `browsers` at
84
+ // refCount:0 forever — no tab ever takes a hold, `releaseBrowser` never
85
+ // fires, and `releaseAllTabs` walks `tabs`, not `browsers`, so the
86
+ // orphaned Chromium/app process / puppeteer handle survives to process
87
+ // exit. (Issue #3963.)
88
+ if (opts.signal?.aborted) {
89
+ await disposeBrowserHandle(handle, { kill: kind.kind === "spawned" }).catch(err => {
90
+ logger.debug("Failed to dispose orphan browser after abort", {
91
+ error: err instanceof Error ? err.message : String(err),
92
+ });
93
+ });
94
+ throw new ToolAbortError("Browser open aborted");
95
+ }
76
96
  browsers.set(key, handle);
77
97
  return handle;
78
98
  }
@@ -239,3 +259,8 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
239
259
  }
240
260
  if (opts.kill && handle.pid !== undefined) await gracefulKillTreeOnce(handle.pid);
241
261
  }
262
+
263
+ /** Test-only accessor for the module-global browsers map. */
264
+ export function getBrowsersMapForTest(): ReadonlyMap<string, BrowserHandle> {
265
+ return browsers;
266
+ }
@@ -0,0 +1,47 @@
1
+ import { untilAborted } from "@oh-my-pi/pi-utils";
2
+ import { throwIfAborted } from "../tool-errors";
3
+
4
+ /** Sleeps inside evaluated browser code while honoring the owning run's cancellation signal. */
5
+ export async function waitForBrowserRun(ms: number, signal: AbortSignal): Promise<void> {
6
+ throwIfAborted(signal);
7
+ await untilAborted(signal, () => Bun.sleep(ms));
8
+ throwIfAborted(signal);
9
+ }
10
+
11
+ /** Binds a long-lived browser facade to one evaluated run's abort signal. */
12
+ export function bindBrowserRunFacade<T extends object>(target: T, signal: AbortSignal): T {
13
+ const cache = new Map<PropertyKey, unknown>();
14
+ return new Proxy(target, {
15
+ get(current, prop) {
16
+ throwIfAborted(signal);
17
+ const cached = cache.get(prop);
18
+ if (cached) return cached;
19
+ const value = Reflect.get(current, prop, current);
20
+ if (typeof value === "function") {
21
+ const wrapped = (...args: unknown[]): unknown => {
22
+ throwIfAborted(signal);
23
+ const result = Reflect.apply(value, current, args);
24
+ if (result && typeof result === "object") {
25
+ const then = Reflect.get(result, "then");
26
+ if (typeof then === "function") {
27
+ return Promise.resolve(result).then(resolved => {
28
+ throwIfAborted(signal);
29
+ return resolved;
30
+ });
31
+ }
32
+ }
33
+ throwIfAborted(signal);
34
+ return result;
35
+ };
36
+ cache.set(prop, wrapped);
37
+ return wrapped;
38
+ }
39
+ if (value && typeof value === "object") {
40
+ const wrapped = bindBrowserRunFacade(value, signal);
41
+ cache.set(prop, wrapped);
42
+ return wrapped;
43
+ }
44
+ return value;
45
+ },
46
+ });
47
+ }
@@ -1,8 +1,8 @@
1
1
  import { getPuppeteerDir, logger, Snowflake, workerHostEntry } from "@oh-my-pi/pi-utils";
2
2
  import type { Page, Target } from "puppeteer-core";
3
3
  import { callSessionTool } from "../../eval/js/tool-bridge";
4
- import type { ToolSession } from "../../sdk";
5
4
  import { webpExclusionForModel } from "../../utils/image-loading";
5
+ import type { ToolSession } from "../index";
6
6
  import { expandPath } from "../path-utils";
7
7
  import { ToolAbortError, ToolError } from "../tool-errors";
8
8
  import { pickElectronTarget } from "./attach";
@@ -59,6 +59,13 @@ interface TabSessionBase<TBrowser extends BrowserHandle = BrowserHandle> {
59
59
  pending: Map<string, PendingRun>;
60
60
  dialogPolicy?: DialogPolicy;
61
61
  kindTag: BrowserKindTag;
62
+ /**
63
+ * Session id of the caller that CREATED the tab. Preserved across reuse so
64
+ * that dispose of the creating session can reap browser resources without
65
+ * yanking the tab out from under a subagent that only reused it.
66
+ * Undefined when the acquirer did not identify itself.
67
+ */
68
+ ownerSessionId?: string;
62
69
  }
63
70
 
64
71
  export interface WorkerTabSession extends TabSessionBase<PuppeteerBrowserHandle> {
@@ -84,6 +91,12 @@ export interface AcquireTabOptions {
84
91
  timeoutMs: number;
85
92
  dialogs?: DialogPolicy;
86
93
  cmuxSurface?: string;
94
+ /**
95
+ * Session id of the acquirer. Recorded on the tab when created (never on
96
+ * reuse) so `releaseTabsForOwner` can walk the shared tabs map on session
97
+ * dispose. Optional — omitting it opts the tab out of session-scoped reap.
98
+ */
99
+ ownerSessionId?: string;
87
100
  }
88
101
 
89
102
  export interface AcquireTabResult {
@@ -239,6 +252,16 @@ async function acquireTabImpl(
239
252
  }
240
253
  }
241
254
 
255
+ // If the caller aborted while we were spawning/initializing the worker,
256
+ // tear the freshly-built worker down before publishing the tab so the
257
+ // browser refCount (which `holdBrowser` below would take) never grows for
258
+ // a tab nobody is waiting for.
259
+ if (opts.signal?.aborted) {
260
+ await worker.terminate().catch(() => undefined);
261
+ if (tempHold) await releaseBrowser(browser, { kill: false }).catch(() => undefined);
262
+ throw new ToolAbortError("Browser tab open aborted");
263
+ }
264
+
242
265
  holdBrowser(browser);
243
266
  if (tempHold) await releaseBrowser(browser, { kill: false });
244
267
  const tab: WorkerTabSession = {
@@ -252,6 +275,7 @@ async function acquireTabImpl(
252
275
  pending: new Map(),
253
276
  dialogPolicy: opts.dialogs,
254
277
  kindTag: browser.kind.kind,
278
+ ownerSessionId: opts.ownerSessionId,
255
279
  };
256
280
  worker.onMessage(msg => handleTabMessage(tab, msg));
257
281
  tabs.set(name, tab);
@@ -303,6 +327,11 @@ async function acquireCmuxTab(
303
327
  await cmuxTab.goto(opts.url, { waitUntil: opts.waitUntil ?? "load", timeoutMs: opts.timeoutMs });
304
328
  }
305
329
  const info = await cmuxTab.readyInfo(opts.viewport ?? DEFAULT_VIEWPORT);
330
+ // If the caller aborted while we were opening the cmux surface, close the
331
+ // surface (if we own it) instead of taking a browser hold on it.
332
+ if (opts.signal?.aborted) {
333
+ throw new ToolAbortError("Browser tab open aborted");
334
+ }
306
335
  holdBrowser(browser);
307
336
  const tab: CmuxTabSession = {
308
337
  name,
@@ -317,6 +346,7 @@ async function acquireCmuxTab(
317
346
  dialogPolicy: opts.dialogs,
318
347
  kindTag: browser.kind.kind,
319
348
  cmuxAttachedSurface: attachedSurface,
349
+ ownerSessionId: opts.ownerSessionId,
320
350
  };
321
351
  tabs.set(name, tab);
322
352
  return { tab, created: true };
@@ -386,12 +416,28 @@ async function runInTabWithSnapshot(
386
416
  timeoutMs: opts.timeoutMs,
387
417
  session: snapshot,
388
418
  });
389
- return await raceWithTimeout(
390
- promise,
391
- opts.timeoutMs + GRACE_MS,
392
- "Browser code execution hung past grace; tab killed",
393
- async reason => await forceKillTab(name, reason),
394
- );
419
+ try {
420
+ return await raceWithTimeout(
421
+ promise,
422
+ opts.timeoutMs + GRACE_MS,
423
+ "Browser code execution hung past grace; tab killed",
424
+ async reason => await forceKillTab(name, reason),
425
+ );
426
+ } catch (error) {
427
+ if (error instanceof ToolError && error.message.startsWith("Browser code execution timed out after ")) {
428
+ try {
429
+ if (tab.worker.mode === "inline")
430
+ await forceKillTab(name, "Browser code execution timed out; tab killed");
431
+ else await recycleTimedOutWorkerTab(tab, opts.timeoutMs + GRACE_MS);
432
+ } catch (recycleError) {
433
+ logger.warn("Failed to recycle timed-out browser tab worker; killing tab", {
434
+ error: recycleError instanceof Error ? recycleError.message : String(recycleError),
435
+ });
436
+ await forceKillTab(name, "Browser code execution timed out; tab killed");
437
+ }
438
+ }
439
+ throw error;
440
+ }
395
441
  } finally {
396
442
  opts.signal?.removeEventListener("abort", abort);
397
443
  tab.pending.delete(id);
@@ -467,6 +513,34 @@ export async function dropHeadlessTabs(): Promise<void> {
467
513
  for (const name of names) await releaseTab(name);
468
514
  }
469
515
 
516
+ /**
517
+ * Release every tab created by the given session id. Invoked from
518
+ * `AgentSession.dispose()` so headless/spawned Chromium and workers the
519
+ * session opened do not leak into the long-lived process — the module-global
520
+ * `tabs`/`browsers` maps that back this tool are not otherwise walked by
521
+ * session teardown. (Issue #3963.)
522
+ *
523
+ * Ownership is recorded ONLY on tab creation (`acquireTab` with
524
+ * `ownerSessionId`), never on reuse: a subagent re-driving a tab another
525
+ * session opened will not yank teardown responsibility away from the
526
+ * creator. Tabs opened with no owner (e.g. from an SDK caller that doesn't
527
+ * identify a session) are skipped and must be released explicitly.
528
+ */
529
+ export async function releaseTabsForOwner(ownerId: string, opts: ReleaseTabOptions = {}): Promise<number> {
530
+ if (!ownerId) return 0;
531
+ const names = [...tabs.values()].filter(tab => tab.ownerSessionId === ownerId).map(tab => tab.name);
532
+ let count = 0;
533
+ for (const name of names) {
534
+ if (await releaseTab(name, opts)) count++;
535
+ }
536
+ return count;
537
+ }
538
+
539
+ /** Test-only accessor for the module-global tabs map. */
540
+ export function getTabsMapForTest(): ReadonlyMap<string, TabSession> {
541
+ return tabs;
542
+ }
543
+
470
544
  function isLastSurfaceCloseError(err: unknown): boolean {
471
545
  const message = err instanceof Error ? err.message : String(err);
472
546
  return /last/i.test(message);
@@ -583,6 +657,45 @@ function toErrorPayload(error: unknown): RunErrorPayload {
583
657
  return { name: "Error", message: String(error), isAbort: false, isToolError: false };
584
658
  }
585
659
 
660
+ async function recycleTimedOutWorkerTab(tab: WorkerTabSession, timeoutMs: number): Promise<void> {
661
+ const oldWorker = tab.worker;
662
+ await oldWorker.terminate().catch(() => undefined);
663
+ const browserWSEndpoint = tab.browser.browser.wsEndpoint();
664
+ if (!browserWSEndpoint) throw new ToolError("Browser websocket endpoint is unavailable");
665
+ const payload: WorkerInitPayload = {
666
+ mode: "attach",
667
+ browserWSEndpoint,
668
+ safeDir: getPuppeteerDir(),
669
+ targetId: tab.targetId,
670
+ dialogs: tab.dialogPolicy,
671
+ };
672
+ let worker = await spawnTabWorker();
673
+ try {
674
+ const info = await initializeTabWorker(worker, payload, timeoutMs);
675
+ tab.worker = worker;
676
+ tab.info = info;
677
+ tab.state = "alive";
678
+ worker.onMessage(msg => handleTabMessage(tab, msg));
679
+ } catch (error) {
680
+ await worker.terminate().catch(() => undefined);
681
+ worker = await spawnInlineWorker();
682
+ try {
683
+ const info = await initializeTabWorker(worker, payload, timeoutMs);
684
+ tab.worker = worker;
685
+ tab.info = info;
686
+ tab.state = "alive";
687
+ worker.onMessage(msg => handleTabMessage(tab, msg));
688
+ } catch (inlineError) {
689
+ await worker.terminate().catch(() => undefined);
690
+ const finalError = new ToolError(
691
+ `Failed to recycle timed-out browser tab worker (inline fallback also failed): ${inlineError instanceof Error ? inlineError.message : String(inlineError)}`,
692
+ );
693
+ Object.defineProperty(finalError, "cause", { value: error, configurable: true });
694
+ throw finalError;
695
+ }
696
+ }
697
+ }
698
+
586
699
  async function forceKillTab(name: string, reason: string): Promise<void> {
587
700
  const tab = tabs.get(name);
588
701
  if (!tab) return;
@@ -36,6 +36,7 @@ import {
36
36
  loadPuppeteerInWorker,
37
37
  } from "./launch";
38
38
  import { extractReadableFromHtml, type ReadableFormat } from "./readable";
39
+ import { waitForBrowserRun } from "./run-cancellation";
39
40
  import type {
40
41
  Observation,
41
42
  ObservationEntry,
@@ -481,13 +482,13 @@ async function clickQueryHandlerText(
481
482
  const target = await resolveActionableQueryHandlerClickTarget(handles);
482
483
  if (!target) {
483
484
  lastReason = handles.length ? "no-visible-candidate" : "no-matches";
484
- await Bun.sleep(100);
485
+ await untilAborted(clickSignal, () => Bun.sleep(100));
485
486
  continue;
486
487
  }
487
488
  const actionability = await isClickActionable(target);
488
489
  if (!actionability.ok) {
489
490
  lastReason = actionability.reason;
490
- await Bun.sleep(100);
491
+ await untilAborted(clickSignal, () => Bun.sleep(100));
491
492
  continue;
492
493
  }
493
494
  try {
@@ -495,7 +496,7 @@ async function clickQueryHandlerText(
495
496
  return;
496
497
  } catch (err) {
497
498
  lastReason = err instanceof Error ? err.message : String(err);
498
- await Bun.sleep(100);
499
+ await untilAborted(clickSignal, () => Bun.sleep(100));
499
500
  }
500
501
  } finally {
501
502
  await Promise.all(handles.map(async handle => handle.dispose().catch(() => undefined)));
@@ -515,6 +516,7 @@ export interface InflightOp {
515
516
  interface ActiveRun {
516
517
  id: string;
517
518
  ac: AbortController;
519
+ signal: AbortSignal;
518
520
  displays: RunResultOk["displays"];
519
521
  screenshots: ScreenshotResult[];
520
522
  pendingTools: Map<string, { resolve(value: unknown): void; reject(error: Error): void }>;
@@ -697,12 +699,14 @@ export class WorkerCore {
697
699
  }
698
700
  const timeoutSignal = AbortSignal.timeout(msg.timeoutMs);
699
701
  const ac = new AbortController();
700
- const signal = AbortSignal.any([timeoutSignal, ac.signal]);
702
+ const runAc = new AbortController();
703
+ const signal = AbortSignal.any([timeoutSignal, ac.signal, runAc.signal]);
701
704
  const displays: RunResultOk["displays"] = [];
702
705
  const screenshots: ScreenshotResult[] = [];
703
706
  const active: ActiveRun = {
704
707
  id: msg.id,
705
708
  ac,
709
+ signal,
706
710
  displays,
707
711
  screenshots,
708
712
  pendingTools: new Map(),
@@ -724,7 +728,7 @@ export class WorkerCore {
724
728
  assert: (cond: unknown, text?: string): void => {
725
729
  if (!cond) throw new ToolError(text ?? "Assertion failed");
726
730
  },
727
- wait: (ms: number): Promise<void> => Bun.sleep(ms),
731
+ wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
728
732
  });
729
733
  const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers<never>();
730
734
  const onCancel = (): void => {
@@ -767,6 +771,7 @@ export class WorkerCore {
767
771
  this.#transport.send({ type: "result", id: msg.id, ok: false, error: errorPayload(error) });
768
772
  } finally {
769
773
  if (this.#active?.id === msg.id) this.#active = null;
774
+ runAc.abort(new ToolAbortError("Browser run ended"));
770
775
  }
771
776
  }
772
777
 
@@ -783,11 +788,18 @@ export class WorkerCore {
783
788
  const active = this.#active;
784
789
  if (!active) return null;
785
790
  return {
786
- // console.* output stays on the supervisor log channel — matches pre-runtime behavior
787
- // where browser cells didn't surface `console.log` to the model.
788
- onText: chunk => this.#log("debug", chunk.replace(/\n$/, "")),
789
- onDisplay: output => this.#pushDisplay(active.displays, output),
790
- callTool: (name, args) => this.#callTool(active, name, args),
791
+ onText: chunk => {
792
+ throwIfAborted(active.signal);
793
+ this.#log("debug", chunk.replace(/\n$/, ""));
794
+ },
795
+ onDisplay: output => {
796
+ throwIfAborted(active.signal);
797
+ this.#pushDisplay(active.displays, output);
798
+ },
799
+ callTool: (name, args) => {
800
+ throwIfAborted(active.signal);
801
+ return this.#callTool(active, name, args);
802
+ },
791
803
  };
792
804
  }
793
805
 
@@ -262,6 +262,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
262
262
  timeoutMs,
263
263
  dialogs: params.dialogs,
264
264
  signal,
265
+ ownerSessionId: this.session.getSessionId?.() ?? undefined,
265
266
  }),
266
267
  );
267
268
  const tab = result.tab;
package/src/tools/eval.ts CHANGED
@@ -10,6 +10,7 @@ import { defaultEvalSessionId } from "../eval/session-id";
10
10
  import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
11
11
  import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
12
12
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
13
+ import { resolveSpawnPolicy } from "../task/spawn-policy";
13
14
  import { webpExclusionForModel } from "../utils/image-loading";
14
15
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
15
16
  import type { ToolSession } from ".";
@@ -164,13 +165,10 @@ export interface EvalToolDescriptionOptions {
164
165
  rb?: boolean;
165
166
  jl?: boolean;
166
167
  /**
167
- * Whether `agent()` is allowed in this session. Driven by the parent's
168
- * spawn policy (`getSessionSpawns`). Defaults to `true` for backward
169
- * compatibility — when the session forbids spawning, the prelude doc
170
- * omits the `agent()` entry so the model does not promise itself a
171
- * helper that will only ever throw "spawns disabled".
168
+ * Parent spawn policy (`getSessionSpawns`). `true`/omitted means unrestricted,
169
+ * `false`/`""` hides `agent()`, and a comma list drives the advertised default.
172
170
  */
173
- spawns?: boolean;
171
+ spawns?: boolean | string | null;
174
172
  }
175
173
 
176
174
  export function getEvalToolDescription(options: EvalToolDescriptionOptions = {}): string {
@@ -178,8 +176,16 @@ export function getEvalToolDescription(options: EvalToolDescriptionOptions = {})
178
176
  const js = options.js ?? true;
179
177
  const rb = options.rb ?? false;
180
178
  const jl = options.jl ?? false;
181
- const spawns = options.spawns ?? true;
182
- return prompt.render(evalDescription, { py, js, rb, jl, spawns });
179
+ const spawnPolicy = resolveSpawnPolicy(options.spawns ?? true);
180
+ return prompt.render(evalDescription, {
181
+ py,
182
+ js,
183
+ rb,
184
+ jl,
185
+ spawns: spawnPolicy.enabled,
186
+ spawnDefaultAgent: spawnPolicy.defaultAgent,
187
+ spawnAllowedAgentsText: spawnPolicy.allowedPromptText,
188
+ });
183
189
  }
184
190
 
185
191
  export interface EvalToolOptions {
@@ -294,13 +300,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
294
300
  if (!this.session) return getEvalToolDescription();
295
301
  const backends = resolveEvalBackends(this.session);
296
302
  const sessionSpawns = this.session.getSessionSpawns?.() ?? "*";
297
- const spawnsAllowed = sessionSpawns !== "" && sessionSpawns !== null;
298
303
  return getEvalToolDescription({
299
304
  py: backends.python,
300
305
  js: backends.js,
301
306
  rb: backends.ruby,
302
307
  jl: backends.julia,
303
- spawns: spawnsAllowed,
308
+ spawns: sessionSpawns,
304
309
  });
305
310
  }
306
311
  /** All reuse-chain examples; the `examples` getter filters by enabled languages. */