@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.
@@ -1,6 +1,7 @@
1
1
  import { execSync, spawnSync } from "node:child_process";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, existsSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
+ import os from "node:os";
4
5
 
5
6
  import { Readable } from "node:stream";
6
7
 
@@ -46,7 +47,7 @@ import {
46
47
  } from "./handlers.js";
47
48
  import { html, json, parseBody, requestUrl, toWebRequest } from "./helpers.js";
48
49
 
49
- import { listenOnPort } from "./network.js";
50
+ import { isRemoteSession, listenOnPort } from "./network.js";
50
51
  import {
51
52
  fetchPRContext,
52
53
  fetchPRFileContent,
@@ -56,11 +57,39 @@ import {
56
57
  submitPRReview,
57
58
  } from "./pr.js";
58
59
  import { getRepoInfo } from "./project.js";
60
+ import {
61
+ CODEX_REVIEW_SYSTEM_PROMPT,
62
+ buildCodexReviewUserMessage,
63
+ buildCodexCommand,
64
+ generateOutputPath,
65
+ parseCodexOutput,
66
+ transformReviewFindings,
67
+ } from "../generated/codex-review.js";
68
+ import {
69
+ CLAUDE_REVIEW_PROMPT,
70
+ buildClaudeCommand,
71
+ parseClaudeStreamOutput,
72
+ transformClaudeFindings,
73
+ } from "../generated/claude-review.js";
74
+
75
+ /** Detect if running inside WSL (Windows Subsystem for Linux) */
76
+ function detectWSL(): boolean {
77
+ if (process.platform !== "linux") return false;
78
+ if (os.release().toLowerCase().includes("microsoft")) return true;
79
+ try {
80
+ if (existsSync("/proc/version")) {
81
+ const content = readFileSync("/proc/version", "utf-8").toLowerCase();
82
+ return content.includes("wsl") || content.includes("microsoft");
83
+ }
84
+ } catch { /* ignore */ }
85
+ return false;
86
+ }
59
87
 
60
88
  export interface ReviewServerResult {
61
89
  port: number;
62
90
  portSource: "env" | "remote-default" | "random";
63
91
  url: string;
92
+ isRemote: boolean;
64
93
  waitForDecision: () => Promise<{
65
94
  approved: boolean;
66
95
  feedback: string;
@@ -70,7 +99,7 @@ export interface ReviewServerResult {
70
99
  stop: () => void;
71
100
  }
72
101
 
73
- const reviewRuntime: ReviewGitRuntime = {
102
+ export const reviewRuntime: ReviewGitRuntime = {
74
103
  async runGit(
75
104
  args: string[],
76
105
  options?: { cwd?: string },
@@ -118,11 +147,20 @@ export async function startReviewServer(options: {
118
147
  sharingEnabled?: boolean;
119
148
  shareBaseUrl?: string;
120
149
  prMetadata?: PRMetadata;
150
+ /** Working directory for agent processes (e.g., --local worktree). Independent of diff pipeline. */
151
+ agentCwd?: string;
152
+ /** Cleanup callback invoked when server stops (e.g., remove temp worktree) */
153
+ onCleanup?: () => void | Promise<void>;
154
+ /** Called when server starts with the URL, remote status, and port */
155
+ onReady?: (url: string, isRemote: boolean, port: number) => void;
121
156
  }): Promise<ReviewServerResult> {
122
157
  const gitUser = detectGitUser();
123
158
  const draftKey = contentHash(options.rawPatch);
124
159
  const prMeta = options.prMetadata;
125
160
  const isPRMode = !!prMeta;
161
+ const hasLocalAccess = !!options.gitContext;
162
+ const isRemote = isRemoteSession();
163
+ const wslFlag = detectWSL();
126
164
  const prRef = prMeta ? prRefFromMetadata(prMeta) : null;
127
165
  const platformUser = prRef ? await getPRUser(prRef) : null;
128
166
 
@@ -154,15 +192,88 @@ export async function startReviewServer(options: {
154
192
 
155
193
  // Agent jobs — background process manager (late-binds serverUrl via getter)
156
194
  let serverUrl = "";
195
+ // Worktree-aware cwd resolver — shared by getCwd, buildCommand, and onJobComplete
196
+ function resolveAgentCwd(): string {
197
+ if (options.agentCwd) return options.agentCwd;
198
+ if (currentDiffType.startsWith("worktree:")) {
199
+ const parsed = parseWorktreeDiffType(currentDiffType);
200
+ if (parsed) return parsed.path;
201
+ }
202
+ return options.gitContext?.cwd ?? process.cwd();
203
+ }
157
204
  const agentJobs = createAgentJobHandler({
158
205
  mode: "review",
159
206
  getServerUrl: () => serverUrl,
160
- getCwd: () => {
161
- if (currentDiffType.startsWith("worktree:")) {
162
- const parsed = parseWorktreeDiffType(currentDiffType);
163
- if (parsed) return parsed.path;
207
+ getCwd: resolveAgentCwd,
208
+
209
+ async buildCommand(provider) {
210
+ const cwd = resolveAgentCwd();
211
+ const hasAgentLocalAccess = !!options.agentCwd || !!options.gitContext;
212
+ const userMessage = buildCodexReviewUserMessage(
213
+ currentPatch,
214
+ currentDiffType,
215
+ { defaultBranch: options.gitContext?.defaultBranch, hasLocalAccess: hasAgentLocalAccess },
216
+ options.prMetadata,
217
+ );
218
+
219
+ if (provider === "codex") {
220
+ const outputPath = generateOutputPath();
221
+ const prompt = CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage;
222
+ const command = await buildCodexCommand({ cwd, outputPath, prompt });
223
+ return { command, outputPath, prompt, label: "Codex Review" };
224
+ }
225
+
226
+ if (provider === "claude") {
227
+ const prompt = CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage;
228
+ const { command, stdinPrompt } = buildClaudeCommand(prompt);
229
+ return { command, stdinPrompt, prompt, cwd, label: "Claude Code Review", captureStdout: true };
230
+ }
231
+
232
+ return null;
233
+ },
234
+
235
+ async onJobComplete(job, meta) {
236
+ const cwd = resolveAgentCwd();
237
+
238
+ if (job.provider === "codex" && meta.outputPath) {
239
+ const output = await parseCodexOutput(meta.outputPath);
240
+ if (!output) return;
241
+
242
+ // Override verdict if there are blocking findings (P0/P1) — Codex's
243
+ // freeform correctness string can say "mostly correct" with real bugs.
244
+ const hasBlockingFindings = output.findings.some((f: any) => f.priority !== null && f.priority <= 1);
245
+ job.summary = {
246
+ correctness: hasBlockingFindings ? "Issues Found" : output.overall_correctness,
247
+ explanation: output.overall_explanation,
248
+ confidence: output.overall_confidence_score,
249
+ };
250
+
251
+ if (output.findings.length > 0) {
252
+ const annotations = transformReviewFindings(output.findings, job.source, cwd, "Codex");
253
+ const result = externalAnnotations.addAnnotations({ annotations });
254
+ if ("error" in result) console.error(`[codex-review] addAnnotations error:`, result.error);
255
+ }
256
+ return;
257
+ }
258
+
259
+ if (job.provider === "claude" && meta.stdout) {
260
+ const output = parseClaudeStreamOutput(meta.stdout);
261
+ if (!output) return;
262
+
263
+ const total = output.summary.important + output.summary.nit + output.summary.pre_existing;
264
+ job.summary = {
265
+ correctness: output.summary.important === 0 ? "Correct" : "Issues Found",
266
+ explanation: `${output.summary.important} important, ${output.summary.nit} nit, ${output.summary.pre_existing} pre-existing`,
267
+ confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (output.summary.important * 0.2)),
268
+ };
269
+
270
+ if (output.findings.length > 0) {
271
+ const annotations = transformClaudeFindings(output.findings, job.source, cwd);
272
+ const result = externalAnnotations.addAnnotations({ annotations });
273
+ if ("error" in result) console.error(`[claude-review] addAnnotations error:`, result.error);
274
+ }
275
+ return;
164
276
  }
165
- return options.gitContext?.cwd ?? process.cwd();
166
277
  },
167
278
  });
168
279
  const sharingEnabled =
@@ -286,13 +397,7 @@ export async function startReviewServer(options: {
286
397
  aiEndpoints = ai.createAIEndpoints({
287
398
  registry,
288
399
  sessionManager,
289
- getCwd: () => {
290
- if (currentDiffType.startsWith("worktree:")) {
291
- const parsed = parseWorktreeDiffType(currentDiffType);
292
- if (parsed) return parsed.path;
293
- }
294
- return options.gitContext?.cwd ?? process.cwd();
295
- },
400
+ getCwd: resolveAgentCwd,
296
401
  });
297
402
  aiSessionManager = sessionManager;
298
403
  aiRegistry = registry;
@@ -309,19 +414,21 @@ export async function startReviewServer(options: {
309
414
  rawPatch: currentPatch,
310
415
  gitRef: currentGitRef,
311
416
  origin: options.origin ?? "pi",
312
- diffType: isPRMode ? undefined : currentDiffType,
313
- gitContext: isPRMode ? undefined : options.gitContext,
417
+ diffType: hasLocalAccess ? currentDiffType : undefined,
418
+ gitContext: hasLocalAccess ? options.gitContext : undefined,
314
419
  sharingEnabled,
315
420
  shareBaseUrl,
316
421
  repoInfo,
422
+ isWSL: wslFlag,
423
+ ...(options.agentCwd && { agentCwd: options.agentCwd }),
317
424
  ...(isPRMode && { prMetadata: prMeta, platformUser }),
318
425
  ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }),
319
- ...(currentError ? { error: currentError } : {}),
426
+ ...(currentError && { error: currentError }),
320
427
  serverConfig: getServerConfig(gitUser),
321
428
  });
322
429
  } else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
323
- if (isPRMode) {
324
- json(res, { error: "Not available for PR reviews" }, 400);
430
+ if (!hasLocalAccess) {
431
+ json(res, { error: "Not available without local file access" }, 400);
325
432
  return;
326
433
  }
327
434
  try {
@@ -373,23 +480,21 @@ export async function startReviewServer(options: {
373
480
  }
374
481
  try {
375
482
  const body = await parseBody(req);
483
+ const fileComments = (body.fileComments as PRReviewFileComment[]) || [];
484
+ console.error(`[pr-action] ${body.action} with ${fileComments.length} file comment(s), headSha=${prMeta.headSha}`);
376
485
  await submitPRReview(
377
486
  prRef,
378
487
  prMeta.headSha,
379
488
  body.action as "approve" | "comment",
380
489
  body.body as string,
381
- (body.fileComments as PRReviewFileComment[]) || [],
490
+ fileComments,
382
491
  );
492
+ console.error(`[pr-action] Success`);
383
493
  json(res, { ok: true, prUrl: prMeta.url });
384
494
  } catch (err) {
385
- json(
386
- res,
387
- {
388
- error:
389
- err instanceof Error ? err.message : "Failed to submit PR review",
390
- },
391
- 500,
392
- );
495
+ const message = err instanceof Error ? err.message : "Failed to submit PR review";
496
+ console.error(`[pr-action] Failed: ${message}`);
497
+ json(res, { error: message }, 500);
393
498
  }
394
499
  } else if (url.pathname === "/api/pr-viewed" && req.method === "POST") {
395
500
  if (!isPRMode || !prMeta || !prRef) {
@@ -415,14 +520,9 @@ export async function startReviewServer(options: {
415
520
  );
416
521
  json(res, { ok: true });
417
522
  } catch (err) {
418
- json(
419
- res,
420
- {
421
- error:
422
- err instanceof Error ? err.message : "Failed to update viewed state",
423
- },
424
- 500,
425
- );
523
+ const message = err instanceof Error ? err.message : "Failed to update viewed state";
524
+ console.error("[plannotator] /api/pr-viewed error:", message);
525
+ json(res, { error: message }, 500);
426
526
  }
427
527
  } else if (url.pathname === "/api/file-content" && req.method === "GET") {
428
528
  const filePath = url.searchParams.get("path");
@@ -446,10 +546,28 @@ export async function startReviewServer(options: {
446
546
  }
447
547
  }
448
548
 
549
+ // Local mode first (matches Bun server priority)
550
+ if (hasLocalAccess && !isPRMode) {
551
+ const defaultBranch = options.gitContext?.defaultBranch || "main";
552
+ const defaultCwd = options.gitContext?.cwd;
553
+ const result = await getFileContentsForDiffCore(
554
+ reviewRuntime,
555
+ currentDiffType,
556
+ defaultBranch,
557
+ filePath,
558
+ oldPath,
559
+ defaultCwd,
560
+ );
561
+ json(res, result);
562
+ return;
563
+ }
564
+
565
+ // PR mode: fetch from platform API using merge-base/head SHAs
449
566
  if (isPRMode && prRef && prMeta) {
450
567
  try {
568
+ const oldSha = prMeta.mergeBaseSha ?? prMeta.baseSha;
451
569
  const [oldContent, newContent] = await Promise.all([
452
- fetchPRFileContent(prRef, prMeta.baseSha, oldPath || filePath),
570
+ fetchPRFileContent(prRef, oldSha, oldPath || filePath),
453
571
  fetchPRFileContent(prRef, prMeta.headSha, filePath),
454
572
  ]);
455
573
  json(res, { oldContent, newContent });
@@ -468,17 +586,7 @@ export async function startReviewServer(options: {
468
586
  return;
469
587
  }
470
588
 
471
- const defaultBranch = options.gitContext?.defaultBranch || "main";
472
- const defaultCwd = options.gitContext?.cwd;
473
- const result = await getFileContentsForDiffCore(
474
- reviewRuntime,
475
- currentDiffType,
476
- defaultBranch,
477
- filePath,
478
- oldPath,
479
- defaultCwd,
480
- );
481
- json(res, result);
589
+ json(res, { error: "No file access available" }, 400);
482
590
  } else if (url.pathname === "/api/config" && req.method === "POST") {
483
591
  try {
484
592
  const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown> };
@@ -497,8 +605,14 @@ export async function startReviewServer(options: {
497
605
  } else if (url.pathname === "/api/agents" && req.method === "GET") {
498
606
  json(res, { agents: [] });
499
607
  } else if (url.pathname === "/api/git-add" && req.method === "POST") {
500
- if (isPRMode) {
501
- json(res, { error: "Not available for PR reviews" }, 400);
608
+ // Staging only available for local diff types that support it (not PR mode, not branch diffs).
609
+ // Worktree diff types use composite format "worktree:/path:uncommitted" — extract the base type.
610
+ const baseDiffType = currentDiffType.startsWith("worktree:")
611
+ ? (parseWorktreeDiffType(currentDiffType)?.subType ?? currentDiffType)
612
+ : currentDiffType;
613
+ const canStage = baseDiffType === "uncommitted" || baseDiffType === "unstaged";
614
+ if (isPRMode || !canStage) {
615
+ json(res, { error: "Staging not available" }, 400);
502
616
  return;
503
617
  }
504
618
  try {
@@ -524,7 +638,7 @@ export async function startReviewServer(options: {
524
638
  json(res, { ok: true });
525
639
  } catch (err) {
526
640
  const message =
527
- err instanceof Error ? err.message : "Failed to git add";
641
+ err instanceof Error ? err.message : "Failed to stage file";
528
642
  json(res, { error: message }, 500);
529
643
  }
530
644
  } else if (url.pathname === "/api/draft") {
@@ -590,10 +704,15 @@ export async function startReviewServer(options: {
590
704
  const exitHandler = () => agentJobs.killAll();
591
705
  process.once("exit", exitHandler);
592
706
 
707
+ if (options.onReady) {
708
+ options.onReady(serverUrl, isRemote, port);
709
+ }
710
+
593
711
  return {
594
712
  port,
595
713
  portSource,
596
714
  url: serverUrl,
715
+ isRemote,
597
716
  waitForDecision: () => decisionPromise,
598
717
  stop: () => {
599
718
  process.removeListener("exit", exitHandler);
@@ -601,6 +720,13 @@ export async function startReviewServer(options: {
601
720
  aiSessionManager?.disposeAll();
602
721
  aiRegistry?.disposeAll();
603
722
  server.close();
723
+ // Invoke cleanup callback (e.g., remove temp worktree)
724
+ if (options.onCleanup) {
725
+ try {
726
+ const result = options.onCleanup();
727
+ if (result instanceof Promise) result.catch(() => {});
728
+ } catch { /* best effort */ }
729
+ }
604
730
  },
605
731
  };
606
732
  }
package/server.ts CHANGED
@@ -21,6 +21,7 @@ export {
21
21
  } from "./server/serverPlan.js";
22
22
  export {
23
23
  getGitContext,
24
+ reviewRuntime,
24
25
  type ReviewServerResult,
25
26
  runGitDiff,
26
27
  startReviewServer,