@oh-my-pi/pi-coding-agent 14.9.8 → 15.0.0

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 (138) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/package.json +7 -7
  3. package/scripts/build-binary.ts +11 -0
  4. package/scripts/format-prompts.ts +1 -1
  5. package/src/cli/args.ts +2 -2
  6. package/src/cli/stats-cli.ts +2 -0
  7. package/src/cli.ts +24 -1
  8. package/src/commands/acp.ts +24 -0
  9. package/src/commands/launch.ts +6 -4
  10. package/src/commit/agentic/prompts/system.md +1 -1
  11. package/src/config/model-resolver.ts +30 -0
  12. package/src/config/settings-schema.ts +61 -9
  13. package/src/config/settings.ts +18 -1
  14. package/src/edit/index.ts +22 -1
  15. package/src/edit/modes/patch.ts +10 -0
  16. package/src/edit/modes/replace.ts +3 -0
  17. package/src/edit/renderer.ts +10 -0
  18. package/src/edit/streaming.ts +1 -1
  19. package/src/eval/js/context-manager.ts +10 -9
  20. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  21. package/src/eval/js/shared/runtime.ts +31 -4
  22. package/src/eval/js/tool-bridge.ts +43 -21
  23. package/src/extensibility/extensions/runner.ts +54 -1
  24. package/src/extensibility/extensions/types.ts +11 -0
  25. package/src/extensibility/skills.ts +33 -1
  26. package/src/hashline/grammar.lark +1 -1
  27. package/src/hashline/input.ts +11 -5
  28. package/src/internal-urls/docs-index.generated.ts +7 -7
  29. package/src/internal-urls/index.ts +1 -0
  30. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  31. package/src/internal-urls/router.ts +6 -3
  32. package/src/internal-urls/types.ts +22 -1
  33. package/src/main.ts +13 -9
  34. package/src/modes/acp/acp-agent.ts +361 -54
  35. package/src/modes/acp/acp-client-bridge.ts +152 -0
  36. package/src/modes/acp/acp-event-mapper.ts +180 -15
  37. package/src/modes/acp/terminal-auth.ts +37 -0
  38. package/src/modes/components/read-tool-group.ts +29 -1
  39. package/src/modes/controllers/command-controller.ts +14 -6
  40. package/src/modes/controllers/event-controller.ts +24 -11
  41. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  42. package/src/modes/controllers/input-controller.ts +72 -39
  43. package/src/modes/interactive-mode.ts +71 -7
  44. package/src/modes/rpc/rpc-mode.ts +17 -2
  45. package/src/modes/types.ts +6 -2
  46. package/src/modes/utils/ui-helpers.ts +15 -3
  47. package/src/prompts/agents/designer.md +5 -5
  48. package/src/prompts/agents/explore.md +7 -7
  49. package/src/prompts/agents/init.md +9 -9
  50. package/src/prompts/agents/librarian.md +14 -14
  51. package/src/prompts/agents/plan.md +4 -4
  52. package/src/prompts/agents/reviewer.md +5 -5
  53. package/src/prompts/agents/task.md +10 -10
  54. package/src/prompts/commands/orchestrate.md +2 -2
  55. package/src/prompts/compaction/branch-summary.md +3 -3
  56. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  57. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  58. package/src/prompts/compaction/compaction-summary.md +5 -5
  59. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  60. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  61. package/src/prompts/memories/consolidation.md +2 -2
  62. package/src/prompts/memories/read-path.md +1 -1
  63. package/src/prompts/memories/stage_one_input.md +1 -1
  64. package/src/prompts/memories/stage_one_system.md +5 -5
  65. package/src/prompts/review-request.md +4 -4
  66. package/src/prompts/system/agent-creation-architect.md +17 -17
  67. package/src/prompts/system/agent-creation-user.md +2 -2
  68. package/src/prompts/system/commit-message-system.md +2 -2
  69. package/src/prompts/system/custom-system-prompt.md +2 -2
  70. package/src/prompts/system/eager-todo.md +6 -6
  71. package/src/prompts/system/handoff-document.md +1 -1
  72. package/src/prompts/system/plan-mode-active.md +22 -21
  73. package/src/prompts/system/plan-mode-approved.md +4 -4
  74. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  75. package/src/prompts/system/plan-mode-reference.md +2 -2
  76. package/src/prompts/system/plan-mode-subagent.md +8 -8
  77. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  78. package/src/prompts/system/project-prompt.md +4 -4
  79. package/src/prompts/system/subagent-system-prompt.md +7 -7
  80. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  81. package/src/prompts/system/system-prompt.md +72 -71
  82. package/src/prompts/system/ttsr-interrupt.md +1 -1
  83. package/src/prompts/tools/apply-patch.md +1 -1
  84. package/src/prompts/tools/ast-edit.md +3 -3
  85. package/src/prompts/tools/ast-grep.md +3 -3
  86. package/src/prompts/tools/browser.md +3 -3
  87. package/src/prompts/tools/checkpoint.md +3 -3
  88. package/src/prompts/tools/exit-plan-mode.md +2 -2
  89. package/src/prompts/tools/find.md +3 -3
  90. package/src/prompts/tools/github.md +2 -5
  91. package/src/prompts/tools/hashline.md +20 -20
  92. package/src/prompts/tools/image-gen.md +3 -3
  93. package/src/prompts/tools/irc.md +1 -1
  94. package/src/prompts/tools/lsp.md +2 -2
  95. package/src/prompts/tools/patch.md +6 -6
  96. package/src/prompts/tools/read.md +7 -7
  97. package/src/prompts/tools/replace.md +5 -5
  98. package/src/prompts/tools/retain.md +1 -1
  99. package/src/prompts/tools/rewind.md +2 -2
  100. package/src/prompts/tools/search.md +2 -2
  101. package/src/prompts/tools/ssh.md +2 -2
  102. package/src/prompts/tools/task.md +12 -6
  103. package/src/prompts/tools/web-search.md +2 -2
  104. package/src/prompts/tools/write.md +3 -3
  105. package/src/sdk.ts +69 -12
  106. package/src/session/agent-session.ts +231 -22
  107. package/src/session/client-bridge.ts +81 -0
  108. package/src/session/compaction/errors.ts +31 -0
  109. package/src/session/compaction/index.ts +1 -0
  110. package/src/slash-commands/acp-builtins.ts +46 -0
  111. package/src/slash-commands/builtin-registry.ts +699 -116
  112. package/src/slash-commands/helpers/context-report.ts +39 -0
  113. package/src/slash-commands/helpers/format.ts +23 -0
  114. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  115. package/src/slash-commands/helpers/mcp.ts +532 -0
  116. package/src/slash-commands/helpers/parse.ts +85 -0
  117. package/src/slash-commands/helpers/ssh.ts +193 -0
  118. package/src/slash-commands/helpers/todo.ts +279 -0
  119. package/src/slash-commands/helpers/usage-report.ts +91 -0
  120. package/src/slash-commands/types.ts +126 -0
  121. package/src/task/executor.ts +10 -3
  122. package/src/task/index.ts +29 -51
  123. package/src/task/render.ts +6 -3
  124. package/src/task/worktree.ts +170 -239
  125. package/src/tools/bash.ts +176 -2
  126. package/src/tools/browser/tab-supervisor.ts +13 -13
  127. package/src/tools/conflict-detect.ts +6 -6
  128. package/src/tools/fetch.ts +15 -4
  129. package/src/tools/find.ts +19 -1
  130. package/src/tools/gh-renderer.ts +0 -12
  131. package/src/tools/gh.ts +682 -176
  132. package/src/tools/github-cache.ts +548 -0
  133. package/src/tools/index.ts +3 -0
  134. package/src/tools/read.ts +110 -27
  135. package/src/tools/write.ts +23 -1
  136. package/src/tui/code-cell.ts +70 -2
  137. package/src/utils/git.ts +5 -0
  138. package/src/task/isolation-backend.ts +0 -94
