@oh-my-pi/pi-coding-agent 16.1.10 → 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 (58) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/cli.js +2811 -2812
  3. package/dist/types/export/html/index.d.ts +31 -2
  4. package/dist/types/export/html/web-palette.d.ts +117 -0
  5. package/dist/types/hindsight/content.d.ts +7 -0
  6. package/dist/types/hindsight/transcript.d.ts +1 -1
  7. package/dist/types/modes/components/hook-editor.d.ts +2 -1
  8. package/dist/types/modes/interactive-mode.d.ts +5 -0
  9. package/dist/types/modes/types.d.ts +17 -0
  10. package/dist/types/modes/utils/keybinding-matchers.d.ts +13 -0
  11. package/dist/types/session/agent-session.d.ts +4 -0
  12. package/dist/types/session/session-context.d.ts +2 -0
  13. package/dist/types/session/session-entries.d.ts +6 -0
  14. package/dist/types/session/session-manager.d.ts +2 -1
  15. package/dist/types/tools/acp-bridge.d.ts +29 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +7 -0
  17. package/dist/types/tools/browser/tab-worker.d.ts +20 -0
  18. package/dist/types/utils/image-loading.d.ts +4 -3
  19. package/dist/types/utils/jj.d.ts +25 -0
  20. package/dist/types/utils/title-generator.d.ts +0 -2
  21. package/package.json +12 -12
  22. package/scripts/generate-share-viewer.ts +4 -2
  23. package/src/autoresearch/git.ts +12 -0
  24. package/src/discovery/opencode.ts +47 -4
  25. package/src/edit/hashline/filesystem.ts +8 -0
  26. package/src/edit/modes/patch.ts +18 -2
  27. package/src/edit/modes/replace.ts +13 -10
  28. package/src/export/html/index.ts +50 -8
  29. package/src/export/html/web-palette.ts +142 -0
  30. package/src/hindsight/backend.ts +4 -4
  31. package/src/hindsight/content.ts +17 -1
  32. package/src/hindsight/transcript.ts +2 -2
  33. package/src/internal-urls/docs-index.generated.txt +1 -1
  34. package/src/modes/components/agent-dashboard.ts +8 -8
  35. package/src/modes/components/hook-editor.ts +13 -10
  36. package/src/modes/components/session-selector.ts +3 -0
  37. package/src/modes/controllers/event-controller.ts +9 -5
  38. package/src/modes/controllers/extension-ui-controller.ts +6 -2
  39. package/src/modes/interactive-mode.ts +69 -29
  40. package/src/modes/theme/dark.json +1 -1
  41. package/src/modes/types.ts +18 -0
  42. package/src/modes/utils/keybinding-matchers.ts +36 -1
  43. package/src/modes/utils/ui-helpers.ts +1 -0
  44. package/src/prompts/tools/browser.md +3 -2
  45. package/src/sdk.ts +12 -1
  46. package/src/session/agent-session.ts +38 -14
  47. package/src/session/session-context.ts +5 -0
  48. package/src/session/session-entries.ts +6 -0
  49. package/src/session/session-manager.ts +3 -1
  50. package/src/task/worktree.ts +12 -4
  51. package/src/thinking.ts +1 -1
  52. package/src/tools/acp-bridge.ts +66 -0
  53. package/src/tools/browser/cmux/cmux-tab.ts +37 -0
  54. package/src/tools/browser/tab-worker.ts +160 -37
  55. package/src/tools/write.ts +2 -25
  56. package/src/utils/image-loading.ts +6 -3
  57. package/src/utils/jj.ts +47 -0
  58. package/src/utils/title-generator.ts +31 -99
