@oh-my-pi/pi-coding-agent 17.0.3 → 17.0.5
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 +88 -35
- package/dist/cli.js +3540 -3521
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- package/dist/types/advisor/runtime.d.ts +7 -1
- package/dist/types/async/job-manager.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +7 -0
- package/dist/types/config/model-resolver.d.ts +8 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/models-config.d.ts +6 -0
- package/dist/types/dap/client.d.ts +10 -0
- package/dist/types/dap/types.d.ts +6 -5
- package/dist/types/extensibility/extensions/runner.d.ts +4 -2
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
- package/dist/types/launch/spawn-options.d.ts +10 -0
- package/dist/types/launch/spawn-options.test.d.ts +1 -0
- package/dist/types/mnemopi/state.d.ts +32 -9
- package/dist/types/modes/components/status-line/component.d.ts +2 -3
- package/dist/types/modes/components/status-line/types.d.ts +3 -1
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/components/transcript-container.d.ts +4 -3
- package/dist/types/modes/components/tree-selector.d.ts +6 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/print-mode.d.ts +4 -0
- package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +19 -1
- package/dist/types/session/messages.d.ts +6 -0
- package/dist/types/task/types.d.ts +6 -6
- package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
- package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker.d.ts +2 -0
- package/dist/types/tools/gh.d.ts +5 -3
- package/dist/types/tools/hub/index.d.ts +1 -1
- package/dist/types/tools/hub/jobs.d.ts +1 -0
- package/dist/types/tools/hub/types.d.ts +2 -0
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/dist/types/utils/git.d.ts +33 -0
- package/dist/types/web/search/providers/codex.d.ts +1 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +150 -0
- package/src/advisor/advise-tool.ts +4 -0
- package/src/advisor/runtime.ts +38 -14
- package/src/async/job-manager.ts +3 -0
- package/src/cli/bench-cli.ts +8 -2
- package/src/cli/dry-balance-cli.ts +1 -0
- package/src/config/model-registry.ts +89 -8
- package/src/config/model-resolver.ts +78 -14
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings.ts +3 -1
- package/src/dap/client.ts +168 -1
- package/src/dap/config.ts +51 -1
- package/src/dap/session.ts +575 -234
- package/src/dap/types.ts +6 -5
- package/src/discovery/agents.ts +2 -2
- package/src/extensibility/extensions/runner.ts +6 -4
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
- package/src/launch/broker.ts +34 -31
- package/src/launch/client.ts +7 -1
- package/src/launch/spawn-options.test.ts +31 -0
- package/src/launch/spawn-options.ts +17 -0
- package/src/lsp/types.ts +5 -1
- package/src/main.ts +17 -4
- package/src/mcp/transports/stdio.test.ts +9 -1
- package/src/mnemopi/backend.ts +1 -1
- package/src/mnemopi/embed-worker.ts +8 -7
- package/src/mnemopi/state.ts +45 -18
- package/src/modes/components/ask-dialog.ts +137 -73
- package/src/modes/components/status-line/component.ts +2 -2
- package/src/modes/components/status-line/segments.ts +20 -3
- package/src/modes/components/status-line/types.ts +3 -1
- package/src/modes/components/tool-execution.ts +5 -0
- package/src/modes/components/transcript-container.ts +18 -111
- package/src/modes/components/tree-selector.ts +10 -4
- package/src/modes/controllers/event-controller.ts +1 -2
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +29 -8
- package/src/modes/interactive-mode.ts +40 -8
- package/src/modes/noninteractive-dispose.test.ts +12 -1
- package/src/modes/print-mode.ts +21 -9
- package/src/modes/rpc/rpc-input.ts +38 -0
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +3 -2
- package/src/prompts/tools/browser.md +3 -2
- package/src/prompts/tools/debug.md +2 -7
- package/src/prompts/tools/github.md +6 -1
- package/src/prompts/tools/hub.md +4 -2
- package/src/prompts/tools/task-async-contract.md +1 -0
- package/src/prompts/tools/task.md +9 -2
- package/src/sdk.ts +38 -6
- package/src/session/agent-session.ts +395 -162
- package/src/session/messages.test.ts +91 -0
- package/src/session/messages.ts +248 -110
- package/src/session/session-loader.ts +31 -3
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/stt/recorder.ts +68 -55
- package/src/task/executor.ts +59 -33
- package/src/task/index.ts +46 -9
- package/src/task/types.ts +11 -8
- package/src/task/worktree.ts +10 -0
- package/src/tools/browser/aria/aria-snapshot.ts +36 -8
- package/src/tools/browser/cmux/cmux-tab.ts +2 -1
- package/src/tools/browser/cmux/socket-client.ts +139 -3
- package/src/tools/browser/run-cancellation.ts +4 -0
- package/src/tools/browser/tab-protocol.ts +2 -0
- package/src/tools/browser/tab-supervisor.ts +21 -11
- package/src/tools/browser/tab-worker.ts +199 -33
- package/src/tools/debug.ts +3 -0
- package/src/tools/gh.ts +40 -2
- package/src/tools/hub/index.ts +4 -1
- package/src/tools/hub/jobs.ts +42 -1
- package/src/tools/hub/types.ts +2 -0
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/utils/git.ts +237 -0
- package/src/web/search/index.ts +9 -5
- package/src/web/search/providers/codex.ts +195 -99
|
@@ -9,7 +9,7 @@ import type { ToolSession } from "../../index";
|
|
|
9
9
|
import { resolveToCwd } from "../../path-utils";
|
|
10
10
|
import { formatScreenshot } from "../../render-utils";
|
|
11
11
|
import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors";
|
|
12
|
-
import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-snapshot";
|
|
12
|
+
import { type AriaSnapshotOptions, assertSelectorString, buildAriaSnapshotScript } from "../aria/aria-snapshot";
|
|
13
13
|
import { DEFAULT_VIEWPORT } from "../launch";
|
|
14
14
|
import { extractReadableFromHtml, type ReadableFormat } from "../readable";
|
|
15
15
|
import {
|
|
@@ -1015,6 +1015,7 @@ export class CmuxTab {
|
|
|
1015
1015
|
}
|
|
1016
1016
|
|
|
1017
1017
|
#selectorSpec(selector: string): SelectorSpec {
|
|
1018
|
+
assertSelectorString(selector);
|
|
1018
1019
|
const raw = selector;
|
|
1019
1020
|
let normalized = selector;
|
|
1020
1021
|
if (normalized.startsWith("p-text/")) normalized = `text/${normalized.slice("p-text/".length)}`;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as net from "node:net";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
3
5
|
import { ToolError } from "../../tool-errors";
|
|
4
6
|
|
|
5
7
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
6
8
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
9
|
+
const UTF8 = new TextEncoder();
|
|
7
10
|
|
|
8
11
|
type RequestJob = {
|
|
9
12
|
method: string;
|
|
@@ -25,6 +28,37 @@ type CmuxErrorPayload = {
|
|
|
25
28
|
details?: unknown;
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
type RelayEndpoint = {
|
|
32
|
+
host: string;
|
|
33
|
+
port: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type RelayCredentials = {
|
|
37
|
+
relayId: string;
|
|
38
|
+
relayToken: Uint8Array<ArrayBuffer>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function parseRelayCredentials(relayIdValue: unknown, relayTokenValue: unknown): RelayCredentials | null {
|
|
42
|
+
if (typeof relayIdValue !== "string" || typeof relayTokenValue !== "string") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const relayId = relayIdValue.trim();
|
|
46
|
+
const relayTokenHex = relayTokenValue.trim();
|
|
47
|
+
if (
|
|
48
|
+
relayId.length === 0 ||
|
|
49
|
+
relayTokenHex.length === 0 ||
|
|
50
|
+
relayTokenHex.length % 2 !== 0 ||
|
|
51
|
+
!/^[0-9a-f]+$/i.test(relayTokenHex)
|
|
52
|
+
) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const relayToken = new Uint8Array(new ArrayBuffer(relayTokenHex.length / 2));
|
|
56
|
+
for (let index = 0; index < relayToken.length; index++) {
|
|
57
|
+
relayToken[index] = Number.parseInt(relayTokenHex.slice(index * 2, index * 2 + 2), 16);
|
|
58
|
+
}
|
|
59
|
+
return { relayId, relayToken };
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
export function formatCmuxError(error: CmuxErrorPayload | undefined): string {
|
|
29
63
|
const code = typeof error?.code === "string" && error.code.length > 0 ? error.code : "error";
|
|
30
64
|
const message = typeof error?.message === "string" && error.message.length > 0 ? error.message : "cmux error";
|
|
@@ -35,6 +69,8 @@ export function formatCmuxError(error: CmuxErrorPayload | undefined): string {
|
|
|
35
69
|
export class CmuxSocketClient {
|
|
36
70
|
readonly #socketPath: string;
|
|
37
71
|
readonly #password: string | undefined;
|
|
72
|
+
readonly #relayId: string | undefined;
|
|
73
|
+
readonly #relayToken: string | undefined;
|
|
38
74
|
#socket: net.Socket | null = null;
|
|
39
75
|
#connectPromise: Promise<void> | null = null;
|
|
40
76
|
#connected = false;
|
|
@@ -45,9 +81,11 @@ export class CmuxSocketClient {
|
|
|
45
81
|
#activeJob: RequestJob | null = null;
|
|
46
82
|
#pumping = false;
|
|
47
83
|
|
|
48
|
-
constructor(opts: { socketPath: string; password?: string }) {
|
|
84
|
+
constructor(opts: { socketPath: string; password?: string; relayId?: string; relayToken?: string }) {
|
|
49
85
|
this.#socketPath = opts.socketPath;
|
|
50
86
|
this.#password = opts.password;
|
|
87
|
+
this.#relayId = opts.relayId ?? process.env.CMUX_RELAY_ID;
|
|
88
|
+
this.#relayToken = opts.relayToken ?? process.env.CMUX_RELAY_TOKEN;
|
|
51
89
|
}
|
|
52
90
|
|
|
53
91
|
async connect(): Promise<void> {
|
|
@@ -101,7 +139,11 @@ export class CmuxSocketClient {
|
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
async #openSocket(): Promise<void> {
|
|
104
|
-
const
|
|
142
|
+
const relayEndpoint = this.#parseRelayEndpoint();
|
|
143
|
+
const relayCredentials = relayEndpoint ? await this.#loadRelayCredentials(relayEndpoint) : null;
|
|
144
|
+
const socket = relayEndpoint
|
|
145
|
+
? net.createConnection({ host: relayEndpoint.host, port: relayEndpoint.port })
|
|
146
|
+
: net.createConnection({ path: this.#socketPath });
|
|
105
147
|
this.#socket = socket;
|
|
106
148
|
this.#buffer = "";
|
|
107
149
|
socket.setEncoding("utf8");
|
|
@@ -111,13 +153,16 @@ export class CmuxSocketClient {
|
|
|
111
153
|
|
|
112
154
|
try {
|
|
113
155
|
await this.#waitForConnect(socket);
|
|
114
|
-
|
|
156
|
+
if (relayEndpoint && relayCredentials) {
|
|
157
|
+
await this.#authenticateRelay(relayEndpoint, relayCredentials);
|
|
158
|
+
}
|
|
115
159
|
if (this.#password) {
|
|
116
160
|
const line = await this.#sendLine(`auth ${this.#password}`, DEFAULT_CONNECT_TIMEOUT_MS);
|
|
117
161
|
if (line.startsWith("ERROR:") && !line.includes("Unknown command 'auth'")) {
|
|
118
162
|
throw new ToolError(line);
|
|
119
163
|
}
|
|
120
164
|
}
|
|
165
|
+
this.#connected = true;
|
|
121
166
|
} catch (err) {
|
|
122
167
|
this.#connected = false;
|
|
123
168
|
socket.destroy();
|
|
@@ -128,6 +173,97 @@ export class CmuxSocketClient {
|
|
|
128
173
|
}
|
|
129
174
|
}
|
|
130
175
|
|
|
176
|
+
#parseRelayEndpoint(): RelayEndpoint | null {
|
|
177
|
+
const value = this.#socketPath.trim();
|
|
178
|
+
if (value.length === 0 || value.startsWith("/")) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const match = /^(127\.0\.0\.1|localhost):([0-9]+)$/.exec(value);
|
|
182
|
+
if (!match) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const port = Number.parseInt(match[2] ?? "", 10);
|
|
186
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return { host: "127.0.0.1", port };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async #loadRelayCredentials(endpoint: RelayEndpoint): Promise<RelayCredentials> {
|
|
193
|
+
const environmentCredentials = parseRelayCredentials(this.#relayId, this.#relayToken);
|
|
194
|
+
if (environmentCredentials) {
|
|
195
|
+
return environmentCredentials;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const authPath = path.join(os.homedir(), ".cmux", "relay", `${endpoint.port}.auth`);
|
|
199
|
+
let payload: unknown;
|
|
200
|
+
try {
|
|
201
|
+
payload = await Bun.file(authPath).json();
|
|
202
|
+
} catch {
|
|
203
|
+
throw new ToolError(
|
|
204
|
+
`Missing cmux relay auth metadata for ${endpoint.host}:${endpoint.port}; set CMUX_RELAY_ID/CMUX_RELAY_TOKEN or restore ~/.cmux/relay/${endpoint.port}.auth`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const relayId = payload && typeof payload === "object" && "relay_id" in payload ? payload.relay_id : undefined;
|
|
208
|
+
const relayToken =
|
|
209
|
+
payload && typeof payload === "object" && "relay_token" in payload ? payload.relay_token : undefined;
|
|
210
|
+
const fileCredentials = parseRelayCredentials(relayId, relayToken);
|
|
211
|
+
if (!fileCredentials) {
|
|
212
|
+
throw new ToolError(`Invalid cmux relay auth metadata in ~/.cmux/relay/${endpoint.port}.auth`);
|
|
213
|
+
}
|
|
214
|
+
return fileCredentials;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #authenticateRelay(endpoint: RelayEndpoint, credentials: RelayCredentials): Promise<void> {
|
|
218
|
+
const challengeLine = await this.#nextLine(DEFAULT_CONNECT_TIMEOUT_MS);
|
|
219
|
+
let challenge: unknown;
|
|
220
|
+
try {
|
|
221
|
+
challenge = JSON.parse(challengeLine);
|
|
222
|
+
} catch {
|
|
223
|
+
throw new ToolError(`Invalid cmux relay authentication challenge from ${endpoint.host}:${endpoint.port}`);
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
!challenge ||
|
|
227
|
+
typeof challenge !== "object" ||
|
|
228
|
+
!("protocol" in challenge) ||
|
|
229
|
+
challenge.protocol !== "cmux-relay-auth" ||
|
|
230
|
+
!("version" in challenge) ||
|
|
231
|
+
typeof challenge.version !== "number" ||
|
|
232
|
+
!Number.isInteger(challenge.version) ||
|
|
233
|
+
!("relay_id" in challenge) ||
|
|
234
|
+
challenge.relay_id !== credentials.relayId ||
|
|
235
|
+
!("nonce" in challenge) ||
|
|
236
|
+
typeof challenge.nonce !== "string" ||
|
|
237
|
+
challenge.nonce.length === 0
|
|
238
|
+
) {
|
|
239
|
+
throw new ToolError(`Invalid cmux relay authentication challenge from ${endpoint.host}:${endpoint.port}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const message = `relay_id=${challenge.relay_id}\nnonce=${challenge.nonce}\nversion=${challenge.version}`;
|
|
243
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
244
|
+
"raw",
|
|
245
|
+
credentials.relayToken,
|
|
246
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
247
|
+
false,
|
|
248
|
+
["sign"],
|
|
249
|
+
);
|
|
250
|
+
const mac = await globalThis.crypto.subtle.sign("HMAC", key, UTF8.encode(message));
|
|
251
|
+
const authLine = JSON.stringify({
|
|
252
|
+
relay_id: credentials.relayId,
|
|
253
|
+
mac: Buffer.from(mac).toString("hex"),
|
|
254
|
+
});
|
|
255
|
+
const responseLine = await this.#sendLine(authLine, DEFAULT_CONNECT_TIMEOUT_MS);
|
|
256
|
+
let response: unknown;
|
|
257
|
+
try {
|
|
258
|
+
response = JSON.parse(responseLine);
|
|
259
|
+
} catch {
|
|
260
|
+
throw new ToolError(`Cmux relay authentication failed for ${endpoint.host}:${endpoint.port}`);
|
|
261
|
+
}
|
|
262
|
+
if (!response || typeof response !== "object" || !("ok" in response) || response.ok !== true) {
|
|
263
|
+
throw new ToolError(`Cmux relay authentication failed for ${endpoint.host}:${endpoint.port}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
131
267
|
#waitForConnect(socket: net.Socket): Promise<void> {
|
|
132
268
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
133
269
|
const timer = setTimeout(() => {
|
|
@@ -117,6 +117,10 @@ export function bindBrowserRunFacade<T extends object>(target: T, signal: AbortS
|
|
|
117
117
|
return wrapped;
|
|
118
118
|
}
|
|
119
119
|
if (value && typeof value === "object") {
|
|
120
|
+
// Never proxy AbortSignals: native combinators (AbortSignal.any, fetch)
|
|
121
|
+
// brand-check internal slots that a Proxy cannot forward, and reading a
|
|
122
|
+
// signal needs no abort gating anyway.
|
|
123
|
+
if (value instanceof AbortSignal) return value;
|
|
120
124
|
const wrapped = bindBrowserRunFacade(value, signal);
|
|
121
125
|
cache.set(prop, wrapped);
|
|
122
126
|
return wrapped;
|
|
@@ -138,6 +138,7 @@ const GRACE_MS = 750;
|
|
|
138
138
|
// vanished instead of a bare "not alive". Cleared when the name is opened again.
|
|
139
139
|
const killedTabs = new Map<string, string>();
|
|
140
140
|
const DEFAULT_TAB_CLOSE_TIMEOUT_MS = 5_000;
|
|
141
|
+
class RecoverableWorkerError extends ToolError {}
|
|
141
142
|
|
|
142
143
|
async function waitForTabCleanup<T>(
|
|
143
144
|
tab: TabSession,
|
|
@@ -488,16 +489,23 @@ async function runInTabWithSnapshot(
|
|
|
488
489
|
async reason => await forceKillTab(name, reason),
|
|
489
490
|
);
|
|
490
491
|
} catch (error) {
|
|
491
|
-
|
|
492
|
+
const runTimedOut =
|
|
493
|
+
error instanceof ToolError && error.message.startsWith("Browser code execution timed out after ");
|
|
494
|
+
if (runTimedOut || error instanceof RecoverableWorkerError) {
|
|
492
495
|
try {
|
|
493
|
-
if (tab.worker.mode === "inline")
|
|
494
|
-
|
|
495
|
-
|
|
496
|
+
if (tab.worker.mode === "inline") {
|
|
497
|
+
const reason = runTimedOut
|
|
498
|
+
? "Browser code execution timed out; tab killed"
|
|
499
|
+
: "Browser request interception cleanup failed; tab killed";
|
|
500
|
+
await forceKillTab(name, reason);
|
|
501
|
+
} else {
|
|
502
|
+
await recycleTimedOutWorkerTab(tab, opts.timeoutMs + GRACE_MS);
|
|
503
|
+
}
|
|
496
504
|
} catch (recycleError) {
|
|
497
|
-
logger.warn("Failed to recycle
|
|
505
|
+
logger.warn("Failed to recycle browser tab worker; killing tab", {
|
|
498
506
|
error: recycleError instanceof Error ? recycleError.message : String(recycleError),
|
|
499
507
|
});
|
|
500
|
-
await forceKillTab(name, "Browser
|
|
508
|
+
await forceKillTab(name, "Browser tab worker recovery failed; tab killed");
|
|
501
509
|
}
|
|
502
510
|
}
|
|
503
511
|
throw error;
|
|
@@ -873,11 +881,13 @@ async function targetIdForTarget(target: Target): Promise<string> {
|
|
|
873
881
|
}
|
|
874
882
|
|
|
875
883
|
function errorFromPayload(payload: RunErrorPayload): Error {
|
|
876
|
-
const error = payload.
|
|
877
|
-
? new
|
|
878
|
-
: payload.
|
|
879
|
-
? new
|
|
880
|
-
:
|
|
884
|
+
const error = payload.recoverTab
|
|
885
|
+
? new RecoverableWorkerError(payload.message)
|
|
886
|
+
: payload.isAbort
|
|
887
|
+
? new ToolAbortError()
|
|
888
|
+
: payload.isToolError
|
|
889
|
+
? new ToolError(payload.message)
|
|
890
|
+
: new Error(payload.message);
|
|
881
891
|
error.name = payload.name;
|
|
882
892
|
if (payload.stack) error.stack = payload.stack;
|
|
883
893
|
return error;
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
|
|
5
|
-
import { postmortem, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { postmortem, Snowflake, untilAborted, withTimeout } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import type { HTMLElement } from "linkedom";
|
|
7
7
|
import type {
|
|
8
8
|
Browser,
|
|
@@ -24,6 +24,7 @@ import { formatScreenshot } from "../render-utils";
|
|
|
24
24
|
import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors";
|
|
25
25
|
import {
|
|
26
26
|
type AriaSnapshotOptions,
|
|
27
|
+
assertSelectorString,
|
|
27
28
|
captureAriaSnapshot,
|
|
28
29
|
parseAriaRefSelector,
|
|
29
30
|
resolveAriaRefHandle,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
} from "./launch";
|
|
38
39
|
import { extractReadableFromHtml, type ReadableFormat } from "./readable";
|
|
39
40
|
import {
|
|
41
|
+
bindBrowserRunFacade,
|
|
40
42
|
CELL_BUDGET_SLACK_MS,
|
|
41
43
|
markHandled,
|
|
42
44
|
resolvePredicateTimeout,
|
|
@@ -144,6 +146,8 @@ interface OpenDialogInfo {
|
|
|
144
146
|
*/
|
|
145
147
|
const QUICK_OP_TIMEOUT_MS = 20_000;
|
|
146
148
|
const ACTION_OP_TIMEOUT_MS = 8_000;
|
|
149
|
+
/** Maximum wait for a renderer acknowledgement after a wheel event is queued. */
|
|
150
|
+
const SCROLL_ACK_TIMEOUT_MS = 2_000;
|
|
147
151
|
/** Headroom subtracted from the cell budget so a per-op deadline fires before it. */
|
|
148
152
|
const OP_DEADLINE_SLACK_MS = CELL_BUDGET_SLACK_MS;
|
|
149
153
|
/**
|
|
@@ -155,6 +159,8 @@ const OP_DEADLINE_SLACK_MS = CELL_BUDGET_SLACK_MS;
|
|
|
155
159
|
const ZERO_MATCH_FAIL_FAST_MS = 2_000;
|
|
156
160
|
/** Poll cadence for the zero-match watchdog. */
|
|
157
161
|
const ZERO_MATCH_POLL_MS = 250;
|
|
162
|
+
/** Cleanup must settle inside the supervisor's 750ms post-run grace window. */
|
|
163
|
+
const REQUEST_INTERCEPTION_CLEANUP_TIMEOUT_MS = 500;
|
|
158
164
|
|
|
159
165
|
export interface OpTimeouts {
|
|
160
166
|
/** Largest per-op deadline allowed — strictly below the cell budget. */
|
|
@@ -175,6 +181,21 @@ export function resolveOpTimeouts(cellTimeoutMs: number): OpTimeouts {
|
|
|
175
181
|
};
|
|
176
182
|
}
|
|
177
183
|
|
|
184
|
+
/** Queue a wheel event without treating a delayed renderer acknowledgement as dispatch failure. */
|
|
185
|
+
export async function dispatchScroll(
|
|
186
|
+
dispatch: () => Promise<void>,
|
|
187
|
+
ackTimeoutMs = SCROLL_ACK_TIMEOUT_MS,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const deadline = Promise.withResolvers<void>();
|
|
190
|
+
const timer = setTimeout(() => deadline.resolve(), ackTimeoutMs);
|
|
191
|
+
timer.unref();
|
|
192
|
+
try {
|
|
193
|
+
await Promise.race([dispatch(), deadline.promise]);
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
178
199
|
/**
|
|
179
200
|
* Effective timeout for a wait helper (`waitFor*`). A positive explicit `{ timeout }` is
|
|
180
201
|
* honored but clamped to the cell budget so it still fails fast + named; raising the tool
|
|
@@ -246,6 +267,7 @@ interface TabApi {
|
|
|
246
267
|
}
|
|
247
268
|
|
|
248
269
|
export function normalizeSelector(selector: string): string {
|
|
270
|
+
assertSelectorString(selector);
|
|
249
271
|
if (!selector) return selector;
|
|
250
272
|
if (
|
|
251
273
|
!SELECTOR_HANDLER_PREFIXES.some(prefix => selector.startsWith(prefix)) &&
|
|
@@ -334,12 +356,125 @@ function redactUrlCredentials(url: string): string {
|
|
|
334
356
|
}
|
|
335
357
|
}
|
|
336
358
|
|
|
359
|
+
class RequestInterceptionCleanupError extends ToolError {}
|
|
360
|
+
|
|
361
|
+
interface RunPageScope {
|
|
362
|
+
page: Page;
|
|
363
|
+
cleanup(): Promise<void>;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Expose the tab page while retaining the request handlers created by this run.
|
|
368
|
+
* Puppeteer's Page wraps an internal emitter, so `removeAllListeners("request")`
|
|
369
|
+
* would also remove its forwarding listener; the facade removes only user handlers.
|
|
370
|
+
*/
|
|
371
|
+
function createRunPageScope(page: Page): RunPageScope {
|
|
372
|
+
const requestHandlers: unknown[] = [];
|
|
373
|
+
const on = page.on;
|
|
374
|
+
const off = page.off;
|
|
375
|
+
const once = page.once;
|
|
376
|
+
const removeAllListeners = page.removeAllListeners;
|
|
377
|
+
const onDescriptor = Object.getOwnPropertyDescriptor(page, "on");
|
|
378
|
+
const offDescriptor = Object.getOwnPropertyDescriptor(page, "off");
|
|
379
|
+
const onceDescriptor = Object.getOwnPropertyDescriptor(page, "once");
|
|
380
|
+
const removeAllDescriptor = Object.getOwnPropertyDescriptor(page, "removeAllListeners");
|
|
381
|
+
|
|
382
|
+
Object.defineProperties(page, {
|
|
383
|
+
on: {
|
|
384
|
+
configurable: true,
|
|
385
|
+
value: (type: unknown, handler: unknown): Page => {
|
|
386
|
+
Reflect.apply(on, page, [type, handler]);
|
|
387
|
+
if (type === "request") requestHandlers.push(handler);
|
|
388
|
+
return page;
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
once: {
|
|
392
|
+
configurable: true,
|
|
393
|
+
value: (type: unknown, handler: unknown): Page => {
|
|
394
|
+
if (type !== "request" || typeof handler !== "function") {
|
|
395
|
+
Reflect.apply(once, page, [type, handler]);
|
|
396
|
+
return page;
|
|
397
|
+
}
|
|
398
|
+
const wrapper = (event: unknown): void => {
|
|
399
|
+
const index = requestHandlers.lastIndexOf(wrapper);
|
|
400
|
+
if (index >= 0) requestHandlers.splice(index, 1);
|
|
401
|
+
Reflect.apply(off, page, ["request", wrapper]);
|
|
402
|
+
Reflect.apply(handler, page, [event]);
|
|
403
|
+
};
|
|
404
|
+
requestHandlers.push(wrapper);
|
|
405
|
+
Reflect.apply(on, page, [type, wrapper]);
|
|
406
|
+
return page;
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
off: {
|
|
410
|
+
configurable: true,
|
|
411
|
+
value: (type: unknown, handler?: unknown): Page => {
|
|
412
|
+
Reflect.apply(off, page, [type, handler]);
|
|
413
|
+
if (type === "request") {
|
|
414
|
+
if (handler === undefined) requestHandlers.length = 0;
|
|
415
|
+
else {
|
|
416
|
+
const index = requestHandlers.lastIndexOf(handler);
|
|
417
|
+
if (index >= 0) requestHandlers.splice(index, 1);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return page;
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
removeAllListeners: {
|
|
424
|
+
configurable: true,
|
|
425
|
+
value: (type?: unknown): Page => {
|
|
426
|
+
Reflect.apply(removeAllListeners, page, [type]);
|
|
427
|
+
if (type === undefined || type === "request") requestHandlers.length = 0;
|
|
428
|
+
return page;
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
page,
|
|
435
|
+
async cleanup() {
|
|
436
|
+
if (onDescriptor) Object.defineProperty(page, "on", onDescriptor);
|
|
437
|
+
else Reflect.deleteProperty(page, "on");
|
|
438
|
+
if (offDescriptor) Object.defineProperty(page, "off", offDescriptor);
|
|
439
|
+
else Reflect.deleteProperty(page, "off");
|
|
440
|
+
if (onceDescriptor) Object.defineProperty(page, "once", onceDescriptor);
|
|
441
|
+
else Reflect.deleteProperty(page, "once");
|
|
442
|
+
if (removeAllDescriptor) Object.defineProperty(page, "removeAllListeners", removeAllDescriptor);
|
|
443
|
+
else Reflect.deleteProperty(page, "removeAllListeners");
|
|
444
|
+
for (const handler of requestHandlers) Reflect.apply(off, page, ["request", handler]);
|
|
445
|
+
requestHandlers.length = 0;
|
|
446
|
+
try {
|
|
447
|
+
await withTimeout(
|
|
448
|
+
page.setRequestInterception(false),
|
|
449
|
+
REQUEST_INTERCEPTION_CLEANUP_TIMEOUT_MS,
|
|
450
|
+
"Timed out clearing browser request interception",
|
|
451
|
+
);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
throw new RequestInterceptionCleanupError(
|
|
454
|
+
"Failed to clear browser request interception after browser.run",
|
|
455
|
+
{
|
|
456
|
+
error: error instanceof Error ? error.message : String(error),
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
337
464
|
function errorPayload(error: unknown): RunErrorPayload {
|
|
465
|
+
const recoverTab = error instanceof RequestInterceptionCleanupError || undefined;
|
|
338
466
|
if (error instanceof ToolAbortError) {
|
|
339
467
|
return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: true };
|
|
340
468
|
}
|
|
341
469
|
if (error instanceof ToolError) {
|
|
342
|
-
return {
|
|
470
|
+
return {
|
|
471
|
+
name: error.name,
|
|
472
|
+
message: error.message,
|
|
473
|
+
stack: error.stack,
|
|
474
|
+
isToolError: true,
|
|
475
|
+
isAbort: false,
|
|
476
|
+
recoverTab,
|
|
477
|
+
};
|
|
343
478
|
}
|
|
344
479
|
if (error instanceof Error) {
|
|
345
480
|
return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: false };
|
|
@@ -720,6 +855,7 @@ export class WorkerCore {
|
|
|
720
855
|
await session.send("Page.enable").catch(() => undefined);
|
|
721
856
|
await session.send("Page.handleJavaScriptDialog", { accept: false }).catch(() => undefined);
|
|
722
857
|
await session.send("Page.stopLoading").catch(() => undefined);
|
|
858
|
+
await session.send("Fetch.disable").catch(() => undefined);
|
|
723
859
|
} catch (error) {
|
|
724
860
|
this.#log("debug", "Recovery CDP session failed; proceeding with attach", {
|
|
725
861
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -816,17 +952,21 @@ export class WorkerCore {
|
|
|
816
952
|
opCounter: 0,
|
|
817
953
|
};
|
|
818
954
|
this.#active = active;
|
|
955
|
+
let completed = false;
|
|
956
|
+
let returnValue: unknown;
|
|
957
|
+
let failure: { error: unknown } | undefined;
|
|
958
|
+
let runPage: RunPageScope | undefined;
|
|
819
959
|
try {
|
|
820
960
|
throwIfAborted(signal);
|
|
821
|
-
|
|
961
|
+
runPage = createRunPageScope(this.#requirePage());
|
|
822
962
|
const browser = this.#requireBrowser();
|
|
823
963
|
const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, output, screenshots, active);
|
|
824
964
|
const runtime = this.#ensureRuntime(msg.session);
|
|
825
965
|
runtime.setCwd(msg.session.cwd);
|
|
826
966
|
runtime.setRunScope({
|
|
827
|
-
page,
|
|
828
|
-
browser,
|
|
829
|
-
tab: tabApi,
|
|
967
|
+
page: bindBrowserRunFacade(runPage.page, signal),
|
|
968
|
+
browser: bindBrowserRunFacade(browser, signal),
|
|
969
|
+
tab: bindBrowserRunFacade(tabApi, signal),
|
|
830
970
|
assert: (cond: unknown, text?: string): void => {
|
|
831
971
|
if (!cond) throw new ToolError(text ?? "Assertion failed");
|
|
832
972
|
},
|
|
@@ -879,25 +1019,37 @@ export class WorkerCore {
|
|
|
879
1019
|
try {
|
|
880
1020
|
const hooks = this.#hooksForActiveRun();
|
|
881
1021
|
if (!hooks) throw new ToolError("Browser runtime started without an active run");
|
|
882
|
-
|
|
1022
|
+
returnValue = await Promise.race([
|
|
883
1023
|
runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, { runId: msg.id, cwd: msg.session.cwd }),
|
|
884
1024
|
cancelRejection,
|
|
885
1025
|
]);
|
|
886
|
-
|
|
887
|
-
this.#transport.send({
|
|
888
|
-
type: "result",
|
|
889
|
-
id: msg.id,
|
|
890
|
-
ok: true,
|
|
891
|
-
payload: { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots },
|
|
892
|
-
});
|
|
1026
|
+
completed = true;
|
|
893
1027
|
} finally {
|
|
894
1028
|
signal.removeEventListener("abort", onCancel);
|
|
895
1029
|
}
|
|
896
1030
|
} catch (error) {
|
|
897
|
-
|
|
1031
|
+
failure = { error };
|
|
898
1032
|
} finally {
|
|
899
|
-
if (this.#active?.id === msg.id) this.#active = null;
|
|
900
1033
|
runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended")));
|
|
1034
|
+
try {
|
|
1035
|
+
await runPage?.cleanup();
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
failure = { error };
|
|
1038
|
+
}
|
|
1039
|
+
if (this.#active?.id === msg.id) this.#active = null;
|
|
1040
|
+
}
|
|
1041
|
+
if (failure) {
|
|
1042
|
+
this.#transport.send({ type: "result", id: msg.id, ok: false, error: errorPayload(failure.error) });
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (completed) {
|
|
1046
|
+
await this.#postReadyInfo();
|
|
1047
|
+
this.#transport.send({
|
|
1048
|
+
type: "result",
|
|
1049
|
+
id: msg.id,
|
|
1050
|
+
ok: true,
|
|
1051
|
+
payload: { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots },
|
|
1052
|
+
});
|
|
901
1053
|
}
|
|
902
1054
|
}
|
|
903
1055
|
|
|
@@ -1212,11 +1364,22 @@ export class WorkerCore {
|
|
|
1212
1364
|
press: (key, opts) =>
|
|
1213
1365
|
op(`tab.press(${JSON.stringify(key)})`, actionOpMs, async sig => {
|
|
1214
1366
|
const selector = opts?.selector;
|
|
1215
|
-
if (selector)
|
|
1367
|
+
if (selector) {
|
|
1368
|
+
if (parseAriaRefSelector(selector) !== null) {
|
|
1369
|
+
const handle = await this.#resolveAriaRef(selector);
|
|
1370
|
+
try {
|
|
1371
|
+
await untilAborted(sig, () => handle.focus());
|
|
1372
|
+
} finally {
|
|
1373
|
+
await handle.dispose().catch(() => undefined);
|
|
1374
|
+
}
|
|
1375
|
+
} else await untilAborted(sig, () => page.focus(normalizeSelector(selector)));
|
|
1376
|
+
}
|
|
1216
1377
|
await untilAborted(sig, () => page.keyboard.press(key));
|
|
1217
1378
|
}),
|
|
1218
1379
|
scroll: (deltaX, deltaY) =>
|
|
1219
|
-
op("tab.scroll()", actionOpMs, sig =>
|
|
1380
|
+
op("tab.scroll()", actionOpMs, sig =>
|
|
1381
|
+
untilAborted(sig, () => dispatchScroll(() => page.mouse.wheel({ deltaX, deltaY }))),
|
|
1382
|
+
),
|
|
1220
1383
|
drag: (from, to) => op("tab.drag()", actionOpMs, sig => this.#drag(from, to, sig)),
|
|
1221
1384
|
waitFor: (selector, opts) => {
|
|
1222
1385
|
const w = waitMs(opts?.timeout);
|
|
@@ -1387,9 +1550,10 @@ export class WorkerCore {
|
|
|
1387
1550
|
const captureMime = `image/${captureType}` as const;
|
|
1388
1551
|
let buffer: Buffer;
|
|
1389
1552
|
if (opts.selector) {
|
|
1390
|
-
const handle =
|
|
1391
|
-
|
|
1392
|
-
|
|
1553
|
+
const handle =
|
|
1554
|
+
parseAriaRefSelector(opts.selector) !== null
|
|
1555
|
+
? await this.#resolveAriaRef(opts.selector)
|
|
1556
|
+
: asElementHandle(await untilAborted(signal, () => page.$(normalizeSelector(opts.selector!))));
|
|
1393
1557
|
if (!handle) throw new ToolError("Screenshot selector did not resolve to an element");
|
|
1394
1558
|
try {
|
|
1395
1559
|
// Bring the element into view with a single instant scroll instead of puppeteer's
|
|
@@ -1462,9 +1626,10 @@ export class WorkerCore {
|
|
|
1462
1626
|
role: "from" | "to",
|
|
1463
1627
|
): Promise<{ x: number; y: number; handle?: ElementHandle }> => {
|
|
1464
1628
|
if (typeof target === "string") {
|
|
1465
|
-
const handle =
|
|
1466
|
-
|
|
1467
|
-
|
|
1629
|
+
const handle =
|
|
1630
|
+
parseAriaRefSelector(target) !== null
|
|
1631
|
+
? await this.#resolveAriaRef(target)
|
|
1632
|
+
: asElementHandle(await untilAborted(signal, () => page.$(normalizeSelector(target))));
|
|
1468
1633
|
if (!handle) throw new ToolError(`Drag ${role} selector did not resolve: ${target}`);
|
|
1469
1634
|
const box = (await untilAborted(signal, () => handle.boundingBox())) as {
|
|
1470
1635
|
x: number;
|
|
@@ -1505,10 +1670,7 @@ export class WorkerCore {
|
|
|
1505
1670
|
}
|
|
1506
1671
|
|
|
1507
1672
|
async #select(selector: string, values: string[], timeoutMs: number, signal: AbortSignal): Promise<string[]> {
|
|
1508
|
-
const
|
|
1509
|
-
const handle = (await untilAborted(signal, () =>
|
|
1510
|
-
page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal }),
|
|
1511
|
-
)) as ElementHandle;
|
|
1673
|
+
const handle = await this.#resolveActionHandle(selector, timeoutMs, signal);
|
|
1512
1674
|
try {
|
|
1513
1675
|
return (await untilAborted(signal, () =>
|
|
1514
1676
|
handle.evaluate((el, vals) => {
|
|
@@ -1527,10 +1689,17 @@ export class WorkerCore {
|
|
|
1527
1689
|
globalThis as unknown as { Event: new (type: string, init?: { bubbles: boolean }) => unknown }
|
|
1528
1690
|
).Event;
|
|
1529
1691
|
const wanted = new Set(vals as string[]);
|
|
1530
|
-
|
|
1692
|
+
// Assign the full selection first, then read back: on a single
|
|
1693
|
+
// <select>, un-selecting the current option mid-loop leaves the
|
|
1694
|
+
// browser reporting it selected until another option takes over,
|
|
1695
|
+
// which double-counted the old value in the returned list.
|
|
1531
1696
|
for (let i = 0; i < select.options.length; i++) {
|
|
1532
1697
|
const opt = select.options[i] as SelectOption;
|
|
1533
1698
|
opt.selected = wanted.has(opt.value);
|
|
1699
|
+
}
|
|
1700
|
+
const selected: string[] = [];
|
|
1701
|
+
for (let i = 0; i < select.options.length; i++) {
|
|
1702
|
+
const opt = select.options[i] as SelectOption;
|
|
1534
1703
|
if (opt.selected) selected.push(opt.value);
|
|
1535
1704
|
}
|
|
1536
1705
|
select.dispatchEvent(new EventCtor("input", { bubbles: true }));
|
|
@@ -1551,10 +1720,7 @@ export class WorkerCore {
|
|
|
1551
1720
|
session: SessionSnapshot,
|
|
1552
1721
|
): Promise<void> {
|
|
1553
1722
|
if (!filePaths.length) throw new ToolError("tab.uploadFile() requires at least one file path");
|
|
1554
|
-
const
|
|
1555
|
-
const handle = (await untilAborted(signal, () =>
|
|
1556
|
-
page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal }),
|
|
1557
|
-
)) as ElementHandle;
|
|
1723
|
+
const handle = await this.#resolveActionHandle(selector, timeoutMs, signal);
|
|
1558
1724
|
try {
|
|
1559
1725
|
const absolute = filePaths.map(filePath => resolveToCwd(filePath, session.cwd));
|
|
1560
1726
|
const upload = handle as unknown as { uploadFile: (...paths: string[]) => Promise<void> };
|
package/src/tools/debug.ts
CHANGED
|
@@ -504,12 +504,15 @@ const ADAPTER_UNAVAILABLE_MESSAGES: Readonly<Record<string, string>> = {
|
|
|
504
504
|
debugpy: "adapter 'debugpy' is not available: python not found in PATH",
|
|
505
505
|
dlv: "adapter 'dlv' is not available: install with 'go install github.com/go-delve/delve/cmd/dlv@latest'",
|
|
506
506
|
rdbg: "adapter 'rdbg' is not available: install with 'gem install debug'",
|
|
507
|
+
"js-debug-adapter":
|
|
508
|
+
"adapter 'js-debug-adapter' is not available: install vscode-js-debug with Mason or set JS_DEBUG_DAP_SERVER to dapDebugServer.js",
|
|
507
509
|
};
|
|
508
510
|
|
|
509
511
|
const ADAPTER_CANONICAL_COMMANDS: Readonly<Record<string, string>> = {
|
|
510
512
|
debugpy: "python",
|
|
511
513
|
dlv: "dlv",
|
|
512
514
|
rdbg: "rdbg",
|
|
515
|
+
"js-debug-adapter": "js-debug-adapter",
|
|
513
516
|
};
|
|
514
517
|
|
|
515
518
|
function formatAdapterUnavailable(adapterName: string, command: string, cwd: string): string {
|