@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.1

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 (230) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/scripts/format-prompts.ts +1 -1
  6. package/src/autoresearch/helpers.ts +17 -0
  7. package/src/autoresearch/tools/log-experiment.ts +9 -17
  8. package/src/autoresearch/tools/run-experiment.ts +2 -17
  9. package/src/capability/skill.ts +7 -0
  10. package/src/cli/args.ts +2 -2
  11. package/src/cli/list-models.ts +1 -1
  12. package/src/cli/shell-cli.ts +3 -13
  13. package/src/cli/update-cli.ts +1 -1
  14. package/src/cli.ts +11 -29
  15. package/src/commands/acp.ts +24 -0
  16. package/src/commands/launch.ts +6 -4
  17. package/src/commit/agentic/prompts/system.md +1 -1
  18. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  19. package/src/commit/analysis/conventional.ts +8 -66
  20. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  21. package/src/commit/pipeline.ts +2 -2
  22. package/src/commit/shared-llm.ts +89 -0
  23. package/src/config/config-file.ts +210 -0
  24. package/src/config/model-equivalence.ts +8 -11
  25. package/src/config/model-registry.ts +13 -2
  26. package/src/config/model-resolver.ts +31 -4
  27. package/src/config/settings-schema.ts +102 -1
  28. package/src/config/settings.ts +1 -1
  29. package/src/config.ts +3 -219
  30. package/src/edit/index.ts +22 -1
  31. package/src/edit/modes/patch.ts +10 -0
  32. package/src/edit/modes/replace.ts +3 -0
  33. package/src/edit/renderer.ts +17 -1
  34. package/src/eval/js/context-manager.ts +1 -1
  35. package/src/eval/js/executor.ts +3 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +122 -50
  37. package/src/eval/js/shared/runtime.ts +31 -4
  38. package/src/eval/js/tool-bridge.ts +43 -21
  39. package/src/eval/py/executor.ts +5 -0
  40. package/src/exa/factory.ts +2 -2
  41. package/src/exa/mcp-client.ts +74 -1
  42. package/src/exec/bash-executor.ts +5 -1
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +0 -11
  45. package/src/extensibility/extensions/runner.ts +55 -2
  46. package/src/extensibility/extensions/types.ts +98 -221
  47. package/src/extensibility/hooks/types.ts +89 -314
  48. package/src/extensibility/shared-events.ts +343 -0
  49. package/src/extensibility/skills.ts +42 -1
  50. package/src/goals/index.ts +3 -0
  51. package/src/goals/runtime.ts +500 -0
  52. package/src/goals/state.ts +37 -0
  53. package/src/goals/tools/goal-tool.ts +237 -0
  54. package/src/hashline/anchors.ts +2 -2
  55. package/src/hindsight/mental-models.ts +1 -1
  56. package/src/internal-urls/agent-protocol.ts +1 -20
  57. package/src/internal-urls/artifact-protocol.ts +1 -19
  58. package/src/internal-urls/docs-index.generated.ts +9 -10
  59. package/src/internal-urls/index.ts +1 -0
  60. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  61. package/src/internal-urls/registry-helpers.ts +25 -0
  62. package/src/internal-urls/router.ts +6 -3
  63. package/src/internal-urls/types.ts +22 -1
  64. package/src/main.ts +24 -11
  65. package/src/mcp/oauth-flow.ts +20 -0
  66. package/src/modes/acp/acp-agent.ts +412 -71
  67. package/src/modes/acp/acp-client-bridge.ts +152 -0
  68. package/src/modes/acp/acp-event-mapper.ts +180 -15
  69. package/src/modes/acp/terminal-auth.ts +37 -0
  70. package/src/modes/components/assistant-message.ts +14 -8
  71. package/src/modes/components/bash-execution.ts +24 -63
  72. package/src/modes/components/custom-message.ts +14 -40
  73. package/src/modes/components/eval-execution.ts +27 -57
  74. package/src/modes/components/execution-shared.ts +102 -0
  75. package/src/modes/components/hook-message.ts +17 -49
  76. package/src/modes/components/mcp-add-wizard.ts +26 -5
  77. package/src/modes/components/message-frame.ts +88 -0
  78. package/src/modes/components/model-selector.ts +1 -1
  79. package/src/modes/components/read-tool-group.ts +29 -1
  80. package/src/modes/components/session-observer-overlay.ts +6 -2
  81. package/src/modes/components/session-selector.ts +1 -1
  82. package/src/modes/components/status-line/segments.ts +55 -4
  83. package/src/modes/components/status-line/types.ts +4 -0
  84. package/src/modes/components/status-line.ts +28 -10
  85. package/src/modes/components/tool-execution.ts +7 -8
  86. package/src/modes/controllers/command-controller-shared.ts +108 -0
  87. package/src/modes/controllers/command-controller.ts +27 -10
  88. package/src/modes/controllers/event-controller.ts +60 -18
  89. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  90. package/src/modes/controllers/input-controller.ts +85 -39
  91. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  92. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  93. package/src/modes/interactive-mode.ts +675 -39
  94. package/src/modes/print-mode.ts +16 -86
  95. package/src/modes/rpc/rpc-mode.ts +30 -88
  96. package/src/modes/runtime-init.ts +115 -0
  97. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  98. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  99. package/src/modes/theme/theme.ts +18 -6
  100. package/src/modes/types.ts +20 -5
  101. package/src/modes/utils/context-usage.ts +13 -13
  102. package/src/modes/utils/ui-helpers.ts +25 -6
  103. package/src/plan-mode/approved-plan.ts +35 -1
  104. package/src/prompts/agents/designer.md +5 -5
  105. package/src/prompts/agents/explore.md +7 -7
  106. package/src/prompts/agents/init.md +9 -9
  107. package/src/prompts/agents/librarian.md +14 -14
  108. package/src/prompts/agents/plan.md +4 -4
  109. package/src/prompts/agents/reviewer.md +5 -5
  110. package/src/prompts/agents/task.md +10 -10
  111. package/src/prompts/commands/orchestrate.md +2 -2
  112. package/src/prompts/compaction/branch-summary.md +3 -3
  113. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  114. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  115. package/src/prompts/compaction/compaction-summary.md +5 -5
  116. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  117. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  118. package/src/prompts/goals/goal-budget-limit.md +16 -0
  119. package/src/prompts/goals/goal-continuation.md +28 -0
  120. package/src/prompts/goals/goal-mode-active.md +23 -0
  121. package/src/prompts/memories/consolidation.md +2 -2
  122. package/src/prompts/memories/read-path.md +1 -1
  123. package/src/prompts/memories/stage_one_input.md +1 -1
  124. package/src/prompts/memories/stage_one_system.md +5 -5
  125. package/src/prompts/review-request.md +4 -4
  126. package/src/prompts/system/agent-creation-architect.md +17 -17
  127. package/src/prompts/system/agent-creation-user.md +2 -2
  128. package/src/prompts/system/commit-message-system.md +2 -2
  129. package/src/prompts/system/custom-system-prompt.md +2 -2
  130. package/src/prompts/system/eager-todo.md +6 -6
  131. package/src/prompts/system/handoff-document.md +1 -1
  132. package/src/prompts/system/plan-mode-active.md +25 -24
  133. package/src/prompts/system/plan-mode-approved.md +4 -4
  134. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  135. package/src/prompts/system/plan-mode-reference.md +2 -2
  136. package/src/prompts/system/plan-mode-subagent.md +8 -8
  137. package/src/prompts/system/plan-mode-tool-decision-reminder.md +3 -3
  138. package/src/prompts/system/project-prompt.md +4 -4
  139. package/src/prompts/system/subagent-system-prompt.md +7 -7
  140. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  141. package/src/prompts/system/system-prompt.md +72 -71
  142. package/src/prompts/system/ttsr-interrupt.md +1 -1
  143. package/src/prompts/tools/apply-patch.md +1 -1
  144. package/src/prompts/tools/ast-edit.md +3 -3
  145. package/src/prompts/tools/ast-grep.md +3 -3
  146. package/src/prompts/tools/bash.md +6 -0
  147. package/src/prompts/tools/browser.md +3 -3
  148. package/src/prompts/tools/checkpoint.md +3 -3
  149. package/src/prompts/tools/find.md +3 -3
  150. package/src/prompts/tools/github.md +2 -5
  151. package/src/prompts/tools/goal.md +13 -0
  152. package/src/prompts/tools/hashline.md +104 -116
  153. package/src/prompts/tools/image-gen.md +3 -3
  154. package/src/prompts/tools/irc.md +1 -1
  155. package/src/prompts/tools/lsp.md +2 -2
  156. package/src/prompts/tools/patch.md +6 -6
  157. package/src/prompts/tools/read.md +8 -7
  158. package/src/prompts/tools/replace.md +5 -5
  159. package/src/prompts/tools/resolve.md +6 -5
  160. package/src/prompts/tools/retain.md +1 -1
  161. package/src/prompts/tools/rewind.md +2 -2
  162. package/src/prompts/tools/search.md +2 -2
  163. package/src/prompts/tools/ssh.md +2 -2
  164. package/src/prompts/tools/task.md +12 -6
  165. package/src/prompts/tools/web-search.md +2 -2
  166. package/src/prompts/tools/write.md +3 -3
  167. package/src/sdk.ts +81 -17
  168. package/src/session/agent-session.ts +656 -125
  169. package/src/session/blob-store.ts +36 -3
  170. package/src/session/client-bridge.ts +81 -0
  171. package/src/session/compaction/errors.ts +31 -0
  172. package/src/session/compaction/index.ts +1 -0
  173. package/src/session/messages.ts +67 -2
  174. package/src/session/session-manager.ts +131 -12
  175. package/src/session/session-storage.ts +33 -15
  176. package/src/session/streaming-output.ts +309 -13
  177. package/src/slash-commands/acp-builtins.ts +46 -0
  178. package/src/slash-commands/builtin-registry.ts +717 -116
  179. package/src/slash-commands/helpers/context-report.ts +39 -0
  180. package/src/slash-commands/helpers/format.ts +23 -0
  181. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  182. package/src/slash-commands/helpers/mcp.ts +532 -0
  183. package/src/slash-commands/helpers/parse.ts +85 -0
  184. package/src/slash-commands/helpers/ssh.ts +193 -0
  185. package/src/slash-commands/helpers/todo.ts +279 -0
  186. package/src/slash-commands/helpers/usage-report.ts +91 -0
  187. package/src/slash-commands/types.ts +126 -0
  188. package/src/ssh/ssh-executor.ts +5 -0
  189. package/src/system-prompt.ts +4 -2
  190. package/src/task/executor.ts +27 -10
  191. package/src/task/index.ts +20 -1
  192. package/src/task/render.ts +27 -18
  193. package/src/task/types.ts +4 -0
  194. package/src/tools/ast-edit.ts +21 -120
  195. package/src/tools/ast-grep.ts +21 -119
  196. package/src/tools/bash-interactive.ts +9 -1
  197. package/src/tools/bash.ts +203 -6
  198. package/src/tools/browser/attach.ts +3 -3
  199. package/src/tools/browser/launch.ts +81 -18
  200. package/src/tools/browser/registry.ts +1 -5
  201. package/src/tools/browser/tab-supervisor.ts +51 -14
  202. package/src/tools/conflict-detect.ts +21 -10
  203. package/src/tools/eval.ts +3 -1
  204. package/src/tools/fetch.ts +15 -4
  205. package/src/tools/find.ts +39 -39
  206. package/src/tools/gh-renderer.ts +0 -12
  207. package/src/tools/gh.ts +689 -182
  208. package/src/tools/github-cache.ts +548 -0
  209. package/src/tools/index.ts +25 -11
  210. package/src/tools/inspect-image.ts +3 -10
  211. package/src/tools/output-meta.ts +176 -37
  212. package/src/tools/path-utils.ts +125 -2
  213. package/src/tools/read.ts +605 -239
  214. package/src/tools/render-utils.ts +92 -0
  215. package/src/tools/renderers.ts +2 -0
  216. package/src/tools/resolve.ts +72 -44
  217. package/src/tools/search.ts +120 -186
  218. package/src/tools/write.ts +67 -10
  219. package/src/tui/code-cell.ts +70 -2
  220. package/src/utils/file-mentions.ts +1 -1
  221. package/src/utils/image-loading.ts +7 -3
  222. package/src/utils/image-resize.ts +32 -43
  223. package/src/vim/parser.ts +0 -17
  224. package/src/vim/render.ts +1 -1
  225. package/src/vim/types.ts +1 -1
  226. package/src/web/search/providers/gemini.ts +35 -95
  227. package/src/prompts/tools/exit-plan-mode.md +0 -6
  228. package/src/tools/exit-plan-mode.ts +0 -97
  229. package/src/utils/fuzzy.ts +0 -108
  230. package/src/utils/image-convert.ts +0 -27
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { $which, getPuppeteerDir, logger } from "@oh-my-pi/pi-utils";
5
5
  import * as browsers from "@puppeteer/browsers";
