@oh-my-pi/pi-coding-agent 14.9.9 → 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 (128) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +1 -1
  4. package/src/cli/args.ts +2 -2
  5. package/src/cli.ts +1 -0
  6. package/src/commands/acp.ts +24 -0
  7. package/src/commands/launch.ts +6 -4
  8. package/src/commit/agentic/prompts/system.md +1 -1
  9. package/src/config/model-resolver.ts +30 -0
  10. package/src/config/settings-schema.ts +31 -0
  11. package/src/edit/index.ts +22 -1
  12. package/src/edit/modes/patch.ts +10 -0
  13. package/src/edit/modes/replace.ts +3 -0
  14. package/src/edit/renderer.ts +10 -0
  15. package/src/eval/js/context-manager.ts +1 -1
  16. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  17. package/src/eval/js/shared/runtime.ts +31 -4
  18. package/src/eval/js/tool-bridge.ts +43 -21
  19. package/src/extensibility/extensions/runner.ts +54 -1
  20. package/src/extensibility/extensions/types.ts +11 -0
  21. package/src/extensibility/skills.ts +33 -1
  22. package/src/internal-urls/docs-index.generated.ts +6 -6
  23. package/src/internal-urls/index.ts +1 -0
  24. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  25. package/src/internal-urls/router.ts +6 -3
  26. package/src/internal-urls/types.ts +22 -1
  27. package/src/main.ts +13 -9
  28. package/src/modes/acp/acp-agent.ts +361 -54
  29. package/src/modes/acp/acp-client-bridge.ts +152 -0
  30. package/src/modes/acp/acp-event-mapper.ts +180 -15
  31. package/src/modes/acp/terminal-auth.ts +37 -0
  32. package/src/modes/components/read-tool-group.ts +29 -1
  33. package/src/modes/controllers/command-controller.ts +14 -6
  34. package/src/modes/controllers/event-controller.ts +24 -11
  35. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  36. package/src/modes/controllers/input-controller.ts +72 -39
  37. package/src/modes/interactive-mode.ts +71 -7
  38. package/src/modes/rpc/rpc-mode.ts +17 -2
  39. package/src/modes/types.ts +6 -2
  40. package/src/modes/utils/ui-helpers.ts +15 -3
  41. package/src/prompts/agents/designer.md +5 -5
  42. package/src/prompts/agents/explore.md +7 -7
  43. package/src/prompts/agents/init.md +9 -9
  44. package/src/prompts/agents/librarian.md +14 -14
  45. package/src/prompts/agents/plan.md +4 -4
  46. package/src/prompts/agents/reviewer.md +5 -5
  47. package/src/prompts/agents/task.md +10 -10
  48. package/src/prompts/commands/orchestrate.md +2 -2
  49. package/src/prompts/compaction/branch-summary.md +3 -3
  50. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  51. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  52. package/src/prompts/compaction/compaction-summary.md +5 -5
  53. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  54. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  55. package/src/prompts/memories/consolidation.md +2 -2
  56. package/src/prompts/memories/read-path.md +1 -1
  57. package/src/prompts/memories/stage_one_input.md +1 -1
  58. package/src/prompts/memories/stage_one_system.md +5 -5
  59. package/src/prompts/review-request.md +4 -4
  60. package/src/prompts/system/agent-creation-architect.md +17 -17
  61. package/src/prompts/system/agent-creation-user.md +2 -2
  62. package/src/prompts/system/commit-message-system.md +2 -2
  63. package/src/prompts/system/custom-system-prompt.md +2 -2
  64. package/src/prompts/system/eager-todo.md +6 -6
  65. package/src/prompts/system/handoff-document.md +1 -1
  66. package/src/prompts/system/plan-mode-active.md +22 -21
  67. package/src/prompts/system/plan-mode-approved.md +4 -4
  68. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  69. package/src/prompts/system/plan-mode-reference.md +2 -2
  70. package/src/prompts/system/plan-mode-subagent.md +8 -8
  71. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  72. package/src/prompts/system/project-prompt.md +4 -4
  73. package/src/prompts/system/subagent-system-prompt.md +7 -7
  74. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  75. package/src/prompts/system/system-prompt.md +72 -71
  76. package/src/prompts/system/ttsr-interrupt.md +1 -1
  77. package/src/prompts/tools/apply-patch.md +1 -1
  78. package/src/prompts/tools/ast-edit.md +3 -3
  79. package/src/prompts/tools/ast-grep.md +3 -3
  80. package/src/prompts/tools/browser.md +3 -3
  81. package/src/prompts/tools/checkpoint.md +3 -3
  82. package/src/prompts/tools/exit-plan-mode.md +2 -2
  83. package/src/prompts/tools/find.md +3 -3
  84. package/src/prompts/tools/github.md +2 -5
  85. package/src/prompts/tools/hashline.md +6 -6
  86. package/src/prompts/tools/image-gen.md +3 -3
  87. package/src/prompts/tools/irc.md +1 -1
  88. package/src/prompts/tools/lsp.md +2 -2
  89. package/src/prompts/tools/patch.md +6 -6
  90. package/src/prompts/tools/read.md +7 -7
  91. package/src/prompts/tools/replace.md +5 -5
  92. package/src/prompts/tools/retain.md +1 -1
  93. package/src/prompts/tools/rewind.md +2 -2
  94. package/src/prompts/tools/search.md +2 -2
  95. package/src/prompts/tools/ssh.md +2 -2
  96. package/src/prompts/tools/task.md +12 -6
  97. package/src/prompts/tools/web-search.md +2 -2
  98. package/src/prompts/tools/write.md +3 -3
  99. package/src/sdk.ts +69 -12
  100. package/src/session/agent-session.ts +231 -22
  101. package/src/session/client-bridge.ts +81 -0
  102. package/src/session/compaction/errors.ts +31 -0
  103. package/src/session/compaction/index.ts +1 -0
  104. package/src/slash-commands/acp-builtins.ts +46 -0
  105. package/src/slash-commands/builtin-registry.ts +699 -116
  106. package/src/slash-commands/helpers/context-report.ts +39 -0
  107. package/src/slash-commands/helpers/format.ts +23 -0
  108. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  109. package/src/slash-commands/helpers/mcp.ts +532 -0
  110. package/src/slash-commands/helpers/parse.ts +85 -0
  111. package/src/slash-commands/helpers/ssh.ts +193 -0
  112. package/src/slash-commands/helpers/todo.ts +279 -0
  113. package/src/slash-commands/helpers/usage-report.ts +91 -0
  114. package/src/slash-commands/types.ts +126 -0
  115. package/src/task/executor.ts +10 -3
  116. package/src/task/index.ts +17 -1
  117. package/src/task/render.ts +6 -3
  118. package/src/tools/bash.ts +176 -2
  119. package/src/tools/conflict-detect.ts +6 -6
  120. package/src/tools/fetch.ts +15 -4
  121. package/src/tools/find.ts +19 -1
  122. package/src/tools/gh-renderer.ts +0 -12
  123. package/src/tools/gh.ts +682 -176
  124. package/src/tools/github-cache.ts +548 -0
  125. package/src/tools/index.ts +3 -0
  126. package/src/tools/read.ts +110 -27
  127. package/src/tools/write.ts +23 -1
  128. package/src/tui/code-cell.ts +70 -2
