@oh-my-pi/pi-coding-agent 16.1.9 → 16.1.11

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 (81) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/cli.js +3455 -3430
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/edit/streaming.d.ts +5 -5
  5. package/dist/types/export/html/index.d.ts +31 -2
  6. package/dist/types/export/html/web-palette.d.ts +117 -0
  7. package/dist/types/hindsight/content.d.ts +7 -0
  8. package/dist/types/hindsight/transcript.d.ts +1 -1
  9. package/dist/types/modes/components/hook-editor.d.ts +2 -1
  10. package/dist/types/modes/controllers/selector-controller.d.ts +0 -1
  11. package/dist/types/modes/interactive-mode.d.ts +5 -0
  12. package/dist/types/modes/types.d.ts +17 -0
  13. package/dist/types/modes/utils/keybinding-matchers.d.ts +13 -0
  14. package/dist/types/sdk.d.ts +1 -0
  15. package/dist/types/session/agent-session.d.ts +4 -0
  16. package/dist/types/session/session-context.d.ts +2 -0
  17. package/dist/types/session/session-entries.d.ts +6 -0
  18. package/dist/types/session/session-manager.d.ts +2 -1
  19. package/dist/types/system-prompt.d.ts +2 -0
  20. package/dist/types/tools/acp-bridge.d.ts +29 -0
  21. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +39 -0
  22. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +10 -0
  23. package/dist/types/tools/browser/tab-worker.d.ts +20 -0
  24. package/dist/types/tools/browser.d.ts +1 -0
  25. package/dist/types/tools/find.d.ts +1 -2
  26. package/dist/types/tools/write.d.ts +1 -2
  27. package/dist/types/utils/image-loading.d.ts +4 -3
  28. package/dist/types/utils/jj.d.ts +25 -0
  29. package/dist/types/utils/title-generator.d.ts +0 -2
  30. package/package.json +12 -12
  31. package/scripts/generate-aria-snapshot.ts +134 -0
  32. package/scripts/generate-share-viewer.ts +4 -2
  33. package/src/autoresearch/git.ts +12 -0
  34. package/src/config/settings-schema.ts +12 -0
  35. package/src/discovery/opencode.ts +47 -4
  36. package/src/edit/hashline/filesystem.ts +8 -0
  37. package/src/edit/modes/patch.ts +18 -2
  38. package/src/edit/modes/replace.ts +13 -10
  39. package/src/edit/streaming.ts +5 -5
  40. package/src/export/html/index.ts +50 -8
  41. package/src/export/html/web-palette.ts +142 -0
  42. package/src/hindsight/backend.ts +4 -4
  43. package/src/hindsight/content.ts +17 -1
  44. package/src/hindsight/transcript.ts +2 -2
  45. package/src/internal-urls/docs-index.generated.txt +1 -1
  46. package/src/modes/components/agent-dashboard.ts +8 -8
  47. package/src/modes/components/hook-editor.ts +13 -10
  48. package/src/modes/components/session-selector.ts +3 -0
  49. package/src/modes/controllers/event-controller.ts +29 -5
  50. package/src/modes/controllers/extension-ui-controller.ts +6 -2
  51. package/src/modes/controllers/selector-controller.ts +3 -5
  52. package/src/modes/interactive-mode.ts +69 -29
  53. package/src/modes/theme/dark.json +1 -1
  54. package/src/modes/types.ts +18 -0
  55. package/src/modes/utils/keybinding-matchers.ts +36 -1
  56. package/src/modes/utils/ui-helpers.ts +1 -0
  57. package/src/prompts/system/project-prompt.md +2 -0
  58. package/src/prompts/system/system-prompt.md +0 -1
  59. package/src/prompts/tools/browser.md +5 -2
  60. package/src/sdk.ts +20 -2
  61. package/src/session/agent-session.ts +38 -14
  62. package/src/session/session-context.ts +5 -0
  63. package/src/session/session-entries.ts +6 -0
  64. package/src/session/session-manager.ts +3 -1
  65. package/src/system-prompt.ts +15 -3
  66. package/src/task/worktree.ts +12 -4
  67. package/src/thinking.ts +1 -1
  68. package/src/tools/acp-bridge.ts +66 -0
  69. package/src/tools/bash.ts +3 -3
  70. package/src/tools/browser/aria/aria-snapshot.bundle.txt +7 -0
  71. package/src/tools/browser/aria/aria-snapshot.ts +103 -0
  72. package/src/tools/browser/cmux/cmux-tab.ts +73 -1
  73. package/src/tools/browser/tab-worker.ts +242 -47
  74. package/src/tools/browser.ts +5 -0
  75. package/src/tools/find.ts +1 -2
  76. package/src/tools/index.ts +1 -1
  77. package/src/tools/write.ts +3 -27
  78. package/src/utils/git.ts +3 -2
  79. package/src/utils/image-loading.ts +6 -3
  80. package/src/utils/jj.ts +47 -0
  81. package/src/utils/title-generator.ts +31 -99
@@ -10,6 +10,7 @@ import { resizeImage } from "../../../utils/image-resize";
10
10
  import { resolveToCwd } from "../../path-utils";