package/src/tools/bash.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui";
5
- import { $env, getProjectDir, isEnoent, prompt } from "@oh-my-pi/pi-utils";
5
+ import { $env, getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
6
6
  import { Type } from "@sinclair/typebox";
7
7
  import { AsyncJobManager } from "../async";
8
8
  import { type BashResult, executeBash } from "../exec/bash-executor";
@@ -11,6 +11,7 @@ import { InternalUrlRouter } from "../internal-urls";
11
11
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
12
12
  import type { Theme } from "../modes/theme/theme";
13
13
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
14
+ import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
14
15
  import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
15
16
  import { renderStatusLine } from "../tui";
16
17
  import { CachedOutputBlock } from "../tui/output-block";
@@ -84,6 +85,7 @@ export interface BashToolDetails {
84
85
  meta?: OutputMeta;
85
86
  timeoutSeconds?: number;
86
87
  requestedTimeoutSeconds?: number;
88
+ terminalId?: string;
87
89
  async?: {
88
90
  state: "running" | "completed" | "failed";
89
91
  jobId: string;
@@ -289,7 +291,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
289
291
  #buildCompletedResult(
290
292
  result: BashResult | BashInteractiveResult,
291
293
  timeoutSec: number,
292
- options: { requestedTimeoutSec?: number; notices?: string[] } = {},
294
+ options: { requestedTimeoutSec?: number; notices?: string[]; terminalId?: string } = {},
293
295
  ): AgentToolResult<BashToolDetails> {
294
296
  const outputLines = [this.#formatResultOutput(result)];
295
297
  const notices = options.notices?.filter(Boolean) ?? [];
@@ -299,6 +301,9 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
299
301
  if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
300
302
  details.requestedTimeoutSeconds = options.requestedTimeoutSec;
301
303
  }
304
+ if (options.terminalId !== undefined) {
305
+ details.terminalId = options.terminalId;
306
+ }
302
307
  const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
303
308
  this.#buildResultText(result, timeoutSec, outputText);
304
309
  return resultBuilder.done();
@@ -618,6 +623,175 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
618
623
  });
619
624
  }
