import { execSync, spawn } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; import { createServer } from "node:http"; import os from "node:os"; import { Readable } from "node:stream"; import { contentHash, deleteDraft } from "../generated/draft.js"; import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; export type { DiffOption, DiffType, GitContext, } from "../generated/review-core.js"; import { getDisplayRepo, getMRLabel, getMRNumberLabel, isSameProject, type PRMetadata, type PRReviewFileComment, prRefFromMetadata, } from "../generated/pr-provider.js"; import { type DiffType, type GitCommandResult, type GitContext, type GitDiffOptions, detectRemoteDefaultBranch, getFileContentsForDiff as getFileContentsForDiffCore, getGitContext as getGitContextCore, gitAddFile as gitAddFileCore, gitResetFile as gitResetFileCore, parseWorktreeDiffType, resolveBaseBranch, type ReviewGitRuntime, runGitDiff as runGitDiffCore, validateFilePath, } from "../generated/review-core.js"; import { checkoutPRHead, getPRDiffScopeOptions, getPRStackInfo, resolveStackInfo, resolvePRFullStackBaseRef, runPRFullStackDiff, type PRDiffScope, } from "../generated/pr-stack.js"; import type { WorktreePool } from "../generated/worktree-pool.js"; import { createEditorAnnotationHandler } from "./annotations.js"; import { createAgentJobHandler } from "./agent-jobs.js"; import type { AgentJobInfo } from "../generated/agent-jobs.js"; import { createExternalAnnotationHandler } from "./external-annotations.js"; import { handleDraftRequest, handleFavicon, handleImageRequest, handleUploadRequest, } from "./handlers.js"; import { html, json, parseBody, requestUrl, toWebRequest } from "./helpers.js"; import { isRemoteSession, listenOnPort } from "./network.js"; import { fetchPR, fetchPRContext, fetchPRFileContent, fetchPRList, fetchPRStack, fetchPRViewedFiles, getPRUser, markPRFilesViewed, parsePRUrl, submitPRReview, } from "./pr.js"; import { getRepoInfo } from "./project.js"; import { CODEX_REVIEW_SYSTEM_PROMPT, buildCodexReviewUserMessage, buildCodexCommand, generateOutputPath, parseCodexOutput, transformReviewFindings, } from "../generated/codex-review.js"; import { CLAUDE_REVIEW_PROMPT, buildClaudeCommand, parseClaudeStreamOutput, transformClaudeFindings, } from "../generated/claude-review.js"; import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "../generated/tour-review.js"; /** Detect if running inside WSL (Windows Subsystem for Linux) */ function detectWSL(): boolean { if (process.platform !== "linux") return false; if (os.release().toLowerCase().includes("microsoft")) return true; try { if (existsSync("/proc/version")) { const content = readFileSync("/proc/version", "utf-8").toLowerCase(); return content.includes("wsl") || content.includes("microsoft"); } } catch { /* ignore */ } return false; } export interface ReviewServerResult { port: number; portSource: "env" | "remote-default" | "random"; url: string; isRemote: boolean; waitForDecision: () => Promise<{ approved: boolean; feedback: string; annotations: unknown[]; agentSwitch?: string; exit?: boolean; }>; stop: () => void; } export const reviewRuntime: ReviewGitRuntime = { runGit( args: string[], options?: { cwd?: string; timeoutMs?: number }, ): Promise { return new Promise((resolve) => { const proc = spawn("git", ["-c", "core.quotePath=false", ...args], { cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"], }); let timer: ReturnType | undefined; if (options?.timeoutMs) { timer = setTimeout(() => proc.kill(), options.timeoutMs); } const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; proc.stdout!.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); proc.stderr!.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); proc.on("close", (code) => { if (timer) clearTimeout(timer); resolve({ stdout: Buffer.concat(stdoutChunks).toString("utf-8"), stderr: Buffer.concat(stderrChunks).toString("utf-8"), exitCode: code ?? 1, }); }); proc.on("error", () => { if (timer) clearTimeout(timer); resolve({ stdout: "", stderr: "git not found", exitCode: 1 }); }); }); }, async readTextFile(path: string): Promise { try { return readFileSync(path, "utf-8"); } catch { return null; } }, }; export function getGitContext(cwd?: string): Promise { return getGitContextCore(reviewRuntime, cwd); } export function runGitDiff( diffType: DiffType, defaultBranch = "main", cwd?: string, options?: GitDiffOptions, ): Promise<{ patch: string; label: string; error?: string }> { return runGitDiffCore(reviewRuntime, diffType, defaultBranch, cwd, options); } export async function startReviewServer(options: { rawPatch: string; gitRef: string; htmlContent: string; origin?: string; diffType?: DiffType; gitContext?: GitContext; /** * Initial base branch the caller used to compute `rawPatch`. When a caller * overrides the detected default (e.g. `openCodeReview({ defaultBranch })`), * this must be forwarded so the server's internal `currentBase` state, the * `/api/diff` response, and downstream agent prompts stay consistent with * the patch that's already on screen. */ initialBase?: string; error?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; prMetadata?: PRMetadata; /** Working directory for agent processes (e.g., --local worktree). Independent of diff pipeline. */ agentCwd?: string; /** Per-PR worktree pool. When set, pr-switch creates worktrees instead of checking out. */ worktreePool?: WorktreePool; /** Cleanup callback invoked when server stops (e.g., remove temp worktree) */ onCleanup?: () => void | Promise; /** Called when server starts with the URL, remote status, and port */ onReady?: (url: string, isRemote: boolean, port: number) => void; }): Promise { const gitUser = detectGitUser(); let draftKey = contentHash(options.rawPatch); let prMeta = options.prMetadata; const isPRMode = !!prMeta; const hasLocalAccess = !!options.gitContext; const isRemote = isRemoteSession(); const wslFlag = detectWSL(); let prRef = prMeta ? prRefFromMetadata(prMeta) : null; const platformUser = prRef ? await getPRUser(prRef) : null; let prStackInfo = isPRMode ? getPRStackInfo(prMeta) : null; let prDiffScopeOptions = isPRMode ? getPRDiffScopeOptions(prMeta, !!(options.worktreePool || options.agentCwd)) : []; let prListCache: import("../generated/pr-provider.js").PRListItem[] | null = null; let prListCacheTime = 0; const prSwitchCache = new Map(); if (isPRMode && prMeta) prSwitchCache.set(prMeta.url, { metadata: prMeta, rawPatch: options.rawPatch }); const prStackTreeCache = new Map(); // Fetch full stack tree (best-effort — always try in PR mode so root PRs // that target the default branch can still discover descendant PRs) let prStackTree: import("../generated/pr-provider.js").PRStackTree | null = null; if (prRef && prMeta) { try { prStackTree = await fetchPRStack(prRef, prMeta); } catch { // Non-fatal: client falls back to buildMinimalStackTree() } prStackTreeCache.set(prMeta.url, prStackTree); const resolved = resolveStackInfo(prMeta, prStackTree, prStackInfo); if (resolved && !prStackInfo) { prStackInfo = resolved; prDiffScopeOptions = getPRDiffScopeOptions(prMeta, !!(options.worktreePool || options.agentCwd)); } } // Fetch GitHub viewed file state (non-blocking — errors are silently ignored) let initialViewedFiles: string[] = []; if (isPRMode && prRef) { try { const viewedMap = await fetchPRViewedFiles(prRef); initialViewedFiles = Object.entries(viewedMap) .filter(([, isViewed]) => isViewed) .map(([path]) => path); } catch { // Non-fatal: viewed state is best-effort } } let repoInfo = prMeta ? { display: getDisplayRepo(prMeta), branch: `${getMRLabel(prMeta)} ${getMRNumberLabel(prMeta)}`, } : getRepoInfo(); const editorAnnotations = createEditorAnnotationHandler(); const externalAnnotations = createExternalAnnotationHandler("review"); let currentPatch = options.rawPatch; let currentGitRef = options.gitRef; let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentError = options.error; let currentHideWhitespace = loadConfig().diffOptions?.hideWhitespace ?? false; let originalPRPatch = options.rawPatch; let originalPRGitRef = options.gitRef; let originalPRError = options.error; let currentPRDiffScope: PRDiffScope = "layer"; // Tracks the base branch the user picked from the UI. Agent review prompts // read this (not gitContext.defaultBranch) so they analyze the same diff // the reviewer is currently looking at. Honors an explicit initialBase from // the caller — e.g. programmatic Pi callers can request a non-detected base. let currentBase = options.initialBase || options.gitContext?.defaultBranch || "main"; let baseEverSwitched = false; // Fire-and-forget: query the remote for its actual default branch. if (options.gitContext && !options.initialBase && !isPRMode) { detectRemoteDefaultBranch(reviewRuntime, options.gitContext.cwd).then((remote) => { if (remote && !baseEverSwitched) currentBase = remote; }); } // Agent jobs — background process manager (late-binds serverUrl via getter) let serverUrl = ""; function resolveAgentCwd(): string { if (options.worktreePool && prMeta) { const poolPath = options.worktreePool.resolve(prMeta.url); if (poolPath) return poolPath; } if (options.agentCwd) return options.agentCwd; if (currentDiffType.startsWith("worktree:")) { const parsed = parseWorktreeDiffType(currentDiffType); if (parsed) return parsed.path; } return options.gitContext?.cwd ?? process.cwd(); } const tour = createTourSession(); const agentJobs = createAgentJobHandler({ mode: "review", getServerUrl: () => serverUrl, getCwd: resolveAgentCwd, async buildCommand(provider, config) { const cwd = resolveAgentCwd(); const hasAgentLocalAccess = !!options.worktreePool || !!options.agentCwd || !!options.gitContext; const userMessageOptions = { defaultBranch: currentBase, hasLocalAccess: hasAgentLocalAccess, prDiffScope: currentPRDiffScope }; // Snapshot the diff context at launch (see review.ts buildCommand // for the rationale — keeps downstream "Copy All" honest across // subsequent context switches). const worktreeParts = currentDiffType.startsWith("worktree:") ? parseWorktreeDiffType(currentDiffType) : null; const launchPrUrl = prMeta?.url; const launchDiffScope = isPRMode ? currentPRDiffScope : undefined; const diffContext: AgentJobInfo["diffContext"] | undefined = prMeta ? undefined : { mode: (worktreeParts?.subType ?? currentDiffType) as string, base: currentBase, worktreePath: worktreeParts?.path ?? null, }; if (provider === "tour") { const built = await tour.buildCommand({ cwd, patch: currentPatch, diffType: currentDiffType, options: userMessageOptions, prMetadata: prMeta, config, }); return built ? { ...built, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext } : built; } const userMessage = buildCodexReviewUserMessage(currentPatch, currentDiffType, userMessageOptions, prMeta); if (provider === "codex") { const model = typeof config?.model === "string" && config.model ? config.model : undefined; const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined; const fastMode = config?.fastMode === true; const outputPath = generateOutputPath(); const prompt = CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage; const command = await buildCodexCommand({ cwd, outputPath, prompt, model, reasoningEffort, fastMode }); return { command, outputPath, prompt, cwd, label: "Code Review", model, reasoningEffort, fastMode: fastMode || undefined, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext }; } if (provider === "claude") { const model = typeof config?.model === "string" && config.model ? config.model : undefined; const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined; const prompt = CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage; const { command, stdinPrompt } = buildClaudeCommand(prompt, model, effort); return { command, stdinPrompt, prompt, cwd, label: "Code Review", captureStdout: true, model, effort, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext }; } return null; }, async onJobComplete(job, meta) { const cwd = meta.cwd ?? resolveAgentCwd(); const jobPrUrl = job.prUrl; const jobDiffScope = job.diffScope; const jobPrMeta = jobPrUrl ? prSwitchCache.get(jobPrUrl)?.metadata : undefined; const jobPrContext = jobPrMeta ? { prUrl: jobPrUrl, prNumber: jobPrMeta.platform === "github" ? jobPrMeta.number : jobPrMeta.iid, prTitle: jobPrMeta.title, prRepo: getDisplayRepo(jobPrMeta), } : jobPrUrl ? { prUrl: jobPrUrl } : {}; if (job.provider === "codex" && meta.outputPath) { const output = await parseCodexOutput(meta.outputPath); if (!output) return; const hasBlockingFindings = output.findings.some(f => f.priority !== null && f.priority <= 1); job.summary = { correctness: hasBlockingFindings ? "Issues Found" : output.overall_correctness, explanation: output.overall_explanation, confidence: output.overall_confidence_score, }; if (output.findings.length > 0) { const annotations = transformReviewFindings(output.findings, job.source, cwd, "Codex") .map(a => ({ ...a, ...jobPrContext, ...(jobDiffScope && { diffScope: jobDiffScope }) })); const result = externalAnnotations.addAnnotations({ annotations }); if ("error" in result) console.error(`[codex-review] addAnnotations error:`, result.error); } return; } if (job.provider === "claude" && meta.stdout) { const output = parseClaudeStreamOutput(meta.stdout); if (!output) { console.error(`[claude-review] Failed to parse output (${meta.stdout.length} bytes, last 200: ${meta.stdout.slice(-200)})`); return; } const total = output.summary.important + output.summary.nit + output.summary.pre_existing; job.summary = { correctness: output.summary.important === 0 ? "Correct" : "Issues Found", explanation: `${output.summary.important} important, ${output.summary.nit} nit, ${output.summary.pre_existing} pre-existing`, confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (output.summary.important * 0.2)), }; if (output.findings.length > 0) { const annotations = transformClaudeFindings(output.findings, job.source, cwd) .map(a => ({ ...a, ...jobPrContext, ...(jobDiffScope && { diffScope: jobDiffScope }) })); const result = externalAnnotations.addAnnotations({ annotations }); if ("error" in result) console.error(`[claude-review] addAnnotations error:`, result.error); } return; } if (job.provider === "tour") { const { summary } = await tour.onJobComplete({ job, meta }); if (summary) { job.summary = summary; } else { // The process exited 0 but the model returned empty or malformed output // and nothing was stored. Flip status so the client doesn't auto-open // a successful-looking card that 404s on /api/tour/:id. job.status = "failed"; job.error = TOUR_EMPTY_OUTPUT_ERROR; } return; } }, }); const sharingEnabled = options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; const shareBaseUrl = (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; const pasteApiUrl = (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined; let resolveDecision!: (result: { approved: boolean; feedback: string; annotations: unknown[]; agentSwitch?: string; exit?: boolean; }) => void; const decisionPromise = new Promise<{ approved: boolean; feedback: string; annotations: unknown[]; agentSwitch?: string; exit?: boolean; }>((r) => { resolveDecision = r; }); // AI provider setup (graceful — AI features degrade if SDK unavailable) // Types are `any` because @plannotator/ai is a dynamic import let aiEndpoints: Record Promise> | null = null; let aiSessionManager: { disposeAll: () => void } | null = null; let aiRegistry: { disposeAll: () => void } | null = null; try { const ai = await import("../generated/ai/index.js"); const registry = new ai.ProviderRegistry(); const sessionManager = new ai.SessionManager(); // which() helper for Node.js const whichCmd = (cmd: string): string | null => { try { return ( execSync(`which ${cmd}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim() || null ); } catch { return null; } }; // Claude Agent SDK try { // @ts-ignore — dynamic import; Bun-only types resolved at runtime await import("../generated/ai/providers/claude-agent-sdk.js"); const claudePath = whichCmd("claude"); const provider = await ai.createProvider({ type: "claude-agent-sdk", cwd: process.cwd(), ...(claudePath && { claudeExecutablePath: claudePath }), }); registry.register(provider); } catch { /* Claude SDK not available */ } // Codex SDK try { // @ts-ignore — dynamic import; Bun-only types resolved at runtime await import("../generated/ai/providers/codex-sdk.js"); await import("@openai/codex-sdk"); const codexPath = whichCmd("codex"); const provider = await ai.createProvider({ type: "codex-sdk", cwd: process.cwd(), ...(codexPath && { codexExecutablePath: codexPath }), }); registry.register(provider); } catch { /* Codex SDK not available */ } // Pi SDK (Node.js variant) try { await import("../generated/ai/providers/pi-sdk-node.js"); const piPath = whichCmd("pi"); if (piPath) { const provider = await ai.createProvider({ type: "pi-sdk", cwd: process.cwd(), piExecutablePath: piPath, } as any); if (provider && "fetchModels" in provider) { await ( provider as { fetchModels: () => Promise } ).fetchModels(); } registry.register(provider); } } catch { /* Pi not available */ } // OpenCode SDK try { // @ts-ignore — dynamic import; Bun-only types resolved at runtime await import("../generated/ai/providers/opencode-sdk.js"); const opencodePath = whichCmd("opencode"); if (opencodePath) { const provider = await ai.createProvider({ type: "opencode-sdk", cwd: process.cwd(), }); if (provider && "fetchModels" in provider) { await ( provider as { fetchModels: () => Promise } ).fetchModels(); } registry.register(provider); } } catch { /* OpenCode not available */ } if (registry.size > 0) { aiEndpoints = ai.createAIEndpoints({ registry, sessionManager, getCwd: resolveAgentCwd, }); aiSessionManager = sessionManager; aiRegistry = registry; } } catch { /* AI backbone not available */ } const server = createServer(async (req, res) => { const url = requestUrl(req); // API: Get tour result if (url.pathname.match(/^\/api\/tour\/[^/]+$/) && req.method === "GET") { const jobId = url.pathname.slice("/api/tour/".length); const result = tour.getTour(jobId); if (!result) { json(res, { error: "Tour not found" }, 404); return; } json(res, result); return; } // API: Save tour checklist state const checklistMatch = url.pathname.match(/^\/api\/tour\/([^/]+)\/checklist$/); if (checklistMatch && req.method === "PUT") { const jobId = checklistMatch[1]; try { const body = await parseBody(req) as { checked: boolean[] }; if (Array.isArray(body.checked)) tour.saveChecklist(jobId, body.checked); json(res, { ok: true }); } catch { json(res, { error: "Invalid JSON" }, 400); } return; } if (url.pathname === "/api/diff" && req.method === "GET") { json(res, { rawPatch: currentPatch, gitRef: currentGitRef, origin: options.origin ?? "pi", diffType: hasLocalAccess ? currentDiffType : undefined, // Echo the active base so page refresh/reconnect rehydrates the // picker to what the server is actually using, not the detected default. base: hasLocalAccess ? currentBase : undefined, hideWhitespace: currentHideWhitespace, gitContext: hasLocalAccess ? options.gitContext : undefined, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, isWSL: wslFlag, ...(options.agentCwd && { agentCwd: options.agentCwd }), ...(isPRMode && { prMetadata: prMeta, platformUser, prStackInfo, prStackTree, prDiffScope: currentPRDiffScope, prDiffScopeOptions, }), ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), ...(currentError && { error: currentError }), serverConfig: getServerConfig(gitUser), }); } else if (url.pathname === "/api/diff/switch" && req.method === "POST") { if (!hasLocalAccess) { json(res, { error: "Not available without local file access" }, 400); return; } try { const body = await parseBody(req); const newType = body.diffType as DiffType; if (!newType) { json(res, { error: "Missing diffType" }, 400); return; } if (typeof body.hideWhitespace === "boolean") { currentHideWhitespace = body.hideWhitespace; } const detectedBase = options.gitContext?.defaultBranch || "main"; const base = resolveBaseBranch( typeof body.base === "string" ? body.base : undefined, detectedBase, ); const defaultCwd = options.gitContext?.cwd; const result = await runGitDiff(newType, base, defaultCwd, { hideWhitespace: currentHideWhitespace, }); currentPatch = result.patch; currentGitRef = result.label; currentDiffType = newType; currentBase = base; baseEverSwitched = true; currentError = result.error; // Recompute gitContext for the effective cwd so the client's // sidebar reflects the worktree we're now reviewing. // Best-effort: on failure the client keeps its existing context. let updatedContext: GitContext | undefined; if (options.gitContext) { try { const worktreeParsed = parseWorktreeDiffType(newType); const effectiveCwd = worktreeParsed?.path ?? options.gitContext.cwd; updatedContext = await getGitContextCore(reviewRuntime, effectiveCwd); } catch { /* best-effort */ } } json(res, { rawPatch: currentPatch, gitRef: currentGitRef, diffType: currentDiffType, // Echo the base the server actually used. resolveBaseBranch // trusts the caller verbatim; this echo lets the client // confirm the request landed (and pick it up when the client // didn't supply one and we fell back to detected default). base: currentBase, hideWhitespace: currentHideWhitespace, ...(updatedContext ? { gitContext: updatedContext } : {}), ...(currentError ? { error: currentError } : {}), }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to switch diff"; json(res, { error: message }, 500); } } else if (url.pathname === "/api/pr-diff-scope" && req.method === "POST") { if (!isPRMode || !prMeta) { json(res, { error: "Not in PR mode" }, 400); return; } try { const body = await parseBody(req) as { scope?: PRDiffScope }; if (body.scope !== "layer" && body.scope !== "full-stack") { json(res, { error: "Invalid PR diff scope" }, 400); return; } if (body.scope === "layer") { currentPatch = originalPRPatch; currentGitRef = originalPRGitRef; currentError = originalPRError; currentPRDiffScope = "layer"; json(res, { rawPatch: currentPatch, gitRef: currentGitRef, prDiffScope: currentPRDiffScope, ...(currentError ? { error: currentError } : {}), }); return; } const fullStackOption = prDiffScopeOptions.find((option) => option.id === "full-stack"); if (!fullStackOption?.enabled || !(options.worktreePool || options.agentCwd)) { json(res, { error: "Full stack diff requires a stacked PR and a local checkout" }, 400); return; } const fullStackCwd = (options.worktreePool && prMeta ? options.worktreePool.resolve(prMeta.url) : undefined) ?? options.agentCwd; const result = await runPRFullStackDiff(reviewRuntime, prMeta, fullStackCwd); if (result.error) { json(res, { error: result.error }, 400); return; } currentPatch = result.patch; currentGitRef = result.label; currentError = undefined; currentPRDiffScope = "full-stack"; json(res, { rawPatch: currentPatch, gitRef: currentGitRef, prDiffScope: currentPRDiffScope, }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to switch PR diff scope"; json(res, { error: message }, 500); } } else if (url.pathname === "/api/pr-switch" && req.method === "POST") { if (!isPRMode || !prRef) { return json(res, { error: "Not in PR mode" }, 400); } try { const body = (await parseBody(req)) as { url?: string }; if (!body?.url) return json(res, { error: "Missing PR URL" }, 400); const newRef = parsePRUrl(body.url); if (!newRef) return json(res, { error: "Invalid PR URL" }, 400); if (!isSameProject(newRef, prRef!)) return json(res, { error: "Cannot switch to a PR in a different repository" }, 400); const cached = prSwitchCache.get(body.url); const pr = cached ?? await fetchPR(newRef); if (!cached) prSwitchCache.set(body.url, pr); prMeta = pr.metadata; prRef = prRefFromMetadata(pr.metadata); currentPatch = pr.rawPatch; currentGitRef = `${getMRLabel(pr.metadata)} ${getMRNumberLabel(pr.metadata)}`; currentError = undefined; originalPRPatch = pr.rawPatch; originalPRGitRef = currentGitRef; originalPRError = undefined; currentPRDiffScope = "layer"; draftKey = contentHash(pr.rawPatch); prListCache = null; prStackInfo = getPRStackInfo(pr.metadata); if (prStackTreeCache.has(body.url)) { prStackTree = prStackTreeCache.get(body.url) ?? null; } else { try { prStackTree = await fetchPRStack(prRef, pr.metadata); } catch { prStackTree = null; } prStackTreeCache.set(body.url, prStackTree); } let hasLocalForNewPR = false; if (options.worktreePool) { try { await options.worktreePool.ensure(reviewRuntime, pr.metadata); hasLocalForNewPR = true; } catch {} } else if (options.agentCwd) { hasLocalForNewPR = await checkoutPRHead(reviewRuntime, pr.metadata, options.agentCwd); } prStackInfo = resolveStackInfo(pr.metadata, prStackTree, prStackInfo); prDiffScopeOptions = prStackInfo ? getPRDiffScopeOptions(pr.metadata, hasLocalForNewPR) : []; let switchedViewedFiles: string[] = []; try { const viewedMap = await fetchPRViewedFiles(prRef); switchedViewedFiles = Object.entries(viewedMap) .filter(([, v]) => v).map(([p]) => p); } catch {} initialViewedFiles = switchedViewedFiles; repoInfo = { display: getDisplayRepo(pr.metadata), branch: `${getMRLabel(pr.metadata)} ${getMRNumberLabel(pr.metadata)}`, }; return json(res, { rawPatch: currentPatch, gitRef: currentGitRef, prMetadata: pr.metadata, prStackInfo, prStackTree, prDiffScope: currentPRDiffScope, prDiffScopeOptions, repoInfo, ...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }), ...(currentError ? { error: currentError } : {}), }); } catch (err) { return json(res, { error: err instanceof Error ? err.message : "Failed to switch PR" }, 500); } } else if (url.pathname === "/api/pr-list" && req.method === "GET") { if (!isPRMode || !prRef) { return json(res, { error: "Not in PR mode" }, 400); } try { const now = Date.now(); if (prListCache && now - prListCacheTime < 30_000) { return json(res, { prs: prListCache }); } const prs = await fetchPRList(prRef); prListCache = prs; prListCacheTime = now; return json(res, { prs }); } catch { return json(res, { error: "Failed to fetch PR list" }, 500); } } else if (url.pathname === "/api/pr-context" && req.method === "GET") { if (!isPRMode || !prRef) { json(res, { error: "Not in PR mode" }, 400); return; } try { const context = await fetchPRContext(prRef); json(res, context); } catch (err) { json( res, { error: err instanceof Error ? err.message : "Failed to fetch PR context", }, 500, ); } } else if (url.pathname === "/api/pr-action" && req.method === "POST") { if (!isPRMode || !prMeta || !prRef) { json(res, { error: "Not in PR mode" }, 400); return; } try { const body = await parseBody(req); const fileComments = (body.fileComments as PRReviewFileComment[]) || []; const targetPrUrl = body.targetPrUrl as string | undefined; let targetRef = prRef; let targetHeadSha = prMeta.headSha; let targetUrl = prMeta.url; if (targetPrUrl) { const cached = prSwitchCache.get(targetPrUrl); if (!cached) { json(res, { error: "Target PR not found in session" }, 400); return; } targetRef = prRefFromMetadata(cached.metadata); targetHeadSha = cached.metadata.headSha; targetUrl = cached.metadata.url; } else if (currentPRDiffScope !== "layer") { json(res, { error: "Switch to Layer diff before posting a platform review" }, 400); return; } console.error(`[pr-action] ${body.action} with ${fileComments.length} file comment(s), target=${targetUrl}, headSha=${targetHeadSha}`); await submitPRReview( targetRef, targetHeadSha, body.action as "approve" | "comment", body.body as string, fileComments, ); console.error(`[pr-action] Success`); json(res, { ok: true, prUrl: targetUrl }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to submit PR review"; console.error(`[pr-action] Failed: ${message}`); json(res, { error: message }, 500); } } else if (url.pathname === "/api/pr-viewed" && req.method === "POST") { if (!isPRMode || !prMeta || !prRef) { json(res, { error: "Not in PR mode" }, 400); return; } if (prMeta.platform !== "github") { json(res, { error: "Viewed sync only supported for GitHub" }, 400); return; } const prNodeId = prMeta.prNodeId; if (!prNodeId) { json(res, { error: "PR node ID not available" }, 400); return; } try { const body = await parseBody(req); await markPRFilesViewed( prRef, prNodeId, body.filePaths as string[], body.viewed as boolean, ); json(res, { ok: true }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to update viewed state"; console.error("[plannotator] /api/pr-viewed error:", message); json(res, { error: message }, 500); } } else if (url.pathname === "/api/file-content" && req.method === "GET") { const filePath = url.searchParams.get("path"); if (!filePath) { json(res, { error: "Missing path" }, 400); return; } try { validateFilePath(filePath); } catch { json(res, { error: "Invalid path" }, 400); return; } const oldPath = url.searchParams.get("oldPath") || undefined; if (oldPath) { try { validateFilePath(oldPath); } catch { json(res, { error: "Invalid path" }, 400); return; } } const fileContentCwd = (options.worktreePool && prMeta) ? options.worktreePool.resolve(prMeta.url) : options.agentCwd; if ( isPRMode && currentPRDiffScope === "full-stack" && fileContentCwd && prMeta?.defaultBranch ) { const baseRef = await resolvePRFullStackBaseRef( reviewRuntime, prMeta.defaultBranch, fileContentCwd, ); if (!baseRef) { json(res, { oldContent: null, newContent: null }); return; } const result = await getFileContentsForDiffCore( reviewRuntime, "merge-base", baseRef, filePath, oldPath, fileContentCwd, ); json(res, result); return; } // Local mode first (matches Bun server priority) if (hasLocalAccess && !isPRMode) { const detectedBase = options.gitContext?.defaultBranch || "main"; const base = resolveBaseBranch( url.searchParams.get("base") ?? undefined, detectedBase, ); const defaultCwd = options.gitContext?.cwd; const result = await getFileContentsForDiffCore( reviewRuntime, currentDiffType, base, filePath, oldPath, defaultCwd, ); json(res, result); return; } // PR mode: fetch from platform API using merge-base/head SHAs if (isPRMode && prRef && prMeta) { try { const oldSha = prMeta.mergeBaseSha ?? prMeta.baseSha; const [oldContent, newContent] = await Promise.all([ fetchPRFileContent(prRef, oldSha, oldPath || filePath), fetchPRFileContent(prRef, prMeta.headSha, filePath), ]); json(res, { oldContent, newContent }); } catch (err) { json( res, { error: err instanceof Error ? err.message : "Failed to fetch file content", }, 500, ); } return; } json(res, { error: "No file access available" }, 400); } else if (url.pathname === "/api/config" && req.method === "POST") { try { const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; const toSave: Record = {}; if (body.displayName !== undefined) toSave.displayName = body.displayName; if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); json(res, { ok: true }); } catch { json(res, { error: "Invalid request" }, 400); } } else if (url.pathname === "/api/image") { handleImageRequest(res, url); } else if (url.pathname === "/api/upload" && req.method === "POST") { await handleUploadRequest(req, res); } else if (url.pathname === "/api/agents" && req.method === "GET") { json(res, { agents: [] }); } else if (url.pathname === "/api/git-add" && req.method === "POST") { // Staging only available for local diff types that support it (not PR mode, not branch diffs). // Worktree diff types use composite format "worktree:/path:uncommitted" — extract the base type. const baseDiffType = currentDiffType.startsWith("worktree:") ? (parseWorktreeDiffType(currentDiffType)?.subType ?? currentDiffType) : currentDiffType; const canStage = baseDiffType === "uncommitted" || baseDiffType === "unstaged"; if (isPRMode || !canStage) { json(res, { error: "Staging not available" }, 400); return; } try { const body = await parseBody(req); const filePath = body.filePath as string | undefined; if (!filePath) { json(res, { error: "Missing filePath" }, 400); return; } let cwd: string | undefined; if (currentDiffType.startsWith("worktree:")) { const parsed = parseWorktreeDiffType(currentDiffType); if (parsed) cwd = parsed.path; } if (!cwd) { cwd = options.gitContext?.cwd; } if (body.undo) { await gitResetFileCore(reviewRuntime, filePath, cwd); } else { await gitAddFileCore(reviewRuntime, filePath, cwd); } json(res, { ok: true }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to stage file"; json(res, { error: message }, 500); } } else if (url.pathname === "/api/draft") { await handleDraftRequest(req, res, draftKey); } else if (url.pathname === "/favicon.svg") { handleFavicon(res); } else if (await editorAnnotations.handle(req, res, url)) { return; } else if (await externalAnnotations.handle(req, res, url)) { return; } else if (await agentJobs.handle(req, res, url)) { return; } else if (aiEndpoints && url.pathname.startsWith("/api/ai/")) { const handler = aiEndpoints[url.pathname]; if (handler) { try { const webReq = toWebRequest(req); const webRes = await handler(webReq); // Pipe Web Response → node:http response const headers: Record = {}; webRes.headers.forEach((v, k) => { headers[k] = v; }); res.writeHead(webRes.status, headers); if (webRes.body) { const nodeStream = Readable.fromWeb(webRes.body as any); nodeStream.pipe(res); } else { res.end(); } } catch (err) { json( res, { error: err instanceof Error ? err.message : "AI endpoint error" }, 500, ); } return; } json(res, { error: "Not found" }, 404); } else if (url.pathname === "/api/exit" && req.method === "POST") { deleteDraft(draftKey); resolveDecision({ approved: false, feedback: '', annotations: [], exit: true }); json(res, { ok: true }); } else if (url.pathname === "/api/feedback" && req.method === "POST") { try { const body = await parseBody(req); deleteDraft(draftKey); resolveDecision({ approved: (body.approved as boolean) ?? false, feedback: (body.feedback as string) || "", annotations: (body.annotations as unknown[]) || [], agentSwitch: body.agentSwitch as string | undefined, }); json(res, { ok: true }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to process feedback"; json(res, { error: message }, 500); } } else { html(res, options.htmlContent); } }); const { port, portSource } = await listenOnPort(server); serverUrl = `http://localhost:${port}`; const exitHandler = () => agentJobs.killAll(); process.once("exit", exitHandler); if (options.onReady) { options.onReady(serverUrl, isRemote, port); } return { port, portSource, url: serverUrl, isRemote, waitForDecision: () => decisionPromise, stop: () => { process.removeListener("exit", exitHandler); agentJobs.killAll(); aiSessionManager?.disposeAll(); aiRegistry?.disposeAll(); server.close(); // Invoke cleanup callback (e.g., remove temp worktree) if (options.onCleanup) { try { const result = options.onCleanup(); if (result instanceof Promise) result.catch(() => {}); } catch { /* best effort */ } } }, }; }