@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.
- package/CHANGELOG.md +31 -0
- package/dist/cli.js +2811 -2812
- package/dist/types/export/html/index.d.ts +31 -2
- package/dist/types/export/html/web-palette.d.ts +117 -0
- package/dist/types/hindsight/content.d.ts +7 -0
- package/dist/types/hindsight/transcript.d.ts +1 -1
- package/dist/types/modes/components/hook-editor.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +5 -0
- package/dist/types/modes/types.d.ts +17 -0
- package/dist/types/modes/utils/keybinding-matchers.d.ts +13 -0
- package/dist/types/session/agent-session.d.ts +4 -0
- package/dist/types/session/session-context.d.ts +2 -0
- package/dist/types/session/session-entries.d.ts +6 -0
- package/dist/types/session/session-manager.d.ts +2 -1
- package/dist/types/tools/acp-bridge.d.ts +29 -0
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +7 -0
- package/dist/types/tools/browser/tab-worker.d.ts +20 -0
- package/dist/types/utils/image-loading.d.ts +4 -3
- package/dist/types/utils/jj.d.ts +25 -0
- package/dist/types/utils/title-generator.d.ts +0 -2
- package/package.json +12 -12
- package/scripts/generate-share-viewer.ts +4 -2
- package/src/autoresearch/git.ts +12 -0
- package/src/discovery/opencode.ts +47 -4
- package/src/edit/hashline/filesystem.ts +8 -0
- package/src/edit/modes/patch.ts +18 -2
- package/src/edit/modes/replace.ts +13 -10
- package/src/export/html/index.ts +50 -8
- package/src/export/html/web-palette.ts +142 -0
- package/src/hindsight/backend.ts +4 -4
- package/src/hindsight/content.ts +17 -1
- package/src/hindsight/transcript.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/modes/components/agent-dashboard.ts +8 -8
- package/src/modes/components/hook-editor.ts +13 -10
- package/src/modes/components/session-selector.ts +3 -0
- package/src/modes/controllers/event-controller.ts +9 -5
- package/src/modes/controllers/extension-ui-controller.ts +6 -2
- package/src/modes/interactive-mode.ts +69 -29
- package/src/modes/theme/dark.json +1 -1
- package/src/modes/types.ts +18 -0
- package/src/modes/utils/keybinding-matchers.ts +36 -1
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/tools/browser.md +3 -2
- package/src/sdk.ts +12 -1
- package/src/session/agent-session.ts +38 -14
- package/src/session/session-context.ts +5 -0
- package/src/session/session-entries.ts +6 -0
- package/src/session/session-manager.ts +3 -1
- package/src/task/worktree.ts +12 -4
- package/src/thinking.ts +1 -1
- package/src/tools/acp-bridge.ts +66 -0
- package/src/tools/browser/cmux/cmux-tab.ts +37 -0
- package/src/tools/browser/tab-worker.ts +160 -37
- package/src/tools/write.ts +2 -25
- package/src/utils/image-loading.ts +6 -3
- package/src/utils/jj.ts +47 -0
- 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
|
-
|
|
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;
|
package/src/task/worktree.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
|
91
|
-
* (`
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
//
|
|
765
|
-
|
|
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 =
|
|
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)})`,
|
|
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,
|
|
859
|
-
else await untilAborted(sig, () => page.locator(resolved).setTimeout(
|
|
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)})`,
|
|
863
|
-
const handle = await this.#resolveActionHandle(selector,
|
|
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)})`,
|
|
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(
|
|
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)})`,
|
|
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()",
|
|
900
|
-
drag: (from, to) => op("tab.drag()",
|
|
901
|
-
waitFor: selector =>
|
|
902
|
-
|
|
903
|
-
|
|
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)})`,
|
|
915
|
-
const handle = await this.#resolveActionHandle(selector,
|
|
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)})`,
|
|
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)})`,
|
|
933
|
-
this.#uploadFile(selector, filePaths,
|
|
1051
|
+
op(`tab.uploadFile(${JSON.stringify(selector)})`, actionOpMs, sig =>
|
|
1052
|
+
this.#uploadFile(selector, filePaths, actionOpMs, sig, session),
|
|
934
1053
|
),
|
|
935
|
-
waitForUrl: (pattern, opts) =>
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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 {
|
package/src/tools/write.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
13
|
-
*
|
|
14
|
-
* to PNG/JPEG instead — the
|
|
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" ||
|