620
625
 
626
+ // Route through the client terminal when the client advertises the terminal capability.
627
+ // Skip when pty=true (PTY needs the local terminal UI).
628
+ const clientBridge = this.session.getClientBridge?.();
629
+ if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
630
+ const handle = await clientBridge.createTerminal({
631
+ command,
632
+ cwd: commandCwd,
633
+ env: resolvedEnv
634
+ ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
635
+ : undefined,
636
+ outputByteLimit: DEFAULT_MAX_BYTES,
637
+ });
638
+
639
+ // Emit partial update so the editor can embed the live terminal card.
640
+ onUpdate?.({ content: [], details: { terminalId: handle.terminalId } });
641
+
642
+ const exitPromise = handle.waitForExit();
643
+ let exitStatus!: ClientBridgeTerminalExitStatus;
644
+
645
+ type BridgeRaceResult =
646
+ | { kind: "exit"; status: ClientBridgeTerminalExitStatus }
647
+ | { kind: "poll" }
648
+ | { kind: "timeout" }
649
+ | { kind: "aborted" };
650
+
651
+ // Set up abort listener before entering the poll loop. The listener
652
+ // kicks off `handle.kill()` synchronously so a `session/cancel`
653
+ // arriving mid-poll terminates the remote command immediately,
654
+ // instead of waiting for the next `currentOutput()` to return.
655
+ const { promise: abortedP, resolve: resolveAborted } = Promise.withResolvers<void>();
656
+ let killStarted = false;
657
+ const fireKill = (): Promise<void> => {
658
+ if (killStarted) return Promise.resolve();
659
+ killStarted = true;
660
+ return handle.kill().catch((error: unknown) => {
661
+ logger.warn("ACP terminal kill failed", { terminalId: handle.terminalId, error });
662
+ });
663
+ };
664
+ const onAbortSignal = () => {
665
+ resolveAborted();
666
+ void fireKill();
667
+ };
668
+ signal?.addEventListener("abort", onAbortSignal, { once: true });
669
+
670
+ try {
671
+ try {
672
+ if (signal?.aborted) {
673
+ await fireKill();
674
+ throw new ToolAbortError("Command aborted");
675
+ }
676
+
677
+ const timeoutPromise = Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const }));
678
+ // Poll until the process exits, times out, or the caller aborts.
679
+ for (;;) {
680
+ const racers: Array<Promise<BridgeRaceResult>> = [
681
+ exitPromise.then(s => ({ kind: "exit" as const, status: s })),
682
+ timeoutPromise,
683
+ Bun.sleep(250).then(() => ({ kind: "poll" as const })),
684
+ ];
685
+ if (signal) {
686
+ racers.push(abortedP.then(() => ({ kind: "aborted" as const })));
687
+ }
688
+ const raced = await Promise.race(racers);
689
+
690
+ if (raced.kind === "aborted" || signal?.aborted) {
691
+ await fireKill();
692
+ throw new ToolAbortError("Command aborted");
693
+ }
694
+
695
+ if (raced.kind === "timeout") {
696
+ // Kill before reading final output so a slow `terminal/output`
697
+ // RPC cannot let a timed-out command keep running past the
698
+ // enforced timeout. The handle stays valid post-kill so the
699
+ // buffered output is still readable.
700
+ await fireKill();
701
+ let current = { output: "", truncated: false };
702
+ try {
703
+ current = await handle.currentOutput();
704
+ } catch (error) {
705
+ logger.warn("ACP terminal final output read failed", {
706
+ terminalId: handle.terminalId,
707
+ error,
708
+ });
709
+ }
710
+ const timedOutResult: BashInteractiveResult = {
711
+ output: current.output,
712
+ exitCode: undefined,
713
+ cancelled: false,
714
+ timedOut: true,
715
+ truncated: current.truncated,
716
+ totalLines: current.output.length > 0 ? current.output.split("\n").length : 0,
717
+ totalBytes: current.output.length,
718
+ outputLines: current.output.length > 0 ? current.output.split("\n").length : 0,
719
+ outputBytes: current.output.length,
720
+ };
721
+ return this.#buildCompletedResult(timedOutResult, timeoutSec, {
722
+ requestedTimeoutSec,
723
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
724
+ terminalId: handle.terminalId,
725
+ });
726
+ }
727
+
728
+ if (raced.kind === "exit") {
729
+ exitStatus = raced.status;
730
+ break;
731
+ }
732
+
733
+ // Poll tick: push current output so agent-loop transcript stays consistent.
734
+ // Race the read against abort so a stuck `terminal/output` RPC does not
735
+ // delay cancellation.
736
+ const pollOutput = await Promise.race([
737
+ handle.currentOutput(),
738
+ abortedP.then(() => undefined as ClientBridgeTerminalOutput | undefined),
739
+ ]);
740
+ if (pollOutput === undefined) {
741
+ // Abort fired during the poll-tick read; let the next loop iteration
742
+ // observe `signal?.aborted` and exit via the abort branch.
743
+ continue;
744
+ }
745
+ onUpdate?.({
746
+ content: [{ type: "text", text: pollOutput.output }],
747
+ details: { terminalId: handle.terminalId },
748
+ });
749
+ }
750
+ } finally {
751
+ signal?.removeEventListener("abort", onAbortSignal);
752
+ }
753
+
754
+ // Fetch final output; the terminal is released in the outer finally.
755
+ const finalOutput = await handle.currentOutput();
756
+
757
+ // Map exit status: null exitCode with a signal → treat as signal kill (137).
758
+ const rawExitCode = exitStatus.exitCode;
759
+ const exitCode: number | undefined =
760
+ rawExitCode != null ? rawExitCode : exitStatus.signal ? 137 : undefined;
761
+
762
+ const outputText = finalOutput.output;
763
+ const outputByteLen = outputText.length;
764
+ const outputLineCount = outputText.length > 0 ? outputText.split("\n").length : 0;
765
+
766
+ const bridgeResult: BashResult = {
767
+ output: outputText,
768
+ exitCode,
769
+ cancelled: false,
770
+ truncated: finalOutput.truncated,
771
+ totalLines: outputLineCount,
772
+ totalBytes: outputByteLen,
773
+ outputLines: outputLineCount,
774
+ outputBytes: outputByteLen,
775
+ };
776
+
777
+ const bridgeNotices: string[] = [];
778
+ if (finalOutput.truncated) bridgeNotices.push("(output truncated)");
779
+ if (timeoutClampNotice) bridgeNotices.push(timeoutClampNotice);
780
+
781
+ return this.#buildCompletedResult(bridgeResult, timeoutSec, {
782
+ requestedTimeoutSec,
783
+ notices: bridgeNotices,
784
+ terminalId: handle.terminalId,
785
+ });
786
+ } finally {
787
+ try {
788
+ await handle.release();
789
+ } catch (error) {
790
+ logger.warn("ACP terminal release failed", { terminalId: handle.terminalId, error });
791
+ }
792
+ }
793
+ }
794
+
621
795
  // Track output for streaming updates (tail only)