@@ -0,0 +1,126 @@
1
+ import type { Settings } from "../config/settings";
2
+ import type { InteractiveModeContext } from "../modes/types";
3
+ import type { AgentSession } from "../session/agent-session";
4
+ import type { SessionManager } from "../session/session-manager";
5
+
6
+ /** Declarative subcommand definition for commands like /mcp. */
7
+ export interface SubcommandDef {
8
+ name: string;
9
+ description: string;
10
+ /** Usage hint shown as dim ghost text, e.g. "<name> [--scope project|user]". */
11
+ usage?: string;
12
+ }
13
+
14
+ /** Declarative builtin slash command metadata used by autocomplete and help UI. */
15
+ export interface BuiltinSlashCommand {
16
+ name: string;
17
+ description: string;
18
+ /** Subcommands for dropdown completion (e.g. /mcp add, /mcp list). */
19
+ subcommands?: SubcommandDef[];
20
+ /** Static inline hint when command takes a simple argument (no subcommands). */
21
+ inlineHint?: string;
22
+ }
23
+
24
+ /** Parsed slash-command text after stripping the leading "/". */
25
+ export interface ParsedSlashCommand {
26
+ name: string;
27
+ args: string;
28
+ text: string;
29
+ }
30
+
31
+ /**
32
+ * Result returned by a slash-command handler.
33
+ *
34
+ * - `void` / `undefined` — command was handled and consumed; no further input.
35
+ * - `{ consumed: true }` — explicit equivalent of the above (ACP shape).
36
+ * - `{ prompt: string }` — command handled, pass `prompt` through as the new
37
+ * user input (e.g. `/force <tool> <prompt>` keeps `<prompt>` as the message).
38
+ */
39
+ export type SlashCommandResult = undefined | { consumed: true } | { prompt: string };
40
+
41
+ /**
42
+ * Runtime visible to slash-command handlers that run in text/ACP mode.
43
+ *
44
+ * Both the TUI dispatcher (when invoking a `handle` via its adapter) and the
45
+ * ACP dispatcher pass this shape. Implementations MUST NOT depend on TUI-only
46
+ * state (editor, selectors, status line).
47
+ */
48
+ export interface SlashCommandRuntime {
49
+ session: AgentSession;
50
+ sessionManager: SessionManager;
51
+ settings: Settings;
52
+ cwd: string;
53
+ /** Emit text to the operator. TUI maps to `ctx.showStatus`, ACP to `sessionUpdate`. */
54
+ output: (text: string) => Promise<void> | void;
55
+ /** Re-advertise the available command list (no-op outside ACP). */
56
+ refreshCommands: () => Promise<void> | void;
57
+ /**
58
+ * Reload plugin state (caches, slash command registry, project registries)
59
+ * and re-emit available commands. Used by `/reload-plugins`, `/move`, and
60
+ * `/marketplace`/`/plugins` mutations so the session sees a consistent view
61
+ * after plugin or project-scope changes.
62
+ */
63
+ reloadPlugins: () => Promise<void>;
64
+ notifyTitleChanged?: () => Promise<void> | void;
65
+ notifyConfigChanged?: () => Promise<void> | void;
66
+ }
67
+
68
+ /**
69
+ * Runtime visible to TUI-only handlers (`handleTui`). Carries the interactive
70
+ * mode context plus the background-detach hook. Intentionally narrower than
71
+ * `SlashCommandRuntime` so existing callers can keep building it from just
72
+ * `{ ctx, handleBackgroundCommand }`; when the TUI dispatcher needs to invoke
73
+ * a `handle` (no `handleTui` override), it synthesizes a `SlashCommandRuntime`
74
+ * from `ctx`.
75
+ */
76
+ export interface TuiSlashCommandRuntime {
77
+ ctx: InteractiveModeContext;
78
+ handleBackgroundCommand: () => void;
79
+ }
80
+
81
+ /** Unified slash-command spec consumed by both TUI and ACP dispatchers. */
82
+ export interface SlashCommandSpec extends BuiltinSlashCommand {
83
+ aliases?: string[];
84
+ /** When false, the dispatcher refuses to handle invocations that include arguments. */
85
+ allowArgs?: boolean;
86
+ /**
87
+ * ACP-specific override for `description`. Used by `ACP_BUILTIN_SLASH_COMMANDS`
88
+ * when building `available_commands_update` payloads so the client receives
89
+ * mode-appropriate copy (e.g. `/dump` advertises "Return full transcript as
90
+ * plain text" in ACP rather than the TUI's clipboard-centric copy).
91
+ */
92
+ acpDescription?: string;
93
+ /**
94
+ * ACP-specific override for the advertised input hint. `subcommands`-only
95
+ * specs that historically advertised `<subcommand>` / `[on|off|status]` /
96
+ * `info|delete` to ACP clients carry the hint here so the unification does
97
+ * not silently drop it from `available_commands_update`.
98
+ */
99
+ acpInputHint?: string;
100
+ /**
101
+ * Text/ACP-mode handler. The same body is invoked from the ACP dispatcher
102
+ * and, via the TUI adapter, when no `handleTui` override is provided.
103
+ */
104
+ handle?: (
105
+ command: ParsedSlashCommand,
106
+ runtime: SlashCommandRuntime,
107
+ ) => Promise<SlashCommandResult> | SlashCommandResult;
108
+ /**
109
+ * TUI-only handler that supersedes `handle` when both are present. Use for
110
+ * selectors, wizards, dashboards, and anything else that requires
111
+ * `InteractiveModeContext`.
112
+ */
113
+ handleTui?: (
114
+ command: ParsedSlashCommand,
115
+ runtime: TuiSlashCommandRuntime,
116
+ ) => Promise<SlashCommandResult> | SlashCommandResult;
117
+ }
118
+
119
+ /**
120
+ * @deprecated Use `SlashCommandRuntime` directly. Retained as an alias so
121
+ * downstream code that imported the ACP-specific name keeps compiling.
122
+ */
123
+ export type AcpBuiltinCommandRuntime = SlashCommandRuntime;
124
+
125
+ /** Result returned by `executeAcpBuiltinSlashCommand`. */
126
+ export type AcpBuiltinSlashCommandResult = false | { consumed: true } | { prompt: string };
@@ -947,10 +947,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
947
947
 
