@oh-my-pi/pi-coding-agent 16.4.2 → 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 (122) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/cli.js +17514 -17799
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/commit/agentic/agent.d.ts +1 -0
  5. package/dist/types/config/settings-schema.d.ts +28 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +10 -22
  7. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  8. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  9. package/dist/types/modes/components/status-line/component.d.ts +3 -0
  10. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  11. package/dist/types/modes/interactive-mode.d.ts +8 -0
  12. package/dist/types/modes/types.d.ts +2 -0
  13. package/dist/types/session/agent-session.d.ts +16 -0
  14. package/dist/types/task/executor.d.ts +31 -0
  15. package/dist/types/tools/__tests__/vibe-render.test.d.ts +1 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  17. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  18. package/dist/types/tools/browser/run-output.d.ts +25 -0
  19. package/dist/types/tools/browser/tab-worker.d.ts +16 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/vibe.d.ts +161 -0
  22. package/dist/types/utils/changelog.d.ts +35 -1
  23. package/dist/types/vibe/runtime.d.ts +124 -0
  24. package/dist/types/vibe/state.d.ts +4 -0
  25. package/dist/types/web/search/provider.d.ts +2 -0
  26. package/dist/types/web/search/providers/bing.d.ts +14 -0
  27. package/dist/types/web/search/providers/browser-headers.d.ts +9 -0
  28. package/dist/types/web/search/providers/browser-page.d.ts +32 -0
  29. package/dist/types/web/search/providers/ecosia.d.ts +14 -0
  30. package/dist/types/web/search/providers/google.d.ts +13 -0
  31. package/dist/types/web/search/providers/mojeek.d.ts +14 -0
  32. package/dist/types/web/search/providers/public.d.ts +37 -0
  33. package/dist/types/web/search/providers/startpage.d.ts +14 -0
  34. package/dist/types/web/search/providers/yahoo.d.ts +14 -0
  35. package/dist/types/web/search/types.d.ts +28 -0
  36. package/package.json +15 -18
  37. package/scripts/build-binary.ts +56 -73
  38. package/scripts/bundle-dist.ts +36 -40
  39. package/scripts/compile-binary.ts +68 -0
  40. package/scripts/generate-docs-index.ts +3 -93
  41. package/scripts/legacy-pi-virtual-module.ts +192 -0
  42. package/src/advisor/__tests__/advisor.test.ts +13 -0
  43. package/src/advisor/__tests__/config.test.ts +36 -0
  44. package/src/advisor/config.ts +11 -8
  45. package/src/commit/agentic/agent.ts +4 -0
  46. package/src/commit/agentic/index.ts +46 -21
  47. package/src/edit/index.ts +10 -76
  48. package/src/exec/non-interactive-env.ts +0 -1
  49. package/src/extensibility/plugins/legacy-pi-compat.ts +328 -162
  50. package/src/extensibility/plugins/legacy-pi-virtual-modules.d.ts +4 -0
  51. package/src/internal-urls/docs-index.ts +2 -1
  52. package/src/internal-urls/skill-protocol.ts +1 -1
  53. package/src/lsp/deferred-diagnostics.ts +66 -0
  54. package/src/main.ts +13 -13
  55. package/src/mcp/transports/stdio.test.ts +45 -1
  56. package/src/mcp/transports/stdio.ts +6 -3
  57. package/src/modes/acp/acp-agent.ts +65 -1
  58. package/src/modes/acp/acp-event-mapper.ts +5 -0
  59. package/src/modes/acp/acp-mode.ts +11 -0
  60. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  61. package/src/modes/components/advisor-config.ts +15 -7
  62. package/src/modes/components/move-overlay.ts +2 -3
  63. package/src/modes/components/status-line/component.ts +6 -0
  64. package/src/modes/components/status-line/segments.ts +6 -0
  65. package/src/modes/components/status-line/types.ts +3 -0
  66. package/src/modes/controllers/command-controller.ts +8 -10
  67. package/src/modes/interactive-mode.ts +117 -1
  68. package/src/modes/types.ts +2 -0
  69. package/src/prompts/system/eager-task.md +2 -2
  70. package/src/prompts/system/system-prompt.md +9 -2
  71. package/src/prompts/system/vibe-mode-active.md +23 -0
  72. package/src/prompts/tools/browser.md +3 -3
  73. package/src/prompts/tools/grep.md +1 -1
  74. package/src/prompts/tools/vibe-kill.md +3 -0
  75. package/src/prompts/tools/vibe-list.md +3 -0
  76. package/src/prompts/tools/vibe-send.md +9 -0
  77. package/src/prompts/tools/vibe-spawn.md +10 -0
  78. package/src/prompts/tools/vibe-turn-result.md +19 -0
  79. package/src/prompts/tools/vibe-wait.md +8 -0
  80. package/src/sdk.ts +5 -0
  81. package/src/session/agent-session.ts +103 -13
  82. package/src/session/snapcompact-inline.ts +3 -19
  83. package/src/slash-commands/builtin-registry.ts +24 -8
  84. package/src/task/agents.ts +0 -2
  85. package/src/task/executor.ts +105 -0
  86. package/src/tools/__tests__/vibe-render.test.ts +210 -0
  87. package/src/tools/bash-skill-urls.ts +1 -1
  88. package/src/tools/browser/cmux/cmux-tab.ts +46 -48
  89. package/src/tools/browser/cmux/rpc.ts +50 -0
  90. package/src/tools/browser/run-output.ts +76 -0
  91. package/src/tools/browser/tab-worker.ts +264 -129
  92. package/src/tools/glob.ts +20 -6
  93. package/src/tools/index.ts +1 -0
  94. package/src/tools/read.ts +17 -9
  95. package/src/tools/renderers.ts +6 -0
  96. package/src/tools/vibe.ts +608 -0
  97. package/src/tools/write.ts +15 -2
  98. package/src/utils/changelog.ts +106 -9
  99. package/src/utils/git.ts +0 -1
  100. package/src/utils/title-generator.ts +70 -7
  101. package/src/vibe/runtime.ts +710 -0
  102. package/src/vibe/state.ts +4 -0
  103. package/src/web/search/index.ts +14 -6
  104. package/src/web/search/provider.ts +37 -1
  105. package/src/web/search/providers/bing.ts +197 -0
  106. package/src/web/search/providers/browser-headers.ts +92 -0
  107. package/src/web/search/providers/browser-page.ts +123 -0
  108. package/src/web/search/providers/duckduckgo.ts +13 -34
  109. package/src/web/search/providers/ecosia.ts +178 -0
  110. package/src/web/search/providers/google.ts +193 -0
  111. package/src/web/search/providers/mojeek.ts +206 -0
  112. package/src/web/search/providers/public.ts +201 -0
  113. package/src/web/search/providers/startpage.ts +213 -0
  114. package/src/web/search/providers/yahoo.ts +179 -0
  115. package/src/web/search/types.ts +35 -0
  116. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +0 -10
  117. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +0 -10
  118. package/scripts/generate-legacy-pi-bundled-registry.ts +0 -420
  119. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +0 -1011
  120. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +0 -3430
  121. package/src/internal-urls/docs-index.generated.txt +0 -2
  122. package/src/prompts/agents/plan.md +0 -47