@@ -7,6 +7,8 @@ import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry }
7
7
  export interface SessionContext {
8
8
  messages: AgentMessage[];
9
9
  thinkingLevel?: string;
10
+ /** Configured thinking selector (`"auto"` or a concrete level) from the latest change. */
11
+ configuredThinkingLevel?: string;
10
12
  serviceTier?: ServiceTier;
11
13
  /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
12
14
  models: Record<string, string>;
@@ -134,6 +136,7 @@ export function buildSessionContext(
134
136
 
135
137
  // Extract settings and find compaction
136
138
  let thinkingLevel: string | undefined = "off";
139
+ let configuredThinkingLevel: string | undefined;
137
140
  let serviceTier: ServiceTier | undefined;
138
141
  const models: Record<string, string> = {};
139
142
  let compaction: CompactionEntry | null = null;
@@ -154,6 +157,7 @@ export function buildSessionContext(
154
157
  for (const entry of path) {
155
158
  if (entry.type === "thinking_level_change") {
156
159
  thinkingLevel = entry.thinkingLevel ?? "off";
160
+ configuredThinkingLevel = entry.configured ?? entry.thinkingLevel ?? undefined;
157
161
  } else if (entry.type === "model_change") {
158
162
  // New format: { model: "provider/id", role?: string }
159
163
  if (entry.model) {
@@ -388,6 +392,7 @@ export function buildSessionContext(
388
392
  messages,
389
393
  cacheMissExplainedAt: options?.transcript ? cacheMissExplainedAt : undefined,
390
394
  thinkingLevel,
395
+ configuredThinkingLevel,
391
396
  serviceTier,
392
397
  models,
393
398
  injectedTtsrRules,
@@ -37,6 +37,12 @@ export interface SessionMessageEntry extends SessionEntryBase {
37
37
  export interface ThinkingLevelChangeEntry extends SessionEntryBase {
38
38
  type: "thinking_level_change";
39
39
  thinkingLevel?: string | null;
40
+ /**
41
+ * The user-configured selector at the time of this change: `"auto"` when auto
42
+ * mode was active, otherwise the concrete level. Absent on entries written
43
+ * before auto-mode persistence existed; readers fall back to `thinkingLevel`.
44
+ */
45
+ configured?: string | null;
40
46
  }
41
47
 
42
48
  export interface ModelChangeEntry extends SessionEntryBase {
@@ -1158,11 +1158,13 @@ export class SessionManager {
1158
1158
  return entry.id;
1159
1159
  }
1160
1160
 
1161
- appendThinkingLevelChange(thinkingLevel?: string): string {
1161
+ /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
1162
+ appendThinkingLevelChange(thinkingLevel?: string, configured?: string): string {
1162
1163
  const entry: ThinkingLevelChangeEntry = {
1163
1164
  type: "thinking_level_change",
1164
1165
  ...this.#freshEntryFields(),
1165
1166
  thinkingLevel: thinkingLevel ?? null,
1167
+ configured: configured ?? null,
1166
1168
  };
1167
1169
  this.#recordEntry(entry);
1168
1170
  return entry.id;
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import * as natives from "@oh-my-pi/pi-natives";
6
6
  import { getWorktreeDir, hashPath, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
7
  import * as git from "../utils/git";
8
+ import * as jj from "../utils/jj";
8
9
  import { mapWithConcurrencyLimit } from "./parallel";
9
10
 
10
11
  const { IsoBackendKind } = natives;
@@ -28,12 +29,19 @@ export interface WorktreeBaseline {
28
29
  }
29
30
 
30
31
  export async function getRepoRoot(cwd: string): Promise<string> {
31
- const repoRoot = await git.repo.root(cwd);
32
- if (!repoRoot) {
33
- throw new Error("Git repository not found for isolated task execution.");
32
+ // Pure-jj check runs first so a jj workspace nested under an unrelated
33
+ // outer Git checkout is rejected at its own root rather than silently
34
+ // mutating the surrounding Git tree behind jj's back.
35
+ if (await jj.isPureJjRepo(cwd)) {
36
+ throw new Error(
37
+ "Isolated task execution requires a Git checkout, but this workspace is pure Jujutsu (`.jj/` without a colocated `.git/`). Run `jj git init --colocate` to add a Git checkout, or set `task.isolation.mode: none` to disable task isolation.",
38
+ );
34
39
  }
35
40
 
36
- return repoRoot;
41
+ const repoRoot = await git.repo.root(cwd);
42
+ if (repoRoot) return repoRoot;
43
+
44
+ throw new Error("Git repository not found for isolated task execution.");
37
45
  }
38
46
 
39
47
  const GIT_NO_INDEX_NULL_PATH = process.platform === "win32" ? "NUL" : "/dev/null";
package/src/thinking.ts CHANGED
@@ -32,7 +32,7 @@ const THINKING_LEVEL_METADATA: Record<ThinkingLevel, ThinkingLevelMetadata> = {
32
32
  [ThinkingLevel.High]: { value: ThinkingLevel.High, label: "high", description: "Deep reasoning (~16k tokens)" },
33
33
  [ThinkingLevel.XHigh]: {
34
34
  value: ThinkingLevel.XHigh,
35
- label: "max",
35
+ label: "xhigh",
36
36
  description: "Maximum reasoning (~32k tokens)",
37
37
  },
38
38
  };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared ACP client bridge routing for file-write sites.
3
+ *
4
+ * When an ACP client (e.g. Zed) advertises the `fs.writeTextFile` capability,
5
+ * all write-mode tools must route through it so the editor's open buffer is
6
+ * updated immediately. Internal artifacts ('/Users/theo/.omp/agent/sessions/-Projects-oh-my-pi/2026-06-10T09-11-41-506Z_019eb0cd-3ec2-7000-92aa-1b82aa4d78f0/local' plan files, other scheme
7
+ * URLs) are always written directly to disk — those are OMP-owned and should
8
+ * never be pushed into the editor.
9
+ */
10
+ import type { ToolSession } from ".";
11
+ import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
12
+ import { isInternalUrlPath } from "./path-utils";
13
+ import { resolvePlanPath } from "./plan-mode-guard";
14
+ import { ToolError } from "./tool-errors";
15
+
16
+ /**
17
+ * Return `true` when an ACP client bridge write is appropriate for this path.
18
+ *
19
+ * Returns `false` for internal-URL paths (e.g. `'/Users/theo/.omp/agent/sessions/-Projects-oh-my-pi/2026-06-10T09-11-41-506Z_019eb0cd-3ec2-7000-92aa-1b82aa4d78f0/local/PLAN.md'`) and for the
20
+ * active plan file while plan mode is enabled — both are OMP-internal artifacts
21
+ * that must stay off the editor's buffer.
22
+ */
23
+ export function shouldRouteWriteThroughBridge(
24
+ session: ToolSession,
25
+ requestedPath: string,
26
+ absolutePath: string,
27
+ ): boolean {
28
+ if (isInternalUrlPath(requestedPath)) return false;
29
+
30
+ const state = session.getPlanModeState?.();
31
+ if (!state?.enabled || !isInternalUrlPath(state.planFilePath)) return true;
32
+
33
+ return absolutePath !== resolvePlanPath(session, state.planFilePath);
34
+ }
35
+
36
+ /**
37
+ * Try to route a file write through the ACP client bridge.
38
+ *
39
+ * Performs the full guard check, bridge call (wrapped in {@link ToolError}),
40
+ * FS-scan cache invalidation, and session mutation-version bump.
41
+ *
42
+ * Returns `true` when the bridge was used and the caller must skip the
43
+ * writethrough path. Returns `false` when the bridge is unavailable or the
44
+ * path should not be routed through it.
45
+ */
46
+ export async function routeWriteThroughBridge(
47
+ session: ToolSession,
48
+ requestedPath: string,
49
+ absolutePath: string,
50
+ content: string,
51
+ ): Promise<boolean> {
52
+ if (!shouldRouteWriteThroughBridge(session, requestedPath, absolutePath)) return false;
53
+
54
+ const bridge = session.getClientBridge?.();
55
+ if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return false;
56
+
57
+ try {
58
+ await bridge.writeTextFile({ path: absolutePath, content });
59
+ } catch (error) {
60
+ throw new ToolError(error instanceof Error ? error.message : String(error));
61
+ }
62
+
63
+ invalidateFsScanAfterWrite(absolutePath);
64
+ session.bumpFileMutationVersion?.(absolutePath);
65
+ return true;
66
+ }
@@ -434,6 +434,12 @@ export class CmuxTab {
434
434
  return new CmuxElementHandle(this, selector);
435
435
  }
436
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
+
437
443
  async evaluate<TResult, TArgs extends unknown[]>(
438
444
  fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
439
445
  ...args: TArgs
@@ -549,6 +555,37 @@ export class CmuxTab {
549
555
  throw new ToolError(`tab.waitForUrl() timed out after ${timeoutMs}ms`);
550
556
  }
551
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
+
552
589
  async drag(from: DragTarget, to: DragTarget): Promise<void> {
553
590
  const start = await this.#dragPoint(from);
554
591
  const end = await this.#dragPoint(to);
@@ -82,17 +82,85 @@ const INTERACTIVE_AX_ROLES = new Set([
82
82
 
83
83
  const LEGACY_SELECTOR_PREFIXES = ["p-aria/", "p-text/", "p-xpath/", "p-pierce/"] as const;
84
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
+
85
105
  type DialogPolicy = "accept" | "dismiss";
86
106
  type DragTarget = string | { readonly x: number; readonly y: number };
87
107
  type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string };
88
108
 
89
109
  /**
90
- * Per-op ceiling for puppeteer-internal helpers that should resolve quickly
91
- * (`observe`, `screenshot`, `extract`). Kept below the default 30s cell budget so a
92
- * single stalled helper fails fast with a named error and leaves budget for the rest
93
- * 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.
94
122
  */
95
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
+ }
96
164
 
97
165
  interface ScreenshotOptions {
98
166
  selector?: string;
@@ -121,7 +189,7 @@ interface TabApi {
121
189
  press(key: KeyInput, opts?: { selector?: string }): Promise<void>;
122
190
  scroll(deltaX: number, deltaY: number): Promise<void>;
123
191
  drag(from: DragTarget, to: DragTarget): Promise<void>;
124
- waitFor(selector: string): Promise<ElementHandle>;
192
+ waitFor(selector: string, opts?: { timeout?: number }): Promise<ElementHandle>;
125
193
  evaluate<TResult, TArgs extends unknown[]>(
126
194
  fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
127
195
  ...args: TArgs
@@ -134,12 +202,29 @@ interface TabApi {
134
202
  pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise<boolean>),
135
203
  opts?: { timeout?: number },
136
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>;
137
213
  id(n: number): Promise<ElementHandle>;
138
214
  ref(id: string): Promise<ElementHandle>;
139
215
  }
140
216
 
141
- function normalizeSelector(selector: string): string {
217
+ export function normalizeSelector(selector: string): string {
142
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
+ }
143
228
  if (selector.startsWith("p-") && !LEGACY_SELECTOR_PREFIXES.some(prefix => selector.startsWith(prefix))) {
144
229
  throw new ToolError(
145
230
  `Unsupported selector prefix. Use CSS or puppeteer query handlers (aria/, text/, xpath/, pierce/). Got: ${selector}`,
@@ -761,8 +846,15 @@ export class WorkerCore {
761
846
  try {
762
847
  return await fn(opSignal);
763
848
  } catch (err) {
764
- // Per-op deadline fired (not the cell budget, not an explicit abort) named, actionable error.
765
- 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
+ ) {
766
858
  throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms`);
767
859
  }
768
860
  throw err;
@@ -781,7 +873,8 @@ export class WorkerCore {
781
873
  active: ActiveRun,
782
874
  ): TabApi {
783
875
  const page = this.#requirePage();
784
- const quickOpMs = Math.min(timeoutMs, QUICK_OP_TIMEOUT_MS);
876
+ const { quickOpMs, actionOpMs } = resolveOpTimeouts(timeoutMs);
877
+ const waitMs = (explicit?: number): number => resolveWaitTimeout(timeoutMs, explicit);
785
878
  const INF = Number.POSITIVE_INFINITY;
786
879
  const op = <T>(label: string, perOpMs: number, fn: (sig: AbortSignal) => Promise<T>): Promise<T> =>
787
880
  this.#runOp(active, label, signal, perOpMs, fn);
@@ -844,7 +937,7 @@ export class WorkerCore {
844
937
  return content;
845
938
  }),
846
939
  click: selector =>
847
- op(`tab.click(${JSON.stringify(selector)})`, INF, async sig => {
940
+ op(`tab.click(${JSON.stringify(selector)})`, actionOpMs, async sig => {
848
941
  if (parseAriaRefSelector(selector) !== null) {
849
942
  const handle = await this.#resolveAriaRef(selector);
850
943
  try {
@@ -855,12 +948,12 @@ export class WorkerCore {
855
948
  return;
856
949
  }
857
950
  const resolved = normalizeSelector(selector);
858
- if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, timeoutMs, sig);
859
- 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 }));
860
953
  }),
861
954
  type: (selector, text) =>
862
- op(`tab.type(${JSON.stringify(selector)})`, INF, async sig => {
863
- const handle = await this.#resolveActionHandle(selector, timeoutMs, sig);
955
+ op(`tab.type(${JSON.stringify(selector)})`, actionOpMs, async sig => {
956
+ const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
864
957
  try {
865
958
  await untilAborted(sig, () => handle.type(text, { delay: 0 }));
866
959
  } finally {
@@ -868,7 +961,7 @@ export class WorkerCore {
868
961
  }
869
962
  }),
870
963
  fill: (selector, value) =>
871
- op(`tab.fill(${JSON.stringify(selector)})`, INF, async sig => {
964
+ op(`tab.fill(${JSON.stringify(selector)})`, actionOpMs, async sig => {
872
965
  if (parseAriaRefSelector(selector) !== null) {
873
966
  const handle = await this.#resolveAriaRef(selector);
874
967
  try {
@@ -886,22 +979,46 @@ export class WorkerCore {
886
979
  return;
887
980
  }
888
981
  await untilAborted(sig, () =>
889
- page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).fill(value),
982
+ page.locator(normalizeSelector(selector)).setTimeout(actionOpMs).fill(value, { signal: sig }),
890
983
  );
891
984
  }),
892
985
  press: (key, opts) =>
893
- op(`tab.press(${JSON.stringify(key)})`, INF, async sig => {
986
+ op(`tab.press(${JSON.stringify(key)})`, actionOpMs, async sig => {
894
987
  const selector = opts?.selector;
895
988
  if (selector) await untilAborted(sig, () => page.focus(normalizeSelector(selector)));
896
989
  await untilAborted(sig, () => page.keyboard.press(key));
897
990
  }),
898
991
  scroll: (deltaX, deltaY) =>
899
- op("tab.scroll()", INF, sig => untilAborted(sig, () => page.mouse.wheel({ deltaX, deltaY }))),
900
- drag: (from, to) => op("tab.drag()", INF, sig => this.#drag(from, to, sig)),
901
- waitFor: selector =>
902
- op(`tab.waitFor(${JSON.stringify(selector)})`, INF, sig =>
903
- this.#resolveActionHandle(selector, timeoutMs, sig),
904
- ),
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
+ },
905
1022
  evaluate: (fn, ...args) =>
906
1023
  op("tab.evaluate()", INF, sig =>
907
1024
  untilAborted(sig, () =>
@@ -911,8 +1028,8 @@ export class WorkerCore {
911
1028
  ),
912
1029
  ) as never,
913
1030
  scrollIntoView: selector =>
914
- op(`tab.scrollIntoView(${JSON.stringify(selector)})`, INF, async sig => {
915
- const handle = await this.#resolveActionHandle(selector, timeoutMs, sig);
1031
+ op(`tab.scrollIntoView(${JSON.stringify(selector)})`, actionOpMs, async sig => {
1032
+ const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
916
1033
  try {
917
1034
  await untilAborted(sig, () =>
918
1035
  handle.evaluate(el => {
@@ -927,15 +1044,21 @@ export class WorkerCore {
927
1044
  }
928
1045
  }),
929
1046
  select: (selector, ...values) =>
930
- 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
+ ),
931
1050
  uploadFile: (selector, ...filePaths) =>
932
- op(`tab.uploadFile(${JSON.stringify(selector)})`, INF, sig =>
933
- this.#uploadFile(selector, filePaths, timeoutMs, sig, session),
1051
+ op(`tab.uploadFile(${JSON.stringify(selector)})`, actionOpMs, sig =>
1052
+ this.#uploadFile(selector, filePaths, actionOpMs, sig, session),
934
1053
  ),
935
- waitForUrl: (pattern, opts) =>
936
- op("tab.waitForUrl()", INF, sig => this.#waitForUrl(pattern, opts?.timeout ?? timeoutMs, sig)),
937
- waitForResponse: (pattern, opts) =>
938
- 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
+ },
939
1062
  id: id => this.#resolveCachedHandle(id),
940
1063
  ref: id => this.#resolveAriaRef(id),
941
1064
  };
@@ -1122,7 +1245,7 @@ export class WorkerCore {
1122
1245
  async #select(selector: string, values: string[], timeoutMs: number, signal: AbortSignal): Promise<string[]> {
1123
1246
  const page = this.#requirePage();
1124
1247
  const handle = (await untilAborted(signal, () =>
1125
- page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
1248
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal }),
1126
1249
  )) as ElementHandle;
1127
1250
  try {
1128
1251
  return (await untilAborted(signal, () =>
@@ -1168,7 +1291,7 @@ export class WorkerCore {
1168
1291
  if (!filePaths.length) throw new ToolError("tab.uploadFile() requires at least one file path");
1169
1292
  const page = this.#requirePage();
1170
1293
  const handle = (await untilAborted(signal, () =>
1171
- page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
1294
+ page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal }),
1172
1295
  )) as ElementHandle;
1173
1296
  try {
1174
1297
  const absolute = filePaths.map(filePath => resolveToCwd(filePath, session.cwd));
@@ -1197,7 +1320,7 @@ export class WorkerCore {
1197
1320
  const url = (globalThis as unknown as { location: { href: string } }).location.href;
1198
1321
  return isRe ? new RegExp(m, fl).test(url) : url.includes(m);
1199
1322
  },
1200
- { timeout, polling: 200 },
1323
+ { timeout, polling: 200, signal },
1201
1324
  matcher,
1202
1325
  isRegex,
1203
1326
  flags,
@@ -1218,7 +1341,7 @@ export class WorkerCore {
1218
1341
  : pattern instanceof RegExp
1219
1342
  ? response => pattern.test(response.url())
1220
1343
  : response => response.url().includes(pattern);
1221
- return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout }))) as HTTPResponse;
1344
+ return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout, signal }))) as HTTPResponse;
1222
1345
  }
1223
1346
 
1224
1347
  async #resolveCachedHandle(id: number): Promise<ElementHandle> {
@@ -1257,7 +1380,7 @@ export class WorkerCore {
1257
1380
  async #resolveActionHandle(selector: string, timeoutMs: number, sig: AbortSignal): Promise<ElementHandle> {
1258
1381
  if (parseAriaRefSelector(selector) !== null) return this.#resolveAriaRef(selector);
1259
1382
  return (await untilAborted(sig, () =>
1260
- this.#requirePage().locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
1383
+ this.#requirePage().locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal: sig }),
1261
1384
  )) as ElementHandle;
1262
1385
  }
1263
1386
  #clearElementCache(): void {
@@ -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).
@@ -771,11 +763,6 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
771
763
  };
772
764
  }
773
765
 
774
- #routeWriteThroughBridge(absolutePath: string, content: string): Promise<void> | undefined {
775
- const bridge = this.session.getClientBridge?.();
776
- if (!bridge?.capabilities.writeTextFile || !bridge.writeTextFile) return undefined;
777
- return bridge.writeTextFile({ path: absolutePath, content });
778
- }
779
766
  async execute(
780
767
  _toolCallId: string,
781
768
  { path: rawPath, content }: WriteParams,
@@ -882,17 +869,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
882
869
 
883
870
  // Try ACP bridge first for editor-visible filesystem paths. Internal
884
871
  // artifacts such as local:// plans are owned by OMP, not the editor.
885
- const bridgePromise = shouldRouteWriteThroughBridge(this.session, path, absolutePath)
886
- ? this.#routeWriteThroughBridge(absolutePath, cleanContent)
887
- : undefined;
888
- if (bridgePromise !== undefined) {
889
- try {
890
- await bridgePromise;
891
- } catch (error) {
892
- throw new ToolError(error instanceof Error ? error.message : String(error));
893
- }
894
- invalidateFsScanAfterWrite(absolutePath);
895
- this.session.bumpFileMutationVersion?.(absolutePath);
872
+ if (await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent)) {
896
873
  const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
897
874
  const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
898
875
  const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
@@ -9,9 +9,10 @@ export const SUPPORTED_INPUT_IMAGE_MIME_TYPES = SUPPORTED_IMAGE_MIME_TYPES;
9
9
 
10
10
  /**
11
11
  * Ollama and its local-backend family decode image input through llama.cpp /
12
- * `stb_image`, which is compiled without WebP support, so a WebP upload fails
13
- * with an opaque HTTP 400. Detect those models so the resize pipeline encodes
14
- * to PNG/JPEG instead — the automatic equivalent of `OMP_NO_WEBP=1`.
12
+ * `stb_image`, which is compiled without WebP support. The first-party Codex
13
+ * Responses backend also rejects WebP `input_image.image_url` data URLs. Detect
14
+ * those models so the resize pipeline encodes to PNG/JPEG instead — the
15
+ * automatic equivalent of `OMP_NO_WEBP=1`.
15
16
  */
16
17
  export function modelLacksWebpSupport(
17
18
  model: Pick<Model, "provider" | "api" | "imageInputDecoder"> | undefined,
@@ -19,6 +20,8 @@ export function modelLacksWebpSupport(
19
20
  if (!model) return false;
20
21
  return (
21
22
  model.imageInputDecoder === "stb" ||
23
+ model.api === "openai-codex-responses" ||
24
+ model.provider === "openai-codex" ||
22
25
  model.provider === "ollama" ||
23
26
  model.provider === "ollama-cloud" ||
24
27
  model.provider === "llama.cpp" ||