jeopi-cli 16.4.1 → 16.4.3

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 (125) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/cli.js +3179 -3140
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/update-cli.d.ts +4 -1
  6. package/dist/types/cli/usage-error.d.ts +4 -0
  7. package/dist/types/commit/agentic/agent.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +7 -1
  9. package/dist/types/config/settings-schema.d.ts +3 -3
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  11. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  14. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  15. package/dist/types/internal-urls/types.d.ts +7 -0
  16. package/dist/types/irc/bus.d.ts +4 -0
  17. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  18. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  19. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  20. package/dist/types/modes/components/welcome.d.ts +4 -0
  21. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  22. package/dist/types/modes/orchestrate.d.ts +1 -1
  23. package/dist/types/modes/ultrathink.d.ts +1 -1
  24. package/dist/types/modes/workflow.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +5 -0
  26. package/dist/types/session/auth-broker-config.d.ts +2 -2
  27. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  28. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  29. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  32. package/dist/types/tools/browser/run-output.d.ts +25 -0
  33. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  34. package/dist/types/utils/changelog.d.ts +35 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +13 -0
  37. package/src/advisor/__tests__/config.test.ts +36 -0
  38. package/src/advisor/config.ts +11 -8
  39. package/src/autolearn/controller.ts +14 -2
  40. package/src/cli/args.ts +12 -0
  41. package/src/cli/flag-tables.ts +21 -7
  42. package/src/cli/update-cli.ts +63 -13
  43. package/src/cli/usage-error.ts +7 -0
  44. package/src/commands/acp.ts +11 -2
  45. package/src/commands/launch.ts +12 -3
  46. package/src/commit/agentic/agent.ts +4 -0
  47. package/src/commit/agentic/index.ts +46 -21
  48. package/src/config/model-resolver.ts +24 -6
  49. package/src/config/settings-schema.ts +3 -3
  50. package/src/edit/index.ts +10 -76
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  53. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  54. package/src/eval/executor-base.ts +84 -7
  55. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  56. package/src/eval/py/tool-bridge.ts +13 -14
  57. package/src/exec/non-interactive-env.ts +0 -1
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/custom-tools/loader.ts +70 -37
  60. package/src/extensibility/custom-tools/types.ts +3 -0
  61. package/src/extensibility/extensions/runner.ts +3 -0
  62. package/src/extensibility/extensions/types.ts +3 -0
  63. package/src/extensibility/plugins/manager.ts +21 -0
  64. package/src/internal-urls/docs-index.generated.txt +1 -1
  65. package/src/internal-urls/memory-protocol.ts +13 -8
  66. package/src/internal-urls/skill-protocol.ts +1 -1
  67. package/src/internal-urls/types.ts +7 -0
  68. package/src/irc/bus.ts +48 -18
  69. package/src/lsp/client.ts +7 -1
  70. package/src/lsp/deferred-diagnostics.ts +66 -0
  71. package/src/main.ts +13 -13
  72. package/src/mcp/tool-bridge.ts +57 -6
  73. package/src/mcp/transports/stdio.test.ts +45 -1
  74. package/src/mcp/transports/stdio.ts +6 -3
  75. package/src/modes/acp/acp-agent.ts +73 -2
  76. package/src/modes/acp/acp-event-mapper.ts +5 -0
  77. package/src/modes/acp/acp-mode.ts +11 -0
  78. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  79. package/src/modes/components/advisor-config.ts +15 -7
  80. package/src/modes/components/move-overlay.ts +2 -3
  81. package/src/modes/components/plan-review-overlay.ts +7 -0
  82. package/src/modes/components/tips.txt +1 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +8 -10
  85. package/src/modes/controllers/event-controller.ts +17 -30
  86. package/src/modes/controllers/input-controller.ts +9 -6
  87. package/src/modes/controllers/selector-controller.ts +3 -2
  88. package/src/modes/controllers/tan-command-controller.ts +40 -1
  89. package/src/modes/interactive-mode.ts +13 -0
  90. package/src/modes/magic-keyword-boundary.ts +23 -0
  91. package/src/modes/orchestrate.ts +6 -5
  92. package/src/modes/print-mode.ts +9 -0
  93. package/src/modes/ultrathink.ts +6 -5
  94. package/src/modes/workflow.ts +5 -4
  95. package/src/prompts/system/eager-task.md +2 -2
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/browser.md +5 -5
  98. package/src/sdk.ts +3 -0
  99. package/src/session/agent-session.ts +237 -50
  100. package/src/session/auth-broker-config.ts +4 -4
  101. package/src/session/snapcompact-inline.ts +3 -19
  102. package/src/session/streaming-output.ts +40 -12
  103. package/src/slash-commands/builtin-registry.ts +8 -8
  104. package/src/task/executor.ts +8 -0
  105. package/src/tools/bash-skill-urls.ts +7 -4
  106. package/src/tools/bash.ts +30 -22
  107. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  108. package/src/tools/browser/cmux/rpc.ts +50 -0
  109. package/src/tools/browser/launch.ts +8 -4
  110. package/src/tools/browser/run-cancellation.ts +44 -5
  111. package/src/tools/browser/run-output.ts +76 -0
  112. package/src/tools/browser/tab-worker.ts +280 -133
  113. package/src/tools/eval-render.ts +16 -14
  114. package/src/tools/glob.ts +20 -6
  115. package/src/tools/grep.ts +1 -0
  116. package/src/tools/image-gen.ts +3 -4
  117. package/src/tools/irc.ts +27 -13
  118. package/src/tools/read.ts +14 -7
  119. package/src/tools/write.ts +22 -5
  120. package/src/tools/yield.ts +29 -1
  121. package/src/utils/changelog.ts +106 -9
  122. package/src/utils/git.ts +0 -1
  123. package/src/utils/title-generator.ts +68 -7
  124. package/src/web/search/index.ts +14 -6
  125. package/src/web/search/providers/perplexity.ts +18 -2