622
796
  const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
623
797
 
@@ -1,4 +1,4 @@
1
- import { getPuppeteerDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
1
+ import { getPuppeteerDir, isCompiledBinary, logger, Snowflake } from "@oh-my-pi/pi-utils";
2
2
  import type { Page, Target } from "puppeteer-core";
3
3
  import { callSessionTool } from "../../eval/js/tool-bridge";
4
4
  import type { ToolSession } from "../../sdk";
@@ -17,17 +17,15 @@ import type {
17
17
  WorkerInitPayload,
18
18
  WorkerOutbound,
19
19
  } from "./tab-protocol";
20
- // Imported with `type: "file"` so Bun's bundler statically discovers the
21
- // worker entry and embeds it inside `bun build --compile` single-file
22
- // binaries. Without this attribute the bundler cannot reach the entry through
23
- // a `new URL(..., import.meta.url)` literal stored in a local variable, and
24
- // the prebuilt binary surfaces `Timed out initializing browser tab worker`
25
- // (issue #1011) because `/$bunfs/root/tab-worker-entry.ts` is missing.
26
- // tsgo doesn't recognize Bun's `with { type: "file" }` attribute and treats
27
- // this as a normal TS source import, raising TS1192/TS5097. Bun's bundler
28
- // (and runtime) honors the attribute and returns the embedded file URL.
29
- // @ts-expect-error -- Bun file-URL import (see comment above).
30
- import tabWorkerEntryUrl from "./tab-worker-entry.ts" with { type: "file" };
20
+
21
+ // Worker entry. The literal string in `new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", …)`
22
+ // below is what Bun's `--compile` static analyzer needs to bundle the worker
23
+ // (registered as an additional entrypoint in `scripts/build-binary.ts`); in
24
+ // dev we resolve the same source via `import.meta.url`. Replaces the older
25
+ // `with { type: "file" }` pattern, which only copied the entry as a raw
26
+ // asset and could not resolve the worker's relative imports inside a
27
+ // compiled binary (issue #1011 was a false-positive fix the regression
28
+ // test only checked emission, not actual worker startup).
31
29
 
32
30
  interface WorkerHandle {
33
31
  send(msg: WorkerInbound, transferList?: Transferable[]): void;
@@ -456,7 +454,9 @@ async function raceWithTimeout<T>(
456
454
 
457
455
  async function spawnTabWorker(): Promise<WorkerHandle> {
458
456
  try {
459
- const worker = new Worker(tabWorkerEntryUrl, { type: "module" });
457
+ const worker = isCompiledBinary()
458
+ ? new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", { type: "module" })
459
+ : new Worker(new URL("./tab-worker-entry.ts", import.meta.url).href, { type: "module" });
460
460
  return wrapBunWorker(worker);
461
461
  } catch (err) {
462
462
  logger.warn("Bun Worker spawn failed; using inline tab worker (no sync-loop guard)", {
@@ -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"]);
@@ -440,7 +440,7 @@ function markerLine(prefix: string, label: string | undefined): string {
440
440
  }
441
441
 
442
442
  /**
443
- * Materialise a conflict block for `read conflict://<N>` (and its
443
+ * Materialise a conflict block for `conflict://<N>` reads (and their
444
444
  * `/ours` / `/theirs` / `/base` scopes).
445
445
  *
446
446
  * Returns:
@@ -534,7 +534,7 @@ export function formatConflictWarning(
534
534
  if (partial) {
535
535
  const hintPath = options.displayPath ?? "<file>";
536
536
  out.push(
537
- `⚠ ${entries.length} of ${total} unresolved ${word} visible in this window (run \`read ${hintPath}:conflicts\` for the full list).`,
537
+ `⚠ ${entries.length} of ${total} unresolved ${word} visible in this window (read \`${hintPath}:conflicts\` for the full list).`,
538
538
  );
539
539
  } else {
540
540
  out.push(`⚠ ${total} unresolved ${word} detected`);
@@ -551,7 +551,7 @@ export function formatConflictWarning(
551
551
  if (theirsLabel) out.push(`- theirs = ${theirsLabel}`);
552
552
  if (anyBase) out.push(`- base = ${baseLabel ?? "(no label)"}`);
553
553
  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).',
554
+ '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
555
  );
556
556
  out.push(
557
557
  '`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 +592,7 @@ export function formatConflictWarning(
592
592
 
593
593
  /**
594
594
  * 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
595
+ * Used by the `<path>:conflicts` read selector to give the agent a cheap overview
596
596
  * of a heavily-conflicted file without dumping every body.
597
597
  */
598
598
  export function formatConflictSummary(
@@ -614,7 +614,7 @@ export function formatConflictSummary(
614
614
  if (theirsLabel) lines.push(`- theirs = ${theirsLabel}`);
615
615
  if (anyBase) lines.push(`- base = ${baseLabel ?? "(no label)"}`);
616
616
  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).',
617
+ '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
618
  );
619
619
  lines.push(
620
620
  "`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.",
@@ -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,6 +8,7 @@ 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";
@@ -25,6 +26,7 @@ import { applyListLimit } from "./list-limit";
25
26
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
26
27
  import {
27
28
  formatPathRelativeToCwd,
29
+ hasGlobPathChars,
28
30
  normalizePathLikeInput,
29
31
  parseFindPattern,
30
32
  partitionExistingPaths,
@@ -116,7 +118,23 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
116
118
 
117
119
  return untilAborted(signal, async () => {
118
120
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
119
- const normalizedPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
121
+ const rawPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
122
+ const internalRouter = InternalUrlRouter.instance();
123
+ const normalizedPatterns: string[] = [];
124
+ for (const rawPattern of rawPatterns) {
125
+ if (!internalRouter.canHandle(rawPattern)) {
126
+ normalizedPatterns.push(rawPattern);
127
+ continue;
128
+ }
129
+ if (hasGlobPathChars(rawPattern)) {
130
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
131
+ }
132
+ const resource = await internalRouter.resolve(rawPattern);
133
+ if (!resource.sourcePath) {
134
+ throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
135
+ }
136
+ normalizedPatterns.push(resource.sourcePath);
137
+ }
120
138
  if (normalizedPatterns.some(pattern => pattern.length === 0)) {
121
139
  throw new ToolError("`paths` must contain non-empty globs or paths");
122
140
  }
@@ -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);