@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
@@ -10,6 +10,7 @@
10
10
 
11
11
  export * from "./agent-protocol";
12
12
  export * from "./artifact-protocol";
13
+ export * from "./issue-pr-protocol";
13
14
  export * from "./json-query";
14
15
  export * from "./local-protocol";
15
16
  export * from "./mcp-protocol";
@@ -0,0 +1,577 @@
1
+ /**
2
+ * Protocol handlers for `issue://` and `pr://`.
3
+ *
4
+ * Both single-item reads route through the SQLite-backed `github-cache`,
5
+ * sharing rendered markdown across sessions. Root and repo-scoped reads
6
+ * (`issue://`, `pr://owner/repo`) issue a live `gh issue list` / `gh pr list`
7
+ * for browsing.
8
+ *
9
+ * URL shapes:
10
+ * - `issue://` / `pr://` — list recent items in the caller's default repo.
11
+ * - `issue://owner/repo` / `pr://owner/repo` — list recent items for that repo.
12
+ * - `issue://123` / `pr://123` — single item; repo derived from the caller's
13
+ * session cwd (passed through `ResolveContext`).
14
+ * - `issue://owner/repo/123` / `pr://owner/repo/123` — fully qualified single
15
+ * item.
16
+ * - `issue://owner/repo/123?comments=0` — single item, comments suppressed.
17
+ * - `issue://owner/repo?state=closed&limit=20` — list options pass through to
18
+ * `gh`.
19
+ */
20
+ import type { Settings } from "../config/settings";
21
+ import { AgentRegistry } from "../registry/agent-registry";
22
+ import {
23
+ getOrFetchIssue,
24
+ getOrFetchPr,
25
+ getOrFetchPrDiff,
26
+ type PrDiffFile,
27
+ parsePositiveDecimalInt,
28
+ resolveDefaultRepoMemoized,
29
+ } from "../tools/gh";
30
+ import { formatFreshnessNote } from "../tools/github-cache";
31
+ import * as git from "../utils/git";
32
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext } from "./types";
33
+
34
+ type Scheme = "issue" | "pr";
35
+
36
+ interface ParsedSingle {
37
+ kind: "single";
38
+ repo?: string;
39
+ number: number;
40
+ comments: boolean;
41
+ }
42
+
43
+ interface ParsedPrDiff {
44
+ kind: "pr-diff";
45
+ repo?: string;
46
+ number: number;
47
+ /**
48
+ * `list` → enumerate changed files.
49
+ * `all` → full unified diff.
50
+ * `slice`→ single file's diff section (1-indexed `index`).
51
+ */
52
+ mode: "list" | "all" | "slice";
53
+ index?: number;
54
+ }
55
+
56
+ interface ParsedList {
57
+ kind: "list";
58
+ repo?: string;
59
+ state: "open" | "closed" | "merged" | "all";
60
+ limit: number;
61
+ author: string | undefined;
62
+ label: string | undefined;
63
+ }
64
+
65
+ type Parsed = ParsedSingle | ParsedList | ParsedPrDiff;
66
+
67
+ const LIST_LIMIT_DEFAULT = 30;
68
+ const LIST_LIMIT_MAX = 100;
69
+
70
+ function parseListOptions(url: InternalUrl, scheme: Scheme, repo: string | undefined): ParsedList {
71
+ const stateRaw = url.searchParams.get("state");
72
+ const allowedStates: ParsedList["state"][] =
73
+ scheme === "pr" ? ["open", "closed", "merged", "all"] : ["open", "closed", "all"];
74
+ const state = (
75
+ stateRaw && (allowedStates as string[]).includes(stateRaw) ? stateRaw : "open"
76
+ ) as ParsedList["state"];
77
+
78
+ const limitRaw = url.searchParams.get("limit");
79
+ let limit = LIST_LIMIT_DEFAULT;
80
+ if (limitRaw !== null) {
81
+ const parsed = parsePositiveDecimalInt(limitRaw);
82
+ if (parsed !== undefined) {
83
+ limit = Math.min(parsed, LIST_LIMIT_MAX);
84
+ }
85
+ }
86
+ return {
87
+ kind: "list",
88
+ repo,
89
+ state,
90
+ limit,
91
+ author: url.searchParams.get("author") ?? undefined,
92
+ label: url.searchParams.get("label") ?? undefined,
93
+ };
94
+ }
95
+
96
+ function parseUrl(url: InternalUrl, scheme: Scheme): Parsed {
97
+ const host = url.rawHost || url.hostname;
98
+ const rawPath = url.rawPathname ?? url.pathname;
99
+ // Strip a single leading slash so we can detect empty internal segments
100
+ // (e.g. `pr://owner//77` → pathname `//77` → stripped `/77` → ["", "77"]).
101
+ const stripped = rawPath.startsWith("/") ? rawPath.slice(1) : rawPath;
102
+ const parts: string[] = [];
103
+ if (stripped !== "") {
104
+ for (const seg of stripped.split("/")) {
105
+ let decoded: string;
106
+ try {
107
+ decoded = decodeURIComponent(seg);
108
+ } catch {
109
+ throw new Error(`Invalid ${scheme}:// URL: empty or unsafe path segment`);
110
+ }
111
+ if (decoded === "" || decoded === "." || decoded === "..") {
112
+ throw new Error(`Invalid ${scheme}:// URL: empty or unsafe path segment`);
113
+ }
114
+ parts.push(seg);
115
+ }
116
+ }
117
+
118
+ // Shapes:
119
+ // scheme:// → list default repo
120
+ // scheme://N → single item, default repo
121
+ // scheme://owner/repo → list specific repo
122
+ // scheme://owner/repo/N → single item, specific repo
123
+ // pr://N/diff[/<sub>] → diff family, default repo
124
+ // pr://owner/repo/N/diff[/<sub>] → diff family, specific repo
125
+ let repo: string | undefined;
126
+ let numberPart: string | undefined;
127
+ let diffParts: string[] = [];
128
+
129
+ if (!host && parts.length === 0) {
130
+ return parseListOptions(url, scheme, undefined);
131
+ }
132
+ if (host && parts.length === 0) {
133
+ // scheme://N (numeric) or scheme://owner (host-only, no repo segment)
134
+ numberPart = host;
135
+ } else if (parts[0] === "diff" && parsePositiveDecimalInt(host) !== undefined) {
136
+ // <scheme>://N/diff[/<sub>] — short form with diff suffix. Restrict this
137
+ // ambiguity to numeric hosts so `<scheme>://owner/diff` remains the valid
138
+ // repo-scoped listing for a repository named `diff`. `issue://` falls
139
+ // through to the `scheme === "issue"` branch below for the "issues have
140
+ // no diff" rejection rather than being misparsed as repo `<N>/diff`.
141
+ numberPart = host;
142
+ diffParts = parts;
143
+ } else if (host && parts.length === 1) {
144
+ // scheme://owner/repo → list
145
+ repo = `${host}/${parts[0]}`;
146
+ return parseListOptions(url, scheme, repo);
147
+ } else if (host && parts.length >= 2) {
148
+ // scheme://owner/repo/N[/diff[/<sub>]]
149
+ repo = `${host}/${parts[0]}`;
150
+ numberPart = parts[1];
151
+ diffParts = parts.slice(2);
152
+ } else {
153
+ throw new Error(
154
+ `Invalid ${scheme}:// URL. Expected ${scheme}://, ${scheme}://<number>, ${scheme}://<owner>/<repo>, or ${scheme}://<owner>/<repo>/<number>`,
155
+ );
156
+ }
157
+
158
+ // Reject unrecognized trailing segments before parsing the number so
159
+ // shapes like `issue://owner/repo/foo/bar` surface as "Invalid URL"
160
+ // rather than the misleading "Invalid number: foo".
161
+ if (diffParts.length > 0) {
162
+ if (scheme === "issue") {
163
+ throw new Error(
164
+ `Invalid issue:// URL. Issue views do not have a diff; use pr://<owner>/<repo>/<n>/diff for pull requests.`,
165
+ );
166
+ }
167
+ if (diffParts[0] !== "diff" || diffParts.length > 2) {
168
+ throw new Error(
169
+ `Invalid pr:// URL. Expected pr://<n>, pr://<n>/diff, pr://<n>/diff/all, or pr://<n>/diff/<i>`,
170
+ );
171
+ }
172
+ }
173
+
174
+ const num = parsePositiveDecimalInt(numberPart);
175
+ if (num === undefined) {
176
+ throw new Error(`Invalid ${scheme}:// number: ${numberPart ?? "(missing)"}`);
177
+ }
178
+
179
+ if (diffParts.length === 0) {
180
+ const commentsParam = url.searchParams.get("comments");
181
+ const comments =
182
+ commentsParam === null ? true : !(commentsParam === "0" || commentsParam.toLowerCase() === "false");
183
+ return { kind: "single", repo, number: num, comments };
184
+ }
185
+
186
+ // diffParts has already been validated above; scheme is `pr`.
187
+ if (diffParts.length === 1) {
188
+ return { kind: "pr-diff", repo, number: num, mode: "list" };
189
+ }
190
+ const sub = diffParts[1] ?? "";
191
+ if (sub === "all") {
192
+ return { kind: "pr-diff", repo, number: num, mode: "all" };
193
+ }
194
+ const idx = parsePositiveDecimalInt(sub);
195
+ if (idx === undefined) {
196
+ throw new Error(`Invalid pr:// diff sub-path '${sub}'. Use 'all' or a 1-indexed file number.`);
197
+ }
198
+ return { kind: "pr-diff", repo, number: num, mode: "slice", index: idx };
199
+ }
200
+
201
+ /**
202
+ * Resolve the working directory the protocol should use.
203
+ *
204
+ * Order:
205
+ * 1. Caller-supplied `context.cwd` (the session that initiated `read`).
206
+ * 2. First registered session via `AgentRegistry` (single-session fallback).
207
+ * 3. `process.cwd()` (last resort).
208
+ *
209
+ * The earlier-fallback drives `gh repo view` and any `gh issue list` /
210
+ * `gh pr list` for short-form URLs, so getting this right is what keeps
211
+ * reads of `issue://N` from picking the wrong repo across concurrent sessions.
212
+ */
213
+ function resolveCwd(context: ResolveContext | undefined): string {
214
+ if (context?.cwd) return context.cwd;
215
+ for (const ref of AgentRegistry.global().list()) {
216
+ const cwd = ref.session?.sessionManager?.getCwd();
217
+ if (cwd) return cwd;
218
+ }
219
+ return process.cwd();
220
+ }
221
+
222
+ function settingsFromContext(context: ResolveContext | undefined): Settings | undefined {
223
+ const raw = context?.settings;
224
+ if (!raw || typeof raw !== "object") return undefined;
225
+ if (typeof (raw as { get?: unknown }).get !== "function") return undefined;
226
+ return raw as Settings;
227
+ }
228
+
229
+ async function resolveListRepo(
230
+ scheme: Scheme,
231
+ parsedRepo: string | undefined,
232
+ context: ResolveContext | undefined,
233
+ ): Promise<string> {
234
+ if (parsedRepo) return parsedRepo;
235
+ const cwd = resolveCwd(context);
236
+ try {
237
+ return await resolveDefaultRepoMemoized(cwd, context?.signal);
238
+ } catch (err) {
239
+ const message = err instanceof Error ? err.message : String(err);
240
+ throw new Error(
241
+ `${scheme}:// could not resolve a default repo from the current session: ${message}\nUse ${scheme}://<owner>/<repo> instead.`,
242
+ );
243
+ }
244
+ }
245
+
246
+ interface IssueListItem {
247
+ number?: number;
248
+ title?: string;
249
+ state?: string;
250
+ stateReason?: string | null;
251
+ author?: { login?: string } | null;
252
+ labels?: Array<{ name?: string }>;
253
+ createdAt?: string;
254
+ updatedAt?: string;
255
+ url?: string;
256
+ }
257
+
258
+ interface PrListItem extends IssueListItem {
259
+ isDraft?: boolean;
260
+ baseRefName?: string;
261
+ headRefName?: string;
262
+ }
263
+
264
+ function formatListItem(scheme: Scheme, repo: string, item: IssueListItem | PrListItem): string {
265
+ const number = item.number ?? "?";
266
+ const title = item.title ?? "(no title)";
267
+ const state = item.state?.toLowerCase() ?? "?";
268
+ const author = item.author?.login ?? "?";
269
+ const updated = item.updatedAt ?? item.createdAt ?? "";
270
+ const draftSuffix = scheme === "pr" && (item as PrListItem).isDraft ? " [draft]" : "";
271
+ const labels = (item.labels ?? [])
272
+ .map(l => l.name)
273
+ .filter(Boolean)
274
+ .join(", ");
275
+ const labelSuffix = labels ? ` labels: ${labels}` : "";
276
+ const itemUrl = number === "?" ? `${scheme}://${repo}` : `${scheme}://${repo}/${number}`;
277
+ return `- [${state}${draftSuffix}] #${number} @${author} ${updated}\n ${title}${labelSuffix}\n ${itemUrl}`;
278
+ }
279
+
280
+ async function fetchAndRenderList(
281
+ scheme: Scheme,
282
+ options: ParsedList,
283
+ url: InternalUrl,
284
+ context: ResolveContext | undefined,
285
+ ): Promise<InternalResource> {
286
+ const repo = await resolveListRepo(scheme, options.repo, context);
287
+ const cwd = resolveCwd(context);
288
+ const fields =
289
+ scheme === "issue"
290
+ ? ["number", "title", "state", "stateReason", "author", "labels", "createdAt", "updatedAt", "url"]
291
+ : [
292
+ "number",
293
+ "title",
294
+ "state",
295
+ "isDraft",
296
+ "author",
297
+ "baseRefName",
298
+ "headRefName",
299
+ "labels",
300
+ "createdAt",
301
+ "updatedAt",
302
+ "url",
303
+ ];
304
+ const args = [
305
+ scheme,
306
+ "list",
307
+ "--repo",
308
+ repo,
309
+ "--state",
310
+ options.state,
311
+ "--limit",
312
+ String(options.limit),
313
+ "--json",
314
+ fields.join(","),
315
+ ];
316
+ if (options.author) args.push("--author", options.author);
317
+ if (options.label) args.push("--label", options.label);
318
+
319
+ const items = await git.github.json<Array<IssueListItem | PrListItem>>(cwd, args, context?.signal, {
320
+ repoProvided: true,
321
+ });
322
+ const header =
323
+ scheme === "issue"
324
+ ? `# Issues in ${repo} (${options.state}, up to ${options.limit})`
325
+ : `# Pull Requests in ${repo} (${options.state}, up to ${options.limit})`;
326
+ const body =
327
+ items.length === 0 ? "_No matches._" : items.map(item => formatListItem(scheme, repo, item)).join("\n\n");
328
+ const footer = `\n\n---\nRead a specific item: \`${scheme}://${repo}/<N>\` (or \`${scheme}://<N>\` for the current repo).`;
329
+ const rendered = `${header}\n\n${body}${footer}`;
330
+
331
+ return {
332
+ url: url.href,
333
+ content: rendered,
334
+ contentType: "text/markdown",
335
+ size: Buffer.byteLength(rendered, "utf-8"),
336
+ notes: [`Live listing for ${repo}`],
337
+ };
338
+ }
339
+
340
+ interface BuildSingleArgs {
341
+ url: InternalUrl;
342
+ scheme: Scheme;
343
+ parsed: ParsedSingle;
344
+ rendered: string;
345
+ status: "miss" | "fresh" | "stale" | "disabled";
346
+ fetchedAt: number;
347
+ /** Resolved repo (post short-form expansion) — used for the PR-only diff hint. */
348
+ repo?: string;
349
+ }
350
+
351
+ function buildSingleResource({
352
+ url,
353
+ scheme,
354
+ parsed,
355
+ rendered,
356
+ status,
357
+ fetchedAt,
358
+ repo,
359
+ }: BuildSingleArgs): InternalResource {
360
+ const notes: string[] = [formatFreshnessNote(status, fetchedAt)];
361
+ if (!parsed.comments) notes.push("Comments disabled");
362
+ if (scheme === "pr") {
363
+ const repoSegment = repo ?? parsed.repo;
364
+ const diffUrl = repoSegment ? `pr://${repoSegment}/${parsed.number}/diff` : `pr://${parsed.number}/diff`;
365
+ notes.push(`Diff: ${diffUrl}`);
366
+ }
367
+ return {
368
+ url: url.href,
369
+ content: rendered,
370
+ contentType: "text/markdown",
371
+ size: Buffer.byteLength(rendered, "utf-8"),
372
+ notes,
373
+ };
374
+ }
375
+
376
+ function formatFileLine(idx: number, file: PrDiffFile, repo: string, prNumber: number): string {
377
+ const stats = file.changeType === "binary" ? "(binary)" : `+${file.additions} -${file.deletions}`;
378
+ const rename = file.oldPath ? ` (renamed from ${file.oldPath})` : "";
379
+ return `${idx}. ${file.path} ${stats} [${file.changeType}]${rename}\n pr://${repo}/${prNumber}/diff/${idx}`;
380
+ }
381
+
382
+ async function fetchAndRenderPrDiff(
383
+ url: InternalUrl,
384
+ parsed: ParsedPrDiff,
385
+ context: ResolveContext | undefined,
386
+ ): Promise<InternalResource> {
387
+ const cwd = resolveCwd(context);
388
+ let repo = parsed.repo;
389
+ if (!repo) {
390
+ try {
391
+ repo = await resolveDefaultRepoMemoized(cwd, context?.signal);
392
+ } catch (err) {
393
+ const message = err instanceof Error ? err.message : String(err);
394
+ throw new Error(
395
+ `pr://${parsed.number}/diff could not resolve a default repo from the current session: ${message}\nUse pr://<owner>/<repo>/${parsed.number}/diff.`,
396
+ );
397
+ }
398
+ }
399
+ const lookup = await getOrFetchPrDiff({
400
+ cwd,
401
+ repo,
402
+ number: parsed.number,
403
+ signal: context?.signal,
404
+ settings: settingsFromContext(context),
405
+ });
406
+ const files = lookup.payload.files;
407
+ const freshness = formatFreshnessNote(lookup.status, lookup.fetchedAt);
408
+
409
+ if (parsed.mode === "all") {
410
+ const content = lookup.payload.unified;
411
+ return {
412
+ url: url.href,
413
+ content,
414
+ contentType: "text/plain",
415
+ size: Buffer.byteLength(content, "utf-8"),
416
+ notes: [
417
+ freshness,
418
+ `Full diff for pr://${repo}/${parsed.number} (${files.length} file${files.length === 1 ? "" : "s"})`,
419
+ ],
420
+ };
421
+ }
422
+
423
+ if (parsed.mode === "slice") {
424
+ const index = parsed.index ?? 0;
425
+ if (index < 1 || index > files.length) {
426
+ throw new Error(
427
+ `pr://${repo}/${parsed.number}/diff/${index} is out of range; PR has ${files.length} file${files.length === 1 ? "" : "s"}. Use pr://${repo}/${parsed.number}/diff to list available indices.`,
428
+ );
429
+ }
430
+ const file = files[index - 1];
431
+ if (!file) {
432
+ throw new Error(`pr://${repo}/${parsed.number}/diff/${index} resolved to a missing slice (parser bug).`);
433
+ }
434
+ const content = lookup.payload.unified.slice(file.startOffset, file.endOffset);
435
+ return {
436
+ url: url.href,
437
+ content,
438
+ contentType: "text/plain",
439
+ size: Buffer.byteLength(content, "utf-8"),
440
+ notes: [
441
+ freshness,
442
+ `Showing file ${index}/${files.length}: ${file.path}`,
443
+ `Read all: pr://${repo}/${parsed.number}/diff/all`,
444
+ ],
445
+ };
446
+ }
447
+
448
+ // mode === "list"
449
+ const header = `# Pull Request Diff: ${repo}#${parsed.number} (${files.length} file${files.length === 1 ? "" : "s"})`;
450
+ const body =
451
+ files.length === 0
452
+ ? "_No file changes._"
453
+ : files.map((f, i) => formatFileLine(i + 1, f, repo, parsed.number)).join("\n\n");
454
+ const footer = `\n\n---\nRead all: \`pr://${repo}/${parsed.number}/diff/all\`. Each file is also available as \`pr://${repo}/${parsed.number}/diff/<i>\`.`;
455
+ const content = `${header}\n\n${body}${footer}`;
456
+ return {
457
+ url: url.href,
458
+ content,
459
+ contentType: "text/markdown",
460
+ size: Buffer.byteLength(content, "utf-8"),
461
+ notes: [freshness, `File listing for pr://${repo}/${parsed.number}`],
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Handler for `issue://` URLs.
467
+ */
468
+ export class IssueProtocolHandler implements ProtocolHandler {
469
+ readonly scheme = "issue";
470
+ readonly immutable = true;
471
+
472
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
473
+ if (context?.signal?.aborted) {
474
+ throw new Error("aborted");
475
+ }
476
+ const parsed = parseUrl(url, "issue");
477
+ if (parsed.kind === "list") {
478
+ try {
479
+ return await fetchAndRenderList("issue", parsed, url, context);
480
+ } catch (err) {
481
+ const message = err instanceof Error ? err.message : String(err);
482
+ throw new Error(`issue:// listing failed: ${message}`);
483
+ }
484
+ }
485
+ // parseUrl already rejects `issue://.../diff`; this guard is a belt-and-
486
+ // suspenders catch in case the union grows.
487
+ if (parsed.kind !== "single") {
488
+ throw new Error(`Invalid issue:// URL: unexpected variant '${parsed.kind}'`);
489
+ }
490
+ try {
491
+ const lookup = await getOrFetchIssue({
492
+ cwd: resolveCwd(context),
493
+ repo: parsed.repo,
494
+ issue: String(parsed.number),
495
+ includeComments: parsed.comments,
496
+ signal: context?.signal,
497
+ settings: settingsFromContext(context),
498
+ });
499
+ return buildSingleResource({
500
+ url,
501
+ scheme: "issue",
502
+ parsed,
503
+ rendered: lookup.rendered,
504
+ status: lookup.status,
505
+ fetchedAt: lookup.fetchedAt,
506
+ });
507
+ } catch (err) {
508
+ const message = err instanceof Error ? err.message : String(err);
509
+ throw new Error(`issue:// resolution failed: ${message}`);
510
+ }
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Handler for `pr://` URLs.
516
+ */
517
+ export class PrProtocolHandler implements ProtocolHandler {
518
+ readonly scheme = "pr";
519
+ readonly immutable = true;
520
+
521
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
522
+ if (context?.signal?.aborted) {
523
+ throw new Error("aborted");
524
+ }
525
+ const parsed = parseUrl(url, "pr");
526
+ if (parsed.kind === "list") {
527
+ try {
528
+ return await fetchAndRenderList("pr", parsed, url, context);
529
+ } catch (err) {
530
+ const message = err instanceof Error ? err.message : String(err);
531
+ throw new Error(`pr:// listing failed: ${message}`);
532
+ }
533
+ }
534
+ if (parsed.kind === "pr-diff") {
535
+ try {
536
+ return await fetchAndRenderPrDiff(url, parsed, context);
537
+ } catch (err) {
538
+ const message = err instanceof Error ? err.message : String(err);
539
+ throw new Error(`pr:// diff resolution failed: ${message}`);
540
+ }
541
+ }
542
+ const cwd = resolveCwd(context);
543
+ let repo = parsed.repo;
544
+ if (!repo) {
545
+ try {
546
+ repo = await resolveDefaultRepoMemoized(cwd, context?.signal);
547
+ } catch (err) {
548
+ const message = err instanceof Error ? err.message : String(err);
549
+ throw new Error(
550
+ `pr://${parsed.number} could not resolve a default repo from the current session: ${message}\nUse pr://<owner>/<repo>/${parsed.number}.`,
551
+ );
552
+ }
553
+ }
554
+ try {
555
+ const lookup = await getOrFetchPr({
556
+ cwd,
557
+ repo,
558
+ number: parsed.number,
559
+ includeComments: parsed.comments,
560
+ signal: context?.signal,
561
+ settings: settingsFromContext(context),
562
+ });
563
+ return buildSingleResource({
564
+ url,
565
+ scheme: "pr",
566
+ parsed,
567
+ rendered: lookup.rendered,
568
+ status: lookup.status,
569
+ fetchedAt: lookup.fetchedAt,
570
+ repo,
571
+ });
572
+ } catch (err) {
573
+ const message = err instanceof Error ? err.message : String(err);
574
+ throw new Error(`pr:// resolution failed: ${message}`);
575
+ }
576
+ }
577
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { AgentProtocolHandler } from "./agent-protocol";
9
9
  import { ArtifactProtocolHandler } from "./artifact-protocol";
10
+ import { IssueProtocolHandler, PrProtocolHandler } from "./issue-pr-protocol";
10
11
  import { LocalProtocolHandler } from "./local-protocol";
11
12
  import { McpProtocolHandler } from "./mcp-protocol";
12
13
  import { MemoryProtocolHandler } from "./memory-protocol";
@@ -14,7 +15,7 @@ import { parseInternalUrl } from "./parse";
14
15
  import { PiProtocolHandler } from "./pi-protocol";
15
16
  import { RuleProtocolHandler } from "./rule-protocol";
16
17
  import { SkillProtocolHandler } from "./skill-protocol";
17
- import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
18
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext } from "./types";
18
19
 
19
20
  export class InternalUrlRouter {
20
21
  static #instance: InternalUrlRouter | undefined;
@@ -30,6 +31,8 @@ export class InternalUrlRouter {
30
31
  this.register(new SkillProtocolHandler());
31
32
  this.register(new RuleProtocolHandler());
32
33
  this.register(new McpProtocolHandler());
34
+ this.register(new IssueProtocolHandler());
35
+ this.register(new PrProtocolHandler());
33
36
  }
34
37
 
35
38
  /** Process-global router instance. */
@@ -53,7 +56,7 @@ export class InternalUrlRouter {
53
56
  return this.#handlers.has(match[1].toLowerCase());
54
57
  }
55
58
 
56
- async resolve(input: string): Promise<InternalResource> {
59
+ async resolve(input: string, context?: ResolveContext): Promise<InternalResource> {
57
60
  const parsed = parseInternalUrl(input);
58
61
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
59
62
  const handler = this.#handlers.get(scheme);
@@ -65,7 +68,7 @@ export class InternalUrlRouter {
65
68
  throw new Error(`Unknown protocol: ${scheme}://\nSupported: ${available || "none"}`);
66
69
  }
67
70
 
68
- const resource = await handler.resolve(parsed as InternalUrl);
71
+ const resource = await handler.resolve(parsed as InternalUrl, context);
69
72
  return { ...resource, immutable: resource.immutable ?? handler.immutable };
70
73
  }
71
74
  }
@@ -46,6 +46,23 @@ export interface InternalUrl extends URL {
46
46
  rawPathname?: string;
47
47
  }
48
48
 
49
+ /**
50
+ * Caller-supplied context that the router threads into protocol handlers.
51
+ *
52
+ * Read tool calls `InternalUrlRouter.resolve(url, { cwd, settings, signal })`
53
+ * so handlers can resolve relative defaults (e.g. `issue://N` → which repo?)
54
+ * against the actual session that initiated the read, not whichever session
55
+ * happens to be registered first in the global `AgentRegistry`.
56
+ */
57
+ export interface ResolveContext {
58
+ /** Working directory of the calling session. */
59
+ cwd?: string;
60
+ /** Settings of the calling session (used by `issue://`/`pr://` for cache TTLs). */
61
+ settings?: unknown;
62
+ /** Caller's abort signal. */
63
+ signal?: AbortSignal;
64
+ }
65
+
49
66
  /**
50
67
  * Handler for a specific internal URL scheme (e.g., agent://, memory://, skill://, mcp://).
51
68
  */
@@ -61,8 +78,12 @@ export interface ProtocolHandler {
61
78
  /**
62
79
  * Resolve an internal URL to its content. The router stamps the
63
80
  * {@link InternalResource.immutable} flag from {@link ProtocolHandler.immutable}.
81
+ *
64
82
  * @param url Parsed URL object
83
+ * @param context Optional caller context. Handlers that depend on caller
84
+ * identity (working directory, settings) **MUST** consume this in
85
+ * preference to global state.
65
86
  * @throws Error with user-friendly message if resolution fails
66
87
  */
67
- resolve(url: InternalUrl): Promise<InternalResource>;
88
+ resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource>;
68
89
  }