@oh-my-pi/pi-coding-agent 17.0.4 → 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 (112) hide show
  1. package/CHANGELOG.md +86 -43
  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/modes/components/status-line/component.d.ts +2 -3
  17. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  18. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  20. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  21. package/dist/types/modes/interactive-mode.d.ts +2 -0
  22. package/dist/types/modes/print-mode.d.ts +4 -0
  23. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/types.d.ts +2 -0
  26. package/dist/types/sdk.d.ts +2 -0
  27. package/dist/types/session/agent-session.d.ts +19 -1
  28. package/dist/types/session/messages.d.ts +6 -0
  29. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  30. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  31. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  32. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  33. package/dist/types/tools/gh.d.ts +5 -3
  34. package/dist/types/tools/hub/index.d.ts +1 -1
  35. package/dist/types/tools/hub/jobs.d.ts +1 -0
  36. package/dist/types/tools/hub/types.d.ts +2 -0
  37. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  38. package/dist/types/utils/git.d.ts +33 -0
  39. package/dist/types/web/search/providers/codex.d.ts +1 -1
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +150 -0
  42. package/src/advisor/advise-tool.ts +4 -0
  43. package/src/advisor/runtime.ts +38 -14
  44. package/src/async/job-manager.ts +3 -0
  45. package/src/cli/bench-cli.ts +8 -2
  46. package/src/cli/dry-balance-cli.ts +1 -0
  47. package/src/config/model-registry.ts +89 -8
  48. package/src/config/model-resolver.ts +78 -14
  49. package/src/config/models-config-schema.ts +1 -0
  50. package/src/config/settings.ts +3 -1
  51. package/src/dap/client.ts +168 -1
  52. package/src/dap/config.ts +51 -1
  53. package/src/dap/session.ts +575 -234
  54. package/src/dap/types.ts +6 -5
  55. package/src/discovery/agents.ts +2 -2
  56. package/src/extensibility/extensions/runner.ts +6 -4
  57. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  58. package/src/launch/broker.ts +34 -31
  59. package/src/launch/client.ts +7 -1
  60. package/src/launch/spawn-options.test.ts +31 -0
  61. package/src/launch/spawn-options.ts +17 -0
  62. package/src/lsp/types.ts +5 -1
  63. package/src/main.ts +17 -4
  64. package/src/mcp/transports/stdio.test.ts +9 -1
  65. package/src/modes/components/ask-dialog.ts +137 -73
  66. package/src/modes/components/status-line/component.ts +2 -2
  67. package/src/modes/components/status-line/segments.ts +20 -3
  68. package/src/modes/components/status-line/types.ts +3 -1
  69. package/src/modes/components/tool-execution.ts +5 -0
  70. package/src/modes/components/transcript-container.ts +23 -122
  71. package/src/modes/components/tree-selector.ts +10 -4
  72. package/src/modes/controllers/event-controller.ts +1 -2
  73. package/src/modes/controllers/input-controller.ts +1 -1
  74. package/src/modes/controllers/selector-controller.ts +29 -8
  75. package/src/modes/interactive-mode.ts +40 -8
  76. package/src/modes/noninteractive-dispose.test.ts +12 -1
  77. package/src/modes/print-mode.ts +21 -9
  78. package/src/modes/rpc/rpc-input.ts +38 -0
  79. package/src/modes/rpc/rpc-mode.ts +7 -2
  80. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  81. package/src/modes/types.ts +2 -0
  82. package/src/modes/utils/ui-helpers.ts +3 -2
  83. package/src/prompts/tools/browser.md +3 -2
  84. package/src/prompts/tools/debug.md +2 -7
  85. package/src/prompts/tools/github.md +6 -1
  86. package/src/prompts/tools/hub.md +4 -2
  87. package/src/prompts/tools/task-async-contract.md +1 -0
  88. package/src/prompts/tools/task.md +9 -2
  89. package/src/sdk.ts +38 -6
  90. package/src/session/agent-session.ts +395 -162
  91. package/src/session/messages.test.ts +91 -0
  92. package/src/session/messages.ts +248 -110
  93. package/src/slash-commands/builtin-registry.ts +1 -0
  94. package/src/task/executor.ts +59 -33
  95. package/src/task/index.ts +46 -9
  96. package/src/task/worktree.ts +10 -0
  97. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  98. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  99. package/src/tools/browser/cmux/socket-client.ts +139 -3
  100. package/src/tools/browser/run-cancellation.ts +4 -0
  101. package/src/tools/browser/tab-protocol.ts +2 -0
  102. package/src/tools/browser/tab-supervisor.ts +21 -11
  103. package/src/tools/browser/tab-worker.ts +199 -33
  104. package/src/tools/debug.ts +3 -0
  105. package/src/tools/gh.ts +40 -2
  106. package/src/tools/hub/index.ts +4 -1
  107. package/src/tools/hub/jobs.ts +42 -1
  108. package/src/tools/hub/types.ts +2 -0
  109. package/src/tools/tool-timeouts.ts +1 -1
  110. package/src/utils/git.ts +237 -0
  111. package/src/web/search/index.ts +9 -5
  112. package/src/web/search/providers/codex.ts +195 -99
@@ -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 {
package/src/tools/gh.ts CHANGED
@@ -249,6 +249,7 @@ const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "a
249
249
  const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]);
250
250
  const GITHUB_READONLY_OPS: ReadonlySet<string> = new Set([
251
251
  "repo_view",
252
+ "file_read",
252
253
  "search_issues",
253
254
  "search_prs",
254
255
  "search_code",
@@ -259,10 +260,11 @@ const GITHUB_READONLY_OPS: ReadonlySet<string> = new Set([
259
260
 
260
261
  const githubSchema = type({
261
262
  op: type(
262
- "'repo_view' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'",
263
+ "'repo_view' | 'file_read' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'",
263
264
  ).describe("github operation"),
264
265
  "repo?": type("string").describe("owner/repo"),
265
266
  "branch?": type("string").describe("branch"),
267
+ "path?": type("string").describe("repository-relative file path"),
266
268
  "pr?": type("string | string[]").describe("pr number, url, or branch"),
267
269
  "force?": type("boolean").describe("reset existing local branch"),
268
270
  "forceWithLease?": type("boolean").describe("force-with-lease push"),
@@ -2455,7 +2457,7 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
2455
2457
  const op = typeof rawOp === "string" ? rawOp : "";
2456
2458
  return GITHUB_READONLY_OPS.has(op) ? "read" : "exec";
2457
2459
  };
2458
- readonly summary = "Interact with GitHub issues, pull requests, and repositories";
2460
+ readonly summary = "Interact with GitHub repositories, files, pull requests, and Actions";
2459
2461
  readonly loadMode = "discoverable";
2460
2462
  readonly label = "GitHub";
2461
2463
  readonly description = prompt.render(githubDescription);
@@ -2480,6 +2482,8 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
2480
2482
  switch (params.op) {
2481
2483
  case "repo_view":
2482
2484
  return executeRepoView(this.session, params, signal);
2485
+ case "file_read":
2486
+ return executeFileRead(this.session, params, signal);
2483
2487
  case "pr_create":
2484
2488
  return executePrCreate(this.session, params, signal);
2485
2489
  case "pr_checkout":
@@ -2525,6 +2529,40 @@ async function executeRepoView(
2525
2529
  return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
2526
2530
  }
2527
2531
 
2532
+ async function executeFileRead(
2533
+ session: ToolSession,
2534
+ params: GithubInput,
2535
+ signal: AbortSignal | undefined,
2536
+ ): Promise<AgentToolResult<GhToolDetails>> {
2537
+ const repo = await resolveGitHubRepo(session.cwd, normalizeOptionalString(params.repo), undefined, signal);
2538
+ const filePath = requireNonEmpty(normalizeOptionalString(params.path), "path");
2539
+ if (filePath.startsWith("/")) {
2540
+ throw new ToolError("path must be repository-relative");
2541
+ }
2542
+ const branch = normalizeOptionalString(params.branch);
2543
+ const endpointPath = filePath
2544
+ .split("/")
2545
+ .map(segment => encodeURIComponent(segment))
2546
+ .join("/");
2547
+ const args = [
2548
+ "api",
2549
+ `/repos/${repo}/contents/${endpointPath}`,
2550
+ "--method",
2551
+ "GET",
2552
+ "-H",
2553
+ "Accept: application/vnd.github.raw+json",
2554
+ ];
2555
+ if (branch) {
2556
+ args.push("-f", `ref=${branch}`);
2557
+ }
2558
+ const text = await git.github.text(session.cwd, args, signal, {
2559
+ repoProvided: true,
2560
+ trimOutput: false,
2561
+ });
2562
+ const sourceUrl = `https://github.com/${repo}/blob/${encodeURIComponent(branch ?? "HEAD")}/${endpointPath}`;
2563
+ return buildTextResult(text, sourceUrl, { repo, branch });
2564
+ }
2565
+
2528
2566
  // ────────────────────────────────────────────────────────────────────────────
2529
2567
  // Cached issue/PR view fetchers
2530
2568
  //
@@ -159,7 +159,10 @@ export class HubTool implements AgentTool<typeof hubSchema, HubDetails> {
159
159
  readonly description: string;
160
160
  readonly parameters = hubSchema;
161
161
  readonly strict = true;
162
- readonly interruptible = true;
162
+ readonly interruptible = (params: Partial<HubParams>): boolean => {
163
+ if (params.op === "wait") return true;
164
+ return params.op === "logs" && params.follow === true;
165
+ };
163
166
  readonly loadMode = "essential";
164
167
 
165
168
  readonly examples: readonly ToolExample<typeof hubSchema.infer>[] = [
@@ -8,6 +8,7 @@ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
8
8
  import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import type { AsyncJob, AsyncJobManager } from "../../async";
11
+ import { settings } from "../../config/settings";
11
12
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
12
13
  import { shimmerEnabled, shimmerText } from "../../modes/theme/shimmer";
13
14
  import type { Theme } from "../../modes/theme/theme";
@@ -134,6 +135,7 @@ interface TrackedJobLike {
134
135
  status: string;
135
136
  label: string;
136
137
  startTime: number;
138
+ latestDetails?: Record<string, unknown>;
137
139
  resultText?: string;
138
140
  errorText?: string;
139
141
  }
@@ -143,12 +145,34 @@ export function snapshotJobs(session: ToolSession, jobs: TrackedJobLike[]): JobS
143
145
  return jobs.map(j => {
144
146
  const current = session.asyncJobManager?.getJob(j.id);
145
147
  const latest = current ?? j;
148
+ let resolvedModel: string | undefined;
149
+ if (latest.type === "task") {
150
+ const progressValue = latest.latestDetails?.progress;
151
+ if (Array.isArray(progressValue)) {
152
+ let progressRecord: Record<string, unknown> | undefined;
153
+ for (const item of progressValue) {
154
+ if (!item || typeof item !== "object") continue;
155
+ const candidate = item as Record<string, unknown>;
156
+ if (!progressRecord) progressRecord = candidate;
157
+ if (candidate.id === latest.id) {
158
+ progressRecord = candidate;
159
+ break;
160
+ }
161
+ }
162
+ const modelValue = progressRecord?.resolvedModel;
163
+ if (typeof modelValue === "string") {
164
+ const trimmed = modelValue.trim();
165
+ if (trimmed) resolvedModel = trimmed;
166
+ }
167
+ }
168
+ }
146
169
  return {
147
170
  id: latest.id,
148
171
  type: latest.type,
149
172
  status: latest.status as JobSnapshot["status"],
150
173
  label: latest.label,
151
174
  durationMs: Math.max(0, now - latest.startTime),
175
+ ...(resolvedModel ? { resolvedModel } : {}),
152
176
  ...(latest.resultText ? { resultText: latest.resultText } : {}),
153
177
  ...(latest.errorText ? { errorText: latest.errorText } : {}),
154
178
  };
@@ -354,6 +378,7 @@ const PREVIEW_LINES_EXPANDED = 4;
354
378
  const LABEL_LINES_COLLAPSED = 1;
355
379
  const LABEL_LINES_EXPANDED = 3;
356
380
  const PREVIEW_LINE_WIDTH = 80;
381
+ const MODEL_BADGE_MAX_WIDTH = 48;
357
382
 
358
383
  function statusToIcon(status: JobSnapshot["status"]): ToolUIStatus {
359
384
  switch (status) {
@@ -545,6 +570,20 @@ export function jobsRenderResult(
545
570
  visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
546
571
  }
547
572
  const durationText = uiTheme.fg("dim", formatDuration(job.durationMs));
573
+ const modelText =
574
+ job.type === "task" &&
575
+ typeof job.resolvedModel === "string" &&
576
+ job.resolvedModel.trim() &&
577
+ settings.get("task.showResolvedModelBadge")
578
+ ? `${uiTheme.sep.dot}${uiTheme.fg(
579
+ "dim",
580
+ truncateToWidth(
581
+ replaceTabs(job.resolvedModel.trim()),
582
+ MODEL_BADGE_MAX_WIDTH,
583
+ Ellipsis.Unicode,
584
+ ),
585
+ )}`
586
+ : "";
548
587
  // Running rows in a live block shimmer their label; once the block
549
588
  // stops animating (sealed, or a settled snapshot — spinnerFrame
550
589
  // cleared) they render static so scrollback never keeps a mid-sweep
@@ -556,7 +595,9 @@ export function jobsRenderResult(
556
595
  ? shimmerText(headRaw, uiTheme)
557
596
  : uiTheme.fg("accent", headRaw)
558
597
  : uiTheme.fg("toolOutput", headRaw);
559
- lines.push(`${icon}${idPart} ${typeBadge} ${headLabel} ${durationText}`);
598
+ lines.push(
599
+ `${icon}${idPart} ${typeBadge} ${headLabel}${modelText}${modelText ? uiTheme.sep.dot : " "}${durationText}`,
600
+ );
560
601
  for (let i = 1; i < visibleLabelLines.length; i++) {
561
602
  lines.push(` ${uiTheme.fg("toolOutput", visibleLabelLines[i]!)}`);
562
603
  }
@@ -46,6 +46,8 @@ export interface JobSnapshot {
46
46
  status: "running" | "completed" | "failed" | "cancelled";
47
47
  label: string;
48
48
  durationMs: number;
49
+ /** Effective task model selector, including an explicit reasoning suffix when configured. */
50
+ resolvedModel?: string;
49
51
  resultText?: string;
50
52
  errorText?: string;
51
53
  }
@@ -13,7 +13,7 @@ export const TOOL_TIMEOUTS = {
13
13
  browser: { default: 30, min: 1, max: 300 },
14
14
  ssh: { default: 60, min: 1, max: 3600 },
15
15
  fetch: { default: 20, min: 1, max: 45 },
16
- lsp: { default: 20, min: 5, max: 60 },
16
+ lsp: { default: 20, min: 5, max: 300 },
17
17
  debug: { default: 30, min: 5, max: 300 },
18
18
  } as const satisfies Record<string, ToolTimeoutConfig>;
19
19