@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +88 -35
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/mnemopi/state.d.ts +32 -9
  17. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  18. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  20. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  21. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  22. package/dist/types/modes/interactive-mode.d.ts +2 -0
  23. package/dist/types/modes/print-mode.d.ts +4 -0
  24. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -0
  27. package/dist/types/sdk.d.ts +2 -0
  28. package/dist/types/session/agent-session.d.ts +19 -1
  29. package/dist/types/session/messages.d.ts +6 -0
  30. package/dist/types/task/types.d.ts +6 -6
  31. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  32. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  33. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  35. package/dist/types/tools/gh.d.ts +5 -3
  36. package/dist/types/tools/hub/index.d.ts +1 -1
  37. package/dist/types/tools/hub/jobs.d.ts +1 -0
  38. package/dist/types/tools/hub/types.d.ts +2 -0
  39. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  40. package/dist/types/utils/git.d.ts +33 -0
  41. package/dist/types/web/search/providers/codex.d.ts +1 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +150 -0
  44. package/src/advisor/advise-tool.ts +4 -0
  45. package/src/advisor/runtime.ts +38 -14
  46. package/src/async/job-manager.ts +3 -0
  47. package/src/cli/bench-cli.ts +8 -2
  48. package/src/cli/dry-balance-cli.ts +1 -0
  49. package/src/config/model-registry.ts +89 -8
  50. package/src/config/model-resolver.ts +78 -14
  51. package/src/config/models-config-schema.ts +1 -0
  52. package/src/config/settings.ts +3 -1
  53. package/src/dap/client.ts +168 -1
  54. package/src/dap/config.ts +51 -1
  55. package/src/dap/session.ts +575 -234
  56. package/src/dap/types.ts +6 -5
  57. package/src/discovery/agents.ts +2 -2
  58. package/src/extensibility/extensions/runner.ts +6 -4
  59. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  60. package/src/launch/broker.ts +34 -31
  61. package/src/launch/client.ts +7 -1
  62. package/src/launch/spawn-options.test.ts +31 -0
  63. package/src/launch/spawn-options.ts +17 -0
  64. package/src/lsp/types.ts +5 -1
  65. package/src/main.ts +17 -4
  66. package/src/mcp/transports/stdio.test.ts +9 -1
  67. package/src/mnemopi/backend.ts +1 -1
  68. package/src/mnemopi/embed-worker.ts +8 -7
  69. package/src/mnemopi/state.ts +45 -18
  70. package/src/modes/components/ask-dialog.ts +137 -73
  71. package/src/modes/components/status-line/component.ts +2 -2
  72. package/src/modes/components/status-line/segments.ts +20 -3
  73. package/src/modes/components/status-line/types.ts +3 -1
  74. package/src/modes/components/tool-execution.ts +5 -0
  75. package/src/modes/components/transcript-container.ts +18 -111
  76. package/src/modes/components/tree-selector.ts +10 -4
  77. package/src/modes/controllers/event-controller.ts +1 -2
  78. package/src/modes/controllers/input-controller.ts +1 -1
  79. package/src/modes/controllers/selector-controller.ts +29 -8
  80. package/src/modes/interactive-mode.ts +40 -8
  81. package/src/modes/noninteractive-dispose.test.ts +12 -1
  82. package/src/modes/print-mode.ts +21 -9
  83. package/src/modes/rpc/rpc-input.ts +38 -0
  84. package/src/modes/rpc/rpc-mode.ts +7 -2
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  86. package/src/modes/types.ts +2 -0
  87. package/src/modes/utils/ui-helpers.ts +3 -2
  88. package/src/prompts/tools/browser.md +3 -2
  89. package/src/prompts/tools/debug.md +2 -7
  90. package/src/prompts/tools/github.md +6 -1
  91. package/src/prompts/tools/hub.md +4 -2
  92. package/src/prompts/tools/task-async-contract.md +1 -0
  93. package/src/prompts/tools/task.md +9 -2
  94. package/src/sdk.ts +38 -6
  95. package/src/session/agent-session.ts +395 -162
  96. package/src/session/messages.test.ts +91 -0
  97. package/src/session/messages.ts +248 -110
  98. package/src/session/session-loader.ts +31 -3
  99. package/src/slash-commands/builtin-registry.ts +1 -0
  100. package/src/stt/recorder.ts +68 -55
  101. package/src/task/executor.ts +59 -33
  102. package/src/task/index.ts +46 -9
  103. package/src/task/types.ts +11 -8
  104. package/src/task/worktree.ts +10 -0
  105. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  106. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  107. package/src/tools/browser/cmux/socket-client.ts +139 -3
  108. package/src/tools/browser/run-cancellation.ts +4 -0
  109. package/src/tools/browser/tab-protocol.ts +2 -0
  110. package/src/tools/browser/tab-supervisor.ts +21 -11
  111. package/src/tools/browser/tab-worker.ts +199 -33
  112. package/src/tools/debug.ts +3 -0
  113. package/src/tools/gh.ts +40 -2
  114. package/src/tools/hub/index.ts +4 -1
  115. package/src/tools/hub/jobs.ts +42 -1
  116. package/src/tools/hub/types.ts +2 -0
  117. package/src/tools/tool-timeouts.ts +1 -1
  118. package/src/utils/git.ts +237 -0
  119. package/src/web/search/index.ts +9 -5
  120. 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 socket = net.createConnection({ path: this.#socketPath });
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
- this.#connected = true;
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;
@@ -95,6 +95,8 @@ export interface RunErrorPayload {
95
95
  stack?: string;
96
96
  isToolError: boolean;
97
97
  isAbort: boolean;
98
+ /** The worker could not restore tab-scoped browser state and must be recycled. */
99
+ recoverTab?: boolean;
98
100
  }
99
101
 
100
102
  export type WorkerOutbound =
@@ -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
- if (error instanceof ToolError && error.message.startsWith("Browser code execution timed out after ")) {
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
- await forceKillTab(name, "Browser code execution timed out; tab killed");
495
- else await recycleTimedOutWorkerTab(tab, opts.timeoutMs + GRACE_MS);
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 timed-out browser tab worker; killing tab", {
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 code execution timed out; tab killed");
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.isAbort
877
- ? new ToolAbortError()
878
- : payload.isToolError
879
- ? new ToolError(payload.message)
880
- : new Error(payload.message);
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 { name: error.name, message: error.message, stack: error.stack, isToolError: true, isAbort: false };
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
- const page = this.#requirePage();
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
- const returnValue = await Promise.race([
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
- await this.#postReadyInfo();
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
- this.#transport.send({ type: "result", id: msg.id, ok: false, error: errorPayload(error) });
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) await untilAborted(sig, () => page.focus(normalizeSelector(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 => untilAborted(sig, () => page.mouse.wheel({ deltaX, deltaY }))),
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 = (await untilAborted(signal, () =>
1391
- page.$(normalizeSelector(opts.selector!)),
1392
- )) as ElementHandle | null;
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 = (await untilAborted(signal, () =>
1466
- page.$(normalizeSelector(target)),
1467
- )) as ElementHandle | null;
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 page = this.#requirePage();
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
- const selected: string[] = [];
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 page = this.#requirePage();
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> };
@@ -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 {