@@ -17,7 +17,6 @@ import type {
17
17
  Target,
18
18
  } from "puppeteer-core";
19
19
  import { JsRuntime, type RuntimeHooks } from "../../eval/js/shared/runtime";
20
- import type { JsDisplayOutput } from "../../eval/js/shared/types";
21
20
  import { resizeImage } from "../../utils/image-resize";
22
21
  import { resolveToCwd } from "../path-utils";
23
22
  import { formatScreenshot } from "../render-utils";
@@ -37,12 +36,12 @@ import {
37
36
  } from "./launch";
38
37
  import { extractReadableFromHtml, type ReadableFormat } from "./readable";
39
38
  import { markHandled, waitForBrowserRun } from "./run-cancellation";
39
+ import { cloneSafe, RunOutput } from "./run-output";
40
40
  import type {
41
41
  Observation,
42
42
  ObservationEntry,
43
43
  ReadyInfo,
44
44
  RunErrorPayload,
45
- RunResultOk,
46
45
  ScreenshotResult,
47
46
  SessionSnapshot,
48
47
  ToolReply,
@@ -116,15 +115,27 @@ type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; rea
116
115
  * - `QUICK_OP_TIMEOUT_MS`: page-coupled reads that should resolve fast (`observe`,
117
116
  * `screenshot`, `extract`, `ariaSnapshot`).
118
117
  * - `ACTION_OP_TIMEOUT_MS`: interactive point actions (`click`, `fill`, `type`, …) and
119
- * the default for wait helpers when no explicit `{ timeout }` is given.
118
+ * the default for wait helpers when no explicit `{ timeout }` is given. Selector ops
119
+ * additionally fail fast after `ZERO_MATCH_FAIL_FAST_MS` of confirmed zero matches
120
+ * (see `#zeroMatchWatchdog`), so the full ceiling is only spent on elements that
121
+ * exist but are not yet actionable.
120
122
  *
121
123
  * `goto` and `evaluate` stay uncapped (`Number.POSITIVE_INFINITY`): navigation and user
122
124
  * code legitimately use the full cell budget.
123
125
  */
124
126
  const QUICK_OP_TIMEOUT_MS = 20_000;
125
- const ACTION_OP_TIMEOUT_MS = 15_000;
127
+ const ACTION_OP_TIMEOUT_MS = 8_000;
126
128
  /** Headroom subtracted from the cell budget so a per-op deadline fires before it. */
127
129
  const OP_DEADLINE_SLACK_MS = 1_000;
130
+ /**
131
+ * A selector op whose selector has matched nothing for this long fails fast with the
132
+ * zero-match hint instead of burning the rest of its deadline: a wrong selector or a
133
+ * wrong page (consent wall, pre-navigation document) is the common agent failure and
134
+ * should cost ~2s, not the full action ceiling. Explicit `{ timeout }` waits opt out.
135
+ */
136
+ const ZERO_MATCH_FAIL_FAST_MS = 2_000;
137
+ /** Poll cadence for the zero-match watchdog. */
138
+ const ZERO_MATCH_POLL_MS = 250;
128
139
 
129
140
  export interface OpTimeouts {
130
141
  /** Largest per-op deadline allowed — strictly below the cell budget. */
@@ -190,7 +201,7 @@ interface TabApi {
190
201
  press(key: KeyInput, opts?: { selector?: string }): Promise<void>;
191
202
  scroll(deltaX: number, deltaY: number): Promise<void>;
192
203
  drag(from: DragTarget, to: DragTarget): Promise<void>;
193
- waitFor(selector: string, opts?: { timeout?: number }): Promise<ElementHandle>;
204
+ waitFor(selector: string, opts?: { timeout?: number }): Promise<ActionableHandle>;
194
205
  evaluate<TResult, TArgs extends unknown[]>(
195
206
  fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
196
207
  ...args: TArgs
@@ -206,13 +217,13 @@ interface TabApi {
206
217
  waitForSelector(
207
218
  selector: string,
208
219
  opts?: { timeout?: number; visible?: boolean; hidden?: boolean },
209
- ): Promise<ElementHandle | null>;
220
+ ): Promise<ActionableHandle | null>;
210
221
  waitForNavigation(opts?: {
211
222
  waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
212
223
  timeout?: number;
213
224
  }): Promise<HTTPResponse | null>;
214
- id(n: number): Promise<ElementHandle>;
215
- ref(id: string): Promise<ElementHandle>;
225
+ id(n: number): Promise<ActionableHandle>;
226
+ ref(id: string): Promise<ActionableHandle>;
216
227
  }
217
228
 
218
229
  export function normalizeSelector(selector: string): string {
@@ -259,16 +270,30 @@ function asElementHandle(handle: unknown): ElementHandle | null {
259
270
  return handle ? (handle as ElementHandle) : null;
260
271
  }
261
272
 
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);
273
+ /** ElementHandle enriched with the `fill()` the tool docs promise on handles from `tab.id()`/`tab.ref()`/`tab.waitFor()`. */
274
+ export type ActionableHandle = ElementHandle & { fill(value: string): Promise<void> };
275
+
276
+ /**
277
+ * Attach `fill()` to a puppeteer ElementHandle before handing it to user code.
278
+ * Puppeteer handles expose `type()` but no `fill()`; the semantics mirror the
279
+ * selector-based `tab.fill()`: focus, clear any existing value, then type.
280
+ */
281
+ export function toActionableHandle(handle: ElementHandle): ActionableHandle {
282
+ const enriched = handle as ActionableHandle;
283
+ enriched.fill = value => fillViaHandle(enriched, value);
284
+ return enriched;
285
+ }
286
+
287
+ /** Focus, clear any existing value, then retype — shared by `tab.fill(aria-ref)` and enriched handles. */
288
+ async function fillViaHandle(handle: ElementHandle, value: string, signal?: AbortSignal): Promise<void> {
289
+ await untilAborted(signal, () =>
290
+ handle.evaluate(el => {
291
+ const node = el as unknown as { value?: string; focus?: () => void };
292
+ node.focus?.();
293
+ if ("value" in node) node.value = "";
294
+ }),
295
+ );
296
+ await untilAborted(signal, () => handle.type(value, { delay: 0 }));
272
297
  }
273
298
 
274
299
  /**
@@ -303,14 +328,6 @@ function errorPayload(error: unknown): RunErrorPayload {
303
328
  return { name: "Error", message: String(error), isToolError: false, isAbort: false };
304
329
  }
305
330
 
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
331
  function replyError(payload: RunErrorPayload): Error {
315
332
  if (payload.isAbort) {
316
333
  const err = new ToolAbortError(payload.message || "Tool call aborted");
@@ -508,6 +525,17 @@ async function clickQueryHandlerText(
508
525
  );
509
526
  }
510
527
 
528
+ /**
529
+ * Hint appended to a selector op's fail-fast timeout, given the selector's current
530
+ * match count: a missing element (consent wall, wrong page) reads differently from
531
+ * a present-but-unactionable one.
532
+ */
533
+ export function formatSelectorMatchHint(count: number): string {
534
+ return count === 0
535
+ ? "; selector currently matches no elements — run tab.observe() or tab.ariaSnapshot() to inspect the page"
536
+ : `; 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)`;
537
+ }
538
+
511
539
  export interface InflightOp {
512
540
  label: string;
513
541
  startedAt: number;
@@ -517,7 +545,7 @@ interface ActiveRun {
517
545
  id: string;
518
546
  ac: AbortController;
519
547
  signal: AbortSignal;
520
- displays: RunResultOk["displays"];
548
+ output: RunOutput;
521
549
  screenshots: ScreenshotResult[];
522
550
  pendingTools: Map<string, { resolve(value: unknown): void; reject(error: Error): void }>;
523
551
  /** Helper invocations currently awaiting the page/network, keyed by op id. */
@@ -706,13 +734,13 @@ export class WorkerCore {
706
734
  const ac = new AbortController();
707
735
  const runAc = new AbortController();
708
736
  const signal = AbortSignal.any([timeoutSignal, ac.signal, runAc.signal]);
709
- const displays: RunResultOk["displays"] = [];
737
+ const output = new RunOutput();
710
738
  const screenshots: ScreenshotResult[] = [];
711
739
  const active: ActiveRun = {
712
740
  id: msg.id,
713
741
  ac,
714
742
  signal,
715
- displays,
743
+ output,
716
744
  screenshots,
717
745
  pendingTools: new Map(),
718
746
  inflight: new Map(),
@@ -723,7 +751,7 @@ export class WorkerCore {
723
751
  throwIfAborted(signal);
724
752
  const page = this.#requirePage();
725
753
  const browser = this.#requireBrowser();
726
- const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, displays, screenshots, active);
754
+ const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, output, screenshots, active);
727
755
  const runtime = this.#ensureRuntime(msg.session);
728
756
  runtime.setCwd(msg.session.cwd);
729
757
  runtime.setRunScope({
@@ -774,7 +802,7 @@ export class WorkerCore {
774
802
  type: "result",
775
803
  id: msg.id,
776
804
  ok: true,
777
- payload: { displays, returnValue: cloneSafe(returnValue), screenshots },
805
+ payload: { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots },
778
806
  });
779
807
  } finally {
780
808
  signal.removeEventListener("abort", onCancel);
@@ -802,11 +830,12 @@ export class WorkerCore {
802
830
  return {
803
831
  onText: chunk => {
804
832
  throwIfAborted(active.signal);
833
+ active.output.pushText(chunk);
805
834
  this.#log("debug", chunk.replace(/\n$/, ""));
806
835
  },
807
836
  onDisplay: output => {
808
837
  throwIfAborted(active.signal);
809
- this.#pushDisplay(active.displays, output);
838
+ active.output.pushDisplay(output);
810
839
  },
811
840
  callTool: (name, args) => {
812
841
  throwIfAborted(active.signal);
@@ -815,20 +844,6 @@ export class WorkerCore {
815
844
  };
816
845
  }
817
846
 
818
- #pushDisplay(displays: RunResultOk["displays"], output: JsDisplayOutput): void {
819
- if (output.type === "image") {
820
- displays.push({ type: "image", data: output.data, mimeType: output.mimeType });
821
- return;
822
- }
823
- if (output.type === "json") {
824
- displays.push({ type: "text", text: safeJsonStringify(output.data) });
825
- return;
826
- }
827
- // status — surface as compact JSON so helper side effects (read/write/env) appear in
828
- // the cell result alongside explicit display() output.
829
- displays.push({ type: "text", text: safeJsonStringify(output.event) });
830
- }
831
-
832
847
  async #callTool(active: ActiveRun, name: string, args: unknown): Promise<unknown> {
833
848
  const id = `tab-tc-${active.id}-${crypto.randomUUID()}`;
834
849
  const { promise, resolve, reject } = Promise.withResolvers<unknown>();
@@ -853,7 +868,10 @@ export class WorkerCore {
853
868
  * with a named error instead of silently consuming the whole cell budget. Pass
854
869
  * `Number.POSITIVE_INFINITY` for `perOpTimeoutMs` to bound the op only by the cell
855
870
  * budget (used for `evaluate` running user code and for locator helpers that already
856
- * carry puppeteer's own `.setTimeout(timeoutMs)`).
871
+ * carry puppeteer's own `.setTimeout(timeoutMs)`). When the op targets a `selector`,
872
+ * the fail-fast timeout carries a best-effort match-count hint, and — when
873
+ * `zeroMatchAfterMs` is set — a watchdog aborts the op early once the selector has
874
+ * matched nothing for that long.
857
875
  */
858
876
  async #runOp<T>(
859
877
  active: ActiveRun,
@@ -861,14 +879,28 @@ export class WorkerCore {
861
879
  cellSignal: AbortSignal,
862
880
  perOpTimeoutMs: number,
863
881
  fn: (signal: AbortSignal) => Promise<T>,
882
+ opts?: { selector?: string; zeroMatchAfterMs?: number },
864
883
  ): Promise<T> {
865
884
  const opId = active.opCounter++;
866
885
  active.inflight.set(opId, { label, startedAt: Date.now() });
867
886
  const capped = Number.isFinite(perOpTimeoutMs) && perOpTimeoutMs > 0;
868
887
  const opTimeout = capped ? AbortSignal.timeout(perOpTimeoutMs) : undefined;
869
888
  const opSignal = opTimeout ? AbortSignal.any([cellSignal, opTimeout]) : cellSignal;
889
+ const selector = opts?.selector;
890
+ const watchdog =
891
+ selector !== undefined && opts?.zeroMatchAfterMs !== undefined && parseAriaRefSelector(selector) === null
892
+ ? { selector, afterMs: opts.zeroMatchAfterMs }
893
+ : undefined;
894
+ // Fired when the watchdog wins the race (tears down the in-flight action) and in
895
+ // the finally (stops the watchdog's polling once the op settles either way).
896
+ const earlyAc = new AbortController();
870
897
  try {
871
- return await fn(opSignal);
898
+ if (!watchdog) return await fn(opSignal);
899
+ const racedSignal = AbortSignal.any([opSignal, earlyAc.signal]);
900
+ return await Promise.race([
901
+ fn(racedSignal),
902
+ this.#zeroMatchWatchdog(watchdog.selector, label, watchdog.afterMs, racedSignal),
903
+ ]);
872
904
  } catch (err) {
873
905
  // Fail fast with a named, attributable error instead of the opaque whole-cell timeout:
874
906
  // our per-op deadline fired, or puppeteer's own (equal) timeout fired first — having
@@ -879,20 +911,76 @@ export class WorkerCore {
879
911
  !cellSignal.aborted &&
880
912
  (opTimeout?.aborted || (err instanceof Error && err.name === "TimeoutError"))
881
913
  ) {
882
- throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms`);
914
+ const hint = selector ? await this.#selectorTimeoutHint(selector) : "";
915
+ throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`);
883
916
  }
884
917
  throw err;
885
918
  } finally {
919
+ earlyAc.abort();
886
920
  active.inflight.delete(opId);
887
921
  }
888
922
  }
889
923
 
924
+ /**
925
+ * Fail-fast arm raced against a selector op: rejects once the selector has matched
926
+ * nothing for the whole `afterMs` window, so a wrong selector or wrong page (consent
927
+ * wall, pre-navigation document) costs ~2s instead of the full action deadline.
928
+ * Disarms — hangs until the settled race drops it — the moment at least one element
929
+ * matches; an inconclusive probe (mid-navigation, detached frame) never counts
930
+ * toward the zero-match window.
931
+ */
932
+ async #zeroMatchWatchdog(selector: string, label: string, afterMs: number, signal: AbortSignal): Promise<never> {
933
+ const page = this.#requirePage();
934
+ const resolved = normalizeSelector(selector);
935
+ const deadline = Date.now() + afterMs;
936
+ while (!signal.aborted) {
937
+ let count: number | null = null;
938
+ try {
939
+ const handles = await page.$$(resolved);
940
+ count = handles.length;
941
+ for (const handle of handles) void handle.dispose().catch(() => undefined);
942
+ } catch {
943
+ // Inconclusive probe — keep polling without advancing toward failure.
944
+ }
945
+ if (count !== null && count > 0) break;
946
+ if (count === 0 && Date.now() >= deadline) {
947
+ throw new ToolError(`${label} failed fast after ${afterMs}ms${formatSelectorMatchHint(0)}`);
948
+ }
949
+ try {
950
+ await untilAborted(signal, () => Bun.sleep(ZERO_MATCH_POLL_MS));
951
+ } catch {
952
+ break;
953
+ }
954
+ }
955
+ return await new Promise<never>(() => {});
956
+ }
957
+
958
+ /**
959
+ * Best-effort match-count probe for a timed-out selector op. Never throws;
960
+ * empty string when the probe fails, stalls, or the selector is an aria-ref.
961
+ */
962
+ async #selectorTimeoutHint(selector: string): Promise<string> {
963
+ if (parseAriaRefSelector(selector) !== null) return "";
964
+ try {
965
+ const handles = await Promise.race([
966
+ this.#requirePage().$$(normalizeSelector(selector)),
967
+ Bun.sleep(1_000).then(() => null),
968
+ ]);
969
+ if (!handles) return "";
970
+ const count = handles.length;
971
+ for (const handle of handles) void handle.dispose().catch(() => undefined);
972
+ return formatSelectorMatchHint(count);
973
+ } catch {
974
+ return "";
975
+ }
976
+ }
977
+
890
978
  #createTabApi(
891
979
  name: string,
892
980
  timeoutMs: number,
893
981
  signal: AbortSignal,
894
982
  session: SessionSnapshot,
895
- displays: RunResultOk["displays"],
983
+ output: RunOutput,
896
984
  screenshots: ScreenshotResult[],
897
985
  active: ActiveRun,
898
986
  ): TabApi {
@@ -900,8 +988,12 @@ export class WorkerCore {
900
988
  const { quickOpMs, actionOpMs } = resolveOpTimeouts(timeoutMs);
901
989
  const waitMs = (explicit?: number): number => resolveWaitTimeout(timeoutMs, explicit);
902
990
  const INF = Number.POSITIVE_INFINITY;
903
- const op = <T>(label: string, perOpMs: number, fn: (sig: AbortSignal) => Promise<T>): Promise<T> =>
904
- markHandled(this.#runOp(active, label, signal, perOpMs, fn));
991
+ const op = <T>(
992
+ label: string,
993
+ perOpMs: number,
994
+ fn: (sig: AbortSignal) => Promise<T>,
995
+ selectorOpts?: { selector?: string; zeroMatchAfterMs?: number },
996
+ ): Promise<T> => markHandled(this.#runOp(active, label, signal, perOpMs, fn, selectorOpts));
905
997
  return {
906
998
  name,
907
999
  page,
@@ -941,7 +1033,7 @@ export class WorkerCore {
941
1033
  ),
942
1034
  screenshot: opts =>
943
1035
  op(describeScreenshot(opts), quickOpMs, sig =>
944
- this.#captureScreenshot(session, displays, screenshots, sig, opts),
1036
+ this.#captureScreenshot(session, output, screenshots, sig, opts),
945
1037
  ),
946
1038
  extract: (format = "markdown") =>
947
1039
  op(`tab.extract(${JSON.stringify(format)})`, quickOpMs, async sig => {
@@ -961,51 +1053,62 @@ export class WorkerCore {
961
1053
  return content;
962
1054
  }),
963
1055
  click: selector =>
964
- op(`tab.click(${JSON.stringify(selector)})`, actionOpMs, async sig => {
965
- if (parseAriaRefSelector(selector) !== null) {
966
- const handle = await this.#resolveAriaRef(selector);
967
- try {
968
- await untilAborted(sig, () => handle.click());
969
- } finally {
970
- await handle.dispose().catch(() => undefined);
1056
+ op(
1057
+ `tab.click(${JSON.stringify(selector)})`,
1058
+ actionOpMs,
1059
+ async sig => {
1060
+ if (parseAriaRefSelector(selector) !== null) {
1061
+ const handle = await this.#resolveAriaRef(selector);
1062
+ try {
1063
+ await untilAborted(sig, () => handle.click());
1064
+ } finally {
1065
+ await handle.dispose().catch(() => undefined);
1066
+ }
1067
+ return;
971
1068
  }
972
- return;
973
- }
974
- const resolved = normalizeSelector(selector);
975
- if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, actionOpMs, sig);
976
- else await untilAborted(sig, () => page.locator(resolved).setTimeout(actionOpMs).click({ signal: sig }));
977
- }),
978
- type: (selector, text) =>
979
- op(`tab.type(${JSON.stringify(selector)})`, actionOpMs, async sig => {
980
- const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
981
- try {
982
- await untilAborted(sig, () => handle.type(text, { delay: 0 }));
983
- } finally {
984
- await handle.dispose().catch(() => undefined);
985
- }
986
- }),
987
- fill: (selector, value) =>
988
- op(`tab.fill(${JSON.stringify(selector)})`, actionOpMs, async sig => {
989
- if (parseAriaRefSelector(selector) !== null) {
990
- const handle = await this.#resolveAriaRef(selector);
991
- try {
1069
+ const resolved = normalizeSelector(selector);
1070
+ if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, actionOpMs, sig);
1071
+ else
992
1072
  await untilAborted(sig, () =>
993
- handle.evaluate(el => {
994
- const node = el as unknown as { value?: string; focus?: () => void };
995
- node.focus?.();
996
- if ("value" in node) node.value = "";
997
- }),
1073
+ page.locator(resolved).setTimeout(actionOpMs).click({ signal: sig }),
998
1074
  );
999
- await untilAborted(sig, () => handle.type(value, { delay: 0 }));
1075
+ },
1076
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1077
+ ),
1078
+ type: (selector, text) =>
1079
+ op(
1080
+ `tab.type(${JSON.stringify(selector)})`,
1081
+ actionOpMs,
1082
+ async sig => {
1083
+ const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
1084
+ try {
1085
+ await untilAborted(sig, () => handle.type(text, { delay: 0 }));
1000
1086
  } finally {
1001
1087
  await handle.dispose().catch(() => undefined);
1002
1088
  }
1003
- return;
1004
- }
1005
- await untilAborted(sig, () =>
1006
- page.locator(normalizeSelector(selector)).setTimeout(actionOpMs).fill(value, { signal: sig }),
1007
- );
1008
- }),
1089
+ },
1090
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1091
+ ),
1092
+ fill: (selector, value) =>
1093
+ op(
1094
+ `tab.fill(${JSON.stringify(selector)})`,
1095
+ actionOpMs,
1096
+ async sig => {
1097
+ if (parseAriaRefSelector(selector) !== null) {
1098
+ const handle = await this.#resolveAriaRef(selector);
1099
+ try {
1100
+ await fillViaHandle(handle, value, sig);
1101
+ } finally {
1102
+ await handle.dispose().catch(() => undefined);
1103
+ }
1104
+ return;
1105
+ }
1106
+ await untilAborted(sig, () =>
1107
+ page.locator(normalizeSelector(selector)).setTimeout(actionOpMs).fill(value, { signal: sig }),
1108
+ );
1109
+ },
1110
+ { selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
1111
+ ),
1009
1112
  press: (key, opts) =>
1010
1113
  op(`tab.press(${JSON.stringify(key)})`, actionOpMs, async sig => {
1011
1114
  const selector = opts?.selector;
@@ -1017,23 +1120,37 @@ export class WorkerCore {
1017
1120
  drag: (from, to) => op("tab.drag()", actionOpMs, sig => this.#drag(from, to, sig)),
1018
1121
  waitFor: (selector, opts) => {
1019
1122
  const w = waitMs(opts?.timeout);
1020
- return op(`tab.waitFor(${JSON.stringify(selector)})`, w, sig =>
1021
- this.#resolveActionHandle(selector, w, sig),
1123
+ return op(
1124
+ `tab.waitFor(${JSON.stringify(selector)})`,
1125
+ w,
1126
+ async sig => toActionableHandle(await this.#resolveActionHandle(selector, w, sig)),
1127
+ { selector, zeroMatchAfterMs: opts?.timeout === undefined ? ZERO_MATCH_FAIL_FAST_MS : undefined },
1022
1128
  );
1023
1129
  },
1024
1130
  waitForSelector: (selector, opts) => {
1025
1131
  const w = waitMs(opts?.timeout);
1026
- return op(`tab.waitForSelector(${JSON.stringify(selector)})`, w, async sig => {
1027
- if (parseAriaRefSelector(selector) !== null) return this.#resolveAriaRef(selector);
1028
- return (await untilAborted(sig, () =>
1029
- page.waitForSelector(normalizeSelector(selector), {
1030
- timeout: w,
1031
- visible: opts?.visible,
1032
- hidden: opts?.hidden,
1033
- signal: sig,
1034
- }),
1035
- )) as ElementHandle | null;
1036
- });
1132
+ return op(
1133
+ `tab.waitForSelector(${JSON.stringify(selector)})`,
1134
+ w,
1135
+ async sig => {
1136
+ if (parseAriaRefSelector(selector) !== null)
1137
+ return toActionableHandle(await this.#resolveAriaRef(selector));
1138
+ const handle = (await untilAborted(sig, () =>
1139
+ page.waitForSelector(normalizeSelector(selector), {
1140
+ timeout: w,
1141
+ visible: opts?.visible,
1142
+ hidden: opts?.hidden,
1143
+ signal: sig,
1144
+ }),
1145
+ )) as ElementHandle | null;
1146
+ return handle ? toActionableHandle(handle) : null;
1147
+ },
1148
+ {
1149
+ selector,
1150
+ // `hidden: true` waits for zero matches — that is success, never a fast-fail.
1151
+ zeroMatchAfterMs: opts?.timeout === undefined && !opts?.hidden ? ZERO_MATCH_FAIL_FAST_MS : undefined,
1152
+ },
1153
+ );
1037
1154
  },
1038
1155
  waitForNavigation: opts => {
1039
1156
  const w = waitMs(opts?.timeout);
@@ -1052,28 +1169,39 @@ export class WorkerCore {
1052
1169
  ),
1053
1170
  ) as never,
1054
1171
  scrollIntoView: selector =>
1055
- op(`tab.scrollIntoView(${JSON.stringify(selector)})`, actionOpMs, async sig => {
1056
- const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
1057
- try {
1058
- await untilAborted(sig, () =>
1059
- handle.evaluate(el => {
1060
- const target = el as unknown as {
1061
- scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void;
1062
- };
1063
- target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
1064
- }),
1065
- );
1066
- } finally {
1067
- await handle.dispose().catch(() => undefined);
1068
- }
1069
- }),
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
+ ),
1070
1192
  select: (selector, ...values) =>
1071
- op(`tab.select(${JSON.stringify(selector)})`, actionOpMs, sig =>
1072
- 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 },
1073
1198
  ),
1074
1199
  uploadFile: (selector, ...filePaths) =>
1075
- op(`tab.uploadFile(${JSON.stringify(selector)})`, actionOpMs, sig =>
1076
- 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 },
1077
1205
  ),
1078
1206
  waitForUrl: (pattern, opts) => {
1079
1207
  const w = waitMs(opts?.timeout);
@@ -1083,8 +1211,8 @@ export class WorkerCore {
1083
1211
  const w = waitMs(opts?.timeout);
1084
1212
  return op("tab.waitForResponse()", w, sig => this.#waitForResponse(pattern, w, sig));
1085
1213
  },
1086
- id: id => this.#resolveCachedHandle(id),
1087
- ref: id => this.#resolveAriaRef(id),
1214
+ id: async id => toActionableHandle(await this.#resolveCachedHandle(id)),
1215
+ ref: async id => toActionableHandle(await this.#resolveAriaRef(id)),
1088
1216
  };
1089
1217
  }
1090
1218
 
@@ -1134,12 +1262,19 @@ export class WorkerCore {
1134
1262
 
1135
1263
  async #captureScreenshot(
1136
1264
  session: SessionSnapshot,
1137
- displays: RunResultOk["displays"],
1265
+ output: RunOutput,
1138
1266
  screenshots: ScreenshotResult[],
1139
1267
  signal: AbortSignal | undefined,
1140
1268
  opts: ScreenshotOptions = {},
1141
1269
  ): Promise<ScreenshotResult> {
1142
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);
1143
1278
  const fullPage = opts.selector ? false : (opts.fullPage ?? false);
1144
1279
  // An explicit save path picks the full-res capture format: puppeteer encodes
1145
1280
  // png/jpeg/webp natively, so `save: "shot.webp"` gets real WebP bytes instead
@@ -1211,8 +1346,8 @@ export class WorkerCore {
1211
1346
  dest,
1212
1347
  resized,
1213
1348
  });
1214
- displays.push({ type: "text", text: lines.join("\n") });
1215
- 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 });
1216
1351
  }
1217
1352
  return info;
1218
1353
  }