@@ -13,11 +13,11 @@ import type {
13
13
  ImageFormat,
14
14
  KeyInput,
15
15
  Page,
16
+ Realm,
16
17
  SerializedAXNode,
17
18
  Target,
18
19
  } from "puppeteer-core";
19
20
  import { JsRuntime, type RuntimeHooks } from "../../eval/js/shared/runtime";
20
- import type { JsDisplayOutput } from "../../eval/js/shared/types";
21
21
  import { resizeImage } from "../../utils/image-resize";
22
22
  import { resolveToCwd } from "../path-utils";
23
23
  import { formatScreenshot } from "../render-utils";
@@ -36,13 +36,13 @@ import {
36
36
  loadPuppeteerInWorker,
37
37
  } from "./launch";
38
38
  import { extractReadableFromHtml, type ReadableFormat } from "./readable";
39
- import { waitForBrowserRun } from "./run-cancellation";
39
+ import { type WaitPredicateOptions, waitForBrowserRun } from "./run-cancellation";
40
+ import { cloneSafe, RunOutput } from "./run-output";
40
41
  import type {
41
42
  Observation,
42
43
  ObservationEntry,
43
44
  ReadyInfo,
44
45
  RunErrorPayload,
45
- RunResultOk,
46
46
  ScreenshotResult,
47
47
  SessionSnapshot,
48
48
  ToolReply,
@@ -51,6 +51,13 @@ import type {
51
51
  WorkerInitPayload,
52
52
  } from "./tab-protocol";
53
53
 
54
+ declare module "puppeteer-core" {
55
+ interface Frame {
56
+ /** Puppeteer's main JavaScript realm, retained by our pinned runtime patch. */
57
+ mainRealm(): Realm;
58
+ }
59
+ }
60
+
54
61
  declare global {
55
62
  interface Element extends HTMLElement {}
56
63
  function getComputedStyle(element: Element): Record<string, unknown>;
@@ -116,15 +123,27 @@ type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; rea
116
123
  * - `QUICK_OP_TIMEOUT_MS`: page-coupled reads that should resolve fast (`observe`,
117
124
  * `screenshot`, `extract`, `ariaSnapshot`).
118
125
  * - `ACTION_OP_TIMEOUT_MS`: interactive point actions (`click`, `fill`, `type`, …) and
119
- * the default for wait helpers when no explicit `{ timeout }` is given.
126
+ * the default for wait helpers when no explicit `{ timeout }` is given. Selector ops
127
+ * additionally fail fast after `ZERO_MATCH_FAIL_FAST_MS` of confirmed zero matches
128
+ * (see `#zeroMatchWatchdog`), so the full ceiling is only spent on elements that
129
+ * exist but are not yet actionable.
120
130
  *
121
131
  * `goto` and `evaluate` stay uncapped (`Number.POSITIVE_INFINITY`): navigation and user
122
132
  * code legitimately use the full cell budget.
123
133
  */
124
134
  const QUICK_OP_TIMEOUT_MS = 20_000;
125
- const ACTION_OP_TIMEOUT_MS = 15_000;
135
+ const ACTION_OP_TIMEOUT_MS = 8_000;
126
136
  /** Headroom subtracted from the cell budget so a per-op deadline fires before it. */
127
137
  const OP_DEADLINE_SLACK_MS = 1_000;
138
+ /**
139
+ * A selector op whose selector has matched nothing for this long fails fast with the
140
+ * zero-match hint instead of burning the rest of its deadline: a wrong selector or a
141
+ * wrong page (consent wall, pre-navigation document) is the common agent failure and
142
+ * should cost ~2s, not the full action ceiling. Explicit `{ timeout }` waits opt out.
143
+ */
144
+ const ZERO_MATCH_FAIL_FAST_MS = 2_000;
145
+ /** Poll cadence for the zero-match watchdog. */
146
+ const ZERO_MATCH_POLL_MS = 250;
128
147
 
129
148
  export interface OpTimeouts {
130
149
  /** Largest per-op deadline allowed — strictly below the cell budget. */
@@ -190,7 +209,7 @@ interface TabApi {
190
209
  press(key: KeyInput, opts?: { selector?: string }): Promise<void>;
191
210
  scroll(deltaX: number, deltaY: number): Promise<void>;
192
211
  drag(from: DragTarget, to: DragTarget): Promise<void>;
193
- waitFor(selector: string, opts?: { timeout?: number }): Promise<ElementHandle>;
212
+ waitFor(selector: string, opts?: { timeout?: number }): Promise<ActionableHandle>;
194
213
  evaluate<TResult, TArgs extends unknown[]>(
195
214
  fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
196
215
  ...args: TArgs
@@ -206,13 +225,13 @@ interface TabApi {
206
225
  waitForSelector(
207
226
  selector: string,
208
227
  opts?: { timeout?: number; visible?: boolean; hidden?: boolean },
209
- ): Promise<ElementHandle | null>;
228
+ ): Promise<ActionableHandle | null>;
210
229
  waitForNavigation(opts?: {
211
230
  waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
212
231
  timeout?: number;
213
232
  }): Promise<HTTPResponse | null>;
214
- id(n: number): Promise<ElementHandle>;
215
- ref(id: string): Promise<ElementHandle>;
233
+ id(n: number): Promise<ActionableHandle>;
234
+ ref(id: string): Promise<ActionableHandle>;
216
235
  }
217
236
 
218
237
  export function normalizeSelector(selector: string): string {
@@ -259,16 +278,30 @@ function asElementHandle(handle: unknown): ElementHandle | null {
259
278
  return handle ? (handle as ElementHandle) : null;
260
279
  }
261
280
 
262
- function cloneSafe(value: unknown): unknown {
263
- if (value === undefined) return undefined;
264
- try {
265
- structuredClone(value);
266
- return value;
267
- } catch {}
268
- try {
269
- return JSON.parse(JSON.stringify(value)) as unknown;
270
- } catch {}
271
- return String(value);
281
+ /** ElementHandle enriched with the `fill()` the tool docs promise on handles from `tab.id()`/`tab.ref()`/`tab.waitFor()`. */
282
+ export type ActionableHandle = ElementHandle & { fill(value: string): Promise<void> };
283
+
284
+ /**
285
+ * Attach `fill()` to a puppeteer ElementHandle before handing it to user code.
286
+ * Puppeteer handles expose `type()` but no `fill()`; the semantics mirror the
287
+ * selector-based `tab.fill()`: focus, clear any existing value, then type.
288
+ */
289
+ export function toActionableHandle(handle: ElementHandle): ActionableHandle {
290
+ const enriched = handle as ActionableHandle;
291
+ enriched.fill = value => fillViaHandle(enriched, value);
292
+ return enriched;
293
+ }
294
+
295
+ /** Focus, clear any existing value, then retype — shared by `tab.fill(aria-ref)` and enriched handles. */
296
+ async function fillViaHandle(handle: ElementHandle, value: string, signal?: AbortSignal): Promise<void> {
297
+ await untilAborted(signal, () =>
298
+ handle.evaluate(el => {
299
+ const node = el as unknown as { value?: string; focus?: () => void };
300
+ node.focus?.();
301
+ if ("value" in node) node.value = "";
302
+ }),
303
+ );
304
+ await untilAborted(signal, () => handle.type(value, { delay: 0 }));
272
305
  }
273
306
 
274
307
  /**
@@ -303,14 +336,6 @@ function errorPayload(error: unknown): RunErrorPayload {
303
336
  return { name: "Error", message: String(error), isToolError: false, isAbort: false };
304
337
  }
305
338
 
306
- function safeJsonStringify(value: unknown): string {
307
- try {
308
- return JSON.stringify(value, null, 2);
309
- } catch {
310
- return String(value);
311
- }
312
- }
313
-
314
339
  function replyError(payload: RunErrorPayload): Error {
315
340
  if (payload.isAbort) {
316
341
  const err = new ToolAbortError(payload.message || "Tool call aborted");
@@ -508,6 +533,17 @@ async function clickQueryHandlerText(
508
533
  );
509
534
  }
510
535
 
536
+ /**
537
+ * Hint appended to a selector op's fail-fast timeout, given the selector's current
538
+ * match count: a missing element (consent wall, wrong page) reads differently from
539
+ * a present-but-unactionable one.
540
+ */
541
+ export function formatSelectorMatchHint(count: number): string {
542
+ return count === 0
543
+ ? "; selector currently matches no elements — run tab.observe() or tab.ariaSnapshot() to inspect the page"
544
+ : `; selector currently matches ${count} element(s) but the action never became possible — the element may be hidden or covered (try tab.scrollIntoView() or a more specific selector)`;
545
+ }
546
+
511
547
  export interface InflightOp {
512
548
  label: string;
513
549
  startedAt: number;
@@ -517,7 +553,7 @@ interface ActiveRun {
517
553
  id: string;
518
554
  ac: AbortController;
519
555
  signal: AbortSignal;
520
- displays: RunResultOk["displays"];
556
+ output: RunOutput;
521
557
  screenshots: ScreenshotResult[];
522
558
  pendingTools: Map<string, { resolve(value: unknown): void; reject(error: Error): void }>;
523
559
  /** Helper invocations currently awaiting the page/network, keyed by op id. */
@@ -701,13 +737,13 @@ export class WorkerCore {
701
737
  const ac = new AbortController();
702
738
  const runAc = new AbortController();
703
739
  const signal = AbortSignal.any([timeoutSignal, ac.signal, runAc.signal]);
704
- const displays: RunResultOk["displays"] = [];
740
+ const output = new RunOutput();
705
741
  const screenshots: ScreenshotResult[] = [];
706
742
  const active: ActiveRun = {
707
743
  id: msg.id,
708
744
  ac,
709
745
  signal,
710
- displays,
746
+ output,
711
747
  screenshots,
712
748
  pendingTools: new Map(),
713
749
  inflight: new Map(),
@@ -718,7 +754,7 @@ export class WorkerCore {
718
754
  throwIfAborted(signal);
719
755
  const page = this.#requirePage();
720
756
  const browser = this.#requireBrowser();
721
- const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, displays, screenshots, active);
757
+ const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, output, screenshots, active);
722
758
  const runtime = this.#ensureRuntime(msg.session);
723
759
  runtime.setCwd(msg.session.cwd);
724
760
  runtime.setRunScope({
@@ -728,7 +764,8 @@ export class WorkerCore {
728
764
  assert: (cond: unknown, text?: string): void => {
729
765
  if (!cond) throw new ToolError(text ?? "Assertion failed");
730
766
  },
731
- wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
767
+ wait: (msOrPredicate: number | (() => unknown), opts?: WaitPredicateOptions): Promise<unknown> =>
768
+ waitForBrowserRun(msOrPredicate, signal, opts),
732
769
  });
733
770
  const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers<never>();
734
771
  const onCancel = (): void => {
@@ -762,7 +799,7 @@ export class WorkerCore {
762
799
  type: "result",
763
800
  id: msg.id,
764
801
  ok: true,
765
- payload: { displays, returnValue: cloneSafe(returnValue), screenshots },
802
+ payload: { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots },
766
803
  });
767
804
  } finally {
768
805
  signal.removeEventListener("abort", onCancel);
@@ -790,11 +827,12 @@ export class WorkerCore {
790
827
  return {
791
828
  onText: chunk => {
792
829
  throwIfAborted(active.signal);
830
+ active.output.pushText(chunk);
793
831
  this.#log("debug", chunk.replace(/\n$/, ""));
794
832
  },
795
833
  onDisplay: output => {
796
834
  throwIfAborted(active.signal);
797
- this.#pushDisplay(active.displays, output);
835
+ active.output.pushDisplay(output);
798
836
  },
799
837
  callTool: (name, args) => {
800
838
  throwIfAborted(active.signal);
@@ -803,20 +841,6 @@ export class WorkerCore {
803
841
  };
804
842
  }
805
843
 
806
- #pushDisplay(displays: RunResultOk["displays"], output: JsDisplayOutput): void {
807
- if (output.type === "image") {
808
- displays.push({ type: "image", data: output.data, mimeType: output.mimeType });
809
- return;
810
- }
811
- if (output.type === "json") {
812
- displays.push({ type: "text", text: safeJsonStringify(output.data) });
813
- return;
814
- }
815
- // status — surface as compact JSON so helper side effects (read/write/env) appear in
816
- // the cell result alongside explicit display() output.
817
- displays.push({ type: "text", text: safeJsonStringify(output.event) });
818
- }
819
-
820
844
  async #callTool(active: ActiveRun, name: string, args: unknown): Promise<unknown> {
821
845
  const id = `tab-tc-${active.id}-${crypto.randomUUID()}`;
822
846
  const { promise, resolve, reject } = Promise.withResolvers<unknown>();
@@ -841,7 +865,10 @@ export class WorkerCore {
841
865
  * with a named error instead of silently consuming the whole cell budget. Pass
842
866
  * `Number.POSITIVE_INFINITY` for `perOpTimeoutMs` to bound the op only by the cell
843
867
  * budget (used for `evaluate` running user code and for locator helpers that already
844
- * carry puppeteer's own `.setTimeout(timeoutMs)`).
868
+ * carry puppeteer's own `.setTimeout(timeoutMs)`). When the op targets a `selector`,
869
+ * the fail-fast timeout carries a best-effort match-count hint, and — when
870
+ * `zeroMatchAfterMs` is set — a watchdog aborts the op early once the selector has
871
+ * matched nothing for that long.
845
872
  */
846
873
  async #runOp<T>(
847
874
  active: ActiveRun,
@@ -849,14 +876,28 @@ export class WorkerCore {
849
876
  cellSignal: AbortSignal,
850
877
  perOpTimeoutMs: number,
851
878
  fn: (signal: AbortSignal) => Promise<T>,
879
+ opts?: { selector?: string; zeroMatchAfterMs?: number },
852
880
  ): Promise<T> {
853
881
  const opId = active.opCounter++;
854
882
  active.inflight.set(opId, { label, startedAt: Date.now() });
855
883
  const capped = Number.isFinite(perOpTimeoutMs) && perOpTimeoutMs > 0;
856
884
  const opTimeout = capped ? AbortSignal.timeout(perOpTimeoutMs) : undefined;
857
885
  const opSignal = opTimeout ? AbortSignal.any([cellSignal, opTimeout]) : cellSignal;
886
+ const selector = opts?.selector;
887
+ const watchdog =
888
+ selector !== undefined && opts?.zeroMatchAfterMs !== undefined && parseAriaRefSelector(selector) === null
889
+ ? { selector, afterMs: opts.zeroMatchAfterMs }
890
+ : undefined;
891
+ // Fired when the watchdog wins the race (tears down the in-flight action) and in
892
+ // the finally (stops the watchdog's polling once the op settles either way).
893
+ const earlyAc = new AbortController();
858
894
  try {
859
- return await fn(opSignal);
895
+ if (!watchdog) return await fn(opSignal);
896
+ const racedSignal = AbortSignal.any([opSignal, earlyAc.signal]);
897
+ return await Promise.race([
898
+ fn(racedSignal),
899
+ this.#zeroMatchWatchdog(watchdog.selector, label, watchdog.afterMs, racedSignal),
900
+ ]);
860
901
  } catch (err) {
861
902
  // Fail fast with a named, attributable error instead of the opaque whole-cell timeout:
862
903
  // our per-op deadline fired, or puppeteer's own (equal) timeout fired first — having
@@ -867,20 +908,76 @@ export class WorkerCore {
867
908
  !cellSignal.aborted &&
868
909
  (opTimeout?.aborted || (err instanceof Error && err.name === "TimeoutError"))
869
910
  ) {
870
- throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms`);
911
+ const hint = selector ? await this.#selectorTimeoutHint(selector) : "";
912
+ throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`);
871
913
  }
872
914
  throw err;
873
915
  } finally {
916
+ earlyAc.abort();
874
917
  active.inflight.delete(opId);
875
918
  }
876
919
  }
877
920
 
921
+ /**
922
+ * Fail-fast arm raced against a selector op: rejects once the selector has matched
923
+ * nothing for the whole `afterMs` window, so a wrong selector or wrong page (consent
924
+ * wall, pre-navigation document) costs ~2s instead of the full action deadline.
925
+ * Disarms — hangs until the settled race drops it — the moment at least one element
926
+ * matches; an inconclusive probe (mid-navigation, detached frame) never counts
927
+ * toward the zero-match window.
928
+ */
929
+ async #zeroMatchWatchdog(selector: string, label: string, afterMs: number, signal: AbortSignal): Promise<never> {
930
+ const page = this.#requirePage();
931
+ const resolved = normalizeSelector(selector);
932
+ const deadline = Date.now() + afterMs;
933
+ while (!signal.aborted) {
934
+ let count: number | null = null;
935
+ try {
936
+ const handles = await page.$$(resolved);
937
+ count = handles.length;
938
+ for (const handle of handles) void handle.dispose().catch(() => undefined);
939
+ } catch {
940
+ // Inconclusive probe — keep polling without advancing toward failure.
941
+ }
942
+ if (count !== null && count > 0) break;
943
+ if (count === 0 && Date.now() >= deadline) {
944
+ throw new ToolError(`${label} failed fast after ${afterMs}ms${formatSelectorMatchHint(0)}`);
945
+ }
946
+ try {
947
+ await untilAborted(signal, () => Bun.sleep(ZERO_MATCH_POLL_MS));
948
+ } catch {
949
+ break;
950
+ }
951
+ }
952
+ return await new Promise<never>(() => {});
953
+ }
954
+
955
+ /**
956
+ * Best-effort match-count probe for a timed-out selector op. Never throws;
957
+ * empty string when the probe fails, stalls, or the selector is an aria-ref.
958
+ */
959
+ async #selectorTimeoutHint(selector: string): Promise<string> {
960
+ if (parseAriaRefSelector(selector) !== null) return "";
961
+ try {
962
+ const handles = await Promise.race([
963
+ this.#requirePage().$$(normalizeSelector(selector)),
964
+ Bun.sleep(1_000).then(() => null),
965
+ ]);
966
+ if (!handles) return "";
967
+ const count = handles.length;
968
+ for (const handle of handles) void handle.dispose().catch(() => undefined);
969
+ return formatSelectorMatchHint(count);
970
+ } catch {
971
+ return "";
972
+ }
973
+ }
974
+
878
975
  #createTabApi(
879
976
  name: string,
880
977
  timeoutMs: number,
881
978
  signal: AbortSignal,
882
979
  session: SessionSnapshot,
883
- displays: RunResultOk["displays"],
980
+ output: RunOutput,
884
981
  screenshots: ScreenshotResult[],
885
982
  active: ActiveRun,
886
983
  ): TabApi {
@@ -888,8 +985,12 @@ export class WorkerCore {
888
985
  const { quickOpMs, actionOpMs } = resolveOpTimeouts(timeoutMs);
889
986
  const waitMs = (explicit?: number): number => resolveWaitTimeout(timeoutMs, explicit);
890
987
  const INF = Number.POSITIVE_INFINITY;
891
- const op = <T>(label: string, perOpMs: number, fn: (sig: AbortSignal) => Promise<T>): Promise<T> =>
892
- this.#runOp(active, label, signal, perOpMs, fn);
988
+ const op = <T>(
989
+ label: string,
990
+ perOpMs: number,
991
+ fn: (sig: AbortSignal) => Promise<T>,
992
+ selectorOpts?: { selector?: string; zeroMatchAfterMs?: number },
993
+ ): Promise<T> => this.#runOp(active, label, signal, perOpMs, fn, selectorOpts);
893
994
  return {
894
995
  name,
895
996
  page,
@@ -929,7 +1030,7 @@ export class WorkerCore {
929
1030
  ),
930
1031
  screenshot: opts =>
931
1032
  op(describeScreenshot(opts), quickOpMs, sig =>
932
- this.#captureScreenshot(session, displays, screenshots, sig, opts),
1033
+ this.#captureScreenshot(session, output, screenshots, sig, opts),
933
1034
  ),
934
1035
  extract: (format = "markdown") =>
935
1036
  op(`tab.extract(${JSON.stringify(format)})`, quickOpMs, async sig => {
@@ -949,51 +1050,62 @@ export class WorkerCore {
949
1050
  return content;
950
1051
  }),
951
1052
  click: selector =>
952
- op(`tab.click(${JSON.stringify(selector)})`, actionOpMs, async sig => {
953
- if (parseAriaRefSelector(selector) !== null) {
954
- const handle = await this.#resolveAriaRef(selector);
955
- try {
956
- await untilAborted(sig, () => handle.click());
957
- } finally {
958
- await handle.dispose().catch(() => undefined);
1053
+ op(
1054
+ `tab.click(${JSON.stringify(selector)})`,
1055
+ actionOpMs,
1056
+ async sig => {
1057
+ if (parseAriaRefSelector(selector) !== null) {
1058
+ const handle = await this.#resolveAriaRef(selector);
1059
+ try {
1060
+ await untilAborted(sig, () => handle.click());
1061
+ } finally {
1062
+ await handle.dispose().catch(() => undefined);
1063
+ }
1064
+ return;
959
1065
  }
960
- return;
961
- }
962
- const resolved = normalizeSelector(selector);
963
- if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, actionOpMs, sig);
964
- else await untilAborted(sig, () => page.locator(resolved).setTimeout(actionOpMs).click({ signal: sig }));
965
- }),
966
- type: (selector, text) =>
967
- op(`tab.type(${JSON.stringify(selector)})`, actionOpMs, async sig => {
968
- const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
969
- try {
970
- await untilAborted(sig, () => handle.type(text, { delay: 0 }));
971
- } finally {
972
- await handle.dispose().catch(() => undefined);
973
- }
974
- }),
975
- fill: (selector, value) =>
976
- op(`tab.fill(${JSON.stringify(selector)})`, actionOpMs, async sig => {
977
- if (parseAriaRefSelector(selector) !== null) {
978
- const handle = await this.#resolveAriaRef(selector);
979
- try {
1066
+ const resolved = normalizeSelector(selector);
1067
+ if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, actionOpMs, sig);
1068
+ else
980
1069
  await untilAborted(sig, () =>
981
- handle.evaluate(el => {
982
- const node = el as unknown as { value?: string; focus?: () => void };
983
- node.focus?.();
984
- if ("value" in node) node.value = "";
985
- }),
1070
+ page.locator(resolved).setTimeout(actionOpMs).click({ signal: sig }),
986
1071
  );
987
- await untilAborted(sig, () => handle.type(value, { delay: 0 }));
1072
+ },
1073
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1074
+ ),
1075
+ type: (selector, text) =>
1076
+ op(
1077
+ `tab.type(${JSON.stringify(selector)})`,
1078
+ actionOpMs,
1079
+ async sig => {
1080
+ const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
1081
+ try {
1082
+ await untilAborted(sig, () => handle.type(text, { delay: 0 }));
988
1083
  } finally {
989
1084
  await handle.dispose().catch(() => undefined);
990
1085
  }
991
- return;
992
- }
993
- await untilAborted(sig, () =>
994
- page.locator(normalizeSelector(selector)).setTimeout(actionOpMs).fill(value, { signal: sig }),
995
- );
996
- }),
1086
+ },
1087
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1088
+ ),
1089
+ fill: (selector, value) =>
1090
+ op(
1091
+ `tab.fill(${JSON.stringify(selector)})`,
1092
+ actionOpMs,
1093
+ async sig => {
1094
+ if (parseAriaRefSelector(selector) !== null) {
1095
+ const handle = await this.#resolveAriaRef(selector);
1096
+ try {
1097
+ await fillViaHandle(handle, value, sig);
1098
+ } finally {
1099
+ await handle.dispose().catch(() => undefined);
1100
+ }
1101
+ return;
1102
+ }
1103
+ await untilAborted(sig, () =>
1104
+ page.locator(normalizeSelector(selector)).setTimeout(actionOpMs).fill(value, { signal: sig }),
1105
+ );
1106
+ },
1107
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1108
+ ),
997
1109
  press: (key, opts) =>
998
1110
  op(`tab.press(${JSON.stringify(key)})`, actionOpMs, async sig => {
999
1111
  const selector = opts?.selector;
@@ -1005,23 +1117,37 @@ export class WorkerCore {
1005
1117
  drag: (from, to) => op("tab.drag()", actionOpMs, sig => this.#drag(from, to, sig)),
1006
1118
  waitFor: (selector, opts) => {
1007
1119
  const w = waitMs(opts?.timeout);
1008
- return op(`tab.waitFor(${JSON.stringify(selector)})`, w, sig =>
1009
- this.#resolveActionHandle(selector, w, sig),
1120
+ return op(
1121
+ `tab.waitFor(${JSON.stringify(selector)})`,
1122
+ w,
1123
+ async sig => toActionableHandle(await this.#resolveActionHandle(selector, w, sig)),
1124
+ { selector, zeroMatchAfterMs: opts?.timeout === undefined ? ZERO_MATCH_FAIL_FAST_MS : undefined },
1010
1125
  );
1011
1126
  },
1012
1127
  waitForSelector: (selector, opts) => {
1013
1128
  const w = waitMs(opts?.timeout);
1014
- return op(`tab.waitForSelector(${JSON.stringify(selector)})`, w, async sig => {
1015
- if (parseAriaRefSelector(selector) !== null) return this.#resolveAriaRef(selector);
1016
- return (await untilAborted(sig, () =>
1017
- page.waitForSelector(normalizeSelector(selector), {
1018
- timeout: w,
1019
- visible: opts?.visible,
1020
- hidden: opts?.hidden,
1021
- signal: sig,
1022
- }),
1023
- )) as ElementHandle | null;
1024
- });
1129
+ return op(
1130
+ `tab.waitForSelector(${JSON.stringify(selector)})`,
1131
+ w,
1132
+ async sig => {
1133
+ if (parseAriaRefSelector(selector) !== null)
1134
+ return toActionableHandle(await this.#resolveAriaRef(selector));
1135
+ const handle = (await untilAborted(sig, () =>
1136
+ page.waitForSelector(normalizeSelector(selector), {
1137
+ timeout: w,
1138
+ visible: opts?.visible,
1139
+ hidden: opts?.hidden,
1140
+ signal: sig,
1141
+ }),
1142
+ )) as ElementHandle | null;
1143
+ return handle ? toActionableHandle(handle) : null;
1144
+ },
1145
+ {
1146
+ selector,
1147
+ // `hidden: true` waits for zero matches — that is success, never a fast-fail.
1148
+ zeroMatchAfterMs: opts?.timeout === undefined && !opts?.hidden ? ZERO_MATCH_FAIL_FAST_MS : undefined,
1149
+ },
1150
+ );
1025
1151
  },
1026
1152
  waitForNavigation: opts => {
1027
1153
  const w = waitMs(opts?.timeout);
@@ -1035,33 +1161,47 @@ export class WorkerCore {
1035
1161
  op("tab.evaluate()", INF, sig =>
1036
1162
  untilAborted(sig, () =>
1037
1163
  typeof fn === "string"
1038
- ? page.evaluate(fn)
1039
- : page.evaluate(fn as (...a: unknown[]) => unknown, ...args),
1164
+ ? page.mainFrame().mainRealm().evaluate(fn)
1165
+ : page
1166
+ .mainFrame()
1167
+ .mainRealm()
1168
+ .evaluate(fn as (...a: unknown[]) => unknown, ...args),
1040
1169
  ),
1041
1170
  ) as never,
1042
1171
  scrollIntoView: selector =>
1043
- op(`tab.scrollIntoView(${JSON.stringify(selector)})`, actionOpMs, async sig => {
1044
- const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
1045
- try {
1046
- await untilAborted(sig, () =>
1047
- handle.evaluate(el => {
1048
- const target = el as unknown as {
1049
- scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void;
1050
- };
1051
- target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
1052
- }),
1053
- );
1054
- } finally {
1055
- await handle.dispose().catch(() => undefined);
1056
- }
1057
- }),
1172
+ op(
1173
+ `tab.scrollIntoView(${JSON.stringify(selector)})`,
1174
+ actionOpMs,
1175
+ async sig => {
1176
+ const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
1177
+ try {
1178
+ await untilAborted(sig, () =>
1179
+ handle.evaluate(el => {
1180
+ const target = el as unknown as {
1181
+ scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void;
1182
+ };
1183
+ target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
1184
+ }),
1185
+ );
1186
+ } finally {
1187
+ await handle.dispose().catch(() => undefined);
1188
+ }
1189
+ },
1190
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1191
+ ),
1058
1192
  select: (selector, ...values) =>
1059
- op(`tab.select(${JSON.stringify(selector)})`, actionOpMs, sig =>
1060
- this.#select(selector, values, actionOpMs, sig),
1193
+ op(
1194
+ `tab.select(${JSON.stringify(selector)})`,
1195
+ actionOpMs,
1196
+ sig => this.#select(selector, values, actionOpMs, sig),
1197
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1061
1198
  ),
1062
1199
  uploadFile: (selector, ...filePaths) =>
1063
- op(`tab.uploadFile(${JSON.stringify(selector)})`, actionOpMs, sig =>
1064
- this.#uploadFile(selector, filePaths, actionOpMs, sig, session),
1200
+ op(
1201
+ `tab.uploadFile(${JSON.stringify(selector)})`,
1202
+ actionOpMs,
1203
+ sig => this.#uploadFile(selector, filePaths, actionOpMs, sig, session),
1204
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1065
1205
  ),
1066
1206
  waitForUrl: (pattern, opts) => {
1067
1207
  const w = waitMs(opts?.timeout);
@@ -1071,8 +1211,8 @@ export class WorkerCore {
1071
1211
  const w = waitMs(opts?.timeout);
1072
1212
  return op("tab.waitForResponse()", w, sig => this.#waitForResponse(pattern, w, sig));
1073
1213
  },
1074
- id: id => this.#resolveCachedHandle(id),
1075
- ref: id => this.#resolveAriaRef(id),
1214
+ id: async id => toActionableHandle(await this.#resolveCachedHandle(id)),
1215
+ ref: async id => toActionableHandle(await this.#resolveAriaRef(id)),
1076
1216
  };
1077
1217
  }
1078
1218
 
@@ -1122,12 +1262,19 @@ export class WorkerCore {
1122
1262
 
1123
1263
  async #captureScreenshot(
1124
1264
  session: SessionSnapshot,
1125
- displays: RunResultOk["displays"],
1265
+ output: RunOutput,
1126
1266
  screenshots: ScreenshotResult[],
1127
1267
  signal: AbortSignal | undefined,
1128
1268
  opts: ScreenshotOptions = {},
1129
1269
  ): Promise<ScreenshotResult> {
1130
1270
  const page = this.#requirePage();
1271
+ // Multiple tabs can share one Chromium (sibling headless tabs on a shared
1272
+ // endpoint, cdp/app attach). CDP `Page.captureScreenshot` reads the
1273
+ // compositor surface, which follows the *active* target — a backgrounded
1274
+ // page can stall waiting for a fresh frame (the 20s screenshot timeouts)
1275
+ // or hand back a sibling tab's pixels. Activate first; best-effort so an
1276
+ // already-active or freshly-closed target never fails the capture.
1277
+ await untilAborted(signal, () => page.bringToFront()).catch(() => undefined);
1131
1278
  const fullPage = opts.selector ? false : (opts.fullPage ?? false);
1132
1279
  // An explicit save path picks the full-res capture format: puppeteer encodes
1133
1280
  // png/jpeg/webp natively, so `save: "shot.webp"` gets real WebP bytes instead
@@ -1199,8 +1346,8 @@ export class WorkerCore {
1199
1346
  dest,
1200
1347
  resized,
1201
1348
  });
1202
- displays.push({ type: "text", text: lines.join("\n") });
1203
- displays.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
1349
+ output.push({ type: "text", text: lines.join("\n") });
1350
+ output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
1204
1351
  }
1205
1352
  return info;
1206
1353
  }