948
948
  try {
949
949
  checkAbort();
950
- const authStorage = options.authStorage ?? (await discoverAuthStorage());
951
- checkAbort();
950
+ // Pin authStorage to modelRegistry.authStorage mirrors the createAgentSession invariant.
952
951
  const registryFromParent = options.modelRegistry !== undefined;
953
- const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage);
952
+ const modelRegistry =
953
+ options.modelRegistry ?? new ModelRegistry(options.authStorage ?? (await discoverAuthStorage()));
954
+ const authStorage = modelRegistry.authStorage;
955
+ if (options.authStorage && options.authStorage !== authStorage) {
956
+ throw new Error(
957
+ "options.authStorage and options.modelRegistry.authStorage must be the same instance when both are provided",
958
+ );
959
+ }
960
+ checkAbort();
954
961
  if (!registryFromParent) {
955
962
  await modelRegistry.refresh();
956
963
  } else {
package/src/task/index.ts CHANGED
@@ -483,11 +483,27 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
483
483
  ? ` Failed to schedule ${failedSchedules.length} task${failedSchedules.length === 1 ? "" : "s"}.`
484
484
  : "";
485
485
 
486
+ const ircEnabled = this.session.settings.get("irc.enabled") === true;
487
+ const taskIdByItemId = new Map<string, string>();
488
+ for (let i = 0; i < taskItems.length; i++) {
489
+ taskIdByItemId.set(taskItems[i].id, uniqueIds[i]);
490
+ }
491
+ const startedListing = startedJobs
492
+ .map(({ taskId }) => {
493
+ const id = taskIdByItemId.get(taskId) ?? taskId;
494
+ const desc = progressByTaskId.get(taskId)?.description;
495
+ return desc ? `- \`${id}\` — ${desc}` : `- \`${id}\``;
496
+ })
497
+ .join("\n");
498
+ const coordinationHint = ircEnabled
499
+ ? ` DM these ids via \`irc\` to coordinate while they run; reach for \`job\` only to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task.`
500
+ : ` Use \`job\` to inspect (\`list\`), wait (\`poll\`), or cancel a stuck task by id.`;
501
+
486
502
  return {
487
503
  content: [
488
504
  {
489
505
  type: "text",
490
- text: `Started ${startedJobs.length} background task job${startedJobs.length === 1 ? "" : "s"} using ${params.agent}.${scheduleFailureSummary} Results will be delivered when complete.`,
506
+ text: `Started ${startedJobs.length} background task job${startedJobs.length === 1 ? "" : "s"} using ${params.agent}.${scheduleFailureSummary} Results will be delivered when complete.\n${startedListing}\n${coordinationHint}`,
491
507
  },
492
508
  ],
493
509
  details: {
@@ -661,8 +661,10 @@ function renderReviewResult(
661
661
  lines.push(`${continuePrefix} ${theme.fg("dim", replaceTabs(line))}`);
662
662
  }
663
663
  } else {
664
- // Preview: first sentence or ~100 chars
665
- const preview = truncateToWidth(`${summary.explanation.split(/[.!?]/)[0]}.`, 100);
664
+ // Preview: first sentence or ~100 chars (flatten tabs/newlines first)
665
+ const flat = replaceTabs(summary.explanation).replace(/[\r\n]+/g, " ");
666
+ const firstSentence = flat.split(/[.!?]/)[0].trim();
667
+ const preview = truncateToWidth(`${firstSentence}.`, 100);
666
668
  lines.push(`${continuePrefix}${theme.fg("dim", preview)}`);
667
669
  }
668
670
  }
@@ -701,7 +703,8 @@ function renderFindings(
701
703
  const findingContinue = isLastFinding ? " " : `${theme.tree.vertical} `;
702
704
 
703
705
  const { color } = getPriorityInfo(finding.priority);
704
- const titleText = finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled";
706
+ const rawTitle = finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled";
707
+ const titleText = replaceTabs(rawTitle).replace(/[\r\n]+/g, " ");
705
708
  const loc = `${path.basename(finding.file_path || "<unknown>")}:${finding.line_start}`;
706
709
 
707
710
  lines.push(
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
 
@@ -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);