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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/scripts/format-prompts.ts +1 -1
  6. package/src/autoresearch/helpers.ts +17 -0
  7. package/src/autoresearch/tools/log-experiment.ts +9 -17
  8. package/src/autoresearch/tools/run-experiment.ts +2 -17
  9. package/src/capability/skill.ts +7 -0
  10. package/src/cli/args.ts +2 -2
  11. package/src/cli/list-models.ts +1 -1
  12. package/src/cli/shell-cli.ts +3 -13
  13. package/src/cli/update-cli.ts +1 -1
  14. package/src/cli.ts +11 -29
  15. package/src/commands/acp.ts +24 -0
  16. package/src/commands/launch.ts +6 -4
  17. package/src/commit/agentic/prompts/system.md +1 -1
  18. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  19. package/src/commit/analysis/conventional.ts +8 -66
  20. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  21. package/src/commit/pipeline.ts +2 -2
  22. package/src/commit/shared-llm.ts +89 -0
  23. package/src/config/config-file.ts +210 -0
  24. package/src/config/model-equivalence.ts +8 -11
  25. package/src/config/model-registry.ts +13 -2
  26. package/src/config/model-resolver.ts +31 -4
  27. package/src/config/settings-schema.ts +102 -1
  28. package/src/config/settings.ts +1 -1
  29. package/src/config.ts +3 -219
  30. package/src/edit/index.ts +22 -1
  31. package/src/edit/modes/patch.ts +10 -0
  32. package/src/edit/modes/replace.ts +3 -0
  33. package/src/edit/renderer.ts +17 -1
  34. package/src/eval/js/context-manager.ts +1 -1
  35. package/src/eval/js/executor.ts +3 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +122 -50
  37. package/src/eval/js/shared/runtime.ts +31 -4
  38. package/src/eval/js/tool-bridge.ts +43 -21
  39. package/src/eval/py/executor.ts +5 -0
  40. package/src/exa/factory.ts +2 -2
  41. package/src/exa/mcp-client.ts +74 -1
  42. package/src/exec/bash-executor.ts +5 -1
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +0 -11
  45. package/src/extensibility/extensions/runner.ts +55 -2
  46. package/src/extensibility/extensions/types.ts +98 -221
  47. package/src/extensibility/hooks/types.ts +89 -314
  48. package/src/extensibility/shared-events.ts +343 -0
  49. package/src/extensibility/skills.ts +42 -1
  50. package/src/goals/index.ts +3 -0
  51. package/src/goals/runtime.ts +500 -0
  52. package/src/goals/state.ts +37 -0
  53. package/src/goals/tools/goal-tool.ts +237 -0
  54. package/src/hashline/anchors.ts +2 -2
  55. package/src/hindsight/mental-models.ts +1 -1
  56. package/src/internal-urls/agent-protocol.ts +1 -20
  57. package/src/internal-urls/artifact-protocol.ts +1 -19
  58. package/src/internal-urls/docs-index.generated.ts +9 -10
  59. package/src/internal-urls/index.ts +1 -0
  60. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  61. package/src/internal-urls/registry-helpers.ts +25 -0
  62. package/src/internal-urls/router.ts +6 -3
  63. package/src/internal-urls/types.ts +22 -1
  64. package/src/main.ts +24 -11
  65. package/src/mcp/oauth-flow.ts +20 -0
  66. package/src/modes/acp/acp-agent.ts +412 -71
  67. package/src/modes/acp/acp-client-bridge.ts +152 -0
  68. package/src/modes/acp/acp-event-mapper.ts +180 -15
  69. package/src/modes/acp/terminal-auth.ts +37 -0
  70. package/src/modes/components/assistant-message.ts +14 -8
  71. package/src/modes/components/bash-execution.ts +24 -63
  72. package/src/modes/components/custom-message.ts +14 -40
  73. package/src/modes/components/eval-execution.ts +27 -57
  74. package/src/modes/components/execution-shared.ts +102 -0
  75. package/src/modes/components/hook-message.ts +17 -49
  76. package/src/modes/components/mcp-add-wizard.ts +26 -5
  77. package/src/modes/components/message-frame.ts +88 -0
  78. package/src/modes/components/model-selector.ts +1 -1
  79. package/src/modes/components/read-tool-group.ts +29 -1
  80. package/src/modes/components/session-observer-overlay.ts +6 -2
  81. package/src/modes/components/session-selector.ts +1 -1
  82. package/src/modes/components/status-line/segments.ts +55 -4
  83. package/src/modes/components/status-line/types.ts +4 -0
  84. package/src/modes/components/status-line.ts +28 -10
  85. package/src/modes/components/tool-execution.ts +7 -8
  86. package/src/modes/controllers/command-controller-shared.ts +108 -0
  87. package/src/modes/controllers/command-controller.ts +27 -10
  88. package/src/modes/controllers/event-controller.ts +60 -18
  89. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  90. package/src/modes/controllers/input-controller.ts +85 -39
  91. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  92. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  93. package/src/modes/interactive-mode.ts +675 -39
  94. package/src/modes/print-mode.ts +16 -86
  95. package/src/modes/rpc/rpc-mode.ts +30 -88
  96. package/src/modes/runtime-init.ts +115 -0
  97. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  98. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  99. package/src/modes/theme/theme.ts +18 -6
  100. package/src/modes/types.ts +20 -5
  101. package/src/modes/utils/context-usage.ts +13 -13
  102. package/src/modes/utils/ui-helpers.ts +25 -6
  103. package/src/plan-mode/approved-plan.ts +35 -1
  104. package/src/prompts/agents/designer.md +5 -5
  105. package/src/prompts/agents/explore.md +7 -7
  106. package/src/prompts/agents/init.md +9 -9
  107. package/src/prompts/agents/librarian.md +14 -14
  108. package/src/prompts/agents/plan.md +4 -4
  109. package/src/prompts/agents/reviewer.md +5 -5
  110. package/src/prompts/agents/task.md +10 -10
  111. package/src/prompts/commands/orchestrate.md +2 -2
  112. package/src/prompts/compaction/branch-summary.md +3 -3
  113. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  114. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  115. package/src/prompts/compaction/compaction-summary.md +5 -5
  116. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  117. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  118. package/src/prompts/goals/goal-budget-limit.md +16 -0
  119. package/src/prompts/goals/goal-continuation.md +28 -0
  120. package/src/prompts/goals/goal-mode-active.md +23 -0
  121. package/src/prompts/memories/consolidation.md +2 -2
  122. package/src/prompts/memories/read-path.md +1 -1
  123. package/src/prompts/memories/stage_one_input.md +1 -1
  124. package/src/prompts/memories/stage_one_system.md +5 -5
  125. package/src/prompts/review-request.md +4 -4
  126. package/src/prompts/system/agent-creation-architect.md +17 -17
  127. package/src/prompts/system/agent-creation-user.md +2 -2
  128. package/src/prompts/system/commit-message-system.md +2 -2
  129. package/src/prompts/system/custom-system-prompt.md +2 -2
  130. package/src/prompts/system/eager-todo.md +6 -6
  131. package/src/prompts/system/handoff-document.md +1 -1
  132. package/src/prompts/system/plan-mode-active.md +25 -24
  133. package/src/prompts/system/plan-mode-approved.md +4 -4
  134. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  135. package/src/prompts/system/plan-mode-reference.md +2 -2
  136. package/src/prompts/system/plan-mode-subagent.md +8 -8
  137. package/src/prompts/system/plan-mode-tool-decision-reminder.md +3 -3
  138. package/src/prompts/system/project-prompt.md +4 -4
  139. package/src/prompts/system/subagent-system-prompt.md +7 -7
  140. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  141. package/src/prompts/system/system-prompt.md +72 -71
  142. package/src/prompts/system/ttsr-interrupt.md +1 -1
  143. package/src/prompts/tools/apply-patch.md +1 -1
  144. package/src/prompts/tools/ast-edit.md +3 -3
  145. package/src/prompts/tools/ast-grep.md +3 -3
  146. package/src/prompts/tools/bash.md +6 -0
  147. package/src/prompts/tools/browser.md +3 -3
  148. package/src/prompts/tools/checkpoint.md +3 -3
  149. package/src/prompts/tools/find.md +3 -3
  150. package/src/prompts/tools/github.md +2 -5
  151. package/src/prompts/tools/goal.md +13 -0
  152. package/src/prompts/tools/hashline.md +104 -116
  153. package/src/prompts/tools/image-gen.md +3 -3
  154. package/src/prompts/tools/irc.md +1 -1
  155. package/src/prompts/tools/lsp.md +2 -2
  156. package/src/prompts/tools/patch.md +6 -6
  157. package/src/prompts/tools/read.md +8 -7
  158. package/src/prompts/tools/replace.md +5 -5
  159. package/src/prompts/tools/resolve.md +6 -5
  160. package/src/prompts/tools/retain.md +1 -1
  161. package/src/prompts/tools/rewind.md +2 -2
  162. package/src/prompts/tools/search.md +2 -2
  163. package/src/prompts/tools/ssh.md +2 -2
  164. package/src/prompts/tools/task.md +12 -6
  165. package/src/prompts/tools/web-search.md +2 -2
  166. package/src/prompts/tools/write.md +3 -3
  167. package/src/sdk.ts +81 -17
  168. package/src/session/agent-session.ts +656 -125
  169. package/src/session/blob-store.ts +36 -3
  170. package/src/session/client-bridge.ts +81 -0
  171. package/src/session/compaction/errors.ts +31 -0
  172. package/src/session/compaction/index.ts +1 -0
  173. package/src/session/messages.ts +67 -2
  174. package/src/session/session-manager.ts +131 -12
  175. package/src/session/session-storage.ts +33 -15
  176. package/src/session/streaming-output.ts +309 -13
  177. package/src/slash-commands/acp-builtins.ts +46 -0
  178. package/src/slash-commands/builtin-registry.ts +717 -116
  179. package/src/slash-commands/helpers/context-report.ts +39 -0
  180. package/src/slash-commands/helpers/format.ts +23 -0
  181. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  182. package/src/slash-commands/helpers/mcp.ts +532 -0
  183. package/src/slash-commands/helpers/parse.ts +85 -0
  184. package/src/slash-commands/helpers/ssh.ts +193 -0
  185. package/src/slash-commands/helpers/todo.ts +279 -0
  186. package/src/slash-commands/helpers/usage-report.ts +91 -0
  187. package/src/slash-commands/types.ts +126 -0
  188. package/src/ssh/ssh-executor.ts +5 -0
  189. package/src/system-prompt.ts +4 -2
  190. package/src/task/executor.ts +27 -10
  191. package/src/task/index.ts +20 -1
  192. package/src/task/render.ts +27 -18
  193. package/src/task/types.ts +4 -0
  194. package/src/tools/ast-edit.ts +21 -120
  195. package/src/tools/ast-grep.ts +21 -119
  196. package/src/tools/bash-interactive.ts +9 -1
  197. package/src/tools/bash.ts +203 -6
  198. package/src/tools/browser/attach.ts +3 -3
  199. package/src/tools/browser/launch.ts +81 -18
  200. package/src/tools/browser/registry.ts +1 -5
  201. package/src/tools/browser/tab-supervisor.ts +51 -14
  202. package/src/tools/conflict-detect.ts +21 -10
  203. package/src/tools/eval.ts +3 -1
  204. package/src/tools/fetch.ts +15 -4
  205. package/src/tools/find.ts +39 -39
  206. package/src/tools/gh-renderer.ts +0 -12
  207. package/src/tools/gh.ts +689 -182
  208. package/src/tools/github-cache.ts +548 -0
  209. package/src/tools/index.ts +25 -11
  210. package/src/tools/inspect-image.ts +3 -10
  211. package/src/tools/output-meta.ts +176 -37
  212. package/src/tools/path-utils.ts +125 -2
  213. package/src/tools/read.ts +605 -239
  214. package/src/tools/render-utils.ts +92 -0
  215. package/src/tools/renderers.ts +2 -0
  216. package/src/tools/resolve.ts +72 -44
  217. package/src/tools/search.ts +120 -186
  218. package/src/tools/write.ts +67 -10
  219. package/src/tui/code-cell.ts +70 -2
  220. package/src/utils/file-mentions.ts +1 -1
  221. package/src/utils/image-loading.ts +7 -3
  222. package/src/utils/image-resize.ts +32 -43
  223. package/src/vim/parser.ts +0 -17
  224. package/src/vim/render.ts +1 -1
  225. package/src/vim/types.ts +1 -1
  226. package/src/web/search/providers/gemini.ts +35 -95
  227. package/src/prompts/tools/exit-plan-mode.md +0 -6
  228. package/src/tools/exit-plan-mode.ts +0 -97
  229. package/src/utils/fuzzy.ts +0 -108
  230. package/src/utils/image-convert.ts +0 -27
package/src/tools/gh.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { scheduler } from "node:timers/promises";
4
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
6
  import { StringEnum } from "@oh-my-pi/pi-ai";
6
- import { abortableSleep, getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
+ import { getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
8
  import { type Static, Type } from "@sinclair/typebox";
9
+ import type { Settings } from "../config/settings";
8
10
  import githubDescription from "../prompts/tools/github.md" with { type: "text" };
9
11
  import * as git from "../utils/git";
10
12
  import type { ToolSession } from ".";
11
13
  import { formatShortSha } from "./gh-format";
14
+ import { type CacheStatus, getOrFetchView, resolveGithubCacheAuthKey } from "./github-cache";
12
15
  import type { OutputMeta } from "./output-meta";
13
16
  import { ToolError, throwIfAborted } from "./tool-errors";
14
17
  import { toolResult } from "./tool-result";
@@ -103,35 +106,81 @@ const GH_PR_CHECKOUT_FIELDS = [
103
106
  "title",
104
107
  "url",
105
108
  ];
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
- ];
109
+ // /search/<endpoint> API response shapes (subset). Used when projecting raw
110
+ // REST results into the normalized `GhSearch*Result` shapes the formatters
111
+ // consume. We talk to the API directly because `gh search prs`/`issues`
112
+ // quotes multi-token positional queries (`is:"merged is:pr"`) and returns 0
113
+ // hits — see https://github.com/cli/cli for the upstream regression.
114
+ interface GhApiSearchResponse<T> {
115
+ total_count?: number;
116
+ incomplete_results?: boolean;
117
+ items?: T[];
118
+ }
119
+ interface GhApiUser {
120
+ login?: string;
121
+ name?: string | null;
122
+ }
123
+ interface GhApiLabel {
124
+ name?: string;
125
+ }
126
+ interface GhApiPullRequestRef {
127
+ merged_at?: string | null;
128
+ }
129
+ interface GhApiSearchIssueItem {
130
+ number?: number;
131
+ title?: string;
132
+ state?: string;
133
+ state_reason?: string | null;
134
+ user?: GhApiUser | null;
135
+ labels?: GhApiLabel[];
136
+ created_at?: string;
137
+ updated_at?: string;
138
+ html_url?: string;
139
+ repository_url?: string;
140
+ pull_request?: GhApiPullRequestRef | null;
141
+ }
142
+ interface GhApiSearchCodeItem {
143
+ name?: string;
144
+ path?: string;
145
+ sha?: string;
146
+ html_url?: string;
147
+ repository?: { full_name?: string } | null;
148
+ text_matches?: Array<{ fragment?: string; property?: string }>;
149
+ }
150
+ interface GhApiSearchCommitGitActor {
151
+ name?: string;
152
+ email?: string;
153
+ date?: string;
154
+ }
155
+ interface GhApiSearchCommitItem {
156
+ sha?: string;
157
+ node_id?: string;
158
+ html_url?: string;
159
+ author?: GhApiUser | null;
160
+ committer?: GhApiUser | null;
161
+ commit?: {
162
+ author?: GhApiSearchCommitGitActor | null;
163
+ committer?: GhApiSearchCommitGitActor | null;
164
+ message?: string;
165
+ } | null;
166
+ repository?: { full_name?: string } | null;
167
+ }
168
+ interface GhApiSearchRepoItem {
169
+ full_name?: string;
170
+ description?: string | null;
171
+ language?: string | null;
172
+ stargazers_count?: number;
173
+ forks_count?: number;
174
+ open_issues_count?: number;
175
+ archived?: boolean;
176
+ fork?: boolean;
177
+ private?: boolean;
178
+ visibility?: string | null;
179
+ updated_at?: string;
180
+ created_at?: string;
181
+ html_url?: string;
182
+ owner?: GhApiUser | null;
183
+ }
135
184
  const SEARCH_LIMIT_DEFAULT = 10;
136
185
  const SEARCH_LIMIT_MAX = 50;
137
186
  const FILE_PREVIEW_LIMIT = 50;
@@ -142,6 +191,7 @@ const RUN_WATCH_TAIL_MAX = 200;
142
191
  const REVIEW_COMMENTS_PAGE_SIZE = 100;
143
192
  const RUN_JOBS_PAGE_SIZE = 100;
144
193
  const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/.*)?$/;
194
+ const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/.*)?$/;
145
195
  const RUN_URL_PATTERN = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/.*)?$/;
146
196
  const RUN_SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
147
197
  const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required", "startup_failure"]);
@@ -151,10 +201,7 @@ const githubSchema = Type.Object({
151
201
  op: StringEnum(
152
202
  [
153
203
  "repo_view",
154
- "issue_view",
155
204
  "pr_create",
156
- "pr_view",
157
- "pr_diff",
158
205
  "pr_checkout",
159
206
  "pr_push",
160
207
  "search_issues",
@@ -178,12 +225,6 @@ const githubSchema = Type.Object({
178
225
  examples: ["main", "develop"],
179
226
  }),
180
227
  ),
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
228
  pr: Type.Optional(
188
229
  Type.Union(
189
230
  [
@@ -194,17 +235,10 @@ const githubSchema = Type.Object({
194
235
  ],
195
236
  {
196
237
  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",
238
+ "pr number, url, or branch (pr_checkout); pass an array to batch-process multiple pull requests in one call",
198
239
  },
199
240
  ),
200
241
  ),
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
242
  force: Type.Optional(Type.Boolean({ description: "reset existing local branch (pr_checkout)" })),
209
243
  forceWithLease: Type.Optional(Type.Boolean({ description: "force-with-lease push (pr_push)" })),
210
244
  title: Type.Optional(
@@ -699,13 +733,7 @@ function appendRepoFlag(args: string[], repo: string | undefined, identifier?: s
699
733
  args.push("--repo", repo);
700
734
  }
701
735
 
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
- };
736
+ const REPO_API_URL_PREFIX = "https://api.github.com/repos/";
709
737
 
710
738
  const RELATIVE_DURATION_PATTERN = /^(\d+)\s*(m|h|d|w|mo|y)$/i;
711
739
  const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -811,21 +839,98 @@ function composeSearchQuery(parts: ReadonlyArray<string | undefined>): string {
811
839
  return cleaned.join(" ");
812
840
  }
813
841
 
814
- function buildGhSearchArgs(
815
- command: "issues" | "prs" | "code" | "commits" | "repos",
842
+ function buildGhApiSearchArgs(
843
+ endpoint: "issues" | "code" | "commits" | "repositories",
816
844
  query: string,
817
845
  limit: number,
818
- repo: string | undefined,
846
+ extraHeaders?: ReadonlyArray<string>,
819
847
  ): 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);
848
+ const args = ["api", "-X", "GET", `/search/${endpoint}`, "-f", `q=${query}`, "-F", `per_page=${limit}`];
849
+ for (const header of extraHeaders ?? []) {
850
+ args.push("-H", header);
824
851
  }
825
- args.push("--", query);
826
852
  return args;
827
853
  }
828
854
 
855
+ function repoFromRepositoryUrl(value: string | undefined): string | undefined {
856
+ if (!value?.startsWith(REPO_API_URL_PREFIX)) return undefined;
857
+ return value.slice(REPO_API_URL_PREFIX.length);
858
+ }
859
+
860
+ function apiUserToGhUser(user: GhApiUser | null | undefined): GhUser | undefined {
861
+ if (!user) return undefined;
862
+ const login = user.login ?? undefined;
863
+ const name = user.name ?? undefined;
864
+ if (login === undefined && name === undefined) return undefined;
865
+ return { login, name };
866
+ }
867
+
868
+ function apiLabelsToGhLabels(labels: GhApiLabel[] | undefined): GhLabel[] {
869
+ return labels?.map(label => ({ name: label.name })) ?? [];
870
+ }
871
+
872
+ function apiIssueToSearchResult(item: GhApiSearchIssueItem): GhSearchResult {
873
+ const merged = Boolean(item.pull_request?.merged_at);
874
+ return {
875
+ author: apiUserToGhUser(item.user) ?? null,
876
+ createdAt: item.created_at,
877
+ labels: apiLabelsToGhLabels(item.labels),
878
+ number: item.number,
879
+ repository: { nameWithOwner: repoFromRepositoryUrl(item.repository_url) },
880
+ state: merged ? "merged" : item.state,
881
+ title: item.title,
882
+ updatedAt: item.updated_at,
883
+ url: item.html_url,
884
+ };
885
+ }
886
+
887
+ function apiCodeToSearchResult(item: GhApiSearchCodeItem): GhSearchCodeResult {
888
+ return {
889
+ path: item.path,
890
+ repository: { nameWithOwner: item.repository?.full_name },
891
+ sha: item.sha,
892
+ textMatches: item.text_matches?.map(match => ({ fragment: match.fragment, property: match.property })),
893
+ url: item.html_url,
894
+ };
895
+ }
896
+
897
+ function apiCommitToSearchResult(item: GhApiSearchCommitItem): GhSearchCommitResult {
898
+ return {
899
+ author: apiUserToGhUser(item.author) ?? null,
900
+ commit: item.commit
901
+ ? {
902
+ author: item.commit.author ?? null,
903
+ committer: item.commit.committer ?? null,
904
+ message: item.commit.message,
905
+ }
906
+ : null,
907
+ committer: apiUserToGhUser(item.committer) ?? null,
908
+ id: item.node_id,
909
+ repository: { nameWithOwner: item.repository?.full_name },
910
+ sha: item.sha,
911
+ url: item.html_url,
912
+ };
913
+ }
914
+
915
+ function apiRepoToSearchResult(item: GhApiSearchRepoItem): GhSearchRepoResult {
916
+ return {
917
+ createdAt: item.created_at,
918
+ description: item.description,
919
+ forksCount: item.forks_count,
920
+ fullName: item.full_name,
921
+ isArchived: item.archived,
922
+ isFork: item.fork,
923
+ isPrivate: item.private,
924
+ language: item.language,
925
+ openIssuesCount: item.open_issues_count,
926
+ owner: apiUserToGhUser(item.owner) ?? null,
927
+ stargazersCount: item.stargazers_count,
928
+ updatedAt: item.updated_at,
929
+ url: item.html_url,
930
+ visibility: item.visibility ?? null,
931
+ };
932
+ }
933
+
829
934
  function sanitizeRemoteName(value: string): string {
830
935
  const sanitized = value
831
936
  .toLowerCase()
@@ -1086,6 +1191,29 @@ function parsePullRequestUrl(value: string | undefined): { repo?: string; prNumb
1086
1191
  };
1087
1192
  }
1088
1193
 
1194
+ /**
1195
+ * Parse a digit-only decimal positive integer or return undefined. Rejects
1196
+ * `1e2`, `0x10`, `12.0`, leading +/-, or any other shape `Number()` would
1197
+ * accept — those would otherwise key the cache against the wrong row.
1198
+ */
1199
+ export function parsePositiveDecimalInt(value: string | undefined): number | undefined {
1200
+ if (!value || !/^\d+$/.test(value)) return undefined;
1201
+ const num = Number(value);
1202
+ if (!Number.isSafeInteger(num) || num <= 0) return undefined;
1203
+ return num;
1204
+ }
1205
+
1206
+ function parseIssueUrl(value: string | undefined): { repo?: string; issueNumber?: number } {
1207
+ const normalized = normalizeOptionalString(value);
1208
+ if (!normalized) return {};
1209
+ const match = normalized.match(ISSUE_URL_PATTERN);
1210
+ if (!match) return {};
1211
+ return {
1212
+ repo: match[1],
1213
+ issueNumber: Number(match[2]),
1214
+ };
1215
+ }
1216
+
1089
1217
  function normalizePrReviewComment(comment: GhPrReviewCommentApi): GhPrReviewComment | null {
1090
1218
  if (typeof comment.id !== "number") {
1091
1219
  return null;
@@ -1600,6 +1728,52 @@ async function resolveGitHubRepo(
1600
1728
  return requireNonEmpty(resolved, "repo");
1601
1729
  }
1602
1730
 
1731
+ /**
1732
+ * Process-lifetime cache of `gh repo view --json nameWithOwner` lookups keyed
1733
+ * by absolute cwd. Avoids repeated `gh` chatter when the same protocol handler
1734
+ * or tool call resolves the default repo many times in a row.
1735
+ *
1736
+ * The shared lookup is intentionally **not** bound to any caller's
1737
+ * AbortSignal. Cancelling one caller would otherwise kill the underlying
1738
+ * `gh repo view` for every concurrent waiter on the same cwd. Each caller's
1739
+ * signal is honored at the wait point via `untilAborted` instead, so an abort
1740
+ * unwinds only that caller.
1741
+ */
1742
+ const DEFAULT_REPO_RESOLVED = new Map<string, string>();
1743
+ const DEFAULT_REPO_INFLIGHT = new Map<string, Promise<string>>();
1744
+
1745
+ export async function resolveDefaultRepoMemoized(cwd: string, signal?: AbortSignal): Promise<string> {
1746
+ const key = path.resolve(cwd);
1747
+ const ready = DEFAULT_REPO_RESOLVED.get(key);
1748
+ if (ready) return ready;
1749
+ let pending = DEFAULT_REPO_INFLIGHT.get(key);
1750
+ if (!pending) {
1751
+ pending = (async () => {
1752
+ // No caller signal: this lookup is shared across every concurrent
1753
+ // waiter on the same cwd.
1754
+ const resolved = await git.github.text(cwd, [
1755
+ "repo",
1756
+ "view",
1757
+ "--json",
1758
+ "nameWithOwner",
1759
+ "-q",
1760
+ ".nameWithOwner",
1761
+ ]);
1762
+ const value = requireNonEmpty(resolved, "repo");
1763
+ DEFAULT_REPO_RESOLVED.set(key, value);
1764
+ return value;
1765
+ })();
1766
+ // Drop the in-flight slot on settle so failures don't poison the cache
1767
+ // and so a successful resolution survives only in `DEFAULT_REPO_RESOLVED`.
1768
+ void pending.then(
1769
+ () => DEFAULT_REPO_INFLIGHT.delete(key),
1770
+ () => DEFAULT_REPO_INFLIGHT.delete(key),
1771
+ );
1772
+ DEFAULT_REPO_INFLIGHT.set(key, pending);
1773
+ }
1774
+ return untilAborted(signal, pending);
1775
+ }
1776
+
1603
1777
  async function resolveGitHubBranchHead(
1604
1778
  cwd: string,
1605
1779
  repo: string,
@@ -2237,14 +2411,8 @@ export class GithubTool implements AgentTool<typeof githubSchema, GhToolDetails>
2237
2411
  switch (params.op) {
2238
2412
  case "repo_view":
2239
2413
  return executeRepoView(this.session, params, signal);
2240
- case "issue_view":
2241
- return executeIssueView(this.session, params, signal);
2242
2414
  case "pr_create":
2243
2415
  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
2416
  case "pr_checkout":
2249
2417
  return executePrCheckout(this.session, params, signal);
2250
2418
  case "pr_push":
@@ -2288,111 +2456,449 @@ async function executeRepoView(
2288
2456
  return buildTextResult(formatRepoView(data, { repo, branch }), data.url);
2289
2457
  }
2290
2458
 
2291
- async function executeIssueView(
2292
- session: ToolSession,
2293
- params: GithubInput,
2459
+ // ────────────────────────────────────────────────────────────────────────────
2460
+ // Cached issue/PR view fetchers
2461
+ //
2462
+ // Used by `executeIssueView`/`executePrView` and by the `issue://` / `pr://`
2463
+ // internal-URL protocol handlers. The cache wrapper lives in `./github-cache`;
2464
+ // the fresh fetchers stay here to share the existing formatter helpers.
2465
+ // ────────────────────────────────────────────────────────────────────────────
2466
+
2467
+ export interface IssueViewLookupOptions {
2468
+ cwd: string;
2469
+ repo?: string;
2470
+ /** Issue number or GitHub issue URL. */
2471
+ issue: string;
2472
+ includeComments?: boolean;
2473
+ signal?: AbortSignal;
2474
+ settings?: Settings;
2475
+ cacheAuthKey?: string | null;
2476
+ }
2477
+
2478
+ export interface PrViewLookupOptions {
2479
+ cwd: string;
2480
+ repo: string;
2481
+ number: number;
2482
+ includeComments?: boolean;
2483
+ signal?: AbortSignal;
2484
+ settings?: Settings;
2485
+ cacheAuthKey?: string | null;
2486
+ }
2487
+
2488
+ export interface ViewLookupResult<T> {
2489
+ rendered: string;
2490
+ sourceUrl: string | undefined;
2491
+ payload: T;
2492
+ status: CacheStatus;
2493
+ fetchedAt: number;
2494
+ }
2495
+
2496
+ async function fetchIssueViewFresh(
2497
+ cwd: string,
2498
+ repo: string | undefined,
2499
+ identifier: string,
2500
+ includeComments: boolean,
2294
2501
  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);
2502
+ ): Promise<{ rendered: string; sourceUrl: string | undefined; payload: GhIssueViewData }> {
2503
+ const args = ["issue", "view", identifier];
2504
+ appendRepoFlag(args, repo, identifier);
2301
2505
  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, {
2506
+ const data = await git.github.json<GhIssueViewData>(cwd, args, signal, {
2304
2507
  repoProvided: Boolean(repo),
2305
2508
  });
2306
- return buildTextResult(formatIssueView(data, { issue, repo, comments: includeComments }), data.url);
2509
+ const rendered = formatIssueView(data, { issue: identifier, repo, comments: includeComments });
2510
+ return { rendered, sourceUrl: data.url, payload: data };
2307
2511
  }
2308
2512
 
2309
- async function executePrView(
2310
- session: ToolSession,
2311
- params: GithubInput,
2513
+ async function fetchPrViewFresh(
2514
+ cwd: string,
2515
+ repo: string,
2516
+ number: number,
2517
+ includeComments: boolean,
2312
2518
  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];
2519
+ ): Promise<{ rendered: string; sourceUrl: string | undefined; payload: GhPrViewData }> {
2520
+ const args = ["pr", "view", String(number)];
2521
+ appendRepoFlag(args, repo, String(number));
2522
+ args.push("--json", (includeComments ? GH_PR_FIELDS : GH_PR_FIELDS_NO_COMMENTS).join(","));
2523
+ const data = await git.github.json<GhPrViewData>(cwd, args, signal, { repoProvided: true });
2524
+ if (includeComments && typeof data.number === "number") {
2525
+ data.reviewComments = await fetchPrReviewComments(cwd, repo, data.number, signal);
2526
+ }
2527
+ const rendered = formatPrView(data, { pr: String(number), repo, comments: includeComments });
2528
+ return { rendered, sourceUrl: data.url, payload: data };
2529
+ }
2318
2530
 
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(","));
2531
+ /**
2532
+ * Cache-aware issue/view fetcher. Used by both the `github` tool op and the
2533
+ * `issue://` protocol handler so a single shared row services both surfaces.
2534
+ */
2535
+ export async function getOrFetchIssue(options: IssueViewLookupOptions): Promise<ViewLookupResult<GhIssueViewData>> {
2536
+ const identifier = requireNonEmpty(options.issue, "issue");
2537
+ const includeComments = options.includeComments ?? true;
2538
+ const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
2539
+ const urlParse = parseIssueUrl(identifier);
2540
+ // Prefer the URL's repo when the identifier is a full URL; fall back to the
2541
+ // explicit `repo` option, then to the cwd's default repo.
2542
+ let repo = urlParse.repo ?? normalizeOptionalString(options.repo);
2543
+ let cacheNumber = urlParse.issueNumber;
2544
+ if (cacheNumber === undefined) {
2545
+ cacheNumber = parsePositiveDecimalInt(identifier);
2546
+ }
2547
+ if (cacheNumber !== undefined && !repo) {
2548
+ try {
2549
+ repo = await resolveDefaultRepoMemoized(options.cwd, options.signal);
2550
+ } catch {
2551
+ // Resolution failure leaves `repo` undefined: we'll fall through to a
2552
+ // direct fetch below so gh produces its own error message instead of
2553
+ // us masking it with a friendlier one.
2554
+ repo = undefined;
2555
+ }
2556
+ }
2325
2557
 
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
- );
2558
+ const doFetch = () => fetchIssueViewFresh(options.cwd, repo, identifier, includeComments, options.signal);
2336
2559
 
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
- );
2560
+ if (!repo || cacheNumber === undefined) {
2561
+ const fresh = await doFetch();
2562
+ return { ...fresh, status: "miss", fetchedAt: Date.now() };
2343
2563
  }
2344
2564
 
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);
2565
+ const lookup = await getOrFetchView<GhIssueViewData>({
2566
+ repo,
2567
+ kind: "issue",
2568
+ number: cacheNumber,
2569
+ includeComments,
2570
+ settings: options.settings,
2571
+ authKey,
2572
+ fetchFresh: doFetch,
2573
+ });
2574
+ return {
2575
+ rendered: lookup.rendered,
2576
+ sourceUrl: lookup.sourceUrl,
2577
+ payload: lookup.payload,
2578
+ status: lookup.status,
2579
+ fetchedAt: lookup.fetchedAt,
2580
+ };
2348
2581
  }
2349
2582
 
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
- }),
2583
+ /**
2584
+ * Cache-aware PR view fetcher. Caller must supply a numeric PR number;
2585
+ * branch-name / current-branch lookups bypass the cache entirely upstream
2586
+ * (see `executePrView`).
2587
+ */
2588
+ export async function getOrFetchPr(options: PrViewLookupOptions): Promise<ViewLookupResult<GhPrViewData>> {
2589
+ const includeComments = options.includeComments ?? true;
2590
+ const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
2591
+ const doFetch = () => fetchPrViewFresh(options.cwd, options.repo, options.number, includeComments, options.signal);
2592
+ const lookup = await getOrFetchView<GhPrViewData>({
2593
+ repo: options.repo,
2594
+ kind: "pr",
2595
+ number: options.number,
2596
+ includeComments,
2597
+ settings: options.settings,
2598
+ authKey,
2599
+ fetchFresh: doFetch,
2600
+ });
2601
+ return {
2602
+ rendered: lookup.rendered,
2603
+ sourceUrl: lookup.sourceUrl,
2604
+ payload: lookup.payload,
2605
+ status: lookup.status,
2606
+ fetchedAt: lookup.fetchedAt,
2607
+ };
2608
+ }
2609
+
2610
+ // ────────────────────────────────────────────────────────────────────────────
2611
+ // PR diff fetcher
2612
+ //
2613
+ // Used by the `pr://<n>/diff[/…]` internal-URL family. Stores the verbatim
2614
+ // `gh pr diff` text plus a parsed file index so the listing, full-diff, and
2615
+ // per-file slice variants all share one cache row.
2616
+ // ────────────────────────────────────────────────────────────────────────────
2617
+
2618
+ export interface PrDiffFile {
2619
+ /** Display path. Prefers the post-image (`b/<path>`) when present. */
2620
+ path: string;
2621
+ additions: number;
2622
+ deletions: number;
2623
+ changeType: "modified" | "added" | "deleted" | "renamed" | "binary";
2624
+ /** Pre-image path for renames/deletes; same as `path` otherwise. */
2625
+ oldPath?: string;
2626
+ /** Byte offset of the section's `diff --git` line in the unified diff. */
2627
+ startOffset: number;
2628
+ /** Byte offset of the next section (or end-of-text). */
2629
+ endOffset: number;
2630
+ }
2631
+
2632
+ export interface PrDiffPayload {
2633
+ /** Full unified diff text as returned by `gh pr diff --color never`. */
2634
+ unified: string;
2635
+ files: PrDiffFile[];
2636
+ }
2637
+
2638
+ export interface PrDiffLookupOptions {
2639
+ cwd: string;
2640
+ repo: string;
2641
+ number: number;
2642
+ signal?: AbortSignal;
2643
+ settings?: Settings;
2644
+ cacheAuthKey?: string | null;
2645
+ }
2646
+ /**
2647
+ * Split `gh pr diff` output on `^diff --git ` boundaries and parse per-file
2648
+ * metadata. The unified diff is preserved verbatim so callers can slice it by
2649
+ * byte offsets without re-running gh.
2650
+ */
2651
+ export function parsePrUnifiedDiff(text: string): PrDiffPayload {
2652
+ const files: PrDiffFile[] = [];
2653
+ if (text.length === 0) {
2654
+ return { unified: text, files };
2655
+ }
2656
+
2657
+ // Walk match positions manually so we capture each section's byte range.
2658
+ const sectionStarts: number[] = [];
2659
+ const re = /^diff --git /gm;
2660
+ let m: RegExpExecArray | null = re.exec(text);
2661
+ while (m !== null) {
2662
+ sectionStarts.push(m.index);
2663
+ // Avoid zero-length match infinite loop (regex has fixed prefix, but
2664
+ // be explicit).
2665
+ if (re.lastIndex === m.index) re.lastIndex += 1;
2666
+ m = re.exec(text);
2667
+ }
2668
+
2669
+ for (let i = 0; i < sectionStarts.length; i += 1) {
2670
+ const startOffset = sectionStarts[i] ?? 0;
2671
+ const endOffset = sectionStarts[i + 1] ?? text.length;
2672
+ const section = text.slice(startOffset, endOffset);
2673
+ files.push(parsePrDiffSection(section, startOffset, endOffset));
2674
+ }
2675
+ return { unified: text, files };
2676
+ }
2677
+
2678
+ interface ParsedDiffHeaderToken {
2679
+ value: string;
2680
+ nextIndex: number;
2681
+ }
2682
+
2683
+ function skipDiffHeaderSpaces(text: string, index: number): number {
2684
+ let i = index;
2685
+ while (text.charAt(i) === " ") i += 1;
2686
+ return i;
2687
+ }
2688
+
2689
+ function parseDiffQuotedEscape(text: string, slashIndex: number): ParsedDiffHeaderToken {
2690
+ const next = text.charAt(slashIndex + 1);
2691
+ if (next === "") return { value: "\\", nextIndex: slashIndex + 1 };
2692
+
2693
+ if (next >= "0" && next <= "7") {
2694
+ let end = slashIndex + 1;
2695
+ while (end < text.length && end < slashIndex + 4) {
2696
+ const digit = text.charAt(end);
2697
+ if (digit < "0" || digit > "7") break;
2698
+ end += 1;
2699
+ }
2700
+ return {
2701
+ value: String.fromCharCode(Number.parseInt(text.slice(slashIndex + 1, end), 8)),
2702
+ nextIndex: end,
2703
+ };
2704
+ }
2705
+
2706
+ switch (next) {
2707
+ case "a":
2708
+ return { value: "\x07", nextIndex: slashIndex + 2 };
2709
+ case "b":
2710
+ return { value: "\b", nextIndex: slashIndex + 2 };
2711
+ case "f":
2712
+ return { value: "\f", nextIndex: slashIndex + 2 };
2713
+ case "n":
2714
+ return { value: "\n", nextIndex: slashIndex + 2 };
2715
+ case "r":
2716
+ return { value: "\r", nextIndex: slashIndex + 2 };
2717
+ case "t":
2718
+ return { value: "\t", nextIndex: slashIndex + 2 };
2719
+ case "v":
2720
+ return { value: "\v", nextIndex: slashIndex + 2 };
2721
+ case "\\":
2722
+ case '"':
2723
+ return { value: next, nextIndex: slashIndex + 2 };
2724
+ default:
2725
+ return { value: next, nextIndex: slashIndex + 2 };
2726
+ }
2727
+ }
2728
+
2729
+ function parseDiffQuotedToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
2730
+ if (text.charAt(startIndex) !== '"') return undefined;
2731
+ let value = "";
2732
+ for (let i = startIndex + 1; i < text.length; i += 1) {
2733
+ const ch = text.charAt(i);
2734
+ if (ch === '"') return { value, nextIndex: i + 1 };
2735
+ if (ch !== "\\") {
2736
+ value += ch;
2737
+ continue;
2738
+ }
2739
+ const escaped = parseDiffQuotedEscape(text, i);
2740
+ value += escaped.value;
2741
+ i = escaped.nextIndex - 1;
2742
+ }
2743
+ return undefined;
2744
+ }
2745
+
2746
+ function parseDiffHeaderToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
2747
+ const start = skipDiffHeaderSpaces(text, startIndex);
2748
+ if (start >= text.length) return undefined;
2749
+ const quoted = parseDiffQuotedToken(text, start);
2750
+ if (quoted) return quoted;
2751
+ const end = text.indexOf(" ", start);
2752
+ if (end === -1) return { value: text.slice(start), nextIndex: text.length };
2753
+ return { value: text.slice(start, end), nextIndex: end };
2754
+ }
2755
+
2756
+ function stripPrDiffPathPrefix(value: string, prefix: "a/" | "b/"): string | undefined {
2757
+ return value.startsWith(prefix) ? value.slice(prefix.length) : undefined;
2758
+ }
2759
+
2760
+ function parsePrDiffHeaderPaths(header: string): { oldPath?: string; newPath?: string } {
2761
+ const trail = header.slice("diff --git ".length);
2762
+ if (trail.startsWith('"')) {
2763
+ const oldToken = parseDiffQuotedToken(trail, 0);
2764
+ if (!oldToken) return {};
2765
+ const newToken = parseDiffHeaderToken(trail, oldToken.nextIndex);
2766
+ if (!newToken) return {};
2767
+ return {
2768
+ oldPath: stripPrDiffPathPrefix(oldToken.value, "a/"),
2769
+ newPath: stripPrDiffPathPrefix(newToken.value, "b/"),
2770
+ };
2771
+ }
2772
+
2773
+ const bIdx = trail.indexOf(" b/");
2774
+ if (trail.startsWith("a/") && bIdx > 0) {
2775
+ return {
2776
+ oldPath: trail.slice(2, bIdx),
2777
+ newPath: trail.slice(bIdx + 3),
2778
+ };
2779
+ }
2780
+ return {};
2781
+ }
2782
+
2783
+ function isPrDiffFileHeaderLine(line: string): boolean {
2784
+ return (
2785
+ line === "--- /dev/null" ||
2786
+ line === "+++ /dev/null" ||
2787
+ line.startsWith("--- a/") ||
2788
+ line.startsWith("+++ b/") ||
2789
+ line.startsWith('--- "a/') ||
2790
+ line.startsWith('+++ "b/')
2375
2791
  );
2792
+ }
2793
+
2794
+ function parsePrDiffSection(section: string, startOffset: number, endOffset: number): PrDiffFile {
2795
+ const lines = section.split("\n");
2796
+ const header = lines[0] ?? "";
2797
+ const headerPaths = parsePrDiffHeaderPaths(header);
2798
+ let oldPath = headerPaths.oldPath;
2799
+ let newPath = headerPaths.newPath;
2800
+
2801
+ let changeType: PrDiffFile["changeType"] = "modified";
2802
+ let isBinary = false;
2803
+ let additions = 0;
2804
+ let deletions = 0;
2805
+
2806
+ let inHunk = false;
2807
+ for (let li = 1; li < lines.length; li += 1) {
2808
+ const line = lines[li] ?? "";
2809
+ if (line.startsWith("new file mode")) {
2810
+ changeType = "added";
2811
+ continue;
2812
+ }
2813
+ if (line.startsWith("deleted file mode")) {
2814
+ changeType = "deleted";
2815
+ continue;
2816
+ }
2817
+ if (line.startsWith("rename from ")) {
2818
+ changeType = "renamed";
2819
+ oldPath = line.slice("rename from ".length);
2820
+ continue;
2821
+ }
2822
+ if (line.startsWith("rename to ")) {
2823
+ newPath = line.slice("rename to ".length);
2824
+ continue;
2825
+ }
2826
+ if (line.startsWith("Binary files ") && line.endsWith(" differ")) {
2827
+ isBinary = true;
2828
+ continue;
2829
+ }
2830
+ if (line.startsWith("@@ ")) {
2831
+ inHunk = true;
2832
+ continue;
2833
+ }
2834
+ if (!inHunk && isPrDiffFileHeaderLine(line)) continue;
2835
+ if (line.startsWith("+")) {
2836
+ additions += 1;
2837
+ } else if (line.startsWith("-")) {
2838
+ deletions += 1;
2839
+ }
2840
+ }
2376
2841
 
2377
- const singleTitle = params.nameOnly ? "# Pull Request Files" : "# Pull Request Diff";
2378
- const emptyBody = params.nameOnly ? "No changed files." : "No diff output.";
2842
+ if (isBinary) {
2843
+ if (changeType === "modified") changeType = "binary";
2844
+ additions = 0;
2845
+ deletions = 0;
2846
+ }
2379
2847
 
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}`);
2848
+ const displayPath =
2849
+ changeType === "deleted" ? (oldPath ?? newPath ?? "(unknown)") : (newPath ?? oldPath ?? "(unknown)");
2850
+ const file: PrDiffFile = {
2851
+ path: displayPath,
2852
+ additions,
2853
+ deletions,
2854
+ changeType,
2855
+ startOffset,
2856
+ endOffset,
2857
+ };
2858
+ if (oldPath && oldPath !== displayPath) {
2859
+ file.oldPath = oldPath;
2384
2860
  }
2861
+ return file;
2862
+ }
2385
2863
 
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}`;
2864
+ async function fetchPrDiffFresh(
2865
+ cwd: string,
2866
+ repo: string,
2867
+ number: number,
2868
+ signal: AbortSignal | undefined,
2869
+ ): Promise<{ rendered: string; sourceUrl: string | undefined; payload: PrDiffPayload }> {
2870
+ const args = ["pr", "diff", String(number), "--color", "never"];
2871
+ appendRepoFlag(args, repo, String(number));
2872
+ const text = await git.github.text(cwd, args, signal, { repoProvided: true, trimOutput: false });
2873
+ const payload = parsePrUnifiedDiff(text);
2874
+ return { rendered: text, sourceUrl: undefined, payload };
2875
+ }
2876
+
2877
+ /**
2878
+ * Cache-aware PR diff fetcher. Stores the full unified diff plus a parsed
2879
+ * file index in a single `pr-diff` cache row so the listing, full-diff, and
2880
+ * per-file slice variants of `pr://<n>/diff` share one `gh pr diff`
2881
+ * invocation.
2882
+ */
2883
+ export async function getOrFetchPrDiff(options: PrDiffLookupOptions): Promise<ViewLookupResult<PrDiffPayload>> {
2884
+ const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
2885
+ const doFetch = () => fetchPrDiffFresh(options.cwd, options.repo, options.number, options.signal);
2886
+ const lookup = await getOrFetchView<PrDiffPayload>({
2887
+ repo: options.repo,
2888
+ kind: "pr-diff",
2889
+ number: options.number,
2890
+ includeComments: false,
2891
+ settings: options.settings,
2892
+ authKey,
2893
+ fetchFresh: doFetch,
2393
2894
  });
2394
- const text = [header, "", ...joinSections(sections)].join("\n").trim();
2395
- return buildTextResult(text);
2895
+ return {
2896
+ rendered: lookup.rendered,
2897
+ sourceUrl: lookup.sourceUrl,
2898
+ payload: lookup.payload,
2899
+ status: lookup.status,
2900
+ fetchedAt: lookup.fetchedAt,
2901
+ };
2396
2902
  }
2397
2903
 
2398
2904
  function joinSections(sections: string[]): string[] {
@@ -2765,13 +3271,13 @@ async function executeSearchIssues(
2765
3271
  const limit = resolveSearchLimit(params.limit);
2766
3272
  const dateField = resolveSearchDateField("issues", params.dateField);
2767
3273
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2768
- const query = composeSearchQuery([params.query, dateQualifier]);
2769
- const args = buildGhSearchArgs("issues", query, limit, repo);
3274
+ const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3275
+ const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:issue"]);
3276
+ const args = buildGhApiSearchArgs("issues", apiQuery, limit);
2770
3277
 
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));
3278
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchIssueItem>>(session.cwd, args, signal);
3279
+ const items = (response.items ?? []).map(apiIssueToSearchResult);
3280
+ return buildTextResult(formatSearchResults("issues", displayQuery, repo, items));
2775
3281
  }
2776
3282
 
2777
3283
  async function executeSearchPrs(
@@ -2783,13 +3289,13 @@ async function executeSearchPrs(
2783
3289
  const limit = resolveSearchLimit(params.limit);
2784
3290
  const dateField = resolveSearchDateField("prs", params.dateField);
2785
3291
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2786
- const query = composeSearchQuery([params.query, dateQualifier]);
2787
- const args = buildGhSearchArgs("prs", query, limit, repo);
3292
+ const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3293
+ const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:pr"]);
3294
+ const args = buildGhApiSearchArgs("issues", apiQuery, limit);
2788
3295
 
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));
3296
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchIssueItem>>(session.cwd, args, signal);
3297
+ const items = (response.items ?? []).map(apiIssueToSearchResult);
3298
+ return buildTextResult(formatSearchResults("pull requests", displayQuery, repo, items));
2793
3299
  }
2794
3300
 
2795
3301
  async function executeSearchCode(
@@ -2803,11 +3309,11 @@ async function executeSearchCode(
2803
3309
  }
2804
3310
  const repo = normalizeOptionalString(params.repo);
2805
3311
  const limit = resolveSearchLimit(params.limit);
2806
- const args = buildGhSearchArgs("code", query, limit, repo);
3312
+ const apiQuery = composeSearchQuery([query, repo ? `repo:${repo}` : undefined]);
3313
+ const args = buildGhApiSearchArgs("code", apiQuery, limit, ["Accept: application/vnd.github.text-match+json"]);
2807
3314
 
2808
- const items = await git.github.json<GhSearchCodeResult[]>(session.cwd, args, signal, {
2809
- repoProvided: Boolean(repo),
2810
- });
3315
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchCodeItem>>(session.cwd, args, signal);
3316
+ const items = (response.items ?? []).map(apiCodeToSearchResult);
2811
3317
  return buildTextResult(formatSearchCodeResults(query, repo, items));
2812
3318
  }
2813
3319
 
@@ -2820,13 +3326,13 @@ async function executeSearchCommits(
2820
3326
  const limit = resolveSearchLimit(params.limit);
2821
3327
  const dateField = resolveSearchDateField("commits", params.dateField);
2822
3328
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2823
- const query = composeSearchQuery([params.query, dateQualifier]);
2824
- const args = buildGhSearchArgs("commits", query, limit, repo);
3329
+ const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3330
+ const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined]);
3331
+ const args = buildGhApiSearchArgs("commits", apiQuery, limit);
2825
3332
 
2826
- const items = await git.github.json<GhSearchCommitResult[]>(session.cwd, args, signal, {
2827
- repoProvided: Boolean(repo),
2828
- });
2829
- return buildTextResult(formatSearchCommitsResults(query, repo, items));
3333
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchCommitItem>>(session.cwd, args, signal);
3334
+ const items = (response.items ?? []).map(apiCommitToSearchResult);
3335
+ return buildTextResult(formatSearchCommitsResults(displayQuery, repo, items));
2830
3336
  }
2831
3337
 
2832
3338
  async function executeSearchRepos(
@@ -2838,9 +3344,10 @@ async function executeSearchRepos(
2838
3344
  const dateField = resolveSearchDateField("repos", params.dateField);
2839
3345
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2840
3346
  const query = composeSearchQuery([params.query, dateQualifier]);
2841
- const args = buildGhSearchArgs("repos", query, limit, undefined);
3347
+ const args = buildGhApiSearchArgs("repositories", query, limit);
2842
3348
 
2843
- const items = await git.github.json<GhSearchRepoResult[]>(session.cwd, args, signal);
3349
+ const response = await git.github.json<GhApiSearchResponse<GhApiSearchRepoItem>>(session.cwd, args, signal);
3350
+ const items = (response.items ?? []).map(apiRepoToSearchResult);
2844
3351
  return buildTextResult(formatSearchReposResults(query, items));
2845
3352
  }
2846
3353
 
@@ -2894,7 +3401,7 @@ async function executeRunWatch(
2894
3401
  note,
2895
3402
  }),
2896
3403
  });
2897
- await abortableSleep(graceSeconds * 1000, signal);
3404
+ await scheduler.wait(graceSeconds * 1000, { signal });
2898
3405
  run = await fetchRunSnapshot(session.cwd, repo, runId, signal);
2899
3406
  }
2900
3407
 
@@ -2929,7 +3436,7 @@ async function executeRunWatch(
2929
3436
  return buildTextResult(formatRunWatchResult(repo, run, [], tail), run.url, finalDetails);
2930
3437
  }
2931
3438
 
2932
- await abortableSleep(intervalSeconds * 1000, signal);
3439
+ await scheduler.wait(intervalSeconds * 1000, { signal });
2933
3440
  }
2934
3441
  }
2935
3442
 
@@ -2971,7 +3478,7 @@ async function executeRunWatch(
2971
3478
  note,
2972
3479
  }),
2973
3480
  });
2974
- await abortableSleep(graceSeconds * 1000, signal);
3481
+ await scheduler.wait(graceSeconds * 1000, { signal });
2975
3482
  runs = await fetchRunsForCommit(session.cwd, repo, headSha, branch, signal);
2976
3483
  }
2977
3484
 
@@ -3027,11 +3534,11 @@ async function executeRunWatch(
3027
3534
  note,
3028
3535
  }),
3029
3536
  });
3030
- await abortableSleep(intervalSeconds * 1000, signal);
3537
+ await scheduler.wait(intervalSeconds * 1000, { signal });
3031
3538
  continue;
3032
3539
  }
3033
3540
 
3034
3541
  settledSuccessSignature = undefined;
3035
- await abortableSleep(intervalSeconds * 1000, signal);
3542
+ await scheduler.wait(intervalSeconds * 1000, { signal });
3036
3543
  }
3037
3544
  }