11
11
  import { formatScreenshot } from "../../render-utils";
12
12
  import { ToolAbortError, ToolError } from "../../tool-errors";
13
+ import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-snapshot";
13
14
  import { DEFAULT_VIEWPORT } from "../launch";
14
15
  import { extractReadableFromHtml, type ReadableFormat } from "../readable";
15
16
  import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol";
@@ -49,7 +50,7 @@ interface RunContext {
49
50
 
50
51
  type WaitUntil = "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
51
52
  type DragTarget = string | { readonly x: number; readonly y: number };
52
- type SelectorKind = "css" | "ref" | "text" | "aria" | "xpath" | "pierce" | "ax";
53
+ type SelectorKind = "css" | "ref" | "aria-ref" | "text" | "aria" | "xpath" | "pierce" | "ax";
53
54
 
54
55
  interface SelectorSpec {
55
56
  kind: SelectorKind;
@@ -124,6 +125,20 @@ const accessibleName = element =>
124
125
  const findElement = spec => {
125
126
  if (spec.kind === "css") return document.querySelector(spec.value);
126
127
  if (spec.kind === "pierce") return pierceQuery(document, spec.value);
128
+ if (spec.kind === "aria-ref") {
129
+ const wanted = spec.value;
130
+ const scan = root => {
131
+ for (const el of Array.from(root.querySelectorAll("*"))) {
132
+ if (el._ariaRef && el._ariaRef.ref === wanted) return el;
133
+ if (el.shadowRoot) {
134
+ const found = scan(el.shadowRoot);
135
+ if (found) return found;
136
+ }
137
+ }
138
+ return null;
139
+ };
140
+ return scan(document);
141
+ }
127
142
  if (spec.kind === "xpath") {
128
143
  const result = document.evaluate(spec.value, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
129
144
  return result.singleNodeValue instanceof Element ? result.singleNodeValue : null;
@@ -352,6 +367,24 @@ export class CmuxTab {
352
367
  return observation;
353
368
  }
354
369
 
370
+ async ariaSnapshot(selector?: string, opts?: AriaSnapshotOptions): Promise<string> {
371
+ const timeoutMs = Math.min(this.#runContext?.timeoutMs ?? 30_000, 30_000);
372
+ const result = (await this.#request(
373
+ "browser.eval",
374
+ { script: buildAriaSnapshotScript(selector, opts) },
375
+ timeoutMs,
376
+ )) as CmuxEvalResult;
377
+ return result.value as string;
378
+ }
379
+
380
+ async ref(id: string): Promise<CmuxElementHandle> {
381
+ const refId = /^e\d+$/.test(id.trim()) ? id.trim() : id.trim().replace(/^(?:aria-ref=|aria-ref\/|ariaref\/)/, "");
382
+ const selector = `aria-ref=${refId}`;
383
+ const timeoutMs = this.#runContext?.timeoutMs ?? 30_000;
384
+ await this.#waitForSelector(selector, timeoutMs);
385
+ return new CmuxElementHandle(this, selector);
386
+ }
387
+
355
388
  async click(selector: string): Promise<void> {
356
389
  await this.#selectorAction(selector, "click");
357
390
  }
@@ -401,6 +434,12 @@ export class CmuxTab {
401
434
  return new CmuxElementHandle(this, selector);
402
435
  }
403
436
 
437
+ async waitForSelector(selector: string, opts?: { timeout?: number }): Promise<CmuxElementHandle> {
438
+ const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
439
+ await this.#waitForSelector(selector, timeoutMs);
440
+ return new CmuxElementHandle(this, selector);
441
+ }
442
+
404
443
  async evaluate<TResult, TArgs extends unknown[]>(
405
444
  fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
406
445
  ...args: TArgs
@@ -516,6 +555,37 @@ export class CmuxTab {
516
555
  throw new ToolError(`tab.waitForUrl() timed out after ${timeoutMs}ms`);
517
556
  }
518
557
 
558
+ async waitForNavigation(opts?: { waitUntil?: WaitUntil; timeout?: number }): Promise<null> {
559
+ const timeoutMs = opts?.timeout ?? this.#runContext?.timeoutMs ?? 30_000;
560
+ // Cmux has no native "next navigation" wait — snapshot the current URL via a fresh
561
+ // `browser.url.get` (never the possibly-stale `#lastUrl`), then poll for a change
562
+ // from it (mirroring headless `page.waitForNavigation` intent) and optionally settle
563
+ // on the requested load state. Start it BEFORE the click/submit that navigates; after
564
+ // 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;
566
+ const startUrl = typeof baseline.url === "string" && baseline.url.length > 0 ? baseline.url : this.#lastUrl;
567
+ if (typeof baseline.url === "string" && baseline.url.length > 0) this.#lastUrl = baseline.url;
568
+ const deadline = Date.now() + timeoutMs;
569
+ while (Date.now() <= deadline) {
570
+ const result = (await this.#request("browser.url.get", {}, Math.min(timeoutMs, 5_000))) as CmuxUrlGetResult;
571
+ if (typeof result.url === "string" && result.url.length > 0) {
572
+ this.#lastUrl = result.url;
573
+ if (result.url !== startUrl) {
574
+ if (opts?.waitUntil) {
575
+ await this.#request(
576
+ "browser.wait",
577
+ { load_state: mapWaitUntil(opts.waitUntil), timeout_ms: timeoutMs },
578
+ timeoutMs,
579
+ );
580
+ }
581
+ return null;
582
+ }
583
+ }
584
+ await Bun.sleep(200);
585
+ }
586
+ throw new ToolError(`tab.waitForNavigation() timed out after ${timeoutMs}ms`);
587
+ }
588
+
519
589
  async drag(from: DragTarget, to: DragTarget): Promise<void> {
520
590
  const start = await this.#dragPoint(from);
521
591
  const end = await this.#dragPoint(to);
@@ -893,6 +963,8 @@ export class CmuxTab {
893
963
  else if (normalized.startsWith("p-aria/")) normalized = `aria/${normalized.slice("p-aria/".length)}`;
894
964
  else if (normalized.startsWith("p-xpath/")) normalized = `xpath/${normalized.slice("p-xpath/".length)}`;
895
965
  else if (normalized.startsWith("p-pierce/")) normalized = `pierce/${normalized.slice("p-pierce/".length)}`;
966
+ const ariaRef = /^(?:aria-ref=|aria-ref\/|ariaref\/)(e\d+)$/.exec(normalized);
967
+ if (ariaRef) return { kind: "aria-ref", value: ariaRef[1]!, raw };
896
968
  const ref = /^@?e(\d+)$/.exec(normalized);
897
969
  if (ref) return { kind: "ref", value: ref[1]!, raw, ref: `@e${ref[1]}` };
898
970
  const slash = normalized.indexOf("/");
@@ -22,6 +22,12 @@ import { resizeImage } from "../../utils/image-resize";
22
22
  import { resolveToCwd } from "../path-utils";
23
23
  import { formatScreenshot } from "../render-utils";
24
24
  import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors";
25
+ import {
26
+ type AriaSnapshotOptions,
27
+ captureAriaSnapshot,
28
+ parseAriaRefSelector,
29
+ resolveAriaRefHandle,
30
+ } from "./aria/aria-snapshot";
25
31
  import {
26
32
  applyStealthPatches,
27
33
  applyViewport,
@@ -76,17 +82,85 @@ const INTERACTIVE_AX_ROLES = new Set([
76
82
 
77
83
  const LEGACY_SELECTOR_PREFIXES = ["p-aria/", "p-text/", "p-xpath/", "p-pierce/"] as const;
78
84
 
85
+ const SELECTOR_HANDLER_PREFIXES = [
86
+ "aria/",
87
+ "text/",
88
+ "xpath/",
89
+ "pierce/",
90
+ "aria-ref=",
91
+ "aria-ref/",
92
+ "ariaref/",
93
+ "p-",
94
+ ] as const;
95
+
96
+ /**
97
+ * Playwright-only selector engines/pseudos puppeteer cannot parse. Without this guard a
98
+ * `tab.click(":has-text(...)")` would wait the full action timeout and fail opaquely;
99
+ * fail fast instead with a pointer to the puppeteer-native alternative. Skipped for
100
+ * explicit query-handler prefixes (`text/`, `aria/`, …) whose payload is literal text.
101
+ */
102
+ const PLAYWRIGHT_ONLY_SELECTOR_RE =
103
+ /:has-text\(|:text\(|:text-is\(|:text-matches\(|:visible\b|:hidden\b|:nth-match\(|:near\(|:above\(|:below\(|:right-of\(|:left-of\(/;
104
+
79
105
  type DialogPolicy = "accept" | "dismiss";
80
106
  type DragTarget = string | { readonly x: number; readonly y: number };
81
107
  type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string };
82
108
 
83
109
  /**
84
- * Per-op ceiling for puppeteer-internal helpers that should resolve quickly
85
- * (`observe`, `screenshot`, `extract`). Kept below the default 30s cell budget so a
86
- * single stalled helper fails fast with a named error and leaves budget for the rest
87
- * of the cell. Effective cap is `min(cellBudget, QUICK_OP_TIMEOUT_MS)`.
110
+ * Per-op fail-fast ceilings for `tab.*` helpers. All are kept strictly under the cell
111
+ * budget (`timeoutMs - OP_DEADLINE_SLACK_MS`) so a stalled helper rejects with a named,
112
+ * attributable error that leaves recovery budget never the opaque whole-cell
113
+ * "Browser code execution timed out" path that consumed the entire run.
114
+ *
115
+ * - `QUICK_OP_TIMEOUT_MS`: page-coupled reads that should resolve fast (`observe`,
116
+ * `screenshot`, `extract`, `ariaSnapshot`).
117
+ * - `ACTION_OP_TIMEOUT_MS`: interactive point actions (`click`, `fill`, `type`, …) and
118
+ * the default for wait helpers when no explicit `{ timeout }` is given.
119
+ *
120
+ * `goto` and `evaluate` stay uncapped (`Number.POSITIVE_INFINITY`): navigation and user
121
+ * code legitimately use the full cell budget.
88
122
  */
89
123
  const QUICK_OP_TIMEOUT_MS = 20_000;
124
+ const ACTION_OP_TIMEOUT_MS = 15_000;
125
+ /** Headroom subtracted from the cell budget so a per-op deadline fires before it. */
126
+ const OP_DEADLINE_SLACK_MS = 1_000;
127
+
128
+ export interface OpTimeouts {
129
+ /** Largest per-op deadline allowed — strictly below the cell budget. */
130
+ budgetBound: number;
131
+ /** Ceiling for quick page reads. */
132
+ quickOpMs: number;
133
+ /** Ceiling for interactive actions + default for waits. */
134
+ actionOpMs: number;
135
+ }
136
+
137
+ /** Resolve the per-op fail-fast ceilings for a given cell budget. */
138
+ export function resolveOpTimeouts(cellTimeoutMs: number): OpTimeouts {
139
+ const budgetBound = Math.max(1, cellTimeoutMs - OP_DEADLINE_SLACK_MS);
140
+ return {
141
+ budgetBound,
142
+ quickOpMs: Math.min(budgetBound, QUICK_OP_TIMEOUT_MS),
143
+ actionOpMs: Math.min(budgetBound, ACTION_OP_TIMEOUT_MS),
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Effective timeout for a wait helper (`waitFor*`). A positive explicit `{ timeout }` is
149
+ * honored but clamped to the cell budget so it still fails fast + named; raising the tool
150
+ * `timeout` raises that cap, so a longer budget stays meaningful. No `{ timeout }` → the
151
+ * action ceiling. Puppeteer's `{ timeout: 0 }` / `Infinity` ("disable") maps to the largest
152
+ * bounded wait (`budgetBound`) — the harness never permits an unbounded wait. Garbage input
153
+ * (negative, `NaN`) falls back to the action ceiling rather than the longest wait.
154
+ */
155
+ export function resolveWaitTimeout(cellTimeoutMs: number, explicit?: number): number {
156
+ const { budgetBound, actionOpMs } = resolveOpTimeouts(cellTimeoutMs);
157
+ if (explicit === undefined) return actionOpMs;
158
+ // Puppeteer "disable" sentinels — still bounded by the budget here.
159
+ if (explicit === 0 || explicit === Number.POSITIVE_INFINITY) return budgetBound;
160
+ // Positive finite → honored + clamped. Negative/NaN garbage → default, not the longest wait.
161
+ if (Number.isFinite(explicit) && explicit > 0) return Math.min(explicit, budgetBound);
162
+ return actionOpMs;
163
+ }
90
164
 
91
165
  interface ScreenshotOptions {
92
166
  selector?: string;
@@ -106,6 +180,7 @@ interface TabApi {
106
180
  opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2" },
107
181
  ): Promise<void>;
108
182
  observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise<Observation>;
183
+ ariaSnapshot(selector?: string, opts?: AriaSnapshotOptions): Promise<string>;
109
184
  screenshot(opts?: ScreenshotOptions): Promise<ScreenshotResult>;
110
185
  extract(format?: ReadableFormat): Promise<string>;
111
186
  click(selector: string): Promise<void>;
@@ -114,7 +189,7 @@ interface TabApi {
114
189
  press(key: KeyInput, opts?: { selector?: string }): Promise<void>;
115
190
  scroll(deltaX: number, deltaY: number): Promise<void>;
116
191
  drag(from: DragTarget, to: DragTarget): Promise<void>;
117
- waitFor(selector: string): Promise<ElementHandle>;
192
+ waitFor(selector: string, opts?: { timeout?: number }): Promise<ElementHandle>;
118
193
  evaluate<TResult, TArgs extends unknown[]>(
119
194
  fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
120
195
  ...args: TArgs
@@ -127,11 +202,29 @@ interface TabApi {
127
202
  pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise<boolean>),
128
203
  opts?: { timeout?: number },
129
204
  ): Promise<HTTPResponse>;
205
+ waitForSelector(
206
+ selector: string,
207
+ opts?: { timeout?: number; visible?: boolean; hidden?: boolean },
208
+ ): Promise<ElementHandle | null>;
209
+ waitForNavigation(opts?: {
210
+ waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
211
+ timeout?: number;
212
+ }): Promise<HTTPResponse | null>;
130
213
  id(n: number): Promise<ElementHandle>;
214
+ ref(id: string): Promise<ElementHandle>;
131
215
  }
132
216
 
133
- function normalizeSelector(selector: string): string {
217
+ export function normalizeSelector(selector: string): string {
134
218
  if (!selector) return selector;
219
+ if (
220
+ !SELECTOR_HANDLER_PREFIXES.some(prefix => selector.startsWith(prefix)) &&
221
+ PLAYWRIGHT_ONLY_SELECTOR_RE.test(selector)
222
+ ) {
223
+ throw new ToolError(
224
+ `Playwright-only selector ${JSON.stringify(selector)} is not supported by the browser tool. ` +
225
+ `Use a puppeteer text selector ("text/Allow all"), an aria selector ("aria/Name"), CSS, or "xpath/...".`,
226
+ );
227
+ }
135
228
  if (selector.startsWith("p-") && !LEGACY_SELECTOR_PREFIXES.some(prefix => selector.startsWith(prefix))) {
136
229
  throw new ToolError(
137
230
  `Unsupported selector prefix. Use CSS or puppeteer query handlers (aria/, text/, xpath/, pierce/). Got: ${selector}`,
@@ -753,8 +846,15 @@ export class WorkerCore {
753
846
  try {
754
847
  return await fn(opSignal);
755
848
  } catch (err) {
756
- // Per-op deadline fired (not the cell budget, not an explicit abort) named, actionable error.
757
- if (opTimeout?.aborted && !cellSignal.aborted) {
849
+ // Fail fast with a named, attributable error instead of the opaque whole-cell timeout:
850
+ // our per-op deadline fired, or puppeteer's own (equal) timeout fired first — having
851
+ // already torn down the CDP action via the op signal, so no work is left dangling.
852
+ // Cell-budget aborts and uncapped helpers (goto/evaluate) keep their native errors.
853
+ if (
854
+ capped &&
855
+ !cellSignal.aborted &&
856
+ (opTimeout?.aborted || (err instanceof Error && err.name === "TimeoutError"))
857
+ ) {
758
858
  throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms`);
759
859
  }
760
860
  throw err;
@@ -773,7 +873,8 @@ export class WorkerCore {
773
873
  active: ActiveRun,
774
874
  ): TabApi {
775
875
  const page = this.#requirePage();
776
- const quickOpMs = Math.min(timeoutMs, QUICK_OP_TIMEOUT_MS);
876
+ const { quickOpMs, actionOpMs } = resolveOpTimeouts(timeoutMs);
877
+ const waitMs = (explicit?: number): number => resolveWaitTimeout(timeoutMs, explicit);
777
878
  const INF = Number.POSITIVE_INFINITY;
778
879
  const op = <T>(label: string, perOpMs: number, fn: (sig: AbortSignal) => Promise<T>): Promise<T> =>
779
880
  this.#runOp(active, label, signal, perOpMs, fn);
@@ -792,6 +893,28 @@ export class WorkerCore {
792
893
  );
793
894
  }),
794
895
  observe: opts => op("tab.observe()", quickOpMs, sig => this.#collectObservation({ ...opts, signal: sig })),
896
+ ariaSnapshot: (selector, opts) =>
897
+ op(
898
+ selector ? `tab.ariaSnapshot(${JSON.stringify(selector)})` : "tab.ariaSnapshot()",
899
+ quickOpMs,
900
+ async sig => {
901
+ let root: ElementHandle | null = null;
902
+ if (selector) {
903
+ root = (await untilAborted(sig, () =>
904
+ page.$(normalizeSelector(selector)),
905
+ )) as ElementHandle | null;
906
+ if (!root)
907
+ throw new ToolError(
908
+ `tab.ariaSnapshot: selector ${JSON.stringify(selector)} matched no element`,
909
+ );
910
+ }
911
+ try {
912
+ return await untilAborted(sig, () => captureAriaSnapshot(page, root, opts));
913
+ } finally {
914
+ await root?.dispose().catch(() => undefined);
915
+ }
916
+ },
917
+ ),
795
918
  screenshot: opts =>
796
919
  op(describeScreenshot(opts), quickOpMs, sig =>
797
920
  this.#captureScreenshot(session, displays, screenshots, sig, opts),
@@ -814,44 +937,88 @@ export class WorkerCore {
814
937
  return content;
815
938
  }),
816
939
  click: selector =>
817
- op(`tab.click(${JSON.stringify(selector)})`, INF, async sig => {
940
+ op(`tab.click(${JSON.stringify(selector)})`, actionOpMs, async sig => {
941
+ if (parseAriaRefSelector(selector) !== null) {
942
+ const handle = await this.#resolveAriaRef(selector);
943
+ try {
944
+ await untilAborted(sig, () => handle.click());
945
+ } finally {
946
+ await handle.dispose().catch(() => undefined);
947
+ }
948
+ return;
949
+ }
818
950
  const resolved = normalizeSelector(selector);
819
- if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, timeoutMs, sig);
820
- else await untilAborted(sig, () => page.locator(resolved).setTimeout(timeoutMs).click());
951
+ if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, actionOpMs, sig);
952
+ else await untilAborted(sig, () => page.locator(resolved).setTimeout(actionOpMs).click({ signal: sig }));
821
953
  }),
822
954
  type: (selector, text) =>
823
- op(`tab.type(${JSON.stringify(selector)})`, INF, async sig => {
824
- const handle = (await untilAborted(sig, () =>
825
- page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
826
- )) as ElementHandle;
955
+ op(`tab.type(${JSON.stringify(selector)})`, actionOpMs, async sig => {
956
+ const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
827
957
  try {
828
958
  await untilAborted(sig, () => handle.type(text, { delay: 0 }));
829
959
  } finally {
830
- await handle.dispose();
960
+ await handle.dispose().catch(() => undefined);
831
961
  }
832
962
  }),
833
963
  fill: (selector, value) =>
834
- op(`tab.fill(${JSON.stringify(selector)})`, INF, sig =>
835
- untilAborted(sig, () => page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).fill(value)),
836
- ),
964
+ op(`tab.fill(${JSON.stringify(selector)})`, actionOpMs, async sig => {
965
+ if (parseAriaRefSelector(selector) !== null) {
966
+ const handle = await this.#resolveAriaRef(selector);
967
+ try {
968
+ await untilAborted(sig, () =>
969
+ handle.evaluate(el => {
970
+ const node = el as unknown as { value?: string; focus?: () => void };
971
+ node.focus?.();
972
+ if ("value" in node) node.value = "";
973
+ }),
974
+ );
975
+ await untilAborted(sig, () => handle.type(value, { delay: 0 }));
976
+ } finally {
977
+ await handle.dispose().catch(() => undefined);
978
+ }
979
+ return;
980
+ }
981
+ await untilAborted(sig, () =>
982
+ page.locator(normalizeSelector(selector)).setTimeout(actionOpMs).fill(value, { signal: sig }),
983
+ );
984
+ }),
837
985
  press: (key, opts) =>
838
- op(`tab.press(${JSON.stringify(key)})`, INF, async sig => {
986
+ op(`tab.press(${JSON.stringify(key)})`, actionOpMs, async sig => {
839
987
  const selector = opts?.selector;
840
988
  if (selector) await untilAborted(sig, () => page.focus(normalizeSelector(selector)));
841
989
  await untilAborted(sig, () => page.keyboard.press(key));
842
990
  }),
843
991
  scroll: (deltaX, deltaY) =>
844
- op("tab.scroll()", INF, sig => untilAborted(sig, () => page.mouse.wheel({ deltaX, deltaY }))),
845
- drag: (from, to) => op("tab.drag()", INF, sig => this.#drag(from, to, sig)),
846
- waitFor: selector =>
847
- op(
848
- `tab.waitFor(${JSON.stringify(selector)})`,
849
- INF,
850
- async sig =>
851
- (await untilAborted(sig, () =>
852
- page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
853
- )) as ElementHandle,
854
- ),
992
+ op("tab.scroll()", actionOpMs, sig => untilAborted(sig, () => page.mouse.wheel({ deltaX, deltaY }))),
993
+ drag: (from, to) => op("tab.drag()", actionOpMs, sig => this.#drag(from, to, sig)),
994
+ waitFor: (selector, opts) => {
995
+ const w = waitMs(opts?.timeout);
996
+ return op(`tab.waitFor(${JSON.stringify(selector)})`, w, sig =>
997
+ this.#resolveActionHandle(selector, w, sig),
998
+ );
999
+ },
1000
+ waitForSelector: (selector, opts) => {
1001
+ const w = waitMs(opts?.timeout);
1002
+ return op(`tab.waitForSelector(${JSON.stringify(selector)})`, w, async sig => {
1003
+ if (parseAriaRefSelector(selector) !== null) return this.#resolveAriaRef(selector);
1004
+ return (await untilAborted(sig, () =>
1005
+ page.waitForSelector(normalizeSelector(selector), {
1006
+ timeout: w,
1007
+ visible: opts?.visible,
1008
+ hidden: opts?.hidden,
1009
+ signal: sig,
1010
+ }),
1011
+ )) as ElementHandle | null;
1012
+ });
1013
+ },
1014
+ waitForNavigation: opts => {
1015
+ const w = waitMs(opts?.timeout);
1016
+ return op("tab.waitForNavigation()", w, sig =>
1017
+ untilAborted(sig, () =>
1018
+ page.waitForNavigation({ waitUntil: opts?.waitUntil ?? "load", timeout: w, signal: sig }),
1019
+ ),
1020
+ );
1021
+ },
855
1022
  evaluate: (fn, ...args) =>
856
1023
  op("tab.evaluate()", INF, sig =>
857
1024
  untilAborted(sig, () =>
@@ -861,10 +1028,8 @@ export class WorkerCore {
861
1028
  ),
862
1029
  ) as never,
863
1030
  scrollIntoView: selector =>
864
- op(`tab.scrollIntoView(${JSON.stringify(selector)})`, INF, async sig => {
865
- const handle = (await untilAborted(sig, () =>
866
- page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
867
- )) as ElementHandle;
1031
+ op(`tab.scrollIntoView(${JSON.stringify(selector)})`, actionOpMs, async sig => {
1032
+ const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
868
1033
  try {
869
1034
  await untilAborted(sig, () =>
870
1035
  handle.evaluate(el => {
@@ -879,16 +1044,23 @@ export class WorkerCore {
879
1044
  }
880
1045
  }),
881
1046
  select: (selector, ...values) =>
882
- op(`tab.select(${JSON.stringify(selector)})`, INF, sig => this.#select(selector, values, timeoutMs, sig)),
1047
+ op(`tab.select(${JSON.stringify(selector)})`, actionOpMs, sig =>
1048
+ this.#select(selector, values, actionOpMs, sig),
1049
+ ),
883
1050
  uploadFile: (selector, ...filePaths) =>
884
- op(`tab.uploadFile(${JSON.stringify(selector)})`, INF, sig =>
885
- this.#uploadFile(selector, filePaths, timeoutMs, sig, session),
1051
+ op(`tab.uploadFile(${JSON.stringify(selector)})`, actionOpMs, sig =>
1052
+ this.#uploadFile(selector, filePaths, actionOpMs, sig, session),
886
1053
  ),
887
- waitForUrl: (pattern, opts) =>
888
- op("tab.waitForUrl()", INF, sig => this.#waitForUrl(pattern, opts?.timeout ?? timeoutMs, sig)),
889
- waitForResponse: (pattern, opts) =>
890
- op("tab.waitForResponse()", INF, sig => this.#waitForResponse(pattern, opts?.timeout ?? timeoutMs, sig)),
1054
+ waitForUrl: (pattern, opts) => {
1055
+ const w = waitMs(opts?.timeout);
1056
+ return op("tab.waitForUrl()", w, sig => this.#waitForUrl(pattern, w, sig));
1057
+ },
1058
+ waitForResponse: (pattern, opts) => {
1059
+ const w = waitMs(opts?.timeout);
1060
+ return op("tab.waitForResponse()", w, sig => this.#waitForResponse(pattern, w, sig));
1061
+ },
891
1062
  id: id => this.#resolveCachedHandle(id),
1063
+ ref: id => this.#resolveAriaRef(id),
892
1064
  };
893
1065
  }
894
1066
 
@@ -1073,7 +1245,7 @@ export class WorkerCore {
1073
1245
  async #select(selector: string, values: string[], timeoutMs: number, signal: AbortSignal): Promise<string[]> {
1074
1246
  const page = this.#requirePage();
1075
1247
  const handle = (await untilAborted(signal, () =>
1076
- page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
1248
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal }),
1077
1249
  )) as ElementHandle;
1078
1250
  try {
1079
1251
  return (await untilAborted(signal, () =>
@@ -1119,7 +1291,7 @@ export class WorkerCore {
1119
1291
  if (!filePaths.length) throw new ToolError("tab.uploadFile() requires at least one file path");
1120
1292
  const page = this.#requirePage();
1121
1293
  const handle = (await untilAborted(signal, () =>
1122
- page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
1294
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal }),
1123
1295
  )) as ElementHandle;
1124
1296
  try {
1125
1297
  const absolute = filePaths.map(filePath => resolveToCwd(filePath, session.cwd));
@@ -1148,7 +1320,7 @@ export class WorkerCore {
1148
1320
  const url = (globalThis as unknown as { location: { href: string } }).location.href;
1149
1321
  return isRe ? new RegExp(m, fl).test(url) : url.includes(m);
1150
1322
  },
1151
- { timeout, polling: 200 },
1323
+ { timeout, polling: 200, signal },
1152
1324
  matcher,
1153
1325
  isRegex,
1154
1326
  flags,
@@ -1169,7 +1341,7 @@ export class WorkerCore {
1169
1341
  : pattern instanceof RegExp
1170
1342
  ? response => pattern.test(response.url())
1171
1343
  : response => response.url().includes(pattern);
1172
- return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout }))) as HTTPResponse;
1344
+ return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout, signal }))) as HTTPResponse;
1173
1345
  }
1174
1346
 
1175
1347
  async #resolveCachedHandle(id: number): Promise<ElementHandle> {
@@ -1188,6 +1360,29 @@ export class WorkerCore {
1188
1360
  }
1189
1361
  return handle;
1190
1362
  }
1363
+
1364
+ async #resolveAriaRef(id: string): Promise<ElementHandle> {
1365
+ const ref = parseAriaRefSelector(id) ?? id.trim();
1366
+ const handle = await resolveAriaRefHandle(this.#requirePage(), ref);
1367
+ if (!handle) {
1368
+ throw new ToolError(
1369
+ `Unknown ARIA ref ${JSON.stringify(ref)}. Run tab.ariaSnapshot() to refresh refs (they renumber each snapshot).`,
1370
+ );
1371
+ }
1372
+ return handle;
1373
+ }
1374
+
1375
+ /**
1376
+ * Resolve a selector to an ElementHandle for handle-based actions. An
1377
+ * `aria-ref=eN` selector resolves against the latest ariaSnapshot's refs
1378
+ * (main world); anything else goes through the normal locator wait.
1379
+ */
1380
+ async #resolveActionHandle(selector: string, timeoutMs: number, sig: AbortSignal): Promise<ElementHandle> {
1381
+ if (parseAriaRefSelector(selector) !== null) return this.#resolveAriaRef(selector);
1382
+ return (await untilAborted(sig, () =>
1383
+ this.#requirePage().locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal: sig }),
1384
+ )) as ElementHandle;
1385
+ }
1191
1386
  #clearElementCache(): void {
1192
1387
  if (this.#elementCache.size === 0) {
1193
1388
  this.#elementCounter = 0;
@@ -16,6 +16,11 @@ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
16
16
  import { toolResult } from "./tool-result";
17
17
  import { clampTimeout } from "./tool-timeouts";
18
18
 
19
+ export {
20
+ type AriaSnapshotOptions,
21
+ buildAriaSnapshotScript,
22
+ parseAriaRefSelector,
23
+ } from "./browser/aria/aria-snapshot";
19
24
  export { cmuxSnapshotToObservation, mapWaitUntil, resolveCmuxKind, serializeEval } from "./browser/cmux/rpc";
20
25
  export { CmuxSocketClient } from "./browser/cmux/socket-client";
21
26
  export { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./browser/readable";
package/src/tools/find.ts CHANGED
@@ -101,8 +101,7 @@ interface FindTarget {
101
101
  export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
102
102
  readonly name = "find";
103
103
  readonly approval = "read" as const;
104
- readonly summary = "Find files and directories matching a glob pattern";
105
- readonly loadMode = "discoverable";
104
+ readonly loadMode = "essential";
106
105
  readonly label = "Find";
107
106
  readonly description: string;
108
107
  readonly parameters = findSchema;
@@ -375,7 +375,7 @@ export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool |
375
375
  export type BuiltinToolLoadMode = "essential" | "discoverable";
376
376
 
377
377
  /** Default essential tool names when tools.essentialOverride is empty. */
378
- export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = ["read", "bash", "edit"] as const;
378
+ export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = ["read", "bash", "edit", "write", "find"] as const;
379
379
 
380
380
  /**
381
381
  * Resolve the active essential built-in tool names from settings.
@@ -27,6 +27,7 @@ import {
27
27
  readArchiveEntries,
28
28
  writeArchive,
29
29
  } from "../utils/zip";
30
+ import { routeWriteThroughBridge } from "./acp-bridge";
30
31
  import { truncateForPrompt } from "./approval";
31
32
  import { assertEditableFile } from "./auto-generated-guard";
32
33
  import {
@@ -143,15 +144,6 @@ function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, co
143
144
  return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
144
145
  }
145
146
 
146
- function shouldRouteWriteThroughBridge(session: ToolSession, requestedPath: string, absolutePath: string): boolean {
147
- if (isInternalUrlPath(requestedPath)) return false;
148
-
149
- const state = session.getPlanModeState?.();
150
- if (!state?.enabled || !isInternalUrlPath(state.planFilePath)) return true;
151
-
152
- return absolutePath !== resolvePlanPath(session, state.planFilePath);
153
- }
154
-
155
147
  /**
156
148
  * Append a trailing note line to the first text block of a tool result.
157
149
  * Mutates `result` in place (the result object is owned by this call).
@@ -293,8 +285,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
293
285
  readonly parameters = writeSchema;
294
286
  readonly strict = true;
295
287
  readonly concurrency = "exclusive";
296
- readonly loadMode = "discoverable";
297
- readonly summary = "Write content to a file (creates or overwrites)";
288
+ readonly loadMode = "essential";
298
289
 
299
290
  /** Stream matchers should see the real file content, not its JSON-escaped argument encoding. */
300
291
  matcherDigest(args: unknown): string | undefined {
@@ -772,11 +763,6 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
772
763
  };
773
764
  }
774
765
 
775
- #routeWriteThroughBridge(absolutePath: string, content: string): Promise<void> | undefined {
776
- const bridge = this.session.getClientBridge?.();
777
- if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return undefined;
778
- return bridge.writeTextFile({ path: absolutePath, content });
779
- }
780
766
  async execute(
781
767
  _toolCallId: string,
782
768
  { path: rawPath, content }: WriteParams,
@@ -883,17 +869,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
883
869
 
884
870
  // Try ACP bridge first for editor-visible filesystem paths. Internal
885
871
  // artifacts such as local:// plans are owned by OMP, not the editor.
886
- const bridgePromise = shouldRouteWriteThroughBridge(this.session, path, absolutePath)
887
- ? this.#routeWriteThroughBridge(absolutePath, cleanContent)
888
- : undefined;
889
- if (bridgePromise !== undefined) {
890
- try {
891
- await bridgePromise;
892
- } catch (error) {
893
- throw new ToolError(error instanceof Error ? error.message : String(error));
894
- }
895
- invalidateFsScanAfterWrite(absolutePath);
896
- this.session.bumpFileMutationVersion?.(absolutePath);
872
+ if (await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent)) {
897
873
  const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
898
874
  const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
899
875
  const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
package/src/utils/git.ts CHANGED
@@ -1,7 +1,7 @@
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 { $which, hasFsCode, isEnoent, Snowflake } from "@oh-my-pi/pi-utils";
4
+ import { $which, hasFsCode, isEisdir, isEnoent, isEnotdir, Snowflake } from "@oh-my-pi/pi-utils";
5
5
  import {
6
6
  parseDiffHunks as parseCommitDiffHunks,
7
7
  parseFileDiffs,
@@ -376,7 +376,8 @@ async function writeTempPatch(content: string): Promise<string> {
376
376
  type EntryType = "directory" | "file";
377
377
 
378
378
  function shouldRetry(err: unknown, n: number) {
379
- if (isEnoent(err) || hasFsCode(err, "ENFILE") || hasFsCode(err, "EMFILE")) return false;
379
+ if (isEnoent(err) || isEisdir(err) || isEnotdir(err) || hasFsCode(err, "ENFILE") || hasFsCode(err, "EMFILE"))
380
+ return false;
380
381
  if (hasFsCode(err, "EINTR")) return n < EINTR_MAX_RETRIES;
381
382
  if (n > EINTR_MAX_RETRIES) throw err;
382
383
  throw err;