6
- import type { Browser, CDPSession, Page, default as Puppeteer } from "puppeteer-core";
6
+ import type { Browser, CDPSession, Page, default as Puppeteer, Target } from "puppeteer-core";
7
7
  import { PUPPETEER_REVISIONS } from "puppeteer-core/internal/revisions.js";
8
8
  import stealthTamperingScript from "../puppeteer/00_stealth_tampering.txt" with { type: "text" };
9
9
  import stealthActivityScript from "../puppeteer/01_stealth_activity.txt" with { type: "text" };
@@ -30,13 +30,15 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
30
30
  * connection dropped, etc.).
31
31
  */
32
32
  export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
33
- export const STEALTH_IGNORE_DEFAULT_ARGS = [
33
+ const STEALTH_IGNORE_DEFAULT_ARGS = [
34
34
  "--disable-extensions",
35
35
  "--disable-default-apps",
36
36
  "--disable-component-extensions-with-background-pages",
37
37
  ];
38
- export const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
38
+ const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
39
39
 
40
+ const USER_AGENT_TARGET_TIMEOUT_MS = 5_000;
41
+ const USER_AGENT_TARGET_TYPES = new Set(["page", "webview", "background_page"]);
40
42
  const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
41
43
 
42
44
  /**
@@ -82,7 +84,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Pup
82
84
  * The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
83
85
  */
84
86
  let chromiumExecutablePromise: Promise<string | undefined> | undefined;
85
- export async function ensureChromiumExecutable(): Promise<string | undefined> {
87
+ async function ensureChromiumExecutable(): Promise<string | undefined> {
86
88
  const sysChrome = resolveSystemChromium();
87
89
  if (sysChrome) return sysChrome;
88
90
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -138,7 +140,7 @@ export async function ensureChromiumExecutable(): Promise<string | undefined> {
138
140
  return chromiumExecutablePromise;
139
141
  }
140
142
 
141
- let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
143
+ let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
142
144
 
143
145
  function isExecutableFile(p: string): boolean {
144
146
  try {
@@ -209,19 +211,19 @@ function systemChromiumCandidates(): string[] {
209
211
  return candidates;
210
212
  }
211
213
 
212
- export function resolveSystemChromium(): string | undefined {
213
- if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
214
+ function resolveSystemChromium(): string | undefined {
215
+ if (resolvedChromium !== undefined) return resolvedChromium ?? undefined;
214
216
  const seen = new Set<string>();
215
217
  for (const candidate of systemChromiumCandidates()) {
216
218
  if (!candidate || seen.has(candidate)) continue;
217
219
  seen.add(candidate);
218
220
  if (isExecutableFile(candidate)) {
219
- _resolvedChromium = candidate;
221
+ resolvedChromium = candidate;
220
222
  logger.debug("Using system Chrome/Chromium", { path: candidate });
221
223
  return candidate;
222
224
  }
223
225
  }
224
- _resolvedChromium = null;
226
+ resolvedChromium = null;
225
227
  return undefined;
226
228
  }
227
229
 
@@ -463,6 +465,7 @@ export interface UserAgentSession {
463
465
  async function configureUserAgentTargets(
464
466
  browser: Browser,
465
467
  state: { browserSession: CDPSession | null; override: UserAgentOverride },
468
+ targetTimeoutMs = USER_AGENT_TARGET_TIMEOUT_MS,
466
469
  ): Promise<void> {
467
470
  if (!state.browserSession) {
468
471
  state.browserSession = await browser.target().createCDPSession();
@@ -471,23 +474,72 @@ async function configureUserAgentTargets(
471
474
  waitForDebuggerOnStart: false,
472
475
  flatten: true,
473
476
  });
474
- state.browserSession.on("Target.attachedToTarget", async (event: { sessionId: string }) => {
475
- const connection = state.browserSession?.connection();
476
- const session = connection?.session(event.sessionId);
477
- if (!session) return;
478
- await sendUserAgentOverride(wrapSession(session), state.override);
479
- });
477
+ state.browserSession.on(
478
+ "Target.attachedToTarget",
479
+ async (event: { sessionId: string; targetInfo?: { type?: string } }) => {
480
+ if (!targetInfoSupportsUserAgentOverride(event.targetInfo)) return;
481
+ const connection = state.browserSession?.connection();
482
+ const session = connection?.session(event.sessionId);
483
+ if (!session) return;
484
+ await withSoftTimeout(
485
+ sendUserAgentOverride(wrapSession(session), state.override),
486
+ targetTimeoutMs,
487
+ "new target user-agent override",
488
+ );
489
+ },
490
+ );
480
491
  }
481
492
 
482
- const targets = browser.targets();
493
+ const targets = browser.targets().filter(targetSupportsUserAgentOverride);
483
494
  await Promise.all(
484
495
  targets.map(async target => {
485
- const session = await target.createCDPSession();
486
- await sendUserAgentOverride(wrapSession(session), state.override);
496
+ await withSoftTimeout(
497
+ applyTargetUserAgentOverride(target, state.override),
498
+ targetTimeoutMs,
499
+ "target user-agent override",
500
+ );
487
501
  }),
488
502
  );
489
503
  }
490
504
 
505
+ function targetSupportsUserAgentOverride(target: Target): boolean {
506
+ return targetInfoSupportsUserAgentOverride({ type: target.type() });
507
+ }
508
+
509
+ function targetInfoSupportsUserAgentOverride(targetInfo: { type?: string } | undefined): boolean {
510
+ return Boolean(targetInfo?.type && USER_AGENT_TARGET_TYPES.has(targetInfo.type));
511
+ }
512
+
513
+ async function applyTargetUserAgentOverride(target: Target, override: UserAgentOverride): Promise<void> {
514
+ const session = await target.createCDPSession();
515
+ try {
516
+ await sendUserAgentOverride(wrapSession(session), override);
517
+ } finally {
518
+ await session.detach().catch(() => undefined);
519
+ }
520
+ }
521
+
522
+ async function withSoftTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T | undefined> {
523
+ let timeout: NodeJS.Timeout | undefined;
524
+ const timeoutPromise = new Promise<undefined>(resolve => {
525
+ timeout = setTimeout(() => {
526
+ logger.debug(`Timed out applying ${label}`);
527
+ resolve(undefined);
528
+ }, timeoutMs);
529
+ });
530
+ try {
531
+ return await Promise.race([
532
+ promise.catch(error => {
533
+ logger.debug(`Failed to apply ${label}`, { error: error instanceof Error ? error.message : String(error) });
534
+ return undefined;
535
+ }),
536
+ timeoutPromise,
537
+ ]);
538
+ } finally {
539
+ if (timeout) clearTimeout(timeout);
540
+ }
541
+ }
542
+
491
543
  async function injectStealthScripts(page: Page): Promise<void> {
492
544
  const scripts = [
493
545
  stealthTamperingScript,
@@ -574,3 +626,14 @@ export async function applyStealthPatches(
574
626
  state.browserSession = targetState.browserSession;
575
627
  await injectStealthScripts(page);
576
628
  }
629
+
630
+ export function targetSupportsUserAgentOverrideForTest(target: Target): boolean {
631
+ return targetSupportsUserAgentOverride(target);
632
+ }
633
+ export async function configureUserAgentTargetsForTest(
634
+ browser: Browser,
635
+ state: { browserSession: CDPSession | null; override: UserAgentOverride },
636
+ targetTimeoutMs?: number,
637
+ ): Promise<void> {
638
+ await configureUserAgentTargets(browser, state, targetTimeoutMs);
639
+ }
@@ -26,10 +26,6 @@ export interface BrowserHandle {
26
26
 
27
27
  const browsers = new Map<string, BrowserHandle>();
28
28
 
29
- export function listBrowsers(): BrowserHandle[] {
30
- return [...browsers.values()];
31
- }
32
-
33
29
  function browserKey(kind: BrowserKind): string {
34
30
  switch (kind.kind) {
35
31
  case "headless":
@@ -166,7 +162,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
166
162
  }
167
163
  }
168
164
 
169
- export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
165
+ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
170
166
  if (handle.kind.kind === "headless") {
171
167
  if (handle.browser.connected) {
172
168
  try {
@@ -30,6 +30,7 @@ import type {
30
30
  interface WorkerHandle {
31
31
  send(msg: WorkerInbound, transferList?: Transferable[]): void;
32
32
  onMessage(handler: (msg: WorkerOutbound) => void): () => void;
33
+ onError(handler: (error: Error) => void): () => void;
33
34
  terminate(): Promise<void>;
34
35
  readonly mode: "worker" | "inline";
35
36
  }
@@ -89,10 +90,6 @@ export function getTab(name: string): TabSession | undefined {
89
90
  return tabs.get(name);
90
91
  }
91
92
 
92
- export function listTabs(): TabSession[] {
93
- return [...tabs.values()];
94
- }
95
-
96
93
  export async function acquireTab(
97
94
  name: string,
98
95
  browser: BrowserHandle,
@@ -124,23 +121,14 @@ export async function acquireTab(
124
121
 
125
122
  const initPayload = await buildInitPayload(browser, opts);
126
123
  const worker = await spawnTabWorker();
127
- const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
128
- const unlisten = worker.onMessage(msg => {
129
- if (msg.type === "ready") resolve(msg.info);
130
- else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
131
- else if (msg.type === "log") logWorkerMessage(msg);
132
- });
133
124
  let info: ReadyInfo;
134
125
  try {
135
- worker.send({ type: "init", payload: initPayload });
136
- info = await raceWithTimeout(promise, opts.timeoutMs + GRACE_MS, "Timed out initializing browser tab worker");
126
+ info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
137
127
  } catch (error) {
138
- unlisten();
139
128
  await worker.terminate().catch(() => undefined);
140
129
  if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
141
130
  throw error;
142
131
  }
143
- unlisten();
144
132
 
145
133
  holdBrowser(browser);
146
134
  const tab: TabSession = {
@@ -477,6 +465,17 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
477
465
  worker.addEventListener("message", wrap);
478
466
  return () => worker.removeEventListener("message", wrap);
479
467
  },
468
+ onError(handler) {
469
+ const onError = (event: ErrorEvent): void => handler(errorFromWorkerEvent(event));
470
+ const onMessageError = (event: MessageEvent): void =>
471
+ handler(new ToolError(`Tab worker message error: ${String(event.data)}`));
472
+ worker.addEventListener("error", onError);
473
+ worker.addEventListener("messageerror", onMessageError);
474
+ return () => {
475
+ worker.removeEventListener("error", onError);
476
+ worker.removeEventListener("messageerror", onMessageError);
477
+ };
478
+ },
480
479
  async terminate() {
481
480
  worker.terminate();
482
481
  },
@@ -515,6 +514,44 @@ async function spawnInlineWorker(): Promise<WorkerHandle> {
515
514
  hostListeners.add(handler);
516
515
  return () => hostListeners.delete(handler);
517
516
  },
517
+ onError: () => () => {},
518
518
  async terminate() {},
519
519
  };
520
520
  }
521
+
522
+ async function initializeTabWorker(
523
+ worker: WorkerHandle,
524
+ payload: WorkerInitPayload,
525
+ timeoutMs: number,
526
+ ): Promise<ReadyInfo> {
527
+ const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
528
+ const unlisten = worker.onMessage(msg => {
529
+ if (msg.type === "ready") resolve(msg.info);
530
+ else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
531
+ else if (msg.type === "log") logWorkerMessage(msg);
532
+ });
533
+ const unlistenError = worker.onError(error => {
534
+ reject(new ToolError(`Tab worker failed during startup: ${error.message}`));
535
+ });
536
+ try {
537
+ worker.send({ type: "init", payload });
538
+ return await raceWithTimeout(promise, timeoutMs, "Timed out initializing browser tab worker");
539
+ } finally {
540
+ unlisten();
541
+ unlistenError();
542
+ }
543
+ }
544
+
545
+ export function initializeTabWorkerForTest(
546
+ worker: WorkerHandle,
547
+ payload: WorkerInitPayload,
548
+ timeoutMs: number,
549
+ ): Promise<ReadyInfo> {
550
+ return initializeTabWorker(worker, payload, timeoutMs);
551
+ }
552
+
553
+ function errorFromWorkerEvent(event: ErrorEvent): Error {
554
+ if (event.error instanceof Error) return event.error;
555
+ if (event.message) return new Error(event.message);
556
+ return new Error("Unknown tab worker error");
557
+ }
@@ -240,7 +240,7 @@ export function getConflictHistory(session: ToolSession): ConflictHistory {
240
240
  return session.conflictHistory;
241
241
  }
242
242
 
243
- /** A side of a conflict block that `read conflict://N/<scope>` can render. */
243
+ /** A side of a conflict block that the `read` tool can render via `conflict://N/<scope>`. */
244
244
  export type ConflictScope = "ours" | "theirs" | "base";
245
245
 
246
246
  const CONFLICT_SCOPES = new Set<ConflictScope>(["ours", "theirs", "base"]);
@@ -250,9 +250,19 @@ export interface ParsedConflictUri {
250
250
  /** `"*"` selects every currently-registered conflict (bulk write only). */
251
251
  id: number | "*";
252
252
  scope?: ConflictScope;
253
+ /**
254
+ * When `raw` was a malformed `<file-prefix>:conflict://…` path, the
255
+ * stripped prefix is preserved here so callers can surface a gentle
256
+ * "you don't need the file path" note. `undefined` for clean URIs.
257
+ */
258
+ recoveredPrefix?: string;
253
259
  }
254
260
 
255
- const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/;
261
+ // Accept an optional `<prefix>:` before the scheme so paths like
262
+ // `path/to/file.ts:conflict://3` (where the agent mixed the `:conflicts`
263
+ // read selector with the `conflict://` scheme) still resolve. The prefix
264
+ // is greedy so the LAST `:conflict://` wins for multi-colon inputs.
265
+ const CONFLICT_URI_RE = /^(?:(.+):)?conflict:\/\/(.+)$/;
256
266
 
257
267
  /**
258
268
  * Parse a `conflict://<N>`, `conflict://<N>/<scope>`, or `conflict://*` URI.
@@ -269,7 +279,8 @@ const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/;
269
279
  export function parseConflictUri(raw: string): ParsedConflictUri | null {
270
280
  const match = raw.match(CONFLICT_URI_RE);
271
281
  if (!match) return null;
272
- const tail = match[1];
282
+ const recoveredPrefix = match[1];
283
+ const tail = match[2];
273
284
  const slashIdx = tail.indexOf("/");
274
285
  const idPart = slashIdx === -1 ? tail : tail.slice(0, slashIdx);
275
286
  const scopePart = slashIdx === -1 ? undefined : tail.slice(slashIdx + 1);
@@ -280,7 +291,7 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
280
291
  `Invalid conflict URI '${raw}': wildcard 'conflict://*' does not accept a scope segment. Drop '/${scopePart}' or use a numeric id.`,
281
292
  );
282
293
  }
283
- return { id: "*" };
294
+ return recoveredPrefix !== undefined ? { id: "*", recoveredPrefix } : { id: "*" };
284
295
  }
285
296
 
286
297
  if (!/^\d+$/.test(idPart)) {
@@ -303,7 +314,7 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
303
314
  scope = scopePart as ConflictScope;
304
315
  }
305
316
 
306
- return { id, scope };
317
+ return recoveredPrefix !== undefined ? { id, scope, recoveredPrefix } : { id, scope };
307
318
  }
308
319
 
309
320
  /**
@@ -440,7 +451,7 @@ function markerLine(prefix: string, label: string | undefined): string {
440
451
  }
441
452
 
442
453
  /**
443
- * Materialise a conflict block for `read conflict://<N>` (and its
454
+ * Materialise a conflict block for `conflict://<N>` reads (and their
444
455
  * `/ours` / `/theirs` / `/base` scopes).
445
456
  *
446
457
  * Returns:
@@ -534,7 +545,7 @@ export function formatConflictWarning(
534
545
  if (partial) {
535
546
  const hintPath = options.displayPath ?? "<file>";
536
547
  out.push(
537
- `⚠ ${entries.length} of ${total} unresolved ${word} visible in this window (run \`read ${hintPath}:conflicts\` for the full list).`,
548
+ `⚠ ${entries.length} of ${total} unresolved ${word} visible in this window (read \`${hintPath}:conflicts\` for the full list).`,
538
549
  );
539
550
  } else {
540
551
  out.push(`⚠ ${total} unresolved ${word} detected`);
@@ -551,7 +562,7 @@ export function formatConflictWarning(
551
562
  if (theirsLabel) out.push(`- theirs = ${theirsLabel}`);
552
563
  if (anyBase) out.push(`- base = ${baseLabel ?? "(no label)"}`);
553
564
  out.push(
554
- 'NOTICE: Inspect a block with `read conflict://<N>` (add `/ours` / `/theirs` / `/base` to render a single side). Resolve with `write({ path: "conflict://<N>", content })`, or bulk-resolve every registered conflict with `write({ path: "conflict://*", content })`. Writes replace the whole conflict region (markers + all sides).',
565
+ 'NOTICE: Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` to render a single side). Resolve with `write({ path: "conflict://<N>", content })`, or bulk-resolve every registered conflict with `write({ path: "conflict://*", content })`. Writes replace the whole conflict region (markers + all sides).',
555
566
  );
556
567
  out.push(
557
568
  '`content` shorthand: a line that is exactly `@ours` / `@theirs` / `@base` / `@both` expands to that recorded section. `@both` is ours-then-theirs with no separator. Lines that are not a token pass through verbatim, so `"// keep both\\n@ours\\n@theirs"` literally writes the comment, then ours, then theirs.',
@@ -592,7 +603,7 @@ export function formatConflictWarning(
592
603
 
593
604
  /**
594
605
  * Render a single-line-per-block index of every conflict in a file.
595
- * Used by `read <path>:conflicts` to give the agent a cheap overview
606
+ * Used by the `<path>:conflicts` read selector to give the agent a cheap overview
596
607
  * of a heavily-conflicted file without dumping every body.
597
608
  */
598
609
  export function formatConflictSummary(
@@ -614,7 +625,7 @@ export function formatConflictSummary(
614
625
  if (theirsLabel) lines.push(`- theirs = ${theirsLabel}`);
615
626
  if (anyBase) lines.push(`- base = ${baseLabel ?? "(no label)"}`);
616
627
  lines.push(
617
- 'NOTICE: Bulk-resolve with `write({ path: "conflict://*", content })`, or address a single block with `write({ path: "conflict://<N>", content })`. Inspect a block with `read conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).',
628
+ 'NOTICE: Bulk-resolve with `write({ path: "conflict://*", content })`, or address a single block with `write({ path: "conflict://<N>", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).',
618
629
  );
619
630
  lines.push(
620
631
  "`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.",
package/src/tools/eval.ts CHANGED
@@ -16,7 +16,7 @@ import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
16
16
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
17
17
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
18
18
  import { resolveEvalBackends, type ToolSession } from ".";
19
- import { formatStyledTruncationWarning } from "./output-meta";
19
+ import { formatStyledTruncationWarning, resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
20
20
  import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
21
21
  import { ToolAbortError, ToolError } from "./tool-errors";
22
22
  import { toolResult } from "./tool-result";
@@ -358,6 +358,8 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
358
358
  outputSink = new OutputSink({
359
359
  artifactPath,
360
360
  artifactId,
361
+ headBytes: resolveOutputSinkHeadBytes(session.settings),
362
+ maxColumns: resolveOutputMaxColumns(session.settings),
361
363
  onChunk: chunk => {
362
364
  appendTail(chunk);
363
365
  pushUpdate();
@@ -22,7 +22,7 @@ import { finalizeOutput, loadPage, looksLikeHtml, MAX_OUTPUT_CHARS } from "../we
22
22
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
23
23
  import { applyListLimit } from "./list-limit";
24
24
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
25
- import { formatExpandHint, getDomain } from "./render-utils";
25
+ import { formatExpandHint, getDomain, replaceTabs } from "./render-utils";
26
26
  import { ToolAbortError, ToolError } from "./tool-errors";
27
27
  import { toolResult } from "./tool-result";
28
28
  import { clampTimeout } from "./tool-timeouts";
@@ -1362,14 +1362,25 @@ export function renderReadUrlCall(
1362
1362
 
1363
1363
  /** Render URL read result with tree-based layout */
1364
1364
  export function renderReadUrlResult(
1365
- result: { content: Array<{ type: string; text?: string }>; details?: ReadUrlToolDetails },
1365
+ result: { content: Array<{ type: string; text?: string }>; details?: ReadUrlToolDetails; isError?: boolean },
1366
1366
  options: RenderResultOptions,
1367
1367
  uiTheme: Theme = theme,
1368
1368
  ): Component {
1369
1369
  const details = result.details;
1370
1370
 
1371
- if (!details) {
1372
- return new Text(uiTheme.fg("error", "No response data"), 0, 0);
1371
+ if (result.isError || !details) {
1372
+ const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
1373
+ const errorText = (rawErrorText || "No response data").replace(/^Error:\s*/, "");
1374
+ const urlText = details?.finalUrl ?? details?.url ?? "";
1375
+ const description = urlText ? `${getDomain(urlText)}${urlText.replace(/^https?:\/\/[^/]+/, "")}` : undefined;
1376
+ const header = renderStatusLine({ icon: "error", title: "Read", description }, uiTheme);
1377
+ const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
1378
+ const outputBlock = new CachedOutputBlock();
1379
+ return {
1380
+ render: (width: number) =>
1381
+ outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
1382
+ invalidate: () => outputBlock.invalidate(),
1383
+ };
1373
1384
  }
1374
1385
 
1375
1386
  const domain = getDomain(details.finalUrl);
package/src/tools/find.ts CHANGED
@@ -8,30 +8,30 @@ import { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import type { Static } from "@sinclair/typebox";
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
+ import { InternalUrlRouter } from "../internal-urls";
11
12
  import type { Theme } from "../modes/theme/theme";
12
13
  import findDescription from "../prompts/tools/find.md" with { type: "text" };
13
14
  import { type TruncationResult, truncateHead } from "../session/streaming-output";
14
- import {
15
- Ellipsis,
16
- Hasher,
17
- type RenderCache,
18
- renderFileList,
19
- renderStatusLine,
20
- renderTreeList,
21
- truncateToWidth,
22
- } from "../tui";
15
+ import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
23
16
  import type { ToolSession } from ".";
24
17
  import { applyListLimit } from "./list-limit";
25
18
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
26
19
  import {
27
20
  formatPathRelativeToCwd,
21
+ hasGlobPathChars,
28
22
  normalizePathLikeInput,
29
23
  parseFindPattern,
30
24
  partitionExistingPaths,
31
25
  resolveExplicitFindPatterns,
32
26
  resolveToCwd,
33
27
  } from "./path-utils";
34
- import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
28
+ import {
29
+ createCachedComponent,
30
+ formatCount,
31
+ formatEmptyMessage,
32
+ formatErrorMessage,
33
+ PREVIEW_LIMITS,
34
+ } from "./render-utils";
35
35
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
36
36
  import { toolResult } from "./tool-result";
37
37
 
@@ -116,7 +116,23 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
116
116
 
117
117
  return untilAborted(signal, async () => {
118
118
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
119
- const normalizedPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
119
+ const rawPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
120
+ const internalRouter = InternalUrlRouter.instance();
121
+ const normalizedPatterns: string[] = [];
122
+ for (const rawPattern of rawPatterns) {
123
+ if (!internalRouter.canHandle(rawPattern)) {
124
+ normalizedPatterns.push(rawPattern);
125
+ continue;
126
+ }
127
+ if (hasGlobPathChars(rawPattern)) {
128
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
129
+ }
130
+ const resource = await internalRouter.resolve(rawPattern);
131
+ if (!resource.sourcePath) {
132
+ throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
133
+ }
134
+ normalizedPatterns.push(resource.sourcePath);
135
+ }
120
136
  if (normalizedPatterns.some(pattern => pattern.length === 0)) {
121
137
  throw new ToolError("`paths` must contain non-empty globs or paths");
122
138
  }
@@ -383,30 +399,22 @@ export const findToolRenderer = {
383
399
  },
384
400
  uiTheme,
385
401
  );
386
- let cached: RenderCache | undefined;
387
- return {
388
- render(width: number): string[] {
389
- const { expanded } = options;
390
- const key = new Hasher().bool(expanded).u32(width).digest();
391
- if (cached?.key === key) return cached.lines;
402
+ return createCachedComponent(
403
+ () => options.expanded,
404
+ width => {
392
405
  const listLines = renderTreeList(
393
406
  {
394
407
  items: lines,
395
- expanded,
408
+ expanded: options.expanded,
396
409
  maxCollapsed: COLLAPSED_LIST_LIMIT,
397
410
  itemType: "file",
398
411
  renderItem: line => uiTheme.fg("accent", line),
399
412
  },
400
413
  uiTheme,
401
414
  );
402
- const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
403
- cached = { key, lines: result };
404
- return result;
405
- },
406
- invalidate() {
407
- cached = undefined;
415
+ return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
408
416
  },
409
- };
417
+ );
410
418
  }
411
419
 
412
420
  const fileCount = details?.fileCount ?? 0;
@@ -449,28 +457,20 @@ export const findToolRenderer = {
449
457
  }
450
458
  if (missingNote) extraLines.push(missingNote);
451
459
 
452
- let cached: RenderCache | undefined;
453
- return {
454
- render(width: number): string[] {
455
- const { expanded } = options;
456
- const key = new Hasher().bool(expanded).u32(width).digest();
457
- if (cached?.key === key) return cached.lines;
460
+ return createCachedComponent(
461
+ () => options.expanded,
462
+ width => {
458
463
  const fileLines = renderFileList(
459
464
  {
460
465
  files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
461
- expanded,
466
+ expanded: options.expanded,
462
467
  maxCollapsed: COLLAPSED_LIST_LIMIT,
463
468
  },
464
469
  uiTheme,
465
470
  );
466
- const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
467
- cached = { key, lines: result };
468
- return result;
469
- },
470
- invalidate() {
471
- cached = undefined;
471
+ return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
472
472
  },
473
- };
473
+ );
474
474
  },
475
475
  mergeCallAndResult: true,
476
476
  };
@@ -27,7 +27,6 @@ type GithubToolRenderArgs = {
27
27
  run?: string;
28
28
  branch?: string;
29
29
  repo?: string;
30
- issue?: string;
31
30
  pr?: string | string[];
32
31
  query?: string;
33
32
  };
@@ -40,9 +39,6 @@ const FALLBACK_WIDTH = 80;
40
39
 
41
40
  const OP_TITLES: Record<string, string> = {
42
41
  repo_view: "GitHub Repo",
43
- issue_view: "GitHub Issue",
44
- pr_view: "GitHub PR",
45
- pr_diff: "GitHub PR Diff",
46
42
  pr_checkout: "GitHub PR Checkout",
47
43
  pr_push: "GitHub PR Push",
48
44
  search_issues: "GitHub Search Issues",
@@ -85,14 +81,6 @@ function buildOpMeta(args: GithubToolRenderArgs): string[] {
85
81
  const meta: string[] = [];
86
82
  const op = args.op;
87
83
  switch (op) {
88
- case "issue_view": {
89
- const id = extractIssueId(args.issue);
90
- if (id) meta.push(id);
91
- if (args.repo) meta.push(args.repo);
92
- break;
93
- }
94
- case "pr_view":
95
- case "pr_diff":
96
84
  case "pr_checkout":
97
85
  case "pr_push": {
98
86
  const id = formatPrIdentifier(args.pr);