@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12

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 (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
@@ -0,0 +1,679 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
5
+ import { getWorktreeDir, hashPath, isEnoent } from "@oh-my-pi/pi-utils";
6
+ import * as git from "../utils/git";
7
+ import type { ToolSession } from ".";
8
+ import type { GhPrCheckoutSummary, GhToolDetails } from "./gh";
9
+ import {
10
+ appendRepoFlag,
11
+ buildTextResult,
12
+ formatAuthor,
13
+ formatLabels,
14
+ normalizeOptionalString,
15
+ normalizePrIdentifierList,
16
+ normalizeText,
17
+ parsePullRequestUrl,
18
+ pushLine,
19
+ requireCurrentGitBranch,
20
+ requireNonEmpty,
21
+ } from "./gh-common";
22
+ import { formatShortSha } from "./gh-format";
23
+ import type { GhPrViewData, GhRepoViewData, GithubInput } from "./gh-types";
24
+ import { GH_PR_FIELDS_NO_COMMENTS } from "./gh-view";
25
+ import { invalidateAllForNumber } from "./github-cache";
26
+ import { ToolError, throwIfAborted } from "./tool-errors";
27
+
28
+ export const GH_REPO_CLONE_FIELDS = ["nameWithOwner", "sshUrl", "url"];
29
+ export const GH_PR_CHECKOUT_FIELDS = [
30
+ "baseRefName",
31
+ "headRefName",
32
+ "headRefOid",
33
+ "headRepository",
34
+ "headRepositoryOwner",
35
+ "isCrossRepository",
36
+ "maintainerCanModify",
37
+ "number",
38
+ "title",
39
+ "url",
40
+ ];
41
+
42
+ export function sanitizeRemoteName(value: string): string {
43
+ const sanitized = value
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .replace(/^-+/g, "")
47
+ .replace(/-+$/g, "");
48
+ return sanitized.length > 0 ? `fork-${sanitized}` : "fork";
49
+ }
50
+
51
+ /** Maximum disambiguation suffixes we try before giving up on a worktree path. */
52
+ export const WORKTREE_PATH_MAX_SUFFIX = 100;
53
+
54
+ export function toLocalBranchRef(value: string): string {
55
+ return `refs/heads/${value}`;
56
+ }
57
+
58
+ export async function requireGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
59
+ const repoRoot = await git.repo.root(cwd, signal);
60
+ if (!repoRoot) {
61
+ throw new ToolError("Current git repository is unavailable.");
62
+ }
63
+
64
+ return repoRoot;
65
+ }
66
+
67
+ export async function requirePrimaryGitRepoRoot(cwd: string, signal?: AbortSignal): Promise<string> {
68
+ const primaryRepoRoot = await git.repo.primaryRoot(cwd, signal);
69
+ if (!primaryRepoRoot) {
70
+ throw new ToolError("Current git repository is unavailable.");
71
+ }
72
+
73
+ return primaryRepoRoot;
74
+ }
75
+
76
+ /**
77
+ * Resolve a worktree path that is free of conflicts.
78
+ *
79
+ * Given a `basePath`, return either `basePath` itself or `${basePath}-2`,
80
+ * `${basePath}-3`, … up to {@link WORKTREE_PATH_MAX_SUFFIX} — whichever is the
81
+ * first variant that is **not** registered with git as another worktree and
82
+ * **not** present on disk. The numeric tail salvages two rare cases that
83
+ * would otherwise abort a checkout: stale leftover dirs from an interrupted
84
+ * `git worktree add`, and the (vanishingly unlikely) `hashPath` collision
85
+ * between two repos that happen to produce the same 7-hex digest.
86
+ */
87
+ export async function resolveAvailableWorktreePath(
88
+ basePath: string,
89
+ existingWorktrees: git.GitWorktreeEntry[],
90
+ ): Promise<string> {
91
+ const registered = new Set(existingWorktrees.map(entry => path.resolve(entry.path)));
92
+ for (let attempt = 0; attempt < WORKTREE_PATH_MAX_SUFFIX; attempt += 1) {
93
+ const candidate = attempt === 0 ? basePath : `${basePath}-${attempt + 1}`;
94
+ const normalized = path.resolve(candidate);
95
+ if (registered.has(normalized)) continue;
96
+ try {
97
+ await fs.stat(normalized);
98
+ } catch (error) {
99
+ if (isEnoent(error)) {
100
+ return candidate;
101
+ }
102
+ throw error;
103
+ }
104
+ }
105
+ throw new ToolError(
106
+ `could not find an unused worktree path under ${basePath} (tried ${WORKTREE_PATH_MAX_SUFFIX} suffixes)`,
107
+ );
108
+ }
109
+
110
+ export function selectPrCloneUrl(originUrl: string | undefined, repo: Pick<GhRepoViewData, "url" | "sshUrl">): string {
111
+ if (originUrl?.startsWith("http://") || originUrl?.startsWith("https://")) {
112
+ return normalizeOptionalString(repo.url) ?? normalizeOptionalString(repo.sshUrl) ?? "";
113
+ }
114
+
115
+ return normalizeOptionalString(repo.sshUrl) ?? normalizeOptionalString(repo.url) ?? "";
116
+ }
117
+
118
+ export async function getRemoteUrls(repoRoot: string, signal?: AbortSignal): Promise<Map<string, string>> {
119
+ const remotes = await git.remote.list(repoRoot, signal);
120
+ const urls = new Map<string, string>();
121
+ for (const remoteName of remotes) {
122
+ const remoteUrl = await git.remote.url(repoRoot, remoteName, signal);
123
+ if (remoteUrl) {
124
+ urls.set(remoteName, remoteUrl);
125
+ }
126
+ }
127
+ return urls;
128
+ }
129
+
130
+ export async function ensurePrRemote(
131
+ repoRoot: string,
132
+ data: GhPrViewData,
133
+ signal?: AbortSignal,
134
+ ): Promise<{ name: string; url: string }> {
135
+ if (!data.isCrossRepository) {
136
+ const originUrl = await git.remote.url(repoRoot, "origin", signal);
137
+ if (!originUrl) {
138
+ throw new ToolError("origin remote is unavailable for this repository.");
139
+ }
140
+
141
+ return {
142
+ name: "origin",
143
+ url: originUrl,
144
+ };
145
+ }
146
+
147
+ const headRepository = requireNonEmpty(data.headRepository?.nameWithOwner, "head repository");
148
+ const repoSummary = await git.github.json<GhRepoViewData>(
149
+ repoRoot,
150
+ ["repo", "view", headRepository, "--json", GH_REPO_CLONE_FIELDS.join(",")],
151
+ signal,
152
+ { repoProvided: true },
153
+ );
154
+ const originUrl = await git.remote.url(repoRoot, "origin", signal);
155
+ const remoteUrl = selectPrCloneUrl(originUrl, repoSummary);
156
+ if (!remoteUrl) {
157
+ throw new ToolError(`Could not determine a clone URL for ${headRepository}.`);
158
+ }
159
+
160
+ const remotes = await getRemoteUrls(repoRoot, signal);
161
+ for (const [remoteName, url] of remotes) {
162
+ if (url === remoteUrl) {
163
+ return { name: remoteName, url };
164
+ }
165
+ }
166
+
167
+ const preferredRemoteName = sanitizeRemoteName(
168
+ data.headRepositoryOwner?.login ?? headRepository.split("/")[0] ?? "fork",
169
+ );
170
+ let remoteName = preferredRemoteName;
171
+ let suffix = 2;
172
+ while (remotes.has(remoteName)) {
173
+ remoteName = `${preferredRemoteName}-${suffix}`;
174
+ suffix += 1;
175
+ }
176
+
177
+ await git.remote.add(repoRoot, remoteName, remoteUrl, signal);
178
+
179
+ return {
180
+ name: remoteName,
181
+ url: remoteUrl,
182
+ };
183
+ }
184
+
185
+ export async function resolvePrBranchPushTarget(
186
+ repoRoot: string,
187
+ localBranch: string,
188
+ signal?: AbortSignal,
189
+ ): Promise<{
190
+ remoteName: string;
191
+ remoteBranch: string;
192
+ remoteUrl?: string;
193
+ prUrl?: string;
194
+ maintainerCanModify?: boolean;
195
+ isCrossRepository: boolean;
196
+ }> {
197
+ const headRef = await git.config.getBranch(repoRoot, localBranch, "ompPrHeadRef", signal);
198
+ if (!headRef) {
199
+ throw new ToolError(`branch ${localBranch} has no PR push metadata; check it out via op: pr_checkout first`);
200
+ }
201
+
202
+ const pushRemote = await git.config.getBranch(repoRoot, localBranch, "pushRemote", signal);
203
+ const remote = await git.config.getBranch(repoRoot, localBranch, "remote", signal);
204
+ const prUrl = await git.config.getBranch(repoRoot, localBranch, "ompPrUrl", signal);
205
+ const maintainerCanModifyValue = await git.config.getBranch(
206
+ repoRoot,
207
+ localBranch,
208
+ "ompPrMaintainerCanModify",
209
+ signal,
210
+ );
211
+ const isCrossRepositoryValue = await git.config.getBranch(repoRoot, localBranch, "ompPrIsCrossRepository", signal);
212
+
213
+ const remoteName = pushRemote ?? remote;
214
+ if (!remoteName) {
215
+ throw new ToolError(`branch ${localBranch} has no configured push remote`);
216
+ }
217
+
218
+ return {
219
+ remoteName,
220
+ remoteBranch: headRef,
221
+ remoteUrl: await git.remote.url(repoRoot, remoteName, signal),
222
+ prUrl,
223
+ maintainerCanModify:
224
+ maintainerCanModifyValue === undefined
225
+ ? undefined
226
+ : ["1", "true", "yes", "on"].includes(maintainerCanModifyValue.toLowerCase()),
227
+ isCrossRepository: ["1", "true", "yes", "on"].includes((isCrossRepositoryValue ?? "").toLowerCase()),
228
+ };
229
+ }
230
+
231
+ export function formatPrCheckoutResult(options: {
232
+ data: GhPrViewData;
233
+ localBranch: string;
234
+ worktreePath: string;
235
+ remoteName: string;
236
+ remoteUrl: string;
237
+ reused: boolean;
238
+ }): string {
239
+ const { data, localBranch, worktreePath, remoteName, remoteUrl, reused } = options;
240
+ const lines: string[] = [
241
+ reused ? `# Pull Request #${data.number ?? "?"} Worktree` : `# Checked Out Pull Request #${data.number ?? "?"}`,
242
+ "",
243
+ ];
244
+ pushLine(lines, "Title", data.title ?? undefined);
245
+ pushLine(lines, "URL", data.url);
246
+ pushLine(lines, "Base", data.baseRefName);
247
+ pushLine(lines, "Head", data.headRefName);
248
+ pushLine(lines, "Local branch", localBranch);
249
+ pushLine(lines, "Worktree", worktreePath);
250
+ pushLine(lines, "Remote", remoteName);
251
+ pushLine(lines, "Remote URL", remoteUrl);
252
+ pushLine(lines, "Cross repository", data.isCrossRepository);
253
+ pushLine(lines, "Maintainer can modify", data.maintainerCanModify);
254
+ lines.push("");
255
+ lines.push(
256
+ reused
257
+ ? "Reused the existing PR worktree."
258
+ : "Created a dedicated worktree for this PR and configured the local branch to push back to the PR head branch.",
259
+ );
260
+ return lines.join("\n").trim();
261
+ }
262
+
263
+ export function formatPrPushResult(options: {
264
+ localBranch: string;
265
+ remoteName: string;
266
+ remoteBranch: string;
267
+ remoteUrl?: string;
268
+ prUrl?: string;
269
+ forceWithLease: boolean;
270
+ }): string {
271
+ const lines: string[] = ["# Pushed Pull Request Branch", ""];
272
+ pushLine(lines, "Local branch", options.localBranch);
273
+ pushLine(lines, "Remote", options.remoteName);
274
+ pushLine(lines, "Remote branch", options.remoteBranch);
275
+ pushLine(lines, "Remote URL", options.remoteUrl);
276
+ pushLine(lines, "PR", options.prUrl);
277
+ pushLine(lines, "Force with lease", options.forceWithLease);
278
+ lines.push("");
279
+ lines.push(`Pushed ${options.localBranch} to ${options.remoteName}:${options.remoteBranch}.`);
280
+ return lines.join("\n").trim();
281
+ }
282
+
283
+ export function joinSections(sections: string[]): string[] {
284
+ return sections.flatMap((section, idx) => (idx === 0 ? [section] : ["", "---", "", section]));
285
+ }
286
+
287
+ export async function executePrCheckout(
288
+ session: ToolSession,
289
+ params: GithubInput,
290
+ signal: AbortSignal | undefined,
291
+ ): Promise<AgentToolResult<GhToolDetails>> {
292
+ const repo = normalizeOptionalString(params.repo);
293
+ const force = params.force ?? false;
294
+ const prList = normalizePrIdentifierList(params.pr);
295
+ const prRefs = prList.length > 0 ? prList : [undefined];
296
+ const isMulti = prRefs.length > 1;
297
+
298
+ const settled = await Promise.allSettled(
299
+ prRefs.map(prRef => checkoutPullRequest(session, signal, { prRef, repo, force })),
300
+ );
301
+ const outcomes: PrCheckoutOutcome[] = [];
302
+ const failures: Array<{ prRef: string | undefined; reason: unknown }> = [];
303
+ for (let i = 0; i < settled.length; i++) {
304
+ const entry = settled[i];
305
+ if (entry.status === "fulfilled") outcomes.push(entry.value);
306
+ else failures.push({ prRef: prRefs[i], reason: entry.reason });
307
+ }
308
+ if (failures.length > 0) {
309
+ throwIfAborted(signal);
310
+ const failureLines = failures.map(
311
+ f => `- ${f.prRef ?? "(current branch)"}: ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`,
312
+ );
313
+ if (outcomes.length === 0) {
314
+ if (failures.length === 1) throw failures[0].reason;
315
+ throw new ToolError(`all ${failures.length} PR checkouts failed:\n${failureLines.join("\n")}`);
316
+ }
317
+ // Partial success: report the worktrees that did get created alongside
318
+ // the failures so the agent does not lose track of them.
319
+ const sections = outcomes.map(formatPrCheckoutResult);
320
+ const header = `# ${outcomes.length}/${settled.length} Pull Request Worktrees checked out (${failures.length} failed)`;
321
+ const text = [header, "", ...joinSections(sections), "", "## Failed", ...failureLines].join("\n").trim();
322
+ return buildTextResult(text, undefined, {
323
+ repo,
324
+ checkouts: outcomes.map(outcomeToSummary),
325
+ });
326
+ }
327
+
328
+ if (!isMulti) {
329
+ const [outcome] = outcomes;
330
+ return buildTextResult(formatPrCheckoutResult(outcome), outcome.data.url, {
331
+ repo: repo ?? outcome.data.headRepository?.nameWithOwner,
332
+ branch: outcome.localBranch,
333
+ worktreePath: outcome.worktreePath,
334
+ remote: outcome.remoteName,
335
+ remoteBranch: outcome.headRefName,
336
+ checkouts: [outcomeToSummary(outcome)],
337
+ });
338
+ }
339
+
340
+ const sections = outcomes.map(formatPrCheckoutResult);
341
+ const reusedCount = outcomes.reduce((acc, o) => acc + (o.reused ? 1 : 0), 0);
342
+ const newCount = outcomes.length - reusedCount;
343
+ const headerParts: string[] = [];
344
+ if (newCount > 0) headerParts.push(`${newCount} checked out`);
345
+ if (reusedCount > 0) headerParts.push(`${reusedCount} reused`);
346
+ const header = `# ${outcomes.length} Pull Request Worktrees (${headerParts.join(", ")})`;
347
+ const text = [header, "", ...joinSections(sections)].join("\n").trim();
348
+
349
+ return buildTextResult(text, undefined, {
350
+ repo,
351
+ checkouts: outcomes.map(outcomeToSummary),
352
+ });
353
+ }
354
+
355
+ export interface PrCheckoutOptions {
356
+ prRef: string | undefined;
357
+ repo: string | undefined;
358
+ force: boolean;
359
+ }
360
+
361
+ export interface PrCheckoutOutcome {
362
+ data: GhPrViewData;
363
+ localBranch: string;
364
+ worktreePath: string;
365
+ remoteName: string;
366
+ remoteUrl: string;
367
+ headRefName: string;
368
+ reused: boolean;
369
+ }
370
+
371
+ export async function checkoutPullRequest(
372
+ session: ToolSession,
373
+ signal: AbortSignal | undefined,
374
+ options: PrCheckoutOptions,
375
+ ): Promise<PrCheckoutOutcome> {
376
+ const { prRef, repo, force } = options;
377
+ if (prRef?.startsWith("-")) {
378
+ throw new ToolError(`invalid PR identifier: ${prRef}. Pass a PR number, URL, or branch name.`);
379
+ }
380
+ const args = ["pr", "view"];
381
+ if (prRef) args.push(prRef);
382
+ appendRepoFlag(args, repo, prRef);
383
+ args.push("--json", GH_PR_CHECKOUT_FIELDS.join(","));
384
+
385
+ const data = await git.github.json<GhPrViewData>(session.cwd, args, signal, {
386
+ repoProvided: Boolean(repo),
387
+ });
388
+ const prNumber = data.number;
389
+ if (typeof prNumber !== "number") {
390
+ throw new ToolError("GitHub CLI did not return a pull request number.");
391
+ }
392
+
393
+ const headRefName = requireNonEmpty(data.headRefName, "head branch");
394
+ const headRefOid = requireNonEmpty(data.headRefOid, "head commit");
395
+ const repoRoot = await requireGitRepoRoot(session.cwd, signal);
396
+ const primaryRepoRoot = await requirePrimaryGitRepoRoot(repoRoot, signal);
397
+ const localBranch = `pr-${prNumber}`;
398
+ const worktreePath = getWorktreeDir(`${prNumber}-${hashPath(primaryRepoRoot)}`);
399
+
400
+ // Every git mutation against `repoRoot` from here on must run under the
401
+ // per-repo lock. Worktrees of the same primary repo share `.git/config`,
402
+ // `commit-graph` chain, `packed-refs`, and worktree metadata files — git
403
+ // uses O_EXCL lock files for each, with no waiter. Concurrent in-process
404
+ // callers (e.g. parallel `pr_checkout` calls) would otherwise lose lock
405
+ // races and surface "could not lock config file" / "Another git process
406
+ // seems to be running" errors. The gh API call above stays outside the
407
+ // lock so multiple checkouts can fetch PR metadata in parallel.
408
+ return git.withRepoLock(
409
+ repoRoot,
410
+ async () => {
411
+ const existingWorktrees = await git.worktree.list(repoRoot, signal);
412
+ const existingWorktree = existingWorktrees.find(entry => entry.branch === toLocalBranchRef(localBranch));
413
+
414
+ const remote = await ensurePrRemote(repoRoot, data, signal);
415
+ await git.fetch(
416
+ repoRoot,
417
+ remote.name,
418
+ `refs/heads/${headRefName}`,
419
+ `refs/remotes/${remote.name}/${headRefName}`,
420
+ { signal },
421
+ );
422
+
423
+ if (!existingWorktree) {
424
+ const localBranchRef = toLocalBranchRef(localBranch);
425
+ const localBranchExists = await git.ref.exists(repoRoot, localBranchRef, signal);
426
+ if (localBranchExists) {
427
+ const existingOid = await git.ref.resolve(repoRoot, localBranchRef, signal);
428
+ if (existingOid !== headRefOid) {
429
+ if (!force) {
430
+ throw new ToolError(
431
+ `local branch ${localBranch} already exists at ${formatShortSha(existingOid ?? undefined) ?? existingOid ?? "unknown commit"}; pass force=true to reset it`,
432
+ );
433
+ }
434
+
435
+ await git.branch.force(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal);
436
+ }
437
+ } else {
438
+ await git.branch.create(repoRoot, localBranch, `refs/remotes/${remote.name}/${headRefName}`, signal);
439
+ }
440
+ }
441
+
442
+ await git.config.setBranch(repoRoot, localBranch, "remote", remote.name, signal);
443
+ await git.config.setBranch(repoRoot, localBranch, "merge", `refs/heads/${headRefName}`, signal);
444
+ await git.config.setBranch(repoRoot, localBranch, "pushRemote", remote.name, signal);
445
+ await git.config.setBranch(repoRoot, localBranch, "ompPrHeadRef", headRefName, signal);
446
+ await git.config.setBranch(repoRoot, localBranch, "ompPrUrl", data.url ?? "", signal);
447
+ await git.config.setBranch(
448
+ repoRoot,
449
+ localBranch,
450
+ "ompPrIsCrossRepository",
451
+ String(Boolean(data.isCrossRepository)),
452
+ signal,
453
+ );
454
+ await git.config.setBranch(
455
+ repoRoot,
456
+ localBranch,
457
+ "ompPrMaintainerCanModify",
458
+ String(Boolean(data.maintainerCanModify)),
459
+ signal,
460
+ );
461
+
462
+ let finalWorktreePath = existingWorktree?.path ?? worktreePath;
463
+ if (!existingWorktree) {
464
+ finalWorktreePath = await resolveAvailableWorktreePath(worktreePath, existingWorktrees);
465
+ await fs.mkdir(path.dirname(finalWorktreePath), { recursive: true });
466
+ await git.worktree.add(repoRoot, finalWorktreePath, localBranch, { signal });
467
+ }
468
+ const resolvedWorktreePath = await fs.realpath(finalWorktreePath);
469
+
470
+ return {
471
+ data,
472
+ localBranch,
473
+ worktreePath: resolvedWorktreePath,
474
+ remoteName: remote.name,
475
+ remoteUrl: remote.url,
476
+ headRefName,
477
+ reused: Boolean(existingWorktree),
478
+ };
479
+ },
480
+ signal,
481
+ );
482
+ }
483
+
484
+ export function outcomeToSummary(outcome: PrCheckoutOutcome): GhPrCheckoutSummary {
485
+ return {
486
+ prNumber: typeof outcome.data.number === "number" ? outcome.data.number : undefined,
487
+ url: outcome.data.url ?? undefined,
488
+ branch: outcome.localBranch,
489
+ worktreePath: outcome.worktreePath,
490
+ remote: outcome.remoteName,
491
+ remoteBranch: outcome.headRefName,
492
+ reused: outcome.reused,
493
+ };
494
+ }
495
+
496
+ export async function executePrPush(
497
+ session: ToolSession,
498
+ params: GithubInput,
499
+ signal: AbortSignal | undefined,
500
+ ): Promise<AgentToolResult<GhToolDetails>> {
501
+ const repoRoot = await requireGitRepoRoot(session.cwd, signal);
502
+ const localBranch = normalizeOptionalString(params.branch) ?? (await requireCurrentGitBranch(repoRoot, signal));
503
+ const refExists = await git.ref.exists(repoRoot, toLocalBranchRef(localBranch), signal);
504
+ if (!refExists) {
505
+ throw new ToolError(`local branch ${localBranch} does not exist`);
506
+ }
507
+
508
+ const target = await resolvePrBranchPushTarget(repoRoot, localBranch, signal);
509
+ const currentBranch = await git.branch.current(repoRoot, signal);
510
+ const sourceRef = currentBranch === localBranch ? "HEAD" : toLocalBranchRef(localBranch);
511
+ const refspec = `${sourceRef}:refs/heads/${target.remoteBranch}`;
512
+ await git.push(repoRoot, {
513
+ forceWithLease: params.forceWithLease,
514
+ refspec,
515
+ remote: target.remoteName,
516
+ signal,
517
+ });
518
+
519
+ // A successful push changes what `pr://N` and `pr://N/diff` should show;
520
+ // drop the cached rows so the canonical "push → re-read diff" flow sees
521
+ // fresh data instead of a soft-TTL stale snapshot.
522
+ const pushedPr = parsePullRequestUrl(target.prUrl);
523
+ if (pushedPr.prNumber !== undefined) {
524
+ invalidateAllForNumber(pushedPr.prNumber, pushedPr.repo);
525
+ }
526
+
527
+ return buildTextResult(
528
+ formatPrPushResult({
529
+ localBranch,
530
+ remoteName: target.remoteName,
531
+ remoteBranch: target.remoteBranch,
532
+ remoteUrl: target.remoteUrl,
533
+ prUrl: target.prUrl,
534
+ forceWithLease: params.forceWithLease ?? false,
535
+ }),
536
+ target.prUrl,
537
+ {
538
+ branch: localBranch,
539
+ remote: target.remoteName,
540
+ remoteBranch: target.remoteBranch,
541
+ },
542
+ );
543
+ }
544
+
545
+ export async function executePrCreate(
546
+ session: ToolSession,
547
+ params: GithubInput,
548
+ signal: AbortSignal | undefined,
549
+ ): Promise<AgentToolResult<GhToolDetails>> {
550
+ const repo = normalizeOptionalString(params.repo);
551
+ const title = normalizeOptionalString(params.title);
552
+ const body = params.body;
553
+ const base = normalizeOptionalString(params.base);
554
+ const head = normalizeOptionalString(params.head);
555
+ const draft = params.draft ?? false;
556
+ const fill = params.fill ?? false;
557
+ const reviewers = normalizePrIdentifierList(params.reviewer);
558
+ const assignees = normalizePrIdentifierList(params.assignee);
559
+ const labels = normalizePrIdentifierList(params.label);
560
+
561
+ if (!fill && !title) {
562
+ throw new ToolError("title is required unless fill is true");
563
+ }
564
+ if (fill && (title || body !== undefined)) {
565
+ throw new ToolError("fill is mutually exclusive with title and body");
566
+ }
567
+
568
+ const args = ["pr", "create"];
569
+ appendRepoFlag(args, repo);
570
+ if (title) args.push("--title", title);
571
+ if (base) args.push("--base", base);
572
+ if (head) args.push("--head", head);
573
+ if (draft) args.push("--draft");
574
+ if (fill) args.push("--fill");
575
+ for (const reviewer of reviewers) args.push("--reviewer", reviewer);
576
+ for (const assignee of assignees) args.push("--assignee", assignee);
577
+ for (const label of labels) args.push("--label", label);
578
+
579
+ let bodyDir: string | undefined;
580
+ try {
581
+ if (!fill) {
582
+ if (body !== undefined && body.length > 0) {
583
+ // Route through a temp file so multi-KB bodies stay clear of any
584
+ // argv-length limits and shell-quoting hazards on uncommon platforms.
585
+ bodyDir = await fs.mkdtemp(path.join(os.tmpdir(), "gh-pr-body-"));
586
+ const bodyFile = path.join(bodyDir, "body.md");
587
+ await Bun.write(bodyFile, body);
588
+ args.push("--body-file", bodyFile);
589
+ } else {
590
+ // Avoid gh dropping into an interactive editor when no body is given.
591
+ args.push("--body", "");
592
+ }
593
+ }
594
+
595
+ const output = await git.github.text(session.cwd, args, signal, {
596
+ repoProvided: Boolean(repo),
597
+ });
598
+ const url =
599
+ output
600
+ .split("\n")
601
+ .map(line => line.trim())
602
+ .find(line => line.startsWith("https://github.com/")) ?? output.trim();
603
+ const parsed = parsePullRequestUrl(url);
604
+ const resolvedRepo = repo ?? parsed.repo;
605
+
606
+ let prView: GhPrViewData | undefined;
607
+ if (resolvedRepo && parsed.prNumber !== undefined) {
608
+ try {
609
+ prView = await git.github.json<GhPrViewData>(
610
+ session.cwd,
611
+ [
612
+ "pr",
613
+ "view",
614
+ String(parsed.prNumber),
615
+ "--repo",
616
+ resolvedRepo,
617
+ "--json",
618
+ GH_PR_FIELDS_NO_COMMENTS.join(","),
619
+ ],
620
+ signal,
621
+ { repoProvided: true },
622
+ );
623
+ } catch {
624
+ // Best-effort summary; PR creation already succeeded.
625
+ }
626
+ }
627
+
628
+ const text = formatPrCreateResult({
629
+ url,
630
+ prNumber: parsed.prNumber,
631
+ data: prView,
632
+ title,
633
+ base,
634
+ head,
635
+ draft,
636
+ });
637
+ return buildTextResult(text, url || prView?.url);
638
+ } finally {
639
+ if (bodyDir) {
640
+ await fs.rm(bodyDir, { recursive: true, force: true }).catch(() => {});
641
+ }
642
+ }
643
+ }
644
+
645
+ export function formatPrCreateResult(options: {
646
+ url: string;
647
+ prNumber?: number;
648
+ data?: GhPrViewData;
649
+ title?: string;
650
+ base?: string;
651
+ head?: string;
652
+ draft?: boolean;
653
+ }): string {
654
+ const number = options.prNumber ?? options.data?.number;
655
+ const headerTitle = options.data?.title ?? options.title ?? "Untitled";
656
+ const header =
657
+ number !== undefined
658
+ ? `# Created Pull Request #${number}: ${headerTitle}`
659
+ : `# Created Pull Request: ${headerTitle}`;
660
+ const lines: string[] = [header, ""];
661
+ pushLine(lines, "URL", options.url || options.data?.url);
662
+ pushLine(lines, "State", options.data?.state);
663
+ pushLine(lines, "Draft", options.data?.isDraft ?? options.draft);
664
+ pushLine(lines, "Base", options.data?.baseRefName ?? options.base);
665
+ pushLine(lines, "Head", options.data?.headRefName ?? options.head);
666
+ pushLine(lines, "Author", formatAuthor(options.data?.author));
667
+ pushLine(lines, "Created", options.data?.createdAt);
668
+ pushLine(lines, "Labels", formatLabels(options.data?.labels));
669
+
670
+ const bodyText = normalizeText(options.data?.body);
671
+ if (bodyText) {
672
+ lines.push("");
673
+ lines.push("## Body");
674
+ lines.push("");
675
+ lines.push(bodyText);
676
+ }
677
+
678
+ return lines.join("\n").trim();
679
+ }