@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
package/src/tools/gh.ts CHANGED
@@ -5,10 +5,12 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
5
5
  import { StringEnum } from "@oh-my-pi/pi-ai";
6
6
  import { abortableSleep, getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
+ import type { Settings } from "../config/settings";
8
9
  import githubDescription from "../prompts/tools/github.md" with { type: "text" };
9
10
  import * as git from "../utils/git";
10
11
  import type { ToolSession } from ".";
11
12
  import { formatShortSha } from "./gh-format";
13
+ import { type CacheStatus, getOrFetchView, resolveGithubCacheAuthKey } from "./github-cache";
12
14
  import type { OutputMeta } from "./output-meta";
13
15
  import { ToolError, throwIfAborted } from "./tool-errors";
14
16
  import { toolResult } from "./tool-result";
@@ -103,35 +105,81 @@ const GH_PR_CHECKOUT_FIELDS = [
103
105
  "title",
104
106
  "url",
105
107
  ];
106
- const GH_SEARCH_FIELDS = [
107
- "author",
108
- "createdAt",
109
- "labels",
110
- "number",
111
- "repository",
112
- "state",
113
- "title",
114
- "updatedAt",
115
- "url",
116
- ];
117
- const GH_SEARCH_CODE_FIELDS = ["path", "repository", "sha", "textMatches", "url"];
118
- const GH_SEARCH_COMMITS_FIELDS = ["author", "commit", "committer", "id", "repository", "sha", "url"];
119
- const GH_SEARCH_REPOS_FIELDS = [
120
- "createdAt",
121
- "description",
122
- "forksCount",
123
- "fullName",
124
- "isArchived",
125
- "isFork",
126
- "isPrivate",
127
- "language",
128
- "openIssuesCount",
129
- "owner",
130
- "stargazersCount",
131
- "updatedAt",
132
- "url",
133
- "visibility",
134
- ];
108
+ // /search/<endpoint> API response shapes (subset). Used when projecting raw
109
+ // REST results into the normalized `GhSearch*Result` shapes the formatters
110
+ // consume. We talk to the API directly because `gh search prs`/`issues`
111
+ // quotes multi-token positional queries (`is:"merged is:pr"`) and returns 0
112
+ // hits — see https://github.com/cli/cli for the upstream regression.
113
+ interface GhApiSearchResponse<T> {
114
+ total_count?: number;
115
+ incomplete_results?: boolean;
116
+ items?: T[];
117
+ }
118
+ interface GhApiUser {
119
+ login?: string;
120
+ name?: string | null;
121
+ }
122
+ interface GhApiLabel {
123
+ name?: string;
124
+ }
125
+ interface GhApiPullRequestRef {
126
+ merged_at?: string | null;
127
+ }
128
+ interface GhApiSearchIssueItem {
129
+ number?: number;
130
+ title?: string;
131
+ state?: string;
132
+ state_reason?: string | null;
133
+ user?: GhApiUser | null;
134
+ labels?: GhApiLabel[];
135
+ created_at?: string;
136
+ updated_at?: string;
137
+ html_url?: string;
138
+ repository_url?: string;
139
+ pull_request?: GhApiPullRequestRef | null;
140
+ }
141
+ interface GhApiSearchCodeItem {
142
+ name?: string;
143
+ path?: string;
144
+ sha?: string;
145
+ html_url?: string;
146
+ repository?: { full_name?: string } | null;
147
+ text_matches?: Array<{ fragment?: string; property?: string }>;
148
+ }
149
+ interface GhApiSearchCommitGitActor {
150
+ name?: string;
151
+ email?: string;
152
+ date?: string;
153
+ }
154
+ interface GhApiSearchCommitItem {
155
+ sha?: string;
156
+ node_id?: string;
157
+ html_url?: string;
158
+ author?: GhApiUser | null;
159
+ committer?: GhApiUser | null;
160
+ commit?: {
161
+ author?: GhApiSearchCommitGitActor | null;
162
+ committer?: GhApiSearchCommitGitActor | null;
163
+ message?: string;
164
+ } | null;
165
+ repository?: { full_name?: string } | null;
166
+ }
167
+ interface GhApiSearchRepoItem {
168
+ full_name?: string;
169
+ description?: string | null;
170
+ language?: string | null;
171
+ stargazers_count?: number;
172
+ forks_count?: number;
173
+ open_issues_count?: number;
174
+ archived?: boolean;
175
+ fork?: boolean;
176
+ private?: boolean;
177
+ visibility?: string | null;
178
+ updated_at?: string;
179
+ created_at?: string;
180
+ html_url?: string;
181
+ owner?: GhApiUser | null;
182
+ }
135
183
  const SEARCH_LIMIT_DEFAULT = 10;
136
184
  const SEARCH_LIMIT_MAX = 50;
137
185
  const FILE_PREVIEW_LIMIT = 50;
@@ -142,6 +190,7 @@ const RUN_WATCH_TAIL_MAX = 200;
142
190
  const REVIEW_COMMENTS_PAGE_SIZE = 100;
143
191
  const RUN_JOBS_PAGE_SIZE = 100;
144
192
  const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/.*)?$/;
193
+ const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/.*)?$/;
145
194
  const RUN_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/.*)?$/;
146
195
  const RUN_SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
147
196
  const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required", "startup_failure"]);
@@ -151,10 +200,7 @@ const githubSchema = Type.Object({
151
200
  op: StringEnum(
152
201
  [
153
202
  "repo_view",
154
- "issue_view",
155
203
  "pr_create",
156
- "pr_view",
157
- "pr_diff",
158
204
  "pr_checkout",
159
205
  "pr_push",
160
206
  "search_issues",
@@ -178,12 +224,6 @@ const githubSchema = Type.Object({
178
224
  examples: ["main", "develop"],
179
225
  }),
180
226
  ),
181
- issue: Type.Optional(
182
- Type.String({
183
- description: "issue number or url (issue_view)",
184
- examples: ["123", "https://github.com/owner/repo/issues/123"],
185
- }),
186
- ),
187
227
  pr: Type.Optional(
188
228
  Type.Union(
189
229
  [
@@ -194,17 +234,10 @@ const githubSchema = Type.Object({
194
234
  ],
195
235
  {
196
236
  description:
197
- "pr number, url, or branch (pr_view, pr_diff, pr_checkout); pass an array to batch-process multiple pull requests in one call",
237
+ "pr number, url, or branch (pr_checkout); pass an array to batch-process multiple pull requests in one call",
198
238
  },
199
239
  ),
200
240
  ),
201
- comments: Type.Optional(Type.Boolean({ description: "include comments (issue_view, pr_view)", default: true })),
202
- nameOnly: Type.Optional(Type.Boolean({ description: "return file names only (pr_diff)" })),
203
- exclude: Type.Optional(
204
- Type.Array(Type.String({ description: "glob to exclude" }), {
205
- description: "file globs to exclude (pr_diff)",
206
- }),
207
- ),
208
241
  force: Type.Optional(Type.Boolean({ description: "reset existing local branch (pr_checkout)" })),
209
242
  forceWithLease: Type.Optional(Type.Boolean({ description: "force-with-lease push (pr_push)" })),
210
243
  title: Type.Optional(
@@ -699,13 +732,7 @@ function appendRepoFlag(args: string[], repo: string | undefined, identifier?: s
699
732
  args.push("--repo", repo);
700
733
  }
701
734
 
702
- const SEARCH_FIELDS_BY_COMMAND: Record<"issues" | "prs" | "code" | "commits" | "repos", readonly string[]> = {
703
- issues: GH_SEARCH_FIELDS,
704
- prs: GH_SEARCH_FIELDS,
705
- code: GH_SEARCH_CODE_FIELDS,
706
- commits: GH_SEARCH_COMMITS_FIELDS,
707
- repos: GH_SEARCH_REPOS_FIELDS,
708
- };
735
+ const REPO_API_URL_PREFIX = "https://api.github.com/repos/";
709
736
 
710
737
  const RELATIVE_DURATION_PATTERN = /^(\d+)\s*(m|h|d|w|mo|y)$/i;
711
738
  const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -811,21 +838,98 @@ function composeSearchQuery(parts: ReadonlyArray<string | undefined>): string {
811
838
  return cleaned.join(" ");
812
839
  }
813
840
 
814
- function buildGhSearchArgs(
815
- command: "issues" | "prs" | "code" | "commits" | "repos",
841
+ function buildGhApiSearchArgs(
842
+ endpoint: "issues" | "code" | "commits" | "repositories",
816
843
  query: string,
817
844
  limit: number,
818
- repo: string | undefined,
845
+ extraHeaders?: ReadonlyArray<string>,
819
846
  ): string[] {
820
- const fields = SEARCH_FIELDS_BY_COMMAND[command];
821
- const args = ["search", command, "--limit", String(limit), "--json", fields.join(",")];
822
- if (command !== "repos") {
823
- appendRepoFlag(args, repo);
847
+ const args = ["api", "-X", "GET", `/search/${endpoint}`, "-f", `q=${query}`, "-F", `per_page=${limit}`];
848
+ for (const header of extraHeaders ?? []) {
849
+ args.push("-H", header);
824
850
  }
825
- args.push("--", query);
826
851
  return args;
827
852
  }
828
853
 
854
+ function repoFromRepositoryUrl(value: string | undefined): string | undefined {
855
+ if (!value?.startsWith(REPO_API_URL_PREFIX)) return undefined;
856
+ return value.slice(REPO_API_URL_PREFIX.length);
857
+ }
858
+
859
+ function apiUserToGhUser(user: GhApiUser | null | undefined): GhUser | undefined {
860
+ if (!user) return undefined;
861
+ const login = user.login ?? undefined;
862
+ const name = user.name ?? undefined;
863
+ if (login === undefined && name === undefined) return undefined;
864
+ return { login, name };
865
+ }
866
+
867
+ function apiLabelsToGhLabels(labels: GhApiLabel[] | undefined): GhLabel[] {
868
+ return labels?.map(label => ({ name: label.name })) ?? [];
869
+ }
870
+
871
+ function apiIssueToSearchResult(item: GhApiSearchIssueItem): GhSearchResult {
872
+ const merged = Boolean(item.pull_request?.merged_at);
873
+ return {
874
+ author: apiUserToGhUser(item.user) ?? null,
875
+ createdAt: item.created_at,
876
+ labels: apiLabelsToGhLabels(item.labels),
877
+ number: item.number,
878
+ repository: { nameWithOwner: repoFromRepositoryUrl(item.repository_url) },
879
+ state: merged ? "merged" : item.state,
880
+ title: item.title,
881
+ updatedAt: item.updated_at,
882
+ url: item.html_url,
883
+ };
884
+ }
885
+
886
+ function apiCodeToSearchResult(item: GhApiSearchCodeItem): GhSearchCodeResult {
887
+ return {
888
+ path: item.path,
889
+ repository: { nameWithOwner: item.repository?.full_name },
890
+ sha: item.sha,
891
+ textMatches: item.text_matches?.map(match => ({ fragment: match.fragment, property: match.property })),
892
+ url: item.html_url,
893
+ };
894
+ }
895
+
896
+ function apiCommitToSearchResult(item: GhApiSearchCommitItem): GhSearchCommitResult {
897
+ return {
898
+ author: apiUserToGhUser(item.author) ?? null,
899
+ commit: item.commit
900
+ ? {
901
+ author: item.commit.author ?? null,
902
+ committer: item.commit.committer ?? null,
903
+ message: item.commit.message,
904
+ }
905
+ : null,
906
+ committer: apiUserToGhUser(item.committer) ?? null,
907
+ id: item.node_id,
908
+ repository: { nameWithOwner: item.repository?.full_name },
909
+ sha: item.sha,
910
+ url: item.html_url,
911
+ };
912
+ }
913
+
914
+ function apiRepoToSearchResult(item: GhApiSearchRepoItem): GhSearchRepoResult {
915
+ return {
916
+ createdAt: item.created_at,
917
+ description: item.description,
918
+ forksCount: item.forks_count,
919
+ fullName: item.full_name,
920
+ isArchived: item.archived,
921
+ isFork: item.fork,
922
+ isPrivate: item.private,
923
+ language: item.language,
924
+ openIssuesCount: item.open_issues_count,
925
+ owner: apiUserToGhUser(item.owner) ?? null,
926
+ stargazersCount: item.stargazers_count,
927
+ updatedAt: item.updated_at,
928
+ url: item.html_url,
929
+ visibility: item.visibility ?? null,
930
+ };
931
+ }
932
+
829
933
  function sanitizeRemoteName(value: string): string {
830
934
  const sanitized = value
831
935
  .toLowerCase()
@@ -1086,6 +1190,29 @@ function parsePullRequestUrl(value: string | undefined): { repo?: string; prNumb
1086
1190
  };
1087
1191
  }
1088
1192
 
1193
+ /**
1194
+ * Parse a digit-only decimal positive integer or return undefined. Rejects
1195
+ * `1e2`, `0x10`, `12.0`, leading +/-, or any other shape `Number()` would
1196
+ * accept — those would otherwise key the cache against the wrong row.
1197
+ */
1198
+ export function parsePositiveDecimalInt(value: string | undefined): number | undefined {
1199
+ if (!value || !/^\d+$/.test(value)) return undefined;
1200
+ const num = Number(value);
1201
+ if (!Number.isSafeInteger(num) || num <= 0) return undefined;
1202
+ return num;
1203
+ }
1204
+
1205
+ function parseIssueUrl(value: string | undefined): { repo?: string; issueNumber?: number } {
1206
+ const normalized = normalizeOptionalString(value);
1207
+ if (!normalized) return {};
1208
+ const match = normalized.match(ISSUE_URL_PATTERN);
1209
+ if (!match) return {};
1210
+ return {
1211
+ repo: match[1],
1212
+ issueNumber: Number(match[2]),
1213
+ };
1214
+ }
1215
+
1089
1216
  function normalizePrReviewComment(comment: GhPrReviewCommentApi): GhPrReviewComment | null {
1090
1217
  if (typeof comment.id !== "number") {
1091
1218
  return null;
@@ -1600,6 +1727,52 @@ async function resolveGitHubRepo(
1600
1727
  return requireNonEmpty(resolved, "repo");
1601
1728
  }
1602
1729
 
1730
+ /**
1731
+ * Process-lifetime cache of `gh repo view --json nameWithOwner` lookups keyed
1732
+ * by absolute cwd. Avoids repeated `gh` chatter when the same protocol handler
1733
+ * or tool call resolves the default repo many times in a row.
1734
+ *
1735
+ * The shared lookup is intentionally **not** bound to any caller's
1736
+ * AbortSignal. Cancelling one caller would otherwise kill the underlying
1737
+ * `gh repo view` for every concurrent waiter on the same cwd. Each caller's
1738
+ * signal is honored at the wait point via `untilAborted` instead, so an abort
1739
+ * unwinds only that caller.
1740
+ */
1741
+ const DEFAULT_REPO_RESOLVED = new Map<string, string>();
1742
+ const DEFAULT_REPO_INFLIGHT = new Map<string, Promise<string>>();
1743
+
1744
+ export async function resolveDefaultRepoMemoized(cwd: string, signal?: AbortSignal): Promise<string> {
1745
+ const key = path.resolve(cwd);
1746
+ const ready = DEFAULT_REPO_RESOLVED.get(key);
1747
+ if (ready) return ready;
1748
+ let pending = DEFAULT_REPO_INFLIGHT.get(key);
1749
+ if (!pending) {
1750
+ pending = (async () => {
1751
+ // No caller signal: this lookup is shared across every concurrent
1752
+ // waiter on the same cwd.
1753
+ const resolved = await git.github.text(cwd, [
1754
+ "repo",
1755
+ "view",
1756
+ "--json",
1757
+ "nameWithOwner",
1758
+ "-q",
1759
+ ".nameWithOwner",
1760
+ ]);
1761
+ const value = requireNonEmpty(resolved, "repo");
1762
+ DEFAULT_REPO_RESOLVED.set(key, value);
1763
+ return value;
1764
+ })();
1765
+ // Drop the in-flight slot on settle so failures don't poison the cache
1766
+ // and so a successful resolution survives only in `DEFAULT_REPO_RESOLVED`.
1767
+ void pending.then(
1768
+ () => DEFAULT_REPO_INFLIGHT.delete(key),
1769
+ () => DEFAULT_REPO_INFLIGHT.delete(key),
1770
+ );
1771
+ DEFAULT_REPO_INFLIGHT.set(key, pending);
1772
+ }
1773
+ return untilAborted(signal, pending);
1774
+ }
1775
+
1603
1776
  async function resolveGitHubBranchHead(
1604
1777
  cwd: string,
1605
1778
  repo: string,
@@ -2237,14 +2410,8 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
2237
2410
  switch (params.op) {
2238
2411
  case "repo_view":
2239
2412
  return executeRepoView(this.session, params, signal);
2240
- case "issue_view":
2241
- return executeIssueView(this.session, params, signal);
2242
2413
  case "pr_create":
2243
2414
  return executePrCreate(this.session, params, signal);
2244
- case "pr_view":
2245
- return executePrView(this.session, params, signal);
2246
- case "pr_diff":
2247
- return executePrDiff(this.session, params, signal);
2248
2415
  case "pr_checkout":
2249
2416
  return executePrCheckout(this.session, params, signal);
2250
2417
  case "pr_push":
@@ -2288,111 +2455,449 @@ async function executeRepoView(
2288
2455
  return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
2289
2456
  }
2290
2457
 
2291
- async function executeIssueView(
2292
- session: ToolSession,
2293
- params: GithubInput,
2458
+ // ────────────────────────────────────────────────────────────────────────────
2459
+ // Cached issue/PR view fetchers
2460
+ //
2461
+ // Used by `executeIssueView`/`executePrView` and by the `issue://` / `pr://`
2462
+ // internal-URL protocol handlers. The cache wrapper lives in `./github-cache`;
2463
+ // the fresh fetchers stay here to share the existing formatter helpers.
2464
+ // ────────────────────────────────────────────────────────────────────────────
2465
+
2466
+ export interface IssueViewLookupOptions {
2467
+ cwd: string;
2468
+ repo?: string;
2469
+ /** Issue number or GitHub issue URL. */
2470
+ issue: string;
2471
+ includeComments?: boolean;
2472
+ signal?: AbortSignal;
2473
+ settings?: Settings;
2474
+ cacheAuthKey?: string | null;
2475
+ }
2476
+
2477
+ export interface PrViewLookupOptions {
2478
+ cwd: string;
2479
+ repo: string;
2480
+ number: number;
2481
+ includeComments?: boolean;
2482
+ signal?: AbortSignal;
2483
+ settings?: Settings;
2484
+ cacheAuthKey?: string | null;
2485
+ }
2486
+
2487
+ export interface ViewLookupResult<T> {
2488
+ rendered: string;
2489
+ sourceUrl: string | undefined;
2490
+ payload: T;
2491
+ status: CacheStatus;
2492
+ fetchedAt: number;
2493
+ }
2494
+
2495
+ async function fetchIssueViewFresh(
2496
+ cwd: string,
2497
+ repo: string | undefined,
2498
+ identifier: string,
2499
+ includeComments: boolean,
2294
2500
  signal: AbortSignal | undefined,
2295
- ): Promise<AgentToolResult<GhToolDetails>> {
2296
- const issue = requireNonEmpty(params.issue, "issue");
2297
- const repo = normalizeOptionalString(params.repo);
2298
- const includeComments = params.comments ?? true;
2299
- const args = ["issue", "view", issue];
2300
- appendRepoFlag(args, repo, issue);
2501
+ ): Promise<{ rendered: string; sourceUrl: string | undefined; payload: GhIssueViewData }> {
2502
+ const args = ["issue", "view", identifier];
2503
+ appendRepoFlag(args, repo, identifier);
2301
2504
  args.push("--json", (includeComments ? GH_ISSUE_FIELDS : GH_ISSUE_FIELDS_NO_COMMENTS).join(","));
2302
-
2303
- const data = await git.github.json<GhIssueViewData>(session.cwd, args, signal, {
2505
+ const data = await git.github.json<GhIssueViewData>(cwd, args, signal, {
2304
2506
  repoProvided: Boolean(repo),
2305
2507
  });
2306
- return buildTextResult(formatIssueView(data, { issue, repo, comments: includeComments }), data.url);
2508
+ const rendered = formatIssueView(data, { issue: identifier, repo, comments: includeComments });
2509
+ return { rendered, sourceUrl: data.url, payload: data };
2307
2510
  }
2308
2511
 
2309
- async function executePrView(
2310
- session: ToolSession,
2311
- params: GithubInput,
2512
+ async function fetchPrViewFresh(
2513
+ cwd: string,
2514
+ repo: string,
2515
+ number: number,
2516
+ includeComments: boolean,
2312
2517
  signal: AbortSignal | undefined,
2313
- ): Promise<AgentToolResult<GhToolDetails>> {
2314
- const repo = normalizeOptionalString(params.repo);
2315
- const includeComments = params.comments ?? true;
2316
- const prList = normalizePrIdentifierList(params.pr);
2317
- const prRefs: (string | undefined)[] = prList.length > 0 ? prList : [undefined];
2518
+ ): Promise<{ rendered: string; sourceUrl: string | undefined; payload: GhPrViewData }> {
2519
+ const args = ["pr", "view", String(number)];
2520
+ appendRepoFlag(args, repo, String(number));
2521
+ args.push("--json", (includeComments ? GH_PR_FIELDS : GH_PR_FIELDS_NO_COMMENTS).join(","));
2522
+ const data = await git.github.json<GhPrViewData>(cwd, args, signal, { repoProvided: true });
2523
+ if (includeComments && typeof data.number === "number") {
2524
+ data.reviewComments = await fetchPrReviewComments(cwd, repo, data.number, signal);
2525
+ }
2526
+ const rendered = formatPrView(data, { pr: String(number), repo, comments: includeComments });
2527
+ return { rendered, sourceUrl: data.url, payload: data };
2528
+ }
2318
2529
 
2319
- const views = await Promise.all(
2320
- prRefs.map(async prRef => {
2321
- const args = ["pr", "view"];
2322
- if (prRef) args.push(prRef);
2323
- appendRepoFlag(args, repo, prRef);
2324
- args.push("--json", (includeComments ? GH_PR_FIELDS : GH_PR_FIELDS_NO_COMMENTS).join(","));
2530
+ /**
2531
+ * Cache-aware issue/view fetcher. Used by both the `github` tool op and the
2532
+ * `issue://` protocol handler so a single shared row services both surfaces.
2533
+ */
2534
+ export async function getOrFetchIssue(options: IssueViewLookupOptions): Promise<ViewLookupResult<GhIssueViewData>> {
2535
+ const identifier = requireNonEmpty(options.issue, "issue");
2536
+ const includeComments = options.includeComments ?? true;
2537
+ const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
2538
+ const urlParse = parseIssueUrl(identifier);
2539
+ // Prefer the URL's repo when the identifier is a full URL; fall back to the
2540
+ // explicit `repo` option, then to the cwd's default repo.
2541
+ let repo = urlParse.repo ?? normalizeOptionalString(options.repo);
2542
+ let cacheNumber = urlParse.issueNumber;
2543
+ if (cacheNumber === undefined) {
2544
+ cacheNumber = parsePositiveDecimalInt(identifier);
2545
+ }
2546
+ if (cacheNumber !== undefined && !repo) {
2547
+ try {
2548
+ repo = await resolveDefaultRepoMemoized(options.cwd, options.signal);
2549
+ } catch {
2550
+ // Resolution failure leaves `repo` undefined: we'll fall through to a
2551
+ // direct fetch below so gh produces its own error message instead of
2552
+ // us masking it with a friendlier one.
2553
+ repo = undefined;
2554
+ }
2555
+ }
2325
2556
 
2326
- const data = await git.github.json<GhPrViewData>(session.cwd, args, signal, {
2327
- repoProvided: Boolean(repo),
2328
- });
2329
- const resolvedRepo = repo ?? parsePullRequestUrl(data.url).repo;
2330
- if (includeComments && resolvedRepo && typeof data.number === "number") {
2331
- data.reviewComments = await fetchPrReviewComments(session.cwd, resolvedRepo, data.number, signal);
2332
- }
2333
- return { prRef, data };
2334
- }),
2335
- );
2557
+ const doFetch = () => fetchIssueViewFresh(options.cwd, repo, identifier, includeComments, options.signal);
2336
2558
 
2337
- if (views.length === 1) {
2338
- const [view] = views;
2339
- return buildTextResult(
2340
- formatPrView(view.data, { pr: view.prRef, repo, comments: includeComments }),
2341
- view.data.url,
2342
- );
2559
+ if (!repo || cacheNumber === undefined) {
2560
+ const fresh = await doFetch();
2561
+ return { ...fresh, status: "miss", fetchedAt: Date.now() };
2343
2562
  }
2344
2563
 
2345
- const sections = views.map(view => formatPrView(view.data, { pr: view.prRef, repo, comments: includeComments }));
2346
- const text = [`# ${views.length} Pull Requests`, "", ...joinSections(sections)].join("\n").trim();
2347
- return buildTextResult(text);
2564
+ const lookup = await getOrFetchView<GhIssueViewData>({
2565
+ repo,
2566
+ kind: "issue",
2567
+ number: cacheNumber,
2568
+ includeComments,
2569
+ settings: options.settings,
2570
+ authKey,
2571
+ fetchFresh: doFetch,
2572
+ });
2573
+ return {
2574
+ rendered: lookup.rendered,
2575
+ sourceUrl: lookup.sourceUrl,
2576
+ payload: lookup.payload,
2577
+ status: lookup.status,
2578
+ fetchedAt: lookup.fetchedAt,
2579
+ };
2348
2580
  }
2349
2581
 
2350
- async function executePrDiff(
2351
- session: ToolSession,
2352
- params: GithubInput,
2353
- signal: AbortSignal | undefined,
2354
- ): Promise<AgentToolResult<GhToolDetails>> {
2355
- const repo = normalizeOptionalString(params.repo);
2356
- const prList = normalizePrIdentifierList(params.pr);
2357
- const prRefs: (string | undefined)[] = prList.length > 0 ? prList : [undefined];
2358
-
2359
- const diffs = await Promise.all(
2360
- prRefs.map(async prRef => {
2361
- const args = ["pr", "diff"];
2362
- if (prRef) args.push(prRef);
2363
- appendRepoFlag(args, repo, prRef);
2364
- args.push("--color", "never");
2365
- if (params.nameOnly) args.push("--name-only");
2366
- for (const pattern of params.exclude ?? []) {
2367
- args.push("--exclude", requireNonEmpty(pattern, "exclude pattern"));
2368
- }
2369
- const output = await git.github.text(session.cwd, args, signal, {
2370
- repoProvided: Boolean(repo),
2371
- trimOutput: false,
2372
- });
2373
- return { prRef, output };
2374
- }),
2582
+ /**
2583
+ * Cache-aware PR view fetcher. Caller must supply a numeric PR number;
2584
+ * branch-name / current-branch lookups bypass the cache entirely upstream
2585
+ * (see `executePrView`).
2586
+ */
2587
+ export async function getOrFetchPr(options: PrViewLookupOptions): Promise<ViewLookupResult<GhPrViewData>> {
2588
+ const includeComments = options.includeComments ?? true;
2589
+ const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
2590
+ const doFetch = () => fetchPrViewFresh(options.cwd, options.repo, options.number, includeComments, options.signal);
2591
+ const lookup = await getOrFetchView<GhPrViewData>({
2592
+ repo: options.repo,
2593
+ kind: "pr",
2594
+ number: options.number,
2595
+ includeComments,
2596
+ settings: options.settings,
2597
+ authKey,
2598
+ fetchFresh: doFetch,
2599
+ });
2600
+ return {
2601
+ rendered: lookup.rendered,
2602
+ sourceUrl: lookup.sourceUrl,
2603
+ payload: lookup.payload,
2604
+ status: lookup.status,
2605
+ fetchedAt: lookup.fetchedAt,
2606
+ };
2607
+ }
2608
+
2609
+ // ────────────────────────────────────────────────────────────────────────────
2610
+ // PR diff fetcher
2611
+ //
2612
+ // Used by the `pr://<n>/diff[/…]` internal-URL family. Stores the verbatim
2613
+ // `gh pr diff` text plus a parsed file index so the listing, full-diff, and
2614
+ // per-file slice variants all share one cache row.
2615
+ // ────────────────────────────────────────────────────────────────────────────
2616
+
2617
+ export interface PrDiffFile {
2618
+ /** Display path. Prefers the post-image (`b/<path>`) when present. */
2619
+ path: string;
2620
+ additions: number;
2621
+ deletions: number;
2622
+ changeType: "modified" | "added" | "deleted" | "renamed" | "binary";
2623
+ /** Pre-image path for renames/deletes; same as `path` otherwise. */
2624
+ oldPath?: string;
2625
+ /** Byte offset of the section's `diff --git` line in the unified diff. */
2626
+ startOffset: number;
2627
+ /** Byte offset of the next section (or end-of-text). */
2628
+ endOffset: number;
2629
+ }
2630
+
2631
+ export interface PrDiffPayload {
2632
+ /** Full unified diff text as returned by `gh pr diff --color never`. */
2633
+ unified: string;
2634
+ files: PrDiffFile[];
2635
+ }
2636
+
2637
+ export interface PrDiffLookupOptions {
2638
+ cwd: string;
2639
+ repo: string;
2640
+ number: number;
2641
+ signal?: AbortSignal;
2642
+ settings?: Settings;
2643
+ cacheAuthKey?: string | null;
2644
+ }
2645
+ /**
2646
+ * Split `gh pr diff` output on `^diff --git ` boundaries and parse per-file
2647
+ * metadata. The unified diff is preserved verbatim so callers can slice it by
2648
+ * byte offsets without re-running gh.
2649
+ */
2650
+ export function parsePrUnifiedDiff(text: string): PrDiffPayload {
2651
+ const files: PrDiffFile[] = [];
2652
+ if (text.length === 0) {
2653
+ return { unified: text, files };
2654
+ }
2655
+
2656
+ // Walk match positions manually so we capture each section's byte range.
2657
+ const sectionStarts: number[] = [];
2658
+ const re = /^diff --git /gm;
2659
+ let m: RegExpExecArray | null = re.exec(text);
2660
+ while (m !== null) {
2661
+ sectionStarts.push(m.index);
2662
+ // Avoid zero-length match infinite loop (regex has fixed prefix, but
2663
+ // be explicit).
2664
+ if (re.lastIndex === m.index) re.lastIndex += 1;
2665
+ m = re.exec(text);
2666
+ }
2667
+
2668
+ for (let i = 0; i < sectionStarts.length; i += 1) {
2669
+ const startOffset = sectionStarts[i] ?? 0;
2670
+ const endOffset = sectionStarts[i + 1] ?? text.length;
2671
+ const section = text.slice(startOffset, endOffset);
2672
+ files.push(parsePrDiffSection(section, startOffset, endOffset));
2673
+ }
2674
+ return { unified: text, files };
2675
+ }
2676
+
2677
+ interface ParsedDiffHeaderToken {
2678
+ value: string;
2679
+ nextIndex: number;
2680
+ }
2681
+
2682
+ function skipDiffHeaderSpaces(text: string, index: number): number {
2683
+ let i = index;
2684
+ while (text.charAt(i) === " ") i += 1;
2685
+ return i;
2686
+ }
2687
+
2688
+ function parseDiffQuotedEscape(text: string, slashIndex: number): ParsedDiffHeaderToken {
2689
+ const next = text.charAt(slashIndex + 1);
2690
+ if (next === "") return { value: "\\", nextIndex: slashIndex + 1 };
2691
+
2692
+ if (next >= "0" && next <= "7") {
2693
+ let end = slashIndex + 1;
2694
+ while (end < text.length && end < slashIndex + 4) {
2695
+ const digit = text.charAt(end);
2696
+ if (digit < "0" || digit > "7") break;
2697
+ end += 1;
2698
+ }
2699
+ return {
2700
+ value: String.fromCharCode(Number.parseInt(text.slice(slashIndex + 1, end), 8)),
2701
+ nextIndex: end,
2702
+ };
2703
+ }
2704
+
2705
+ switch (next) {
2706
+ case "a":
2707
+ return { value: "\x07", nextIndex: slashIndex + 2 };
2708
+ case "b":
2709
+ return { value: "\b", nextIndex: slashIndex + 2 };
2710
+ case "f":
2711
+ return { value: "\f", nextIndex: slashIndex + 2 };
2712
+ case "n":
2713
+ return { value: "\n", nextIndex: slashIndex + 2 };
2714
+ case "r":
2715
+ return { value: "\r", nextIndex: slashIndex + 2 };
2716
+ case "t":
2717
+ return { value: "\t", nextIndex: slashIndex + 2 };
2718
+ case "v":
2719
+ return { value: "\v", nextIndex: slashIndex + 2 };
2720
+ case "\\":
2721
+ case '"':
2722
+ return { value: next, nextIndex: slashIndex + 2 };
2723
+ default:
2724
+ return { value: next, nextIndex: slashIndex + 2 };
2725
+ }
2726
+ }
2727
+
2728
+ function parseDiffQuotedToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
2729
+ if (text.charAt(startIndex) !== '"') return undefined;
2730
+ let value = "";
2731
+ for (let i = startIndex + 1; i < text.length; i += 1) {
2732
+ const ch = text.charAt(i);
2733
+ if (ch === '"') return { value, nextIndex: i + 1 };
2734
+ if (ch !== "\\") {
2735
+ value += ch;
2736
+ continue;
2737
+ }
2738
+ const escaped = parseDiffQuotedEscape(text, i);
2739
+ value += escaped.value;
2740
+ i = escaped.nextIndex - 1;
2741
+ }
2742
+ return undefined;
2743
+ }
2744
+
2745
+ function parseDiffHeaderToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
2746
+ const start = skipDiffHeaderSpaces(text, startIndex);
2747
+ if (start >= text.length) return undefined;
2748
+ const quoted = parseDiffQuotedToken(text, start);
2749
+ if (quoted) return quoted;
2750
+ const end = text.indexOf(" ", start);
2751
+ if (end === -1) return { value: text.slice(start), nextIndex: text.length };
2752
+ return { value: text.slice(start, end), nextIndex: end };
2753
+ }
2754
+
2755
+ function stripPrDiffPathPrefix(value: string, prefix: "a/" | "b/"): string | undefined {
2756
+ return value.startsWith(prefix) ? value.slice(prefix.length) : undefined;
2757
+ }
2758
+
2759
+ function parsePrDiffHeaderPaths(header: string): { oldPath?: string; newPath?: string } {
2760
+ const trail = header.slice("diff --git ".length);
2761
+ if (trail.startsWith('"')) {
2762
+ const oldToken = parseDiffQuotedToken(trail, 0);
2763
+ if (!oldToken) return {};
2764
+ const newToken = parseDiffHeaderToken(trail, oldToken.nextIndex);
2765
+ if (!newToken) return {};
2766
+ return {
2767
+ oldPath: stripPrDiffPathPrefix(oldToken.value, "a/"),
2768
+ newPath: stripPrDiffPathPrefix(newToken.value, "b/"),
2769
+ };
2770
+ }
2771
+
2772
+ const bIdx = trail.indexOf(" b/");
2773
+ if (trail.startsWith("a/") && bIdx > 0) {
2774
+ return {
2775
+ oldPath: trail.slice(2, bIdx),
2776
+ newPath: trail.slice(bIdx + 3),
2777
+ };
2778
+ }
2779
+ return {};
2780
+ }
2781
+
2782
+ function isPrDiffFileHeaderLine(line: string): boolean {
2783
+ return (
2784
+ line === "--- /dev/null" ||
2785
+ line === "+++ /dev/null" ||
2786
+ line.startsWith("--- a/") ||
2787
+ line.startsWith("+++ b/") ||
2788
+ line.startsWith('--- "a/') ||
2789
+ line.startsWith('+++ "b/')
2375
2790
  );
2791
+ }
2792
+
2793
+ function parsePrDiffSection(section: string, startOffset: number, endOffset: number): PrDiffFile {
2794
+ const lines = section.split("\n");
2795
+ const header = lines[0] ?? "";
2796
+ const headerPaths = parsePrDiffHeaderPaths(header);
2797
+ let oldPath = headerPaths.oldPath;
2798
+ let newPath = headerPaths.newPath;
2799
+
2800
+ let changeType: PrDiffFile["changeType"] = "modified";
2801
+ let isBinary = false;
2802
+ let additions = 0;
2803
+ let deletions = 0;
2804
+
2805
+ let inHunk = false;
2806
+ for (let li = 1; li < lines.length; li += 1) {
2807
+ const line = lines[li] ?? "";
2808
+ if (line.startsWith("new file mode")) {
2809
+ changeType = "added";
2810
+ continue;
2811
+ }
2812
+ if (line.startsWith("deleted file mode")) {
2813
+ changeType = "deleted";
2814
+ continue;
2815
+ }
2816
+ if (line.startsWith("rename from ")) {
2817
+ changeType = "renamed";
2818
+ oldPath = line.slice("rename from ".length);
2819
+ continue;
2820
+ }
2821
+ if (line.startsWith("rename to ")) {
2822
+ newPath = line.slice("rename to ".length);
2823
+ continue;
2824
+ }
2825
+ if (line.startsWith("Binary files ") && line.endsWith(" differ")) {
2826
+ isBinary = true;
2827
+ continue;
2828
+ }
2829
+ if (line.startsWith("@@ ")) {
2830
+ inHunk = true;
2831
+ continue;
2832
+ }
2833
+ if (!inHunk && isPrDiffFileHeaderLine(line)) continue;
2834
+ if (line.startsWith("+")) {
2835
+ additions += 1;
2836
+ } else if (line.startsWith("-")) {
2837
+ deletions += 1;
2838
+ }
2839
+ }
2376
2840
 
2377
- const singleTitle = params.nameOnly ? "# Pull Request Files" : "# Pull Request Diff";
2378
- const emptyBody = params.nameOnly ? "No changed files." : "No diff output.";
2841
+ if (isBinary) {
2842
+ if (changeType === "modified") changeType = "binary";
2843
+ additions = 0;
2844
+ deletions = 0;
2845
+ }
2379
2846
 
2380
- if (diffs.length === 1) {
2381
- const [diff] = diffs;
2382
- const body = diff.output.length > 0 ? diff.output : emptyBody;
2383
- return buildTextResult(`${singleTitle}\n\n${body}`);
2847
+ const displayPath =
2848
+ changeType === "deleted" ? (oldPath ?? newPath ?? "(unknown)") : (newPath ?? oldPath ?? "(unknown)");
2849
+ const file: PrDiffFile = {
2850
+ path: displayPath,
2851
+ additions,
2852
+ deletions,
2853
+ changeType,
2854
+ startOffset,
2855
+ endOffset,
2856
+ };
2857
+ if (oldPath && oldPath !== displayPath) {
2858
+ file.oldPath = oldPath;
2384
2859
  }
2860
+ return file;
2861
+ }
2385
2862
 
2386
- const header = params.nameOnly
2387
- ? `# ${diffs.length} Pull Request File Lists`
2388
- : `# ${diffs.length} Pull Request Diffs`;
2389
- const sections = diffs.map(diff => {
2390
- const label = diff.prRef ? `PR ${diff.prRef}` : "PR (current branch)";
2391
- const body = diff.output.length > 0 ? diff.output : emptyBody;
2392
- return `## ${label}\n\n${body}`;
2863
+ async function fetchPrDiffFresh(
2864
+ cwd: string,
2865
+ repo: string,
2866
+ number: number,
2867
+ signal: AbortSignal | undefined,
2868
+ ): Promise<{ rendered: string; sourceUrl: string | undefined; payload: PrDiffPayload }> {
2869
+ const args = ["pr", "diff", String(number), "--color", "never"];
2870
+ appendRepoFlag(args, repo, String(number));
2871
+ const text = await git.github.text(cwd, args, signal, { repoProvided: true, trimOutput: false });
2872
+ const payload = parsePrUnifiedDiff(text);
2873
+ return { rendered: text, sourceUrl: undefined, payload };
2874
+ }
2875
+
2876
+ /**
2877
+ * Cache-aware PR diff fetcher. Stores the full unified diff plus a parsed
2878
+ * file index in a single `pr-diff` cache row so the listing, full-diff, and
2879
+ * per-file slice variants of `pr://<n>/diff` share one `gh pr diff`
2880
+ * invocation.
2881
+ */
2882
+ export async function getOrFetchPrDiff(options: PrDiffLookupOptions): Promise<ViewLookupResult<PrDiffPayload>> {
2883
+ const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
2884
+ const doFetch = () => fetchPrDiffFresh(options.cwd, options.repo, options.number, options.signal);
2885
+ const lookup = await getOrFetchView<PrDiffPayload>({
2886
+ repo: options.repo,
2887
+ kind: "pr-diff",
2888
+ number: options.number,
2889
+ includeComments: false,
2890
+ settings: options.settings,
2891
+ authKey,
2892
+ fetchFresh: doFetch,
2393
2893
  });
2394
- const text = [header, "", ...joinSections(sections)].join("\n").trim();
2395
- return buildTextResult(text);
2894
+ return {
2895
+ rendered: lookup.rendered,
2896
+ sourceUrl: lookup.sourceUrl,
2897
+ payload: lookup.payload,
2898
+ status: lookup.status,
2899
+ fetchedAt: lookup.fetchedAt,
2900
+ };
2396
2901
  }
2397
2902
 
2398
2903
  function joinSections(sections: string[]): string[] {
@@ -2765,13 +3270,13 @@ async function executeSearchIssues(
2765
3270
  const limit = resolveSearchLimit(params.limit);
2766
3271
  const dateField = resolveSearchDateField("issues", params.dateField);
2767
3272
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2768
- const query = composeSearchQuery([params.query, dateQualifier]);
2769
- const args = buildGhSearchArgs("issues", query, limit, repo);
3273
+ const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3274
+ const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:issue"]);
3275
+ const args = buildGhApiSearchArgs("issues", apiQuery, limit);
2770
3276
 
2771
- const items = await git.github.json<GhSearchResult[]>(session.cwd, args, signal, {
2772
- repoProvided: Boolean(repo),
2773
- });
2774
- return buildTextResult(formatSearchResults("issues", query, repo, items));
3277
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchIssueItem>>(session.cwd, args, signal);
3278
+ const items = (response.items ?? []).map(apiIssueToSearchResult);
3279
+ return buildTextResult(formatSearchResults("issues", displayQuery, repo, items));
2775
3280
  }
2776
3281
 
2777
3282
  async function executeSearchPrs(
@@ -2783,13 +3288,13 @@ async function executeSearchPrs(
2783
3288
  const limit = resolveSearchLimit(params.limit);
2784
3289
  const dateField = resolveSearchDateField("prs", params.dateField);
2785
3290
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2786
- const query = composeSearchQuery([params.query, dateQualifier]);
2787
- const args = buildGhSearchArgs("prs", query, limit, repo);
3291
+ const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3292
+ const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:pr"]);
3293
+ const args = buildGhApiSearchArgs("issues", apiQuery, limit);
2788
3294
 
2789
- const items = await git.github.json<GhSearchResult[]>(session.cwd, args, signal, {
2790
- repoProvided: Boolean(repo),
2791
- });
2792
- return buildTextResult(formatSearchResults("pull requests", query, repo, items));
3295
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchIssueItem>>(session.cwd, args, signal);
3296
+ const items = (response.items ?? []).map(apiIssueToSearchResult);
3297
+ return buildTextResult(formatSearchResults("pull requests", displayQuery, repo, items));
2793
3298
  }
2794
3299
 
2795
3300
  async function executeSearchCode(
@@ -2803,11 +3308,11 @@ async function executeSearchCode(
2803
3308
  }
2804
3309
  const repo = normalizeOptionalString(params.repo);
2805
3310
  const limit = resolveSearchLimit(params.limit);
2806
- const args = buildGhSearchArgs("code", query, limit, repo);
3311
+ const apiQuery = composeSearchQuery([query, repo ? `repo:${repo}` : undefined]);
3312
+ const args = buildGhApiSearchArgs("code", apiQuery, limit, ["Accept: application/vnd.github.text-match+json"]);
2807
3313
 
2808
- const items = await git.github.json<GhSearchCodeResult[]>(session.cwd, args, signal, {
2809
- repoProvided: Boolean(repo),
2810
- });
3314
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchCodeItem>>(session.cwd, args, signal);
3315
+ const items = (response.items ?? []).map(apiCodeToSearchResult);
2811
3316
  return buildTextResult(formatSearchCodeResults(query, repo, items));
2812
3317
  }
2813
3318
 
@@ -2820,13 +3325,13 @@ async function executeSearchCommits(
2820
3325
  const limit = resolveSearchLimit(params.limit);
2821
3326
  const dateField = resolveSearchDateField("commits", params.dateField);
2822
3327
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2823
- const query = composeSearchQuery([params.query, dateQualifier]);
2824
- const args = buildGhSearchArgs("commits", query, limit, repo);
3328
+ const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3329
+ const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined]);
3330
+ const args = buildGhApiSearchArgs("commits", apiQuery, limit);
2825
3331
 
2826
- const items = await git.github.json<GhSearchCommitResult[]>(session.cwd, args, signal, {
2827
- repoProvided: Boolean(repo),
2828
- });
2829
- return buildTextResult(formatSearchCommitsResults(query, repo, items));
3332
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchCommitItem>>(session.cwd, args, signal);
3333
+ const items = (response.items ?? []).map(apiCommitToSearchResult);
3334
+ return buildTextResult(formatSearchCommitsResults(displayQuery, repo, items));
2830
3335
  }
2831
3336
 
2832
3337
  async function executeSearchRepos(
@@ -2838,9 +3343,10 @@ async function executeSearchRepos(
2838
3343
  const dateField = resolveSearchDateField("repos", params.dateField);
2839
3344
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2840
3345
  const query = composeSearchQuery([params.query, dateQualifier]);
2841
- const args = buildGhSearchArgs("repos", query, limit, undefined);
3346
+ const args = buildGhApiSearchArgs("repositories", query, limit);
2842
3347
 
2843
- const items = await git.github.json<GhSearchRepoResult[]>(session.cwd, args, signal);
3348
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchRepoItem>>(session.cwd, args, signal);
3349
+ const items = (response.items ?? []).map(apiRepoToSearchResult);
2844
3350
  return buildTextResult(formatSearchReposResults(query, items));
2845
3351
  }
2846
3352