@plannotator/pi-extension 0.16.7 → 0.17.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.
@@ -79,6 +79,20 @@ function isSearchableMarkdownPath(input: string): boolean {
79
79
  return MARKDOWN_PATH_REGEX.test(input.trim());
80
80
  }
81
81
 
82
+ function stripWrappingQuotes(input: string): string {
83
+ if (input.length < 2) {
84
+ return input;
85
+ }
86
+
87
+ const first = input[0];
88
+ const last = input[input.length - 1];
89
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
90
+ return input.slice(1, -1);
91
+ }
92
+
93
+ return input;
94
+ }
95
+
82
96
  /** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */
83
97
  function hasWindowsDriveLetter(input: string): boolean {
84
98
  return /^[a-zA-Z]:[/\\]/.test(input);
@@ -156,12 +170,10 @@ export function isAbsoluteMarkdownPath(
156
170
  * @param input - User-provided path (absolute, relative, or bare filename)
157
171
  * @param projectRoot - Project root directory to search within
158
172
  */
159
- export function resolveMarkdownFile(
173
+ function resolveMarkdownFileCore(
160
174
  input: string,
161
175
  projectRoot: string,
162
176
  ): ResolveResult {
163
- // Trim whitespace/CR that may leak from Windows shell pipelines
164
- input = input.trim();
165
177
  const normalizedInput = normalizeMarkdownPathInput(input);
166
178
  const searchInput = normalizeSeparators(normalizedInput);
167
179
  const isBareFilename = !searchInput.includes("/");
@@ -219,6 +231,47 @@ export function resolveMarkdownFile(
219
231
  return { kind: "not_found", input };
220
232
  }
221
233
 
234
+ /**
235
+ * Resolve a markdown file path within a project root.
236
+ *
237
+ * @param input - User-provided path (absolute, relative, or bare filename)
238
+ * @param projectRoot - Project root directory to search within
239
+ */
240
+ export function resolveMarkdownFile(
241
+ input: string,
242
+ projectRoot: string,
243
+ ): ResolveResult {
244
+ const originalInput = input.trim();
245
+ const unquotedInput = stripWrappingQuotes(originalInput);
246
+
247
+ const primary = resolveMarkdownFileCore(unquotedInput, projectRoot);
248
+ if (primary.kind === "found") {
249
+ return primary;
250
+ }
251
+ if (primary.kind === "ambiguous") {
252
+ return { ...primary, input: originalInput };
253
+ }
254
+
255
+ if (!unquotedInput.startsWith("@")) {
256
+ return { kind: "not_found", input: originalInput };
257
+ }
258
+
259
+ const normalizedInput = unquotedInput.replace(/^@+/, "");
260
+ if (!normalizedInput) {
261
+ return { kind: "not_found", input: originalInput };
262
+ }
263
+
264
+ const fallback = resolveMarkdownFileCore(normalizedInput, projectRoot);
265
+ if (fallback.kind === "found") {
266
+ return fallback;
267
+ }
268
+ if (fallback.kind === "ambiguous") {
269
+ return { ...fallback, input: originalInput };
270
+ }
271
+
272
+ return { kind: "not_found", input: originalInput };
273
+ }
274
+
222
275
  /**
223
276
  * Check if a directory contains at least one markdown file.
224
277
  * Used to validate folder annotation targets.
@@ -14,6 +14,7 @@ export type DiffType =
14
14
  | "unstaged"
15
15
  | "last-commit"
16
16
  | "branch"
17
+ | "merge-base"
17
18
  | `worktree:${string}`
18
19
  | "p4-default"
19
20
  | `p4-changelist:${string}`;
@@ -151,6 +152,7 @@ export async function getGitContext(
151
152
 
152
153
  if (currentBranch !== defaultBranch) {
153
154
  diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` });
155
+ diffOptions.push({ id: "merge-base", label: `Current PR Diff` });
154
156
  }
155
157
 
156
158
  const [worktrees, currentTreePathResult] = await Promise.all([
@@ -386,6 +388,28 @@ export async function runGitDiff(
386
388
  break;
387
389
  }
388
390
 
391
+ case "merge-base": {
392
+ const mergeBaseResult = assertGitSuccess(
393
+ await runtime.runGit(["merge-base", defaultBranch, "HEAD"], { cwd }),
394
+ ["merge-base", defaultBranch, "HEAD"],
395
+ );
396
+ const mergeBase = mergeBaseResult.stdout.trim();
397
+ const mergeBaseDiffArgs = [
398
+ "diff",
399
+ "--no-ext-diff",
400
+ `${mergeBase}..HEAD`,
401
+ "--src-prefix=a/",
402
+ "--dst-prefix=b/",
403
+ ];
404
+ const mergeBaseDiff = assertGitSuccess(
405
+ await runtime.runGit(mergeBaseDiffArgs, { cwd }),
406
+ mergeBaseDiffArgs,
407
+ );
408
+ patch = mergeBaseDiff.stdout;
409
+ label = `PR diff vs ${defaultBranch}`;
410
+ break;
411
+ }
412
+
389
413
  default:
390
414
  return { patch: "", label: "Unknown diff type" };
391
415
  }
@@ -475,6 +499,14 @@ export async function getFileContentsForDiff(
475
499
  oldContent: await gitShow(defaultBranch, oldFilePath),
476
500
  newContent: await gitShow("HEAD", filePath),
477
501
  };
502
+ case "merge-base": {
503
+ const mbResult = await runtime.runGit(["merge-base", defaultBranch, "HEAD"], { cwd });
504
+ const mb = mbResult.exitCode === 0 ? mbResult.stdout.trim() : defaultBranch;
505
+ return {
506
+ oldContent: await gitShow(mb, oldFilePath),
507
+ newContent: await gitShow("HEAD", filePath),
508
+ };
509
+ }
478
510
  default:
479
511
  return { oldContent: null, newContent: null };
480
512
  }
@@ -0,0 +1,120 @@
1
+ // @generated — DO NOT EDIT. Source: packages/shared/worktree.ts
2
+ /**
3
+ * Worktree — runtime-agnostic git worktree primitives.
4
+ *
5
+ * Uses ReviewGitRuntime so both Bun and Node runtimes can use the same logic.
6
+ * Lives in packages/shared/ and gets vendored to Pi via vendor.sh.
7
+ *
8
+ * Designed as composable primitives, not tied to any specific use case.
9
+ * PR local checkout, agent sandboxes, parallel sessions — all compose from these.
10
+ */
11
+
12
+ import type { ReviewGitRuntime } from "./review-core";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface CreateWorktreeOptions {
19
+ /** Git ref to check out (branch name, SHA, FETCH_HEAD, etc.) */
20
+ ref: string;
21
+ /** Absolute path where the worktree will be created. */
22
+ path: string;
23
+ /** Create in detached HEAD mode (no branch). Default: false. */
24
+ detach?: boolean;
25
+ /** CWD of the source repository. Defaults to process.cwd(). */
26
+ cwd?: string;
27
+ }
28
+
29
+ export interface RemoveWorktreeOptions {
30
+ /** Force removal even if the worktree has modifications. Default: false. */
31
+ force?: boolean;
32
+ /** CWD of the source repository. Required if the worktree was created from a different cwd. */
33
+ cwd?: string;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Primitives
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Fetch a ref from origin.
42
+ * Runs: `git fetch origin <ref>`
43
+ * Throws on failure.
44
+ */
45
+ export async function fetchRef(
46
+ runtime: ReviewGitRuntime,
47
+ ref: string,
48
+ options?: { cwd?: string },
49
+ ): Promise<void> {
50
+ const result = await runtime.runGit(["fetch", "origin", "--", ref], { cwd: options?.cwd });
51
+ if (result.exitCode !== 0) {
52
+ throw new Error(`git fetch origin ${ref} failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Ensure a git object (commit SHA) is available locally.
58
+ * Checks with `git cat-file -t`, fetches from origin if missing.
59
+ * Returns true if the object is available after the attempt.
60
+ */
61
+ export async function ensureObjectAvailable(
62
+ runtime: ReviewGitRuntime,
63
+ sha: string,
64
+ options?: { cwd?: string },
65
+ ): Promise<boolean> {
66
+ const check = await runtime.runGit(["cat-file", "-t", sha], { cwd: options?.cwd });
67
+ if (check.exitCode === 0) return true;
68
+
69
+ // Object missing locally — try fetching it
70
+ const fetch = await runtime.runGit(["fetch", "origin", "--", sha], { cwd: options?.cwd });
71
+ if (fetch.exitCode !== 0) return false;
72
+
73
+ // Verify it's now available
74
+ const recheck = await runtime.runGit(["cat-file", "-t", sha], { cwd: options?.cwd });
75
+ return recheck.exitCode === 0;
76
+ }
77
+
78
+ /**
79
+ * Create a git worktree.
80
+ * Runs: `git worktree add [--detach] <path> <ref>`
81
+ * Throws on failure with a descriptive error.
82
+ */
83
+ export async function createWorktree(
84
+ runtime: ReviewGitRuntime,
85
+ options: CreateWorktreeOptions,
86
+ ): Promise<{ worktreePath: string }> {
87
+ const args = ["worktree", "add"];
88
+ if (options.detach) args.push("--detach");
89
+ args.push(options.path, options.ref);
90
+
91
+ const result = await runtime.runGit(args, { cwd: options.cwd });
92
+ if (result.exitCode !== 0) {
93
+ throw new Error(`git worktree add failed: ${result.stderr.trim() || `exit code ${result.exitCode}`}`);
94
+ }
95
+
96
+ return { worktreePath: options.path };
97
+ }
98
+
99
+ /**
100
+ * Remove a git worktree. Best-effort — logs errors but does not throw.
101
+ * Runs: `git worktree remove [--force] <path>`
102
+ */
103
+ export async function removeWorktree(
104
+ runtime: ReviewGitRuntime,
105
+ worktreePath: string,
106
+ options?: RemoveWorktreeOptions,
107
+ ): Promise<void> {
108
+ const args = ["worktree", "remove"];
109
+ if (options?.force) args.push("--force");
110
+ args.push(worktreePath);
111
+
112
+ try {
113
+ const result = await runtime.runGit(args, { cwd: options?.cwd });
114
+ if (result.exitCode !== 0) {
115
+ console.error(`Warning: git worktree remove failed for ${worktreePath}: ${result.stderr.trim()}`);
116
+ }
117
+ } catch (err) {
118
+ console.error(`Warning: worktree cleanup error: ${err instanceof Error ? err.message : String(err)}`);
119
+ }
120
+ }
package/index.ts CHANGED
@@ -333,8 +333,8 @@ export default function plannotator(pi: ExtensionAPI): void {
333
333
  });
334
334
 
335
335
  pi.registerCommand("plannotator-review", {
336
- description: "Open interactive code review for current changes",
337
- handler: async (_args, ctx) => {
336
+ description: "Open interactive code review for current changes or a PR URL",
337
+ handler: async (args, ctx) => {
338
338
  if (!hasReviewBrowserHtml()) {
339
339
  ctx.ui.notify(
340
340
  "Code review UI not available. Run 'bun run build' in the pi-extension directory.",
@@ -344,12 +344,18 @@ export default function plannotator(pi: ExtensionAPI): void {
344
344
  }
345
345
 
346
346
  try {
347
- const result = await openCodeReview(ctx);
347
+ const prUrl = args?.trim() || undefined;
348
+ const isPRReview = prUrl?.startsWith("http://") || prUrl?.startsWith("https://");
349
+ const result = await openCodeReview(ctx, { prUrl });
348
350
  if (result.feedback) {
349
351
  if (result.approved) {
350
352
  pi.sendUserMessage(
351
353
  `# Code Review\n\nCode review completed — no changes requested.`,
352
354
  );
355
+ } else if (isPRReview) {
356
+ // Platform PR actions (approve/comment) return approved:false with a
357
+ // status message — don't tell the agent to "address" a platform action.
358
+ pi.sendUserMessage(result.feedback);
353
359
  } else {
354
360
  pi.sendUserMessage(
355
361
  `${result.feedback}\n\nPlease address this feedback.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.16.7",
3
+ "version": "0.17.1",
4
4
  "type": "module",
5
5
  "description": "Plannotator extension for Pi coding agent - interactive plan review with visual annotation",
6
6
  "author": "backnotprop",
@@ -1,9 +1,12 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
1
+ import { existsSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { spawnSync } from "node:child_process";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
7
  import {
6
8
  getGitContext,
9
+ reviewRuntime,
7
10
  runGitDiff,
8
11
  startAnnotateServer,
9
12
  startPlanReviewServer,
@@ -11,6 +14,16 @@ import {
11
14
  type DiffType,
12
15
  } from "./server.js";
13
16
  import { openBrowser } from "./server/network.js";
17
+ import { parsePRUrl, checkPRAuth, fetchPR } from "./server/pr.js";
18
+ import {
19
+ getMRLabel,
20
+ getMRNumberLabel,
21
+ getDisplayRepo,
22
+ getCliName,
23
+ getCliInstallUrl,
24
+ } from "./generated/pr-provider.js";
25
+ import { parseRemoteUrl } from "./generated/repo.js";
26
+ import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "./generated/worktree.js";
14
27
 
15
28
  export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last";
16
29
  export interface PlanReviewDecision {
@@ -151,27 +164,198 @@ export async function openPlanReviewBrowser(
151
164
 
152
165
  export async function openCodeReview(
153
166
  ctx: ExtensionContext,
154
- options: { cwd?: string; defaultBranch?: string; diffType?: DiffType } = {},
167
+ options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string } = {},
155
168
  ): Promise<{ approved: boolean; feedback?: string; annotations?: unknown[]; agentSwitch?: string }> {
156
169
  if (!ctx.hasUI || !reviewHtmlContent) {
157
170
  throw new Error("Plannotator code review browser is unavailable in this session.");
158
171
  }
159
172
 
160
- const cwd = options.cwd ?? ctx.cwd;
161
- const gitCtx = await getGitContext(cwd);
162
- const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch;
163
- const diffType: DiffType = options.diffType ?? "uncommitted";
164
- const { patch: rawPatch, label: gitRef, error } = await runGitDiff(diffType, defaultBranch, cwd);
173
+ const urlArg = options.prUrl;
174
+ const isPRMode = urlArg?.startsWith("http://") || urlArg?.startsWith("https://");
175
+
176
+ let rawPatch: string;
177
+ let gitRef: string;
178
+ let diffError: string | undefined;
179
+ let gitCtx: Awaited<ReturnType<typeof getGitContext>> | undefined;
180
+ let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
181
+ let diffType: DiffType | undefined;
182
+ let agentCwd: string | undefined;
183
+ let worktreeCleanup: (() => void | Promise<void>) | undefined;
184
+ let exitHandler: (() => void) | undefined;
185
+
186
+ if (isPRMode && urlArg) {
187
+ // --- PR Review Mode ---
188
+ const prRef = parsePRUrl(urlArg);
189
+ if (!prRef) {
190
+ throw new Error(
191
+ `Invalid PR/MR URL: ${urlArg}\n` +
192
+ "Supported formats:\n" +
193
+ " GitHub: https://github.com/owner/repo/pull/123\n" +
194
+ " GitLab: https://gitlab.com/group/project/-/merge_requests/42",
195
+ );
196
+ }
197
+
198
+ const cliName = getCliName(prRef);
199
+ const cliUrl = getCliInstallUrl(prRef);
200
+
201
+ try {
202
+ await checkPRAuth(prRef);
203
+ } catch (err) {
204
+ const msg = err instanceof Error ? err.message : String(err);
205
+ if (msg.includes("not found") || msg.includes("ENOENT")) {
206
+ throw new Error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed. Install it from ${cliUrl}`);
207
+ }
208
+ throw err;
209
+ }
210
+
211
+ console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`);
212
+ const pr = await fetchPR(prRef);
213
+ rawPatch = pr.rawPatch;
214
+ gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`;
215
+ prMetadata = pr.metadata;
216
+
217
+ // Create local worktree for agent file access (--local is the default for PR reviews)
218
+ let localPath: string | undefined;
219
+ try {
220
+ const repoDir = options.cwd ?? ctx.cwd;
221
+ const identifier = prMetadata.platform === "github"
222
+ ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}`
223
+ : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`;
224
+ const suffix = Math.random().toString(36).slice(2, 8);
225
+ localPath = join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
226
+ const fetchRefStr = prMetadata.platform === "github"
227
+ ? `refs/pull/${prMetadata.number}/head`
228
+ : `refs/merge-requests/${prMetadata.iid}/head`;
229
+
230
+ // Validate inputs from platform API to prevent git flag/path injection
231
+ if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`);
232
+ if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`);
233
+
234
+ // Detect same-repo vs cross-repo (must match both owner/repo AND host)
235
+ let isSameRepo = false;
236
+ try {
237
+ const remoteResult = await reviewRuntime.runGit(["remote", "get-url", "origin"], { cwd: repoDir });
238
+ if (remoteResult.exitCode === 0) {
239
+ const remoteUrl = remoteResult.stdout.trim();
240
+ const currentRepo = parseRemoteUrl(remoteUrl);
241
+ const prRepo = prMetadata.platform === "github"
242
+ ? `${prMetadata.owner}/${prMetadata.repo}`
243
+ : prMetadata.projectPath;
244
+ const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase();
245
+ const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1];
246
+ const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })();
247
+ const remoteHost = (sshHost || httpsHost || "").toLowerCase();
248
+ const prHost = prMetadata.host.toLowerCase();
249
+ isSameRepo = repoMatches && remoteHost === prHost;
250
+ }
251
+ } catch { /* not in a git repo — cross-repo path */ }
252
+
253
+ if (isSameRepo) {
254
+ // ── Same-repo: fast worktree path ──
255
+ console.error("Fetching PR branch and creating local worktree...");
256
+ await fetchRef(reviewRuntime, prMetadata.baseBranch, { cwd: repoDir });
257
+ await ensureObjectAvailable(reviewRuntime, prMetadata.baseSha, { cwd: repoDir });
258
+ await fetchRef(reviewRuntime, fetchRefStr, { cwd: repoDir });
259
+
260
+ await createWorktree(reviewRuntime, {
261
+ ref: "FETCH_HEAD",
262
+ path: localPath,
263
+ detach: true,
264
+ cwd: repoDir,
265
+ });
266
+
267
+ const worktreePath = localPath;
268
+ const wtRepoDir = repoDir;
269
+ exitHandler = () => {
270
+ try { spawnSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: wtRepoDir }); } catch {}
271
+ };
272
+ worktreeCleanup = () => {
273
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
274
+ return removeWorktree(reviewRuntime, worktreePath, { force: true, cwd: wtRepoDir });
275
+ };
276
+ process.once("exit", exitHandler);
277
+ } else {
278
+ // ── Cross-repo: shallow clone + fetch PR head ──
279
+ const prRepo = prMetadata.platform === "github"
280
+ ? `${prMetadata.owner}/${prMetadata.repo}`
281
+ : prMetadata.projectPath;
282
+ if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`);
283
+ const cli = prMetadata.platform === "github" ? "gh" : "glab";
284
+ const host = prMetadata.host;
285
+ // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead
286
+ const isDefaultHost = host === "github.com" || host === "gitlab.com";
287
+ const cloneEnv = isDefaultHost ? undefined : {
288
+ ...process.env,
289
+ ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
290
+ };
291
+
292
+ console.error(`Cloning ${prRepo} (shallow)...`);
293
+ const cloneResult = spawnSync(cli, ["repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], { encoding: "utf-8", env: cloneEnv });
294
+ if ((cloneResult.status ?? 1) !== 0) {
295
+ throw new Error(`${cli} repo clone failed: ${(cloneResult.stderr ?? "").trim()}`);
296
+ }
297
+
298
+ console.error("Fetching PR branch...");
299
+ const fetchResult = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath });
300
+ if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${fetchResult.stderr.trim()}`);
301
+
302
+ const checkoutResult = await reviewRuntime.runGit(["checkout", "FETCH_HEAD"], { cwd: localPath });
303
+ if (checkoutResult.exitCode !== 0) {
304
+ throw new Error(`git checkout FETCH_HEAD failed: ${checkoutResult.stderr.trim()}`);
305
+ }
306
+
307
+ // Best-effort: create base refs so agent diffs work
308
+ const baseFetch = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath });
309
+ if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate");
310
+ await reviewRuntime.runGit(["branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath });
311
+ await reviewRuntime.runGit(["update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath });
312
+
313
+ const clonePath = localPath;
314
+ exitHandler = () => {
315
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
316
+ };
317
+ worktreeCleanup = () => {
318
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
319
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
320
+ };
321
+ process.once("exit", exitHandler);
322
+ }
323
+
324
+ agentCwd = localPath;
325
+ console.error(`Local checkout ready at ${localPath}`);
326
+ } catch (err) {
327
+ console.error("Warning: local worktree creation failed, falling back to remote diff");
328
+ console.error(err instanceof Error ? err.message : String(err));
329
+ if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; }
330
+ if (localPath) try { rmSync(localPath, { recursive: true, force: true }); } catch {}
331
+ agentCwd = undefined;
332
+ worktreeCleanup = undefined;
333
+ }
334
+ } else {
335
+ // --- Local Review Mode ---
336
+ const cwd = options.cwd ?? ctx.cwd;
337
+ gitCtx = await getGitContext(cwd);
338
+ const defaultBranch = options.defaultBranch ?? gitCtx.defaultBranch;
339
+ diffType = options.diffType ?? "uncommitted";
340
+ const result = await runGitDiff(diffType, defaultBranch, cwd);
341
+ rawPatch = result.patch;
342
+ gitRef = result.label;
343
+ diffError = result.error;
344
+ }
345
+
165
346
  const server = await startReviewServer({
166
347
  rawPatch,
167
348
  gitRef,
168
- error,
349
+ error: diffError,
169
350
  origin: "pi",
170
351
  diffType,
171
352
  gitContext: gitCtx,
353
+ prMetadata,
354
+ agentCwd,
172
355
  htmlContent: reviewHtmlContent,
173
356
  sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
174
357
  shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
358
+ onCleanup: worktreeCleanup,
175
359
  });
176
360
 
177
361
  return openBrowserAndWait(server, ctx, server.waitForDecision);
@@ -85,6 +85,7 @@ export interface PlannotatorCodeReviewPayload {
85
85
  diffType?: DiffType;
86
86
  defaultBranch?: string;
87
87
  cwd?: string;
88
+ prUrl?: string;
88
89
  }
89
90
 
90
91
  export interface PlannotatorCodeReviewResult {
@@ -254,6 +255,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void {
254
255
  cwd: request.payload?.cwd,
255
256
  defaultBranch: request.payload?.defaultBranch,
256
257
  diffType: request.payload?.diffType,
258
+ prUrl: request.payload?.prUrl,
257
259
  });
258
260
  request.respond({ status: "handled", result });
259
261
  return;