Files
backnotprop__plannotator/packages/server/review.ts
Michael Ramos 15f8d4fe4c feat(review): collapse generated files by default in the all-files view (#1346)
* feat(review): collapse linguist-generated files by default (#1317)

Code review now respects linguist-generated (and linguist-generated=true)
from .gitattributes, collapsing those diffs by default the way GitHub does.

Server (Bun + Pi mirror): a generatedFiles sidecar rides /api/diff and
/api/diff/switch, resolved through git's own attribute machinery — one
batched 'git check-attr --stdin -z' over the served patch's paths at the
review cwd, so stacked and negated rules land exactly as git resolves
them. Plain local git sessions only; PR worktrees, workspace multi-repo,
jj, GitButler, and P4 omit the sidecar (degrade to no-collapse). Shared
logic in packages/shared/generated-files.ts, vendored to Pi.

Client: generated files SEED their CodeView item collapsed (the existing
Pierre collapse state — same mechanism as commit-diff folding), render
the one-line FileHeader bar with a 'generated' tag next to the +/- counts,
and expand per file on click. Expansion is session-local App state so it
survives remounts and diff switches. Presentation-only: the diff data,
annotations, search, and Edit Mode are untouched; the file tree and
single-file tabs list generated files normally (tag, no auto-collapse).

Guide viewer manifest pin regenerated (AllFilesCodeView/FileHeader are
bundled into the guides.show viewer) from a clean frozen-lockfile install.

* feat(review): built-in generated defaults, visible collapsed strip, review-round fixes (#1317)

Round 2 on PR #1346, per maintainer review.

Built-in generated defaults (industry-standard two-layer detection):
packages/shared/generated-files.ts (vendored to Pi) now carries
DEFAULT_GENERATED_PATTERNS — lockfiles (package-lock.json, yarn.lock,
bun.lock, Cargo.lock, go.sum, ...) plus *.min.js / *.min.css / *.map —
matched against the path's last segment only. Explicit .gitattributes wins
in BOTH directions: linguist-generated (set/true) marks any file,
-linguist-generated / =false un-marks even a built-in name, unspecified
falls through to the defaults. In plain local git sessions check-attr
refines the defaults; the non-git degrade modes (piped patches, PR
worktrees, workspace, jj, GitButler, P4) now emit the sidecar from the
name-based defaults alone instead of omitting it.

Visible collapsed state: a collapsed generated card no longer renders as a
bare header — a GeneratedFileNotice strip ('Generated file collapsed',
+N/-N, 'Click to view') styled like the other below-header notices sits in
the card, and clicking it expands through the SAME reportFileCollapsed
funnel as the chevron.

Review findings:
- F1: search-match and sidebar-comment navigation expanded items without
  reporting through the funnel, so those expansions died on diff switch.
  Both now call syncAllCollapsedMirror + reportFileCollapsed; the funnel
  invariant comment lists the navigation-driven sites.
- F2: the check-attr call gets the same 5000ms timeout as review-core's
  stdin git callers, and Pi's vcs.ts stdin write gets the one-line EPIPE
  guard (call-flow.ts shape) a timeout kill makes reachable.
- F3: removed the dead prevGeneratedRef + collectSetDelta leg — a changed
  generated set always remounts via fileSetKey, so the delta path was
  unreachable.

Tests: default-list matching (glob + directory-named-bun.lock), both-
direction precedence, non-git sidecar from defaults (dual-runtime), the
placeholder strip through the funnel, and search expansion surviving a
re-seed round-trip. AGENTS.md payload docs updated. Guide viewer manifest
pin regenerated from this clean frozen-lockfile worktree.
2026-08-17 23:32:29 -07:00

3495 lines
167 KiB
TypeScript

/**
* Code Review Server
*
* Provides a server implementation for code review with git diff rendering.
* Follows the same patterns as the plan server.
*
* Environment variables:
* PLANNOTATOR_REMOTE - Set to "1"/"true" for remote, "0"/"false" for local
* PLANNOTATOR_PORT - Fixed port or inclusive range (default: random locally, 19432 for remote)
*/
import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote";
import type { Origin } from "@plannotator/shared/agents";
import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, getVcsDiffFingerprint, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, detectRemoteDefaultCompareTarget, vcsOwnsDiffType, vcsSupportsSnapshot, materializeVcsSnapshot, gitRuntime } from "./vcs";
import { basename } from "node:path";
import { existsSync } from "node:fs";
import { SingleFlight } from "@plannotator/shared/single-flight";
import {
isSameCwdCommitSwitch,
parseCommitDiffType,
parseWorktreeDiffType,
resolveBaseBranch,
getSinceBaseSections,
detectRemoteDefaultInfo,
isBinaryPatchFile,
listPatchFiles,
type RemoteDefaultInfo,
type SinceBaseSections,
} from "@plannotator/shared/review-core";
import {
getGitButlerContextRevision,
getGitButlerPatchFingerprint,
} from "@plannotator/shared/gitbutler-core";
import {
getCommitDiffInfo,
listCommitHistory,
type CommitDiffInfo,
} from "@plannotator/shared/commit-history";
import { resolvePoolCwd } from "@plannotator/shared/worktree-pool";
import {
createDefaultSemanticDiffRuntime,
getSemanticDiffAvailability,
getSemanticDiffScratchCwd,
runSemanticDiff,
semanticDiffCacheKey,
semanticDiffFileExtsFromSearchParams,
SemanticDiffResponseCache,
} from "@plannotator/shared/semantic-diff";
import type { SemanticDiffAvailability, SemanticDiffResponse } from "@plannotator/shared/semantic-diff-types";
import { CallFlowService } from "@plannotator/shared/call-flow";
import { CallFlowInstallCoordinator, callFlowInstallOriginAllowed } from "@plannotator/shared/call-flow-install";
import { parseCallFlowInstallRequest, resolveCallFlowInstallTargets } from "@plannotator/shared/call-flow-languages";
import type { CallFlowResponse } from "@plannotator/shared/call-flow-types";
import {
getPRDiffScopeOptions,
getPRFullStackFingerprint,
getPRStackInfo,
resolveStackInfo,
resolvePRFullStackBaseRef,
runPRFullStackDiff,
runPRLayerLocalDiff,
checkoutPRHead,
type PRDiffScope,
} from "@plannotator/shared/pr-stack";
import { type AgentJobInfo, REVIEW_OUTPUT_FAILED, getAgentJobAnnotationContext, markJobReviewFailed } from "@plannotator/shared/agent-jobs";
import { createCommitAvatarResolver } from "@plannotator/shared/commit-avatars";
import { detectGeneratedFiles, detectGeneratedFilesByName } from "@plannotator/shared/generated-files";
import { getRepoInfo } from "./repo";
import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleApiNotFound, handleFavicon, readDraftGenerationFromBody, readDraftGenerationFromUrl, type OpencodeClient } from "./shared-handlers";
import { contentHash, deleteDraft } from "./draft";
import { createEditorAnnotationHandler } from "./editor-annotations";
import { createExternalAnnotationHandler } from "./external-annotations";
import { createAgentJobHandler } from "./agent-jobs";
import {
composeCodexReviewPrompt,
buildCodexCommand,
generateOutputPath,
parseCodexOutput,
transformReviewFindings,
} from "./codex-review";
import { buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "./agent-review-message";
import {
composeClaudeReviewPrompt,
buildClaudeCommand,
parseClaudeStreamOutput,
transformClaudeFindings,
} from "./claude-review";
import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "./tour/tour-review";
import { createGuideSession, GUIDE_EMPTY_OUTPUT_ERROR } from "./guide/guide-review";
import { GuideShareError, shareGuide, unshareBeforeDelete, unshareGuide } from "./guide/guide-share";
import { createGuideStoreSession, SAVED_GUIDE_ID_PREFIX, updateGuideShare } from "@plannotator/shared/guide-store";
import { resolveGuideShareUrl, resolveSharingEnabled } from "@plannotator/shared/config";
import {
buildGuideSnapshot,
createGuideHtml,
detectGuideLanguages,
guideExportFilename,
resolveGuideViewerAssets,
type GuideLaunchReview,
type GuideSnapshot,
type GuideSnapshotSource,
} from "@plannotator/shared/guide-format";
import { GUIDE_VIEWER_MANIFEST } from "@plannotator/shared/guide-viewer-manifest";
import {
MARKER_ENGINES,
composeMarkerReviewPrompt,
buildMarkerCommand,
parseMarkerStreamOutput,
reduceMarkerStream,
transformMarkerFindings,
makeMarkerNonce,
extractMarkerNonce,
type MarkerEngineId,
} from "./marker-review";
import { loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, resolveAIEnabled, resolveCursorSandbox, resolveGuideHistory } from "./config";
import { isFaviconStyle, type FaviconStyle } from "@plannotator/shared/favicon";
import { type PRMetadata, type PRRef, type PRReviewFileComment, type PRStackTree, type PRListItem, fetchPR, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, fetchPRStack, fetchPRList, getPRUser, parsePRUrl, prRefFromMetadata, isSameProject, getDisplayRepo, getMRLabel, getMRNumberLabel, prCommandRuntime } from "./pr";
import {
PR_CONTEXT_HEARTBEAT_COMMENT,
PR_CONTEXT_HEARTBEAT_INTERVAL_MS,
createPRContextLiveCache,
serializePRContextSSEEvent,
} from "@plannotator/shared/pr-context-live";
import {
fetchPRArtifactContent,
fetchPRArtifactDocument,
PRArtifactDocumentError,
} from "@plannotator/shared/pr-artifact-document";
import { AI_QUERY_ENDPOINT, createAIRuntime } from "./ai-runtime";
import { isAIEndpointPath, type AIEndpoints } from "@plannotator/ai";
import { isWSL } from "./browser";
import { handleOpenInApps, handleOpenIn } from "./open-in";
import type { LocalWorkspaceReview, WorkspaceDiffType } from "./review-workspace";
import { handleCodeNavResolve, extractChangedFiles } from "./code-nav";
import { discoverCuratedSkills, resolveRequestedReviewProfile, listAllSkills, enableReviewSkill } from "./review-skill-loader";
import { readGuideInstructions, writeGuideInstructions } from "@plannotator/shared/guide-instructions-store";
import {
BUILTIN_DEFAULT_PROFILE,
type ReviewProfilesResponse,
} from "@plannotator/shared/review-profiles";
// Review ingestion completion semantics (REVIEW_OUTPUT_FAILED,
// markJobReviewFailed) now live in @plannotator/shared/agent-jobs.
// Re-export utilities
export { isRemoteSession, getServerPort } from "./remote";
export { openBrowser } from "./browser";
export { type DiffType, type DiffOption, type GitContext, type WorktreeInfo } from "./vcs";
export { type PRMetadata } from "./pr";
export { handleServerReady as handleReviewServerReady } from "./shared-handlers";
// --- Types ---
export interface ReviewServerOptions {
/** Raw git diff patch string */
rawPatch: string;
/** Git ref used for the diff (e.g., "HEAD", "main..HEAD", "--staged") */
gitRef: string;
/** Error message if git diff failed */
error?: string;
/** HTML content to serve for the UI */
htmlContent: string;
/** Origin identifier for UI customization */
origin?: Origin;
/** Current diff type being displayed */
diffType?: DiffType | WorkspaceDiffType;
/** Git context with branch info and available diff options */
gitContext?: GitContext;
/** Local parent directory containing multiple child VCS repositories. */
workspace?: LocalWorkspaceReview;
/**
* Initial base branch the caller used to compute `rawPatch`. When a caller
* overrides the detected default (e.g. Pi's `openCodeReview` accepting a
* custom `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;
/** Freshness token captured atomically with the initial provider patch. */
initialFingerprint?: string;
/** Whether URL sharing is enabled (default: true) */
sharingEnabled?: boolean;
/** Custom base URL for share links (default: https://share.plannotator.ai) */
shareBaseUrl?: string;
/** Called when server starts with the URL, remote status, and port */
onReady?: (url: string, isRemote: boolean, port: number) => void | Promise<void>;
/** OpenCode client for querying available agents (OpenCode only) */
opencodeClient?: OpencodeClient;
/** PR metadata when reviewing a pull request (PR mode) */
prMetadata?: PRMetadata;
/** Platform review writer override used by isolated runtime tests. */
prReviewSubmitter?: typeof submitPRReview;
/**
* The initial layer patch is missing per-file content (platform APIs
* withhold patches on very large PRs). Enables the local recompute upgrade
* once a pool checkout is ready.
*/
prPatchIncomplete?: boolean;
/** 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?: import("@plannotator/shared/worktree-pool").WorktreePool;
/** Cleanup callback invoked when server stops (e.g., remove temp worktree) */
onCleanup?: () => void | Promise<void>;
}
export interface ReviewServerResult {
/** The port the server is running on */
port: number;
/** The full URL to access the server */
url: string;
/** Whether running in remote mode */
isRemote: boolean;
/** Wait for user review decision */
waitForDecision: () => Promise<{
approved: boolean;
feedback: string;
annotations: unknown[];
agentSwitch?: string;
exit?: boolean;
}>;
/** Stop the server */
stop: () => void;
}
// --- Server Implementation ---
/**
* Start the Code Review server
*
* Handles:
* - Remote detection and port configuration
* - API routes (/api/diff, /api/feedback)
* - Port conflict retries
*/
export async function startReviewServer(
options: ReviewServerOptions
): Promise<ReviewServerResult> {
const { htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl, onReady } = options;
const submitPlatformReview = options.prReviewSubmitter ?? submitPRReview;
const aiEnabled = resolveAIEnabled();
let prMetadata = options.prMetadata;
const isPRMode = !!prMetadata;
const workspace = options.workspace;
const isWorkspaceMode = !!workspace;
const hasLocalAccess = !!gitContext;
const sessionVcsType = gitContext?.vcsType;
let clientGitContext = gitContext;
let draftKey = contentHash(options.rawPatch);
const editorAnnotations = createEditorAnnotationHandler();
const externalAnnotations = createExternalAnnotationHandler("review");
const tour = createTourSession();
const guide = createGuideSession();
// Durable guide persistence (#1112): autosaves validated guides to
// ${PLANNOTATOR_DATA_DIR}/guides/{repo-key}/ and serves them back through
// the existing guide endpoints as `saved:{id}` pseudo job ids. All getters
// are late-bound — prMetadata/currentDiffType can change mid-session.
const guideStore = createGuideStoreSession({
runGit: async (args, cwd) => {
const result = await gitRuntime.runGit(args, { cwd });
return result.exitCode === 0 ? result.stdout : null;
},
getGitCwd: () =>
isPRMode || isWorkspaceMode
? undefined
: gitContext
? resolveVcsCwd(currentDiffType as DiffType, gitContext.cwd) ?? gitContext.cwd ?? process.cwd()
: undefined,
getPRInfo: () =>
prMetadata
? {
url: prMetadata.url,
headSha: prMetadata.headSha,
label: `${getMRLabel(prMetadata)} ${getMRNumberLabel(prMetadata)}`,
}
: null,
getBranchLabel: () => clientGitContext?.currentBranch || gitContext?.currentBranch,
getFallbackDir: () => workspace?.root ?? options.agentCwd ?? process.cwd(),
writesEnabled: () => resolveGuideHistory(loadConfig()),
});
/** Job fields the store persists for export provenance (engine/model already on the job). */
const guideSaveJob = (job: AgentJobInfo) => ({
id: job.id,
engine: job.engine,
model: job.model,
generatedAt: job.endedAt ?? Date.now(),
});
/**
* Snapshot for exporting a guide: `saved:{id}` → the store; a live job →
* this session's launch review + guide (the persisted copy is the fallback,
* e.g. when the in-memory review was evicted).
*/
async function resolveGuideSnapshotForExport(jobId: string): Promise<GuideSnapshot | null> {
if (jobId.startsWith(SAVED_GUIDE_ID_PREFIX)) {
return guideStore.getSavedGuideSnapshot(jobId.slice(SAVED_GUIDE_ID_PREFIX.length));
}
const live = guide.getGuide(jobId);
const launchReview = guide.getLaunchReview(jobId);
if (live && launchReview) {
const job = agentJobs.getJob(jobId);
return buildGuideSnapshot({
guide: live,
reviewed: live.reviewed,
review: launchReview,
generator: {
engine: job?.engine,
model: job?.model,
generatedAt: job?.endedAt ? new Date(job.endedAt).toISOString() : undefined,
},
});
}
return guideStore.getJobGuideSnapshot(jobId);
}
// Mutable state for diff switching
let currentPatch = options.rawPatch;
let currentGitRef = options.gitRef;
let currentDiffType: DiffType | WorkspaceDiffType = options.diffType || workspace?.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";
// Monotonic guard for PR scope/switch state writes. Scope requests now park
// on long awaits (checkout warmup, full recompute) — a request that resumed
// after a NEWER scope select or pr-switch must not overwrite their state.
let prScopeEpoch = 0;
// Monotonic guard for /api/diff/switch: concurrent switches mutate shared
// state across awaits, so a slower earlier request could overwrite a newer
// one's snapshot and hand the client a self-consistent-but-wrong diff. A
// superseded request writes nothing and returns { superseded: true }.
let diffSwitchEpoch = 0;
// Analysis-setting requests can overlap each other and view switches. Older
// responses must never re-advertise capabilities for a superseded view.
let reviewAnalysisEpoch = 0;
let reviewAnalysisMutationEpoch: number | null = null;
// Platform APIs withhold per-file patches on very large PRs. When the layer
// patch is incomplete, a local recompute (exact merge-base diff, no size
// limits) becomes available once the checkout warmup finishes — the layer
// fingerprint flips to drive the refresh notice, and the pr-diff-scope
// "layer" branch performs the upgrade. Tracked per-PR across pr-switch.
// Partiality is INFORMATION (the platform withheld content) and is always
// reported; whether a local recompute can be OFFERED is a separate
// capability, gated on the pool below (layerUpgradeAvailable).
let layerPatchIncomplete = (options.prPatchIncomplete ?? false) && isPRMode;
const layerUpgradeAvailable = !!options.worktreePool;
let prListCache: PRListItem[] | null = null;
let prListCacheTime = 0;
const prSwitchCache = new Map<string, { metadata: PRMetadata; rawPatch: string; patchIncomplete?: boolean }>();
if (isPRMode && prMetadata) {
prSwitchCache.set(prMetadata.url, {
metadata: prMetadata,
rawPatch: options.rawPatch,
patchIncomplete: layerPatchIncomplete,
});
}
const prStackTreeCache = new Map<string, PRStackTree | null>();
const prContextLive = createPRContextLiveCache({ fetchContext: fetchPRContext });
const warmPRContext = (url: string, ref: PRRef): void => {
prContextLive.warm(url, ref);
};
// 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.
const detectedCompareTarget = (): string => gitContext?.defaultBranch || gitContext?.compareTarget?.fallback || "main";
let currentBase = options.initialBase || detectedCompareTarget();
const isGitButlerCommittedView = (diffType: string = currentDiffType as string): boolean =>
diffType.startsWith("gitbutler:stack:") || diffType.startsWith("gitbutler:branch:");
let baseEverSwitched = false;
// True once the user picks a base from the picker (explicitBase on the
// switch body). Disables the bare-local-name → origin/* canonicalization:
// the picker offers local and remote refs as distinct choices, so an
// explicit local pick must be honored even when the two point at
// different commits.
let baseExplicitlyChosen = false;
// --- PR local checkout resolution -----------------------------------------
// The pool's initial entry may still be warming up: the checkout is built in
// the background so the server can start on the platform diff alone. Three
// states matter:
// ready entry → use its path
// entry, not ready → the path does not exist on disk yet (or warmup
// failed) — never hand it out; options.agentCwd points
// at the same not-yet-created path
// no entry → PR not in the pool (e.g. cross-repo pr-switch) —
// legacy fallback to the initial checkout (agentCwd)
// The initial checkout path is only trustworthy once it actually exists —
// the warmup may not have created it yet, or may have failed and removed it.
const agentCwdIfExists = (): string | undefined =>
options.agentCwd && existsSync(options.agentCwd) ? options.agentCwd : undefined;
const resolvePRLocalCwd = (meta: PRMetadata | undefined = prMetadata): string | undefined => {
const pool = options.worktreePool;
if (pool && meta) {
const r = resolvePoolCwd(pool, meta.url);
if (r.kind === "ready") return r.path;
if (r.kind === "pending") return undefined; // warming up — don't fall back
}
return agentCwdIfExists();
};
// Failure memo: a persistently-failing checkout (network down, ref denied)
// must not turn every code-nav hover / agent launch into a multi-second
// re-fetch against origin. Failed URLs are skipped for a cooldown window.
const prLocalFailureMemo = new Map<string, number>();
const PR_LOCAL_RETRY_COOLDOWN_MS = 30_000;
// Await the current PR's checkout: blocks on the in-flight warmup, retries
// failed same-repo creations, returns undefined when no checkout can exist.
const ensurePRLocalCwd = async (meta: PRMetadata | undefined = prMetadata): Promise<string | undefined> => {
const pool = options.worktreePool;
if (pool && meta) {
const hadEntry = pool.has(meta.url);
const failedAt = prLocalFailureMemo.get(meta.url);
if (failedAt && Date.now() - failedAt < PR_LOCAL_RETRY_COOLDOWN_MS) {
return hadEntry ? undefined : agentCwdIfExists();
}
try {
const entry = await pool.ensure(gitRuntime, meta);
prLocalFailureMemo.delete(meta.url);
return entry.path;
} catch {
prLocalFailureMemo.set(meta.url, Date.now());
return hadEntry ? undefined : agentCwdIfExists();
}
}
return options.agentCwd;
};
const ensurePRCallFlowCwd = async (meta: PRMetadata | undefined = prMetadata): Promise<string | undefined> => {
if (options.worktreePool && meta) {
try {
return (await options.worktreePool.ensure(gitRuntime, meta)).path;
} catch {
return undefined;
}
}
return resolvePRLocalCwd(meta);
};
// --- Diff staleness fingerprint -------------------------------------------
// Captured beside every patch snapshot (startup + every switch endpoint);
// GET /api/diff/fresh recomputes and compares so the client can show a
// "diff out of date — refresh" notice when files change mid-review (e.g. an
// agent editing/committing while the user reviews). Best-effort everywhere:
// null means "cannot fingerprint" and is reported as fresh, never stale.
let currentFingerprint = options.initialFingerprint ?? getGitButlerPatchFingerprint(
currentDiffType as DiffType,
currentPatch,
clientGitContext,
);
const computeDiffFingerprint = async (): Promise<string | null> => {
try {
if (workspace) return await workspace.getFingerprint();
if (isPRMode) {
if (currentPRDiffScope === "layer") {
// Platform-computed diff — immutable locally. The :incomplete
// suffix keeps the baseline honest across the local-recompute
// upgrade (the upgrade recaptures without it); the upgrade notice
// itself is client-driven via prPatchIncomplete, not this probe.
// Recaptured on pr-switch; remote-side PR updates are out of scope.
const suffix = layerPatchIncomplete ? ":incomplete" : "";
return `pr-layer:${prMetadata?.url ?? ""}${suffix}`;
}
// Full-stack: three-dot diff against the local checkout — fingerprint
// (merge-base, HEAD), which changes exactly when the patch can.
const fullStackCwd = resolvePRLocalCwd();
if (!prMetadata) return null;
return await getPRFullStackFingerprint(gitRuntime, prMetadata, fullStackCwd);
}
if (!hasLocalAccess) return null;
return await getVcsDiffFingerprint(currentDiffType as DiffType, currentBase, gitContext?.cwd, {
hideWhitespace: currentHideWhitespace,
});
} catch {
return null;
}
};
// Fire-and-forget capture: never delays the snapshot response it describes.
// Generation-guarded: two rapid switches can resolve their captures out of
// order — only the LATEST capture may write the baseline, otherwise a stale
// fingerprint would make /api/diff/fresh report stale forever.
let fingerprintGeneration = 0;
let pendingFingerprintCapture: Promise<string | null> | null = null;
const fileContentFingerprintProbes = new SingleFlight<string | null>();
const callFlowService = new CallFlowService();
// In-app opt-in runtime install. Completion invalidates the service's
// 30 second runtime probe cache so the very next capability advert
// resolves available without a server restart.
const callFlowInstall = new CallFlowInstallCoordinator({
onSettled: (ok) => {
if (ok) callFlowService.invalidateRuntimeState();
},
});
const captureDiffFingerprint = (knownFingerprint?: string): void => {
// A fingerprint capture marks a committed review-view change. Stop work
// for the prior snapshot even when the new view cannot run CallDiff.
callFlowService.cancelAll();
fileContentFingerprintProbes.clear();
const generation = ++fingerprintGeneration;
if (knownFingerprint !== undefined) {
currentFingerprint = knownFingerprint;
pendingFingerprintCapture = null;
return;
}
// The previous snapshot's fingerprint must never be treated as the new
// snapshot's baseline while this capture is in flight. File expansion can
// await this exact promise when it needs a trustworthy baseline.
currentFingerprint = null;
const capture = computeDiffFingerprint();
pendingFingerprintCapture = capture;
void capture.then((fingerprint) => {
if (generation === fingerprintGeneration) {
currentFingerprint = fingerprint;
pendingFingerprintCapture = null;
}
});
};
if (currentFingerprint === null) captureDiffFingerprint();
const resolveReviewBase = (
requestedBase?: string,
explicitlyChosen = baseExplicitlyChosen,
activeBase = currentBase,
): string => {
const resolved = resolveBaseBranch(requestedBase, detectedCompareTarget());
// Canonicalize a bare local default name ("main") to its tracking ref
// ("origin/main"). The startup upgrade races the first /api/diff, so a
// client that loaded early re-sends the un-upgraded "main" on the next
// switch/refresh; without this the server would revert to the stale local
// branch and lose the upstream baseline. Only when the remote default is
// known, the requested base is exactly its local name, AND the user has
// never explicitly picked a base — an explicit local pick (and every
// echo after it) is honored verbatim.
const remoteBranch = remoteDefaultInfo?.branch;
if (
!explicitlyChosen &&
remoteBranch &&
remoteBranch.startsWith("origin/") &&
resolved === remoteBranch.replace(/^origin\//, "")
) {
return remoteBranch;
}
// Second rule, independent of remoteDefaultInfo: if the SESSION is
// already on the upgraded tracking ref and a non-explicit request echoes
// its bare local name, stay on the tracking ref. remoteDefaultInfo comes
// from a SECOND probe that can lag the startup upgrade by seconds — in
// that window the rule above is blind, and a diff-type/whitespace switch
// echoing "main" would commit the session back onto the stale local
// branch (and set baseEverSwitched, permanently blocking the upgrade).
if (!explicitlyChosen && activeBase === `origin/${resolved}`) {
return activeBase;
}
return resolved;
};
// --- Base staleness vs the remote ----------------------------------------
// `origin/<default>` is GitHub's state as of the last fetch. The startup
// ls-remote (below) also carries the remote tip SHA; comparing it to the
// local tracking ref tells us whether the baseline is behind. Surfaced as
// `baseBehindRemote` on diff payloads and the freshness probe, refreshed
// lazily at most once a minute (it is a network call, unlike the 5s
// fingerprint probe).
let remoteDefaultInfo: RemoteDefaultInfo | null = null;
let baseBehindRemote = false;
let lastRemoteBaseCheck = 0;
const REMOTE_BASE_CHECK_INTERVAL_MS = 60_000;
const remoteBaseCheckApplies = (): boolean =>
!!gitContext && !isPRMode && (!sessionVcsType || sessionVcsType === "git");
// The "behind GitHub" check is only meaningful for diff types that actually
// compare against a base (since-base / branch / merge-base). Under
// uncommitted/staged/last-commit/all the base ref is irrelevant, so the
// banner must not show.
const baseRelevantDiffType = (diffType: string = currentDiffType as string): boolean => {
const t = parseWorktreeDiffType(diffType)?.subType ?? diffType;
return t === "since-base" || t === "branch" || t === "merge-base";
};
// Local-only computation from the cached remote tip — no network. Parameters
// let switch handlers evaluate a staged snapshot before committing it.
const computeBaseBehindRemote = async (
base: string = currentBase,
diffType: string = currentDiffType as string,
explicitlyChosen = baseExplicitlyChosen,
): Promise<boolean> => {
// Capture once: a concurrent refreshRemoteBaseInfo can null
// remoteDefaultInfo (transient ls-remote failure) during the rev-parse
// await below — reading the global after it would throw.
const remoteInfo = remoteDefaultInfo;
if (!remoteBaseCheckApplies() || !baseRelevantDiffType(diffType) || !remoteInfo?.remoteHeadSha) {
return false;
}
// Meaningful only when the base we're diffing against IS the remote default
// branch — matched as either its local name ("main") or the tracking ref
// ("origin/main"). Comparing by RESOLVED SHA (not ref-name string) is what
// makes this correct when currentBase is the bare local name, which is the
// case whenever origin/HEAD's local symref isn't set (Pi forwards that
// local name as initialBase; the hook upgrades to origin/*).
//
// A local name the user EXPLICITLY picked is exempt: they chose the local
// ref over origin/* on purpose, and Fetch advances origin/* — the banner
// would be un-clearable nagging about a deliberate choice (same treatment
// as any non-default base).
const remoteBranch = remoteInfo.branch;
const localName = remoteBranch.replace(/^origin\//, "");
const matchesDefault =
base === remoteBranch ||
(base === localName && !explicitlyChosen);
if (!matchesDefault) {
return false;
}
// --verify: without it, `rev-parse --end-of-options <ref>` echoes the flag
// as a literal first output line, so .trim() could never equal the SHA and
// baseBehindRemote was stuck true on every repo with a remote.
const local = await gitRuntime.runGit(
["--no-optional-locks", "rev-parse", "--verify", "--end-of-options", base],
{ cwd: gitContext?.cwd },
);
return local.exitCode === 0 && local.stdout.trim() !== remoteInfo.remoteHeadSha;
};
const recomputeBaseBehindRemote = async (): Promise<void> => {
baseBehindRemote = await computeBaseBehindRemote();
};
const refreshRemoteBaseInfo = async (): Promise<void> => {
if (!remoteBaseCheckApplies()) return;
lastRemoteBaseCheck = Date.now();
remoteDefaultInfo = await detectRemoteDefaultInfo(gitRuntime, gitContext?.cwd);
await recomputeBaseBehindRemote();
};
const maybeRefreshRemoteBaseInfo = (): void => {
if (!remoteBaseCheckApplies()) return;
if (Date.now() - lastRemoteBaseCheck < REMOTE_BASE_CHECK_INTERVAL_MS) return;
lastRemoteBaseCheck = Date.now();
void refreshRemoteBaseInfo().catch(() => {});
};
// Two independent startup probes (decoupled so a forwarded initialBase can't
// suppress the staleness check — the Pi divergence):
// 1. Always probe remote staleness once at boot.
// 2. Upgrade currentBase to the upstream tracking ref ("origin/main") when
// no explicit base was requested, OR when the forwarded base is just the
// bare LOCAL name of that same default ("main"). Only origin/* is
// fetchable — leaving currentBase as bare "main" makes the "behind GitHub"
// banner un-clearable, since Fetch advances origin/main, not local main.
// Canonicalizing "main" -> "origin/main" is safe; it never overrides a
// deliberately-chosen different base (a feature branch is left as-is).
if (gitContext && !isPRMode) {
detectRemoteDefaultCompareTarget(gitContext.cwd, sessionVcsType).then(
async (remote) => {
if (remote && !baseEverSwitched && currentBase !== remote) {
const localName = remote.replace(/^origin\//, "");
if (!options.initialBase || currentBase === localName) {
// Rebuild the diff for the upgraded base BEFORE swapping it in, and
// commit base+patch+ref+fingerprint together — otherwise the initial
// patch (built against the old base by the caller) would be served
// under the new base label: a mixed-base review. Skip if the user
// switched meanwhile. The fingerprint change makes the client's
// freshness poll pick up the rebuilt diff.
try {
const rebuilt = await runVcsDiff(
currentDiffType as DiffType,
remote,
gitContext.cwd,
{ hideWhitespace: currentHideWhitespace },
);
if (!baseEverSwitched) {
currentBase = remote;
currentPatch = rebuilt.patch;
currentGitRef = rebuilt.label;
currentError = rebuilt.error;
// draftKey doubles as the snapshot id the freshness probe
// compares against each client's echoed ?snapshot= — a client
// that loaded the pre-upgrade patch mismatches and gets the
// "Diff out of date · Refresh" banner; later loads carry the
// new id and stay fresh. That per-client signal is what lets
// the fingerprint re-baseline unconditionally here.
draftKey = contentHash(currentPatch);
captureDiffFingerprint();
}
} catch {
/* keep the initial base+patch — they still match each other */
}
}
}
void refreshRemoteBaseInfo().catch(() => {});
},
() => {
void refreshRemoteBaseInfo().catch(() => {});
},
);
}
// Commit-author avatar resolution for /api/commits — session-scoped so the
// forge lookups (gh/glab) and their failures are paid at most once.
const commitAvatars = createCommitAvatarResolver(prCommandRuntime);
// --- Since-base sections sidecar ------------------------------------------
// Groups the composite since-base patch's files by lifecycle state
// (committed / changes / untracked) for the three-stack panel. Only
// computed when the since-base mode (or its worktree variant) is active.
const isSinceBaseActive = (diffType: string = currentDiffType as string): boolean => {
if (isPRMode || workspace || !gitContext) return false;
const effective = parseWorktreeDiffType(diffType)?.subType ?? diffType;
return effective === "since-base";
};
// Base AND diff type are parameterized so callers can pin them to a
// snapshot taken before an await — reading the globals inside would race
// the startup base upgrade and concurrent diff-type switches.
const buildSectionsSidecar = async (
base: string = currentBase,
diffType: string = currentDiffType as string,
): Promise<SinceBaseSections | undefined> => {
if (!isSinceBaseActive(diffType)) return undefined;
const cwd = resolveVcsCwd(diffType as DiffType, gitContext?.cwd);
return (await getSinceBaseSections(gitRuntime, base, cwd)) ?? undefined;
};
// --- Commit metadata sidecar -----------------------------------------------
// When a commit:<sha> diff is active, the full commit message (rendered as
// markdown client-side) heads the all-files view. Same mode-conditional
// shape as the sections sidecar; avatar enrichment reuses the session cache.
// diffType parameterized for the same pin-before-await discipline as
// buildSectionsSidecar.
const buildCommitInfoSidecar = async (diffType: string = currentDiffType as string): Promise<CommitDiffInfo | undefined> => {
if (isPRMode || workspace || !gitContext) return undefined;
const effective = parseWorktreeDiffType(diffType)?.subType ?? diffType;
const sha = parseCommitDiffType(effective as string)?.sha;
if (!sha) return undefined;
const cwd = resolveVcsCwd(diffType as DiffType, gitContext.cwd);
const info = await getCommitDiffInfo(gitRuntime, sha, cwd);
if (!info) return undefined;
const avatars = await commitAvatars.resolve(cwd, [info.authorEmail]);
const avatarUrl = avatars.get(info.authorEmail);
return avatarUrl ? { ...info, avatarUrl } : info;
};
// --- Generated-files sidecar (#1317) ---------------------------------------
// Two-layer generated detection for the served patch's paths so the client
// can collapse those diffs by default, GitHub-style: built-in name defaults
// (lockfiles, minified assets — no git needed) refined by `.gitattributes`
// `linguist-generated`, which wins in both directions (set marks, unset
// un-marks even a built-in name, unspecified keeps the default).
// Presentation-layer only: the patch is never filtered and snapshot/
// fingerprint semantics are untouched. Attribute refinement runs for plain
// local Git sessions only — PR worktrees, workspace multi-repo, jj,
// GitButler, and P4 get the name-based defaults alone rather than guessing
// attributes for a tree git can't authoritatively resolve here. Patch and
// diff type are parameterized for the same pin-before-await discipline as
// buildSectionsSidecar.
const buildGeneratedFilesSidecar = async (
patch: string = currentPatch,
diffType: string = currentDiffType as string,
): Promise<string[] | undefined> => {
const paths = listPatchFiles(patch).map((f) => f.path);
const plainLocalGit =
!isPRMode && !workspace && gitContext && (sessionVcsType ?? "git") === "git";
const generated = plainLocalGit
? await detectGeneratedFiles(
gitRuntime,
resolveVcsCwd(diffType as DiffType, gitContext.cwd),
paths,
)
: detectGeneratedFilesByName(paths);
return generated.length > 0 ? generated : undefined;
};
// Agent jobs — background process manager (late-binds serverUrl via getter).
// Spawned jobs run on this machine, so their API URL is pinned to loopback
// and never inherits the advertised-URL host override (a tailnet-only
// hostname must not break local agent jobs).
let serverUrl = "";
let agentApiUrl = "";
const resolveAgentCwd = (): string => {
if (workspace) return workspace.root;
if (options.worktreePool && prMetadata) {
return resolvePRLocalCwd()
?? resolveVcsCwd(currentDiffType as DiffType, gitContext?.cwd)
?? process.cwd();
}
return options.agentCwd ?? resolveVcsCwd(currentDiffType as DiffType, gitContext?.cwd) ?? process.cwd();
};
// Strict launch root for /api/open-in: in PR pool mode only the PR's own
// checkout is acceptable — never the launch-repo fallback resolveAgentCwd
// uses, which would open a file from the wrong tree. Returns [] until the
// checkout is ready so resolveOpenInTarget rejects (the button is gated off
// then anyway); non-PR resolves to the working tree as usual.
const resolveOpenInRoot = (): string | string[] => {
if (workspace) return workspace.root;
if (options.worktreePool && prMetadata) return resolvePRLocalCwd() ?? [];
return options.agentCwd ?? resolveVcsCwd(currentDiffType as DiffType, gitContext?.cwd) ?? process.cwd();
};
// Async sibling of resolveAgentCwd: waits for the current PR's checkout
// warmup instead of falling back while it is still being created.
const resolveAgentCwdReady = async (): Promise<string> => {
if (options.worktreePool && prMetadata) {
const poolPath = await ensurePRLocalCwd();
if (poolPath) return poolPath;
}
return resolveAgentCwd();
};
const getWorkspacePromptContext = (): WorkspaceReviewPromptContext | undefined => {
if (!workspace) return undefined;
return workspace.getPromptContext();
};
// GitButler's picker topology is live session state: stacks and branches can
// change without changing the currently-rendered patch. Carry a compact
// revision in the snapshot id so another tab cannot rebaseline the shared
// fingerprint and make an old picker look fresh. Ordinary Git/JJ/P4 snapshot
// ids remain byte-for-byte unchanged.
let currentContextRevision = getGitButlerContextRevision(clientGitContext) ?? "";
// The "changes under review" context for Ask AI, built from the CURRENT view
// by the SAME machine the launchable review jobs use (buildCommand above) —
// contextOnly=true so it carries only the changeset/how-to-inspect-it text, no
// "provide findings" framing. Returned in the diff payloads so the chat can
// latch it onto the user's messages; recomputed wherever the view changes so a
// mid-session switch (diff type, base, whitespace, PR, scope) stays accurate.
// Parameterized so response handlers that SNAPSHOT the served state before
// an await can build the AI context from that same snapshot — reading the
// live globals here would let the startup base upgrade hand Ask AI a
// context for a different changeset than the rendered patch.
// Snapshot identity clients echo on freshness probes: the content hash
// PLUS the view mode. Mode is included so a cross-tab mode switch with a
// byte-identical patch (layer vs full-stack on a single-PR stack) still
// flags old tabs; the BASE is deliberately excluded so a same-commit base
// canonicalization (main -> origin/main) stays banner-silent. draftKey
// itself stays a pure content hash — drafts survive content-identical
// mode round-trips.
const currentSnapshotId = (): string =>
`${draftKey}:${currentDiffType}${isPRMode ? `:${currentPRDiffScope}` : ""}${currentContextRevision ? `:${currentContextRevision}` : ""}`;
const buildCurrentAiReviewContext = (
patch: string = currentPatch,
base: string = currentBase,
diffType: DiffType = currentDiffType as DiffType,
): string | undefined => {
if (!aiEnabled) return undefined;
const workspacePrompt = getWorkspacePromptContext();
if (workspacePrompt) {
return buildAgentReviewUserMessageForTarget(
{ kind: "workspace", patch, workspace: workspacePrompt },
true,
);
}
const hasLocalAccess = !!gitContext ||
(options.worktreePool && prMetadata
? resolvePRLocalCwd(prMetadata) !== undefined
: !!options.agentCwd);
return buildAgentReviewUserMessage(
patch,
diffType,
{ defaultBranch: base, hasLocalAccess, prDiffScope: currentPRDiffScope },
prMetadata,
true,
);
};
const semanticDiffScratchCwd = getSemanticDiffScratchCwd();
const resolveSemanticDiffCwd = (diffType: DiffType = currentDiffType as DiffType): string => {
if (workspace) return workspace.root;
if (options.worktreePool && prMetadata) {
const poolPath = resolvePRLocalCwd();
if (poolPath) return poolPath;
// Checkout warming up — probe sem availability in the scratch dir; the
// real run below awaits the checkout before resolving its cwd.
if (options.worktreePool.has(prMetadata.url)) return semanticDiffScratchCwd;
}
if (options.agentCwd) return options.agentCwd;
if (gitContext) {
const vcsCwd = resolveVcsCwd(diffType, gitContext.cwd);
if (vcsCwd) return vcsCwd;
if (gitContext.cwd) return gitContext.cwd;
}
return semanticDiffScratchCwd;
};
const semanticDiffCache = new SemanticDiffResponseCache();
const semanticDiffAvailabilityCache = new Map<string, Promise<SemanticDiffAvailability>>();
const semanticDiffEnabled = (): boolean => loadConfig().reviewAnalysis?.semanticDiff !== false;
const callFlowEnabled = (): boolean => loadConfig().reviewAnalysis?.callFlow === true;
const createSemanticDiffRuntime = (cwd: string) => ({
...createDefaultSemanticDiffRuntime(),
cwd,
});
const getSemanticDiffAvailabilityForCwd = (cwd: string): Promise<SemanticDiffAvailability> => {
const cached = semanticDiffAvailabilityCache.get(cwd);
if (cached) return cached;
const next: Promise<SemanticDiffAvailability> = getSemanticDiffAvailability(createSemanticDiffRuntime(cwd)).catch((error) => ({
available: false,
reason: "sem-probe-failed",
message: error instanceof Error ? error.message : String(error),
}));
semanticDiffAvailabilityCache.set(cwd, next);
return next;
};
const getSemanticDiffAdvert = async (
diffType: DiffType = currentDiffType as DiffType,
enabled = semanticDiffEnabled(),
) => {
if (!enabled) return { available: false, enabled: false };
if (isGitButlerCommittedView(diffType)) return { available: false };
const availability = await getSemanticDiffAvailabilityForCwd(resolveSemanticDiffCwd(diffType));
return {
available: availability.available,
...(availability.semVersion && { semVersion: availability.semVersion }),
...(availability.semSource && { semSource: availability.semSource }),
};
};
const getSemanticDiff = async (url: URL): Promise<SemanticDiffResponse> => {
if (!semanticDiffEnabled()) {
return { status: "unavailable", reason: "disabled", message: "Semantic diff is disabled in Settings → Analysis." };
}
if (isGitButlerCommittedView()) {
return {
status: "unavailable",
reason: "gitbutler-committed-view",
message: "Semantic diff is unavailable for committed GitButler views because the live workspace may contain other layers.",
};
}
// Semantic diff reads real files — wait out the checkout warmup in PR mode.
if (isPRMode && options.worktreePool) await ensurePRLocalCwd();
const cwd = resolveSemanticDiffCwd();
const fileExts = semanticDiffFileExtsFromSearchParams(url.searchParams);
const cacheKey = semanticDiffCacheKey({ rawPatch: currentPatch, cwd, fileExts });
const cached = semanticDiffCache.get(cacheKey, currentPatch);
if (cached) return cached;
const result = await runSemanticDiff(
{ rawPatch: currentPatch, cwd, fileExts },
createSemanticDiffRuntime(cwd),
);
if (result.status === "ok") {
semanticDiffCache.set(cacheKey, currentPatch, result);
} else if (result.status === "error") {
// Cooldown-memoized: request rate (file badges remount on scroll) must
// not drive sem execution rate when it's failing.
semanticDiffCache.setFailure(cacheKey, currentPatch, result);
}
return result;
};
const getCallFlowAdvert = (
diffType: DiffType = currentDiffType as DiffType,
enabled = callFlowEnabled(),
) =>
callFlowService.getAdvert(enabled, {
snapshotSupported: !workspace && (isPRMode || vcsSupportsSnapshot(sessionVcsType ?? "git", diffType)),
rawPatch: currentPatch,
});
const getCallFlow = async (url: URL): Promise<CallFlowResponse> => {
const requestedSnapshot = url.searchParams.get("snapshot");
if (!requestedSnapshot || requestedSnapshot !== currentSnapshotId()) {
return { status: "stale", reason: "snapshot-mismatch", message: "The review changed before call flow could start. Refresh and try again." };
}
if (!callFlowEnabled()) {
return { status: "disabled", reason: "disabled", message: "Call flow is disabled in Settings → Analysis." };
}
if (workspace) {
return { status: "unsupported", reason: "workspace-unsupported", message: "Call flow does not yet support multi-repository workspace reviews." };
}
const advert = await getCallFlowAdvert();
if (advert.state === "unsupported") {
return {
status: "unsupported",
reason: advert.reason ?? "view-unsupported",
message: advert.message ?? "Call flow is not available for this review view.",
};
}
let analysisCwd: string | undefined;
let analysisDiffType = currentDiffType as string;
let analysisBase = currentBase;
let prCommitPair: { from: string; to: string } | undefined;
if (isPRMode && prMetadata) {
if (currentPRDiffScope === "layer" && layerPatchIncomplete) {
return { status: "unsupported", reason: "incomplete-patch", message: "Call flow is unavailable until the complete PR layer diff is available locally." };
}
analysisCwd = await ensurePRCallFlowCwd();
if (!analysisCwd) {
return { status: "unavailable", reason: "checkout-unavailable", message: "Call flow needs a local PR checkout, which is not ready." };
}
if (currentPRDiffScope === "full-stack" && prMetadata.defaultBranch) {
const baseRef = await resolvePRFullStackBaseRef(gitRuntime, prMetadata.defaultBranch, analysisCwd);
if (!baseRef) return { status: "unavailable", reason: "base-unavailable", message: "The full-stack base commit is unavailable locally." };
analysisDiffType = "merge-base";
analysisBase = baseRef;
} else {
prCommitPair = { from: prMetadata.mergeBaseSha ?? prMetadata.baseSha, to: prMetadata.headSha };
}
} else {
analysisCwd = resolveVcsCwd(currentDiffType as DiffType, gitContext?.cwd)
?? gitContext?.cwd
?? options.agentCwd
?? process.cwd();
}
if (!analysisCwd) {
return { status: "unavailable", reason: "checkout-unavailable", message: "Call flow requires a local Git checkout." };
}
const baseline = pendingFingerprintCapture ? await pendingFingerprintCapture : currentFingerprint;
const before = await computeDiffFingerprint();
if (requestedSnapshot !== currentSnapshotId() || (baseline && before && baseline !== before)) {
return { status: "stale", reason: "snapshot-stale", message: "The files changed before call flow could start. Refresh the review first." };
}
const analysisVcsType = isPRMode ? "git" : sessionVcsType ?? "git";
return callFlowService.analyze({
snapshotId: requestedSnapshot,
rawPatch: currentPatch,
snapshot: {
materialize: ({ includedExtensions, signal }) => materializeVcsSnapshot(analysisVcsType, {
cwd: analysisCwd,
diffType: analysisDiffType as DiffType,
base: analysisBase,
rawPatch: currentPatch,
includedExtensions,
...(prCommitPair && { prCommitPair }),
signal,
}),
},
verifySnapshot: async () => {
const after = await computeDiffFingerprint();
return requestedSnapshot === currentSnapshotId() && !(before && after && before !== after);
},
});
};
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => agentApiUrl,
getCwd: resolveAgentCwd,
async buildCommand(provider, config) {
// Snapshot ALL launch-relevant state before any await: waiting out the
// checkout warmup below yields to other requests (e.g. pr-switch), and
// the job's cwd, prompt, and PR attribution must describe the same PR.
const launchMetadata = prMetadata;
const launchPatch = currentPatch;
const launchDiffType = currentDiffType;
const launchBase = currentBase;
const launchScope = currentPRDiffScope;
const launchGitRef = currentGitRef;
const launchSnapshotId = currentSnapshotId();
const launchWorkspacePrompt = getWorkspacePromptContext();
// Snapshotted WITH the patch it describes: layerPatchIncomplete is live
// mutable state (a concurrent layer upgrade or pr-switch can flip it),
// and the guide branch's recompute below must decide against the SAME
// moment launchPatch was captured — positional same-sync-segment
// coherence held today, but a future await inserted between here and
// that read would silently break it.
const launchLayerPatchIncomplete = layerPatchIncomplete;
const requestedProfileId =
typeof config?.reviewProfileId === "string" ? config.reviewProfileId : undefined;
// Resolve the requested review, or throw a clear error. An unresolvable
// non-default id (renamed/removed skill, stale cookie, malformed request)
// never silently downgrades to the default — explicit selection is
// authoritative at this boundary.
const reviewProfile = resolveRequestedReviewProfile(requestedProfileId);
// Agents run inside the PR checkout — wait out the background warmup so
// the spawn-time getCwd() below resolves to a path that exists.
let cwd: string;
if (options.worktreePool && launchMetadata) {
const checkout = await ensurePRLocalCwd(launchMetadata);
if (!checkout) {
// Fail fast: without the checkout the job would run in whatever
// directory the CLI was launched from — possibly an unrelated repo.
throw new Error(
"Local PR checkout unavailable — the agent can't run against the PR files. Retry shortly (the checkout may still be recovering).",
);
}
cwd = checkout;
} else {
cwd = await resolveAgentCwdReady();
}
const workspacePrompt = launchWorkspacePrompt;
// Honest local-access claim: in PR mode the checkout must actually be
// available (warmup done, not failed) — the prompt tells the agent it
// can read PR files, so a bare pool/agentCwd existence check would have
// it confidently reviewing whatever directory it landed in.
const hasAgentLocalAccess = !!workspacePrompt || !!gitContext ||
(options.worktreePool && launchMetadata
? resolvePRLocalCwd(launchMetadata) !== undefined
: !!options.agentCwd);
const userMessageOptions = {
defaultBranch: launchBase,
hasLocalAccess: hasAgentLocalAccess,
prDiffScope: launchScope,
...(workspacePrompt && { workspace: workspacePrompt }),
};
// Snapshot the diff context at launch — stored on the job so
// downstream "Copy All" produces the same markdown as /api/feedback
// would right now, even if the reviewer switches modes/bases later.
// Skipped in PR mode (prMetadata carries equivalent context).
const worktreeParts = String(launchDiffType).startsWith("worktree:")
? parseWorktreeDiffType(launchDiffType as DiffType)
: null;
const launchPrUrl = launchMetadata?.url;
const launchDiffScope = isPRMode ? launchScope : undefined;
const diffContext: AgentJobInfo["diffContext"] | undefined = workspacePrompt
? { mode: String(launchDiffType), worktreePath: null }
: launchMetadata
? undefined
: {
mode: (worktreeParts?.subType ?? launchDiffType) as string,
base: launchBase,
worktreePath: worktreeParts?.path ?? null,
...(String(launchDiffType).startsWith("gitbutler:") && {
label: launchGitRef,
snapshotId: launchSnapshotId,
}),
};
if (provider === "tour") {
const built = await tour.buildCommand({
cwd,
patch: launchPatch,
diffType: launchDiffType as DiffType,
options: userMessageOptions,
prMetadata: launchMetadata,
config,
});
return built ? { ...built, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label } : built;
}
if (provider === "guide") {
// The changed-file list is derived from the same launch-time patch
// snapshot as the rest of this closure — it's what the model plans
// section placement against at generation time. The SAME list is
// snapshotted onto the job (changedFilesSnapshot, below) and reused
// by onJobComplete to validate refs, rather than re-deriving from
// whatever patch/diff/base the reviewer has switched to by the time
// the job finishes — a mid-generation diff/base switch would
// otherwise invalidate every ref in an otherwise-valid guide.
let changedFiles = listPatchFiles(launchPatch);
// Very large PRs: the platform API withholds per-file patches
// (layerPatchIncomplete) — but the PR-mode prompt tells the agent to
// read the FULL local diff (git diff origin/<base>...HEAD in the
// checkout). The changed-files block and the validation snapshot must
// describe that SAME diff: derived from the partial API patch they
// under-list files to the model and then validation drops its valid
// refs (or fails the guide closed when no section survives).
// Recompute names+counts locally when the checkout is ready; on any
// failure fall back to the partial list — no worse than before.
// Layer scope only: in full-stack scope launchPatch is already a LOCAL
// full recompute (default branch...HEAD in the checkout — full-stack
// is only offered when the checkout exists), so it's complete AND the
// layer diff below would be the WRONG file set (it omits earlier
// stack layers' files, dropping their refs at validation).
if (launchLayerPatchIncomplete && launchScope !== "full-stack" && launchMetadata?.baseBranch) {
const localCwd = resolvePRLocalCwd(launchMetadata);
if (localCwd) {
try {
const res = await gitRuntime.runGit(
["diff", "--numstat", `origin/${launchMetadata.baseBranch}...HEAD`],
{ cwd: localCwd },
);
if (res.exitCode === 0) {
const recomputed = res.stdout
.split("\n")
.filter((line) => line.trim())
.map((line) => {
const [a, d, ...rest] = line.split("\t");
const raw = rest.join("\t");
if (!raw) return null;
// numstat rename forms: "src/{old => new}/f" or "old => new"
// — refs use post-image paths, matching what the agent sees.
const brace = raw.match(/^(.*)\{.* => (.*)\}(.*)$/);
const path = brace
? `${brace[1]}${brace[2]}${brace[3]}`.replace(/\/\//g, "/")
: raw.includes(" => ")
? raw.split(" => ").pop()!
: raw;
// Binary files report "-\t-\tpath" — count as 0/0.
return { path, additions: Number(a) || 0, deletions: Number(d) || 0 };
})
.filter((f): f is { path: string; additions: number; deletions: number } => f !== null);
if (recomputed.length > 0) changedFiles = recomputed;
}
} catch {
// keep the partial-patch list
}
}
}
const repairOf = typeof config?.repairOf === "string" ? config.repairOf : undefined;
let repair: { payload: string } | undefined;
let guideConfig = config;
if (repairOf) {
const payload = guide.getFailedPayload(repairOf);
if (!payload) {
throw new Error("No captured output to repair for that job — run the guide again instead.");
}
// Prefer the failed job's OWN engine, marker or not, when its binary
// is present on this machine: the failed job got far enough to
// produce capturable output, so that engine is PROVABLY runnable
// here — a fact no other candidate can claim. claude/codex are only
// a FALLBACK (in that order) when the failed engine's binary is
// missing, because binary presence alone means installed, not
// authenticated/usable — a broken claude repair would itself become
// the newest failed job and hijack the recovery panel next render,
// a doom loop. Marker engines' binary name can differ from the
// engine id (Cursor's CLI binary is `agent`, not `cursor`), so
// resolve via MARKER_ENGINES[...].binary before falling back to the
// engine id itself for claude/codex.
const failedEngine = typeof config?.engine === "string" && config.engine ? config.engine : undefined;
const failedEngineBinary = failedEngine
? MARKER_ENGINES[failedEngine as MarkerEngineId]?.binary ?? failedEngine
: undefined;
const repairEngine =
failedEngine && Bun.which(failedEngineBinary!)
? failedEngine
: Bun.which("claude")
? "claude"
: Bun.which("codex")
? "codex"
: (failedEngine ?? "claude");
repair = { payload };
guideConfig = { ...config, engine: repairEngine };
}
const built = await guide.buildCommand({
cwd,
patch: launchPatch,
diffType: launchDiffType as DiffType,
options: userMessageOptions,
prMetadata: launchMetadata,
changedFiles,
config: guideConfig,
...(repair && { repair }),
});
// A repair job's payload is the FAILED job's previously-captured
// output, not this launch's diff — its file refs were validated
// (and, for onJobComplete, must be re-validated) against the failed
// job's own recorded changed-file set. Falling back to this launch's
// freshly-derived `changedFiles` here would validate a repair against
// whatever diff/base happens to be on screen right now, reintroducing
// the destroy-on-switch bug this snapshot exists to prevent — just
// for repairs instead of the original launch. Fall back only if the
// failed job's set was never recorded (defensive; shouldn't happen
// since onJobComplete always records it before returning).
const changedFilesSnapshot = repairOf
? guide.getLaunchChangedFiles(repairOf) ?? changedFiles.map((f) => f.path)
: changedFiles.map((f) => f.path);
// Snapshot the launch-time review-target context (#1112): guide jobs
// run for minutes while the session supports mid-generation PR/diff
// switching, so the persisted envelope must be labeled with the
// context this guide is GENERATED against — captured now, carried on
// the job (guideContext), and read back at completion instead of the
// live session state. Repairs reuse the FAILED job's own snapshot,
// same as changedFilesSnapshot above.
const guideContext = (repairOf ? agentJobs.getJob(repairOf)?.guideContext : undefined)
?? await guideStore.captureLaunchContext();
// The review this guide describes, for portable export (decision
// record D6): captured from the SAME launch-time snapshot as the
// patch the model is given, never re-read at export time. Repairs
// reuse the FAILED job's own review, like changedFilesSnapshot.
const commitSha = parseCommitDiffType(String(worktreeParts?.subType ?? launchDiffType))?.sha;
const launchSource: GuideSnapshotSource = workspacePrompt
? { kind: "workspace", ...(repoInfo?.display && { repo: repoInfo.display }) }
: launchMetadata
? {
kind: "pr",
repo: getDisplayRepo(launchMetadata),
branch: launchMetadata.headBranch,
headSha: launchMetadata.headSha,
pr: {
url: launchMetadata.url,
number: launchMetadata.platform === "github" ? launchMetadata.number : launchMetadata.iid,
title: launchMetadata.title,
platform: launchMetadata.platform,
},
}
: {
kind: commitSha ? "commit" : "local",
...(repoInfo?.display && { repo: repoInfo.display }),
...(clientGitContext?.currentBranch && { branch: clientGitContext.currentBranch }),
...(guideContext.headSha && { headSha: guideContext.headSha }),
...(commitSha && { commitSha }),
};
const launchReview: GuideLaunchReview = (repairOf ? guide.getLaunchReview(repairOf) : null) ?? {
rawPatch: launchPatch,
gitRef: launchGitRef,
diffType: String(launchDiffType),
...(launchBase && { base: launchBase }),
source: launchSource,
...(typeof config?.instructions === "string" && config.instructions.trim() && { customInstructions: config.instructions }),
};
return {
...built,
prUrl: launchPrUrl,
diffScope: launchDiffScope,
diffContext,
reviewProfileId: reviewProfile.id,
reviewProfileLabel: reviewProfile.label,
changedFilesSnapshot,
guideContext,
launchReview,
};
}
// A custom review skill carries its own instructions and becomes the whole
// prompt; strip the default framing prose from the user message so only the
// git/PR context remains. The default review keeps today's message verbatim.
const isCustomReview = reviewProfile.source === "user";
const userMessage = workspacePrompt
? buildAgentReviewUserMessageForTarget({
kind: "workspace",
patch: launchPatch,
workspace: workspacePrompt,
}, isCustomReview)
: buildAgentReviewUserMessage(launchPatch, launchDiffType as DiffType, userMessageOptions, launchMetadata, isCustomReview);
const jobLabel = workspacePrompt ? "Workspace Review" : "Code Review";
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 = composeCodexReviewPrompt(userMessage, reviewProfile);
const command = await buildCodexCommand({ cwd, outputPath, prompt, model, reasoningEffort, fastMode });
return { command, outputPath, prompt, cwd, label: jobLabel, model, reasoningEffort, fastMode: fastMode || undefined, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
}
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 = composeClaudeReviewPrompt(userMessage, reviewProfile);
const { command, stdinPrompt } = buildClaudeCommand(prompt, model, effort);
return { command, stdinPrompt, prompt, cwd, label: jobLabel, captureStdout: true, model, effort, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
}
// Marker engines (Cursor, OpenCode, Pi) — one branch, same shape as Claude.
// None of the three has a schema flag, so composeMarkerReviewPrompt ALWAYS
// appends the marker-block output contract (even for a custom profile —
// it's the only thing that makes their prose output parseable). The
// engine's buildArgv passes the prompt as the trailing positional arg and
// threads the spawn cwd (--workspace for Cursor, --dir for OpenCode; Pi has
// no cwd flag — it always uses the process's actual cwd, which spawnJob
// already sets from this same cwd).
// captureStdout is required: the marker block comes back on stdout NDJSON.
const markerEngine = MARKER_ENGINES[provider as MarkerEngineId];
if (markerEngine) {
const model = typeof config?.model === "string" && config.model ? config.model : undefined;
const thinking = typeof config?.thinking === "string" && config.thinking ? config.thinking : undefined;
// Per-job nonce embedded in the marker contract; recovered from job.prompt
// at parse time so echoed/quoted bare tags can't be mistaken for the payload.
const nonce = makeMarkerNonce();
const prompt = composeMarkerReviewPrompt(reviewProfile, userMessage, nonce);
const { command } = buildMarkerCommand(markerEngine, prompt, model, cwd, { thinking, cursorSandbox: resolveCursorSandbox(loadConfig()) });
return { command, prompt, cwd, label: jobLabel, captureStdout: true, model, thinking, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext, reviewProfileId: reviewProfile.id, reviewProfileLabel: reviewProfile.label };
}
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 } : {};
// Only tag annotations with a *custom* profile — the default review needs no tag.
const profileLabel =
job.reviewProfileId && job.reviewProfileId !== BUILTIN_DEFAULT_PROFILE.id
? job.reviewProfileLabel
: undefined;
// Map findings onto annotations and ingest. Shared by both engine branches;
// no-ops on an empty set so a clean (zero-finding) review stays "done".
const ingest = <T extends object>(transformed: readonly T[], logTag: string) => {
if (transformed.length === 0) return undefined;
const annotations = transformed.map((a) => ({
...a,
...jobPrContext,
...(jobDiffScope && { diffScope: jobDiffScope }),
...getAgentJobAnnotationContext(job.diffContext),
...(profileLabel && { reviewProfileLabel: profileLabel }),
}));
const result = externalAnnotations.addAnnotations({ annotations });
if ("error" in result) console.error(`[${logTag}] addAnnotations error:`, result.error);
return result;
};
// --- Codex path ---
if (job.provider === "codex") {
const output = meta.outputPath ? await parseCodexOutput(meta.outputPath) : null;
if (!output) {
// Process exited 0 but output is missing/unparseable — not a green run.
markJobReviewFailed(job, REVIEW_OUTPUT_FAILED);
return;
}
// Override verdict if there are blocking findings (P0/P1) — Codex's
// freeform correctness string can say "mostly correct" with real bugs.
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,
};
ingest(
transformReviewFindings(
output.findings,
job.source,
cwd,
"Codex",
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
),
"codex-review",
);
return;
}
// --- Claude path ---
if (job.provider === "claude") {
const stdout = meta.stdout ?? "";
const output = parseClaudeStreamOutput(stdout);
if (!output) {
console.error(`[claude-review] Failed to parse output (${stdout.length} bytes, last 200: ${stdout.slice(-200)})`);
markJobReviewFailed(job, REVIEW_OUTPUT_FAILED);
return;
}
// Recompute the verdict from the findings we actually render. Nothing is
// dropped now (un-pinnable findings become file/general comments), so the
// count reflects reality and the card can never claim more than it shows.
const transformed = transformClaudeFindings(
output.findings,
job.source,
cwd,
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
);
const counts = { important: 0, nit: 0, pre_existing: 0 };
for (const a of transformed) counts[a.severity]++;
const total = counts.important + counts.nit + counts.pre_existing;
job.summary = {
correctness: counts.important === 0 ? "Correct" : "Issues Found",
explanation: `${counts.important} important, ${counts.nit} nit, ${counts.pre_existing} pre-existing`,
confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (counts.important * 0.2)),
};
ingest(transformed, "claude-review");
return;
}
// --- Marker path (Cursor, OpenCode, Pi) ---
// FAIL-CLOSED: marker output is prompt-enforced (no schema flag), so any
// missing/malformed/schema/transform/insertion failure must MUTATE the job
// to failed — NEVER throw (agent-jobs.ts swallows throws, silently leaving
// an exit-0 job marked done). Mirrors the Tour fail-closed pattern below.
// Findings carry nullable file/line, classified into line/whole-file/
// general by transformMarkerFindings — nothing is dropped (same as Claude).
const markerEngine = MARKER_ENGINES[job.provider as MarkerEngineId];
if (markerEngine) {
// Recover the per-job nonce embedded in the prompt; without it no block
// can be trusted, so parse fails closed below.
const nonce = extractMarkerNonce(job.prompt ?? "");
const output = nonce && meta.stdout ? parseMarkerStreamOutput(meta.stdout, markerEngine, nonce) : null;
if (!output) {
job.status = "failed";
const providerError = meta.stdout
? reduceMarkerStream(meta.stdout, markerEngine).providerError
: null;
job.error = providerError
?? `${markerEngine.author} review output missing or unparseable (no valid marker JSON).`;
if (providerError) {
console.error(`[${markerEngine.id}-review] Provider error for job ${job.id}: ${providerError}`);
}
return;
}
// Derive the verdict from finding severities (like Claude) rather than
// trusting the model's free-form `correctness` string. Marker engines
// have no schema flag, so a model value like "not correct" would be
// stored verbatim and the detail panel (any string containing "correct"
// except "incorrect" → green) would invert the displayed result.
const hasImportant = output.findings.some((f) => f.severity === "important");
job.summary = {
correctness: hasImportant ? "Issues Found" : "Correct",
explanation: output.summary.explanation,
confidence: output.summary.confidence,
};
// Reuse the shared ingest() decoration (PR context, diff scope, profile
// label); marker engines add a fail-closed check on the returned result.
const result = ingest(
transformMarkerFindings(
output.findings,
job.source,
markerEngine.author,
cwd,
workspace ? (filePath) => workspace.normalizeAnnotationPath(filePath) : undefined,
),
`${markerEngine.id}-review`,
);
if (result && "error" in result) {
job.status = "failed";
job.error = `${markerEngine.author} annotation insertion failed: ${result.error}`;
return;
}
return;
}
// --- Tour path ---
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;
}
// --- Guide path ---
if (job.provider === "guide") {
// Validate refs against the LAUNCH-time changed-file set (snapshotted
// on the job at buildCommand time), not the current patch — the model
// planned section placement against that exact file set, and the
// client already degrades stale refs per-file if the reviewer has
// since switched diff/base/PR. Re-deriving from the current patch
// here would spuriously invalidate every ref in an otherwise-valid
// guide the moment the view changes mid-generation. Falls back to the
// current patch only if the snapshot is missing (defensive; should
// not happen in practice — see agent-jobs.ts's changedFilesSnapshot).
const changedFiles = meta.changedFilesSnapshot ?? listPatchFiles(currentPatch).map((f) => f.path);
const { summary, error } = await guide.onJobComplete({ job, meta, changedFiles, launchReview: meta.launchReview });
if (summary) {
job.summary = summary;
// Autosave (#1112): only guides that passed validateGuideOutput ever
// reach guideResults, so a getGuide hit here IS the validation gate.
// Failed/invalid guides never write. The job's launch-time context
// snapshot labels the envelope — never the live session state, which
// may have PR/diff-switched while the job ran. The launch-time review
// (the diff the guide describes) is stored beside it for export.
const validated = guide.getGuide(job.id);
if (validated) await guideStore.saveForJob(guideSaveJob(job), validated, job.guideContext, meta.launchReview);
} else {
// Same fail-closed precedent as Tour: an exit-0 job with empty,
// malformed, or fully-invalidated output must not look like a
// successful card that 404s on /api/guide/:id.
job.status = "failed";
job.error = error ?? GUIDE_EMPTY_OUTPUT_ERROR;
}
return;
}
},
});
// AI provider setup (graceful — capabilities report unavailable if no provider is registered)
const aiRuntime = aiEnabled ? await createAIRuntime({ getCwd: resolveAgentCwd }) : null;
const isRemote = isRemoteSession();
const wslFlag = await isWSL();
const gitUser = detectGitUser();
// Detect repo info (cached for this session)
// In PR mode, derive from metadata instead of local git
let repoInfo = isPRMode && prMetadata
? { display: getDisplayRepo(prMetadata), branch: `${getMRLabel(prMetadata)} ${getMRNumberLabel(prMetadata)}` }
: workspace
? { display: basename(workspace.root), branch: "Workspace" }
: await getRepoInfo();
if (gitContext?.repository?.displayFallback) {
repoInfo = {
...repoInfo,
display: repoInfo?.display || gitContext.repository.displayFallback,
};
}
// Fetch current platform user (for own-PR/MR detection)
let prRef = isPRMode && prMetadata ? prRefFromMetadata(prMetadata) : null;
if (prRef && prMetadata) {
warmPRContext(prMetadata.url, prRef);
}
const platformUser = prRef ? await getPRUser(prRef) : null;
let prStackInfo = prMetadata ? getPRStackInfo(prMetadata) : null;
let prDiffScopeOptions = prMetadata
? getPRDiffScopeOptions(prMetadata, !!(options.worktreePool || options.agentCwd))
: [];
// 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: PRStackTree | null = null;
if (prRef && prMetadata) {
try {
prStackTree = await fetchPRStack(prRef, prMetadata);
} catch {
// Non-fatal: client falls back to buildMinimalStackTree()
}
prStackTreeCache.set(prMetadata.url, prStackTree);
const resolved = resolveStackInfo(prMetadata, prStackTree, prStackInfo);
if (resolved && !prStackInfo) {
prStackInfo = resolved;
prDiffScopeOptions = getPRDiffScopeOptions(prMetadata, !!(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
}
}
// Decision promise
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;
}>((resolve) => {
resolveDecision = resolve;
});
const server = await startBunServerOnAvailablePort((port) =>
Bun.serve({
hostname: getServerHostname(),
port,
// Bun's default 10s idleTimeout kills requests that legitimately park:
// PR-mode endpoints await the background checkout warmup (a clone that
// can take minutes) and AI SSE streams can stall between bytes.
idleTimeout: 0,
async fetch(req, server) {
const url = new URL(req.url);
// 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) return Response.json({ error: "Tour not found" }, { status: 404 });
return Response.json(result);
}
// 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 req.json() as { checked: boolean[] };
if (Array.isArray(body.checked)) tour.saveChecklist(jobId, body.checked);
return Response.json({ ok: true });
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
}
// API: Get guide result — live job ids, or `saved:{id}` for a
// persisted guide loaded from the on-disk store (#1112).
if (url.pathname.match(/^\/api\/guide\/[^/]+$/) && req.method === "GET") {
const jobId = decodeURIComponent(url.pathname.slice("/api/guide/".length));
if (jobId.startsWith(SAVED_GUIDE_ID_PREFIX)) {
const saved = await guideStore.getSavedGuideData(jobId.slice(SAVED_GUIDE_ID_PREFIX.length));
if (!saved) return Response.json({ error: "Guide not found" }, { status: 404 });
return Response.json(saved);
}
const result = guide.getGuide(jobId);
if (!result) return Response.json({ error: "Guide not found" }, { status: 404 });
return Response.json({ ...result, ...(guideStore.isJobSaved(jobId) ? { saved: true } : {}) });
}
// API: Save guide reviewed state. Live job ids also write through to
// the job's autosaved file; `saved:{id}` ids persist directly.
const reviewedMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/reviewed$/);
if (reviewedMatch && req.method === "PUT") {
const jobId = decodeURIComponent(reviewedMatch[1]);
try {
const body = await req.json() as { reviewed: boolean[] };
if (Array.isArray(body.reviewed)) {
if (jobId.startsWith(SAVED_GUIDE_ID_PREFIX)) {
const ok = await guideStore.updateSavedReviewed(jobId.slice(SAVED_GUIDE_ID_PREFIX.length), body.reviewed);
if (!ok) return Response.json({ error: "Guide not found" }, { status: 404 });
} else {
guide.saveReviewed(jobId, body.reviewed);
await guideStore.writeThroughReviewed(jobId, body.reviewed);
}
}
return Response.json({ ok: true });
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
}
// API: Portable export of a guide (decision record D1/D9). Resolves
// `saved:{id}` from the store and live ids from this session's
// launch review (persisted copy as fallback). No size gate: the file
// is as big as the diff, and that is the user's call.
const guideExportMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/(export|export-info)$/);
if (guideExportMatch && req.method === "GET") {
const jobId = decodeURIComponent(guideExportMatch[1]);
const snapshot = await resolveGuideSnapshotForExport(jobId);
if (!snapshot) {
return Response.json({ error: "This guide cannot be exported: its diff was not retained." }, { status: 404 });
}
const viewer = resolveGuideViewerAssets(GUIDE_VIEWER_MANIFEST, { baseUrl: process.env.PLANNOTATOR_GUIDE_VIEWER_URL });
const html = createGuideHtml(snapshot, { viewer });
const filename = guideExportFilename(snapshot.guide.title);
if (guideExportMatch[2] === "export-info") {
return Response.json({
bytes: new TextEncoder().encode(html).byteLength,
filename,
languages: detectGuideLanguages(snapshot.review.rawPatch),
});
}
return new Response(html, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
}
// API: Share a guide on the guide host (guide share hosting
// contract §7). POST uploads (encrypted unless `public`) and records
// the link on the saved envelope; DELETE removes it with the stored
// token; share-info tells the UI whether sharing is on and whether a
// link already exists. The mutating verbs carry the same
// cross-origin guard as the other state-changing endpoints: an
// upload publishes the diff off-machine.
const guideShareMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/(share|share-info)$/);
if (guideShareMatch && guideShareMatch[2] === "share-info" && req.method === "GET") {
const jobId = decodeURIComponent(guideShareMatch[1]);
const config = loadConfig();
const existing = (await guideStore.locateEnvelope(jobId))?.envelope.share;
return Response.json({
enabled: resolveSharingEnabled(config),
serviceUrl: resolveGuideShareUrl(config),
...(existing ? { existing: { url: existing.url, createdAt: existing.createdAt } } : {}),
});
}
if (guideShareMatch && guideShareMatch[2] === "share" && (req.method === "POST" || req.method === "DELETE")) {
if (!callFlowInstallOriginAllowed(req.headers.get("origin"), url.host)) {
return Response.json({ error: "Cross-origin share requests are not allowed" }, { status: 403 });
}
const jobId = decodeURIComponent(guideShareMatch[1]);
const config = loadConfig();
const serviceUrl = resolveGuideShareUrl(config);
if (req.method === "POST") {
if (!resolveSharingEnabled(config)) return Response.json({ error: "sharing disabled" }, { status: 403 });
// Every body field is optional, so no body at all means the defaults.
let body: { public?: unknown; ttlSeconds?: unknown };
try {
const raw = await req.text();
const parsed: unknown = raw.trim() === "" ? {} : JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
body = parsed as { public?: unknown; ttlSeconds?: unknown };
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (body.public !== undefined && typeof body.public !== "boolean") return Response.json({ error: "public must be a boolean" }, { status: 400 });
if (body.ttlSeconds !== undefined && (typeof body.ttlSeconds !== "number" || !Number.isSafeInteger(body.ttlSeconds) || body.ttlSeconds <= 0)) {
return Response.json({ error: "ttlSeconds must be a positive integer" }, { status: 400 });
}
// One link per guide: the envelope is the only place the delete
// token lives, so a second upload would orphan the first on the
// host. Remove the existing link before creating another.
const located = await guideStore.locateEnvelope(jobId);
const existing = located?.envelope.share;
if (existing) {
return Response.json({ error: "This guide already has a share link. Remove it before creating another.", url: existing.url }, { status: 409 });
}
const snapshot = await resolveGuideSnapshotForExport(jobId);
if (!snapshot) {
return Response.json({ error: "This guide cannot be shared: its diff was not retained." }, { status: 404 });
}
try {
const shared = await shareGuide(snapshot, {
serviceUrl,
mode: body.public === true ? "plain" : "encrypted",
...(body.ttlSeconds !== undefined ? { ttlSeconds: body.ttlSeconds } : {}),
viewer: GUIDE_VIEWER_MANIFEST,
});
// `recorded` tells the client whether this Plannotator can
// remove the link later; without an envelope (guide history
// off, or an autosave that never happened) only the one-time
// token can.
const recorded = located
? updateGuideShare(located.repoKey, located.id, {
id: shared.id,
url: shared.url,
createdAt: new Date().toISOString(),
deleteToken: shared.deleteToken,
serviceUrl,
})
: false;
return Response.json({ ...shared, recorded });
} catch (e) {
if (e instanceof GuideShareError) return Response.json({ error: e.message }, { status: 502 });
throw e;
}
}
// DELETE: the record is the only place the delete token lives, and
// it names the host the link was created on; the configured share
// URL may have changed since (or differ from the CLI shell that
// created the link), and a 404 from the wrong host would forget a
// link that is still live.
const located = await guideStore.locateEnvelope(jobId);
const record = located?.envelope.share;
if (!located || !record) return Response.json({ error: "No share link for this guide" }, { status: 404 });
try {
await unshareGuide(record.id, record.deleteToken, { serviceUrl: record.serviceUrl });
} catch (e) {
// Already gone on the host (expired or removed elsewhere): the
// link is dead either way, so forget it here too.
if (!(e instanceof GuideShareError && e.status === 404)) {
if (e instanceof GuideShareError) return Response.json({ error: e.message }, { status: 502 });
throw e;
}
}
updateGuideShare(located.repoKey, located.id, null);
return new Response(null, { status: 204 });
}
// API: List saved guides for the current repo (#1112)
if (url.pathname === "/api/guides" && req.method === "GET") {
return Response.json(await guideStore.listSaved());
}
// API: Delete a saved guide (#1112). Its share link goes with it,
// best effort: the envelope is the only copy of the delete token.
const savedGuideDeleteMatch = url.pathname.match(/^\/api\/guides\/([^/]+)$/);
if (savedGuideDeleteMatch && req.method === "DELETE") {
const savedId = decodeURIComponent(savedGuideDeleteMatch[1]);
await unshareBeforeDelete((await guideStore.locateEnvelope(`${SAVED_GUIDE_ID_PREFIX}${savedId}`))?.envelope.share);
const ok = await guideStore.deleteSaved(savedId);
if (!ok) return Response.json({ error: "Guide not found" }, { status: 404 });
return Response.json({ ok: true });
}
// API: Get a failed guide job's captured raw output for manual repair
const guideOutputMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/output$/);
if (guideOutputMatch && req.method === "GET") {
const jobId = guideOutputMatch[1];
const payload = guide.getFailedPayload(jobId);
if (payload === null) return Response.json({ error: "No captured output" }, { status: 404 });
return Response.json({ payload });
}
// API: Manually submit corrected guide JSON for a failed job
const guideSubmitMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/submit$/);
if (guideSubmitMatch && req.method === "POST") {
const jobId = guideSubmitMatch[1];
const existingJob = agentJobs.getJob(jobId);
if (!existingJob) return Response.json({ error: "Job not found" }, { status: 404 });
if (existingJob.status !== "failed" && existingJob.status !== "killed") {
return Response.json({ error: "This job already has a guide" }, { status: 409 });
}
try {
const body = await req.json() as { payload?: string };
const payload = typeof body.payload === "string" ? body.payload : "";
// Fallback only — submitManualOutput prefers the job's own
// launch-time changed-file set (guide.launchChangedFiles,
// recorded by onJobComplete) over this current-patch derivation.
const changedFiles = listPatchFiles(currentPatch).map((f) => f.path);
const result = guide.submitManualOutput(jobId, payload, changedFiles);
if ("error" in result) return Response.json({ error: result.error }, { status: 400 });
const { sections, files } = result;
agentJobs.completeJobExternally(jobId, {
correctness: "Guide Generated",
explanation: `${sections} section${sections !== 1 ? "s" : ""}, ${files} file${files !== 1 ? "s" : ""} placed (manually repaired)`,
confidence: 1,
});
// A manually repaired guide passed the same validateGuideOutput
// gate as an automatic one — persist it too (#1112), labeled
// with the job's own launch-time context snapshot.
const repaired = guide.getGuide(jobId);
if (repaired) await guideStore.saveForJob(guideSaveJob(existingJob), repaired, existingJob.guideContext, guide.getLaunchReview(jobId) ?? undefined);
return Response.json({ ok: true, sections, files });
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
}
// API: Get diff content
if (url.pathname === "/api/diff" && req.method === "GET") {
maybeRefreshRemoteBaseInfo();
// Snapshot the served state BEFORE the sidecar await: the startup
// base upgrade can land mid-await, and reading the globals after
// it would pair a rebuilt patch with sections computed from the
// old base — a misgrouped panel. snapshotId travels with the
// patch it identifies: a mid-await upgrade bumps draftKey, and
// this client's next freshness probe (echoing the OLD id) raises
// the Refresh banner for the consistent old snapshot served here.
const servedPatch = currentPatch;
const servedBase = currentBase;
const servedGitRef = currentGitRef;
const servedError = currentError;
const servedDiffType = currentDiffType;
const servedHideWhitespace = currentHideWhitespace;
const servedPRDiffScope = currentPRDiffScope;
const servedSnapshotId = currentSnapshotId();
const servedGitContext = clientGitContext;
const sections = await buildSectionsSidecar(servedBase, servedDiffType as string);
const commitInfo = await buildCommitInfoSidecar(servedDiffType as string);
const generatedFiles = await buildGeneratedFilesSidecar(servedPatch, servedDiffType as string);
return Response.json({
rawPatch: servedPatch,
aiReviewContext: buildCurrentAiReviewContext(servedPatch, servedBase, servedDiffType as DiffType),
aiEnabled,
gitRef: servedGitRef,
snapshotId: servedSnapshotId,
origin,
mode: isWorkspaceMode ? "workspace" : undefined,
diffType: hasLocalAccess || isWorkspaceMode ? servedDiffType : undefined,
// Echo the active base so a page refresh or reconnect rehydrates
// the picker to what the server is actually using — not the
// detected default.
base: hasLocalAccess ? servedBase : undefined,
hideWhitespace: servedHideWhitespace,
...(workspace && { diffOptions: workspace.diffOptions }),
gitContext: hasLocalAccess ? servedGitContext : undefined,
sharingEnabled,
shareBaseUrl,
repoInfo,
isWSL: wslFlag,
// PR mode advertises the ready PR checkout (null while warming), so
// the Open-in button gates correctly from the initial load — not
// the launch repo. Non-PR keeps the workspace/local cwd.
...(isPRMode
? { agentCwd: resolvePRLocalCwd() ?? null }
: workspace
? { agentCwd: workspace.root }
: options.agentCwd
? { agentCwd: options.agentCwd }
: {}),
...(isPRMode && {
prMetadata,
platformUser,
prStackInfo,
prStackTree,
prDiffScope: servedPRDiffScope,
prDiffScopeOptions,
}),
...(isPRMode && layerPatchIncomplete && { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable }),
...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }),
...(sections && { sections }),
...(commitInfo && { commitInfo }),
...(generatedFiles && { generatedFiles }),
...(baseBehindRemote && { baseBehindRemote: true }),
...(servedError && { error: servedError }),
semanticDiff: await getSemanticDiffAdvert(servedDiffType as DiffType),
callFlow: await getCallFlowAdvert(servedDiffType as DiffType),
serverConfig: getServerConfig(gitUser),
});
}
// API: List apps the host can open a file in (Open in App control).
if (url.pathname === "/api/open-in/apps" && req.method === "GET") {
return handleOpenInApps();
}
// API: Open a file in an app. Resolves the repo-relative `git diff`
// path against the VCS root server-side (resolveAgentCwd folds in
// workspace.root, the PR local checkout, resolveVcsCwd(gitContext.cwd),
// and process.cwd()) — not the client `base`, which is wrong when
// review runs from a subdirectory — then containment-checks it.
if (url.pathname === "/api/open-in" && req.method === "POST") {
if (isGitButlerCommittedView()) {
return Response.json(
{ error: "Open in app is unavailable for committed GitButler views" },
{ status: 400 },
);
}
return handleOpenIn(req, { resolveRoot: resolveOpenInRoot });
}
// API: cheap staleness probe — has the underlying VCS state changed
// since the current diff snapshot was computed? Best-effort: anything
// that cannot be fingerprinted reports fresh (no banner).
if (url.pathname === "/api/diff/fresh" && req.method === "GET") {
// In PR review the local checkout can appear (pool warmup) or change
// (in-place PR switch) after the initial /api/diff, so re-advertise it
// on every probe — the Open-in control tracks the current checkout
// without a page reload. resolvePRLocalCwd() is null until a usable
// checkout exists. Non-PR sessions never carry this field.
const prCwdAdvert = isPRMode ? { agentCwd: resolvePRLocalCwd() ?? null } : {};
const baseline = currentFingerprint;
// Carry baseBehindRemote on EVERY response — the client sets the flag
// unconditionally on each probe, so omitting it here clears the
// "behind GitHub" banner for that poll (a flicker) until the next one.
const behind = baseBehindRemote ? { baseBehindRemote: true } : {};
// Per-CLIENT staleness: the client echoes the snapshotId it is
// rendering; a mismatch means the SERVER's snapshot moved under it
// (startup base upgrade, a switch from another tab, an in-place PR
// switch) regardless of what the VCS fingerprint says. This is what
// lets one server serve multiple tabs holding different snapshots
// without lying to any of them. The "snapshot:" fingerprint keys
// the client's dismissal to the server snapshot that made it stale.
const clientSnapshot = url.searchParams.get("snapshot");
const serverSnapshot = currentSnapshotId();
if (clientSnapshot && clientSnapshot !== serverSnapshot) {
return Response.json({
fresh: false,
fingerprint: `snapshot:${serverSnapshot}`,
...behind,
...prCwdAdvert,
});
}
if (baseline == null) return Response.json({ fresh: true, ...behind, ...prCwdAdvert });
const probe = await computeDiffFingerprint();
// A diff switch landing mid-probe replaces the snapshot (and its
// fingerprint); report fresh and let the next poll compare
// against the new baseline.
if (currentFingerprint !== baseline) return Response.json({ fresh: true, ...behind, ...prCwdAdvert });
const fresh = probe == null || probe === baseline;
maybeRefreshRemoteBaseInfo();
// The probe fingerprint lets the client distinguish "still the
// same staleness I dismissed" from "ANOTHER change landed since".
return Response.json({
fresh,
...(fresh ? {} : { fingerprint: probe }),
...(baseBehindRemote && { baseBehindRemote: true }),
...prCwdAdvert,
});
}
// API: fetch the remote default branch so the local baseline catches
// up with GitHub. Client re-runs /api/diff/switch afterwards.
if (url.pathname === "/api/fetch-base" && req.method === "POST") {
if (!remoteBaseCheckApplies()) {
return Response.json({ error: "Not available in this mode" }, { status: 400 });
}
const branchRef =
remoteDefaultInfo?.branch ??
(currentBase.startsWith("origin/") ? currentBase : null);
if (!branchRef) {
return Response.json({ error: "No remote-tracking base to fetch" }, { status: 400 });
}
const branchName = branchRef.replace(/^origin\//, "");
const result = await gitRuntime.runGit(
["fetch", "--end-of-options", "origin", branchName],
{ cwd: gitContext?.cwd, timeoutMs: 30_000 },
);
if (result.exitCode !== 0) {
return Response.json(
{ error: result.stderr.trim() || "git fetch failed" },
{ status: 500 },
);
}
// Re-query the remote (fresh ls-remote) and recompute, rather than
// trusting a cached tip: a narrow/single-branch fetch refspec can
// exit 0 without advancing refs/remotes/origin/<branch>, so we must
// observe the actual post-fetch state. If the ref didn't move, the
// banner honestly stays instead of silently clearing.
await refreshRemoteBaseInfo();
return Response.json({ ok: true, baseBehindRemote });
}
// API: Get semantic diff content
if (url.pathname === "/api/semantic-diff" && req.method === "GET") {
return Response.json(await getSemanticDiff(url));
}
// API: Snapshot-bound call-stack impact analysis.
if (url.pathname === "/api/call-flow" && req.method === "GET") {
// A hard VCS failure before analyze() (e.g. patch materialization
// hitting an unreadable file) must still produce the JSON error
// envelope the client's quiet-failure UX parses, never a bare 500.
let result: CallFlowResponse;
try {
result = await getCallFlow(url);
} catch (error) {
result = {
status: "error",
reason: "analysis-failed",
message: error instanceof Error ? error.message : String(error),
};
}
return Response.json(result, {
status: result.status === "stale" ? 409 : 200,
headers: { "Cache-Control": "no-store" },
});
}
// API: Opt-in CallDiff runtime install. Single-flighted: concurrent
// POSTs join the in-flight install. Node preflight runs before any
// download, and a cross-origin POST is rejected because this
// endpoint starts a native runtime download and build.
if (url.pathname === "/api/call-flow/install" && req.method === "POST") {
if (!callFlowInstallOriginAllowed(req.headers.get("origin"), url.host)) {
return Response.json({ error: "Cross-origin install requests are not allowed" }, { status: 403 });
}
let request: ReturnType<typeof parseCallFlowInstallRequest>;
try {
request = parseCallFlowInstallRequest(await req.json());
} catch {
request = null;
}
if (!request) return Response.json({ error: "Invalid call-flow install request" }, { status: 400 });
const advert = await getCallFlowAdvert(currentDiffType as DiffType, true);
const languageIds = resolveCallFlowInstallTargets(
request.languageIds,
advert.installPlan?.languageIds,
advert.available,
);
if (!advert.installable || languageIds.length === 0) {
return Response.json({ error: advert.message ?? "No call-flow language support needs installation." }, { status: 409 });
}
const status = await callFlowInstall.start(languageIds);
return Response.json(status, { headers: { "Cache-Control": "no-store" } });
}
// API: Poll the in-app runtime install. done persists until the
// runtime advert resolves available; error persists until the next
// install POST retries.
if (url.pathname === "/api/call-flow/install-status" && req.method === "GET") {
return Response.json(callFlowInstall.getStatus(), { headers: { "Cache-Control": "no-store" } });
}
// Read-only capability refresh. It deliberately does not share the
// settings mutation epoch, so install completion cannot supersede a
// concurrent toggle write.
if (url.pathname === "/api/review-analysis" && req.method === "GET") {
if (reviewAnalysisMutationEpoch !== null) {
return Response.json({ superseded: true }, { headers: { "Cache-Control": "no-store" } });
}
const analysisEpoch = reviewAnalysisEpoch;
const viewEpoch = diffSwitchEpoch;
const scopeEpoch = prScopeEpoch;
const [semanticDiff, callFlow] = await Promise.all([
getSemanticDiffAdvert(),
getCallFlowAdvert(),
]);
if (
analysisEpoch !== reviewAnalysisEpoch
|| reviewAnalysisMutationEpoch !== null
|| viewEpoch !== diffSwitchEpoch
|| scopeEpoch !== prScopeEpoch
) {
return Response.json({ superseded: true });
}
return Response.json({ semanticDiff, callFlow }, { headers: { "Cache-Control": "no-store" } });
}
// API: Persist analysis toggles and immediately re-advertise both
// independent capabilities. This makes enabling a layer live in the
// current review instead of requiring a reload.
if (url.pathname === "/api/review-analysis" && req.method === "POST") {
const analysisEpoch = ++reviewAnalysisEpoch;
reviewAnalysisMutationEpoch = analysisEpoch;
const viewEpoch = diffSwitchEpoch;
const scopeEpoch = prScopeEpoch;
try {
const reviewAnalysis = parseReviewAnalysisConfig(await req.json());
if (!reviewAnalysis) {
return Response.json({ error: "Invalid analysis settings" }, { status: 400 });
}
if (analysisEpoch !== reviewAnalysisEpoch) {
return Response.json({ superseded: true });
}
const nextSemanticDiffEnabled = reviewAnalysis.semanticDiff ?? semanticDiffEnabled();
const nextCallFlowEnabled = reviewAnalysis.callFlow ?? callFlowEnabled();
const [semanticDiff, callFlow] = await Promise.all([
getSemanticDiffAdvert(currentDiffType as DiffType, nextSemanticDiffEnabled),
getCallFlowAdvert(currentDiffType as DiffType, nextCallFlowEnabled),
]);
if (
analysisEpoch !== reviewAnalysisEpoch
|| viewEpoch !== diffSwitchEpoch
|| scopeEpoch !== prScopeEpoch
) {
return Response.json({ superseded: true });
}
saveConfig({ reviewAnalysis });
if (!nextCallFlowEnabled) callFlowService.cancelAll();
return Response.json({ semanticDiff, callFlow });
} catch {
return Response.json({ error: "Invalid request" }, { status: 400 });
} finally {
// A read-only advert refresh that began during this mutation
// must not publish capabilities computed from the old config
// after the mutation response. The second transition marks the
// write as settled without allowing GET to supersede it.
if (reviewAnalysisEpoch === analysisEpoch) reviewAnalysisEpoch++;
if (reviewAnalysisMutationEpoch === analysisEpoch) reviewAnalysisMutationEpoch = null;
}
}
// API: Linear commit history for the Commits panel. Git-local
// sessions only — PR/workspace/jj/p4 don't offer the view (same
// gate the client's commitsCapable applies). Computed against the
// same cwd as the active diff so worktree sessions list the
// worktree's history, and against the active base so the divider
// matches the review baseline.
if (url.pathname === "/api/commits" && req.method === "GET") {
if (!gitContext || isPRMode || workspace || (sessionVcsType && sessionVcsType !== "git")) {
return Response.json(
{ error: "Commit history is only available for local git reviews" },
{ status: 400 },
);
}
const limitParam = Number.parseInt(url.searchParams.get("limit") ?? "", 10);
const before = url.searchParams.get("before") ?? undefined;
const commitsCwd = resolveVcsCwd(currentDiffType as DiffType, gitContext.cwd);
const page = await listCommitHistory(gitRuntime, currentBase, commitsCwd, {
...(Number.isFinite(limitParam) && { limit: limitParam }),
...(before !== undefined && { before }),
});
if (!page) {
return Response.json({ error: "Could not read commit history" }, { status: 500 });
}
// Best-effort author avatars from the origin forge (memoized per
// session; misses just render the initials fallback client-side).
const avatars = await commitAvatars.resolve(
commitsCwd,
page.commits.map((c) => c.authorEmail),
);
for (const c of page.commits) {
const avatarUrl = avatars.get(c.authorEmail);
if (avatarUrl) c.avatarUrl = avatarUrl;
}
return Response.json(page);
}
// API: Switch diff type (requires local file access)
if (url.pathname === "/api/diff/switch" && req.method === "POST") {
// Capture the ordering token BEFORE any await. Body delivery can
// finish out of arrival order under network jitter, so capturing the
// epoch after `await req.json()` let a slow-body OLDER request bump
// last and overwrite a newer, already-confirmed switch.
const switchEpoch = ++diffSwitchEpoch;
if (!hasLocalAccess && !workspace) {
return Response.json(
{ error: "Not available without local file access" },
{ status: 400 },
);
}
try {
const body = (await req.json()) as { diffType: DiffType | WorkspaceDiffType; base?: string; hideWhitespace?: boolean; explicitBase?: boolean };
let newDiffType = body.diffType;
if (typeof newDiffType !== "string" || !newDiffType) {
return Response.json(
{ error: "Missing diffType" },
{ status: 400 }
);
}
// Don't commit hideWhitespace to shared state yet — a request that
// ends up superseded must not leave its value behind. Compute the
// diff with a local, then commit only if we win the epoch check.
const effectiveHideWhitespace = typeof body.hideWhitespace === "boolean"
? body.hideWhitespace
: currentHideWhitespace;
if (workspace) {
const snapshot = await workspace.rebuild({
diffType: newDiffType,
hideWhitespace: effectiveHideWhitespace,
});
if (switchEpoch !== diffSwitchEpoch) {
return Response.json({ superseded: true });
}
currentHideWhitespace = effectiveHideWhitespace;
currentPatch = snapshot.rawPatch;
currentGitRef = snapshot.gitRef;
currentDiffType = workspace.diffType;
currentError = snapshot.error;
draftKey = contentHash(currentPatch);
captureDiffFingerprint();
return Response.json({
rawPatch: currentPatch,
// Snapshot arg: robust against a future await sneaking in
// between the epoch check and this response.
aiReviewContext: buildCurrentAiReviewContext(snapshot.rawPatch),
gitRef: currentGitRef,
snapshotId: currentSnapshotId(),
diffType: currentDiffType,
diffOptions: workspace.diffOptions,
hideWhitespace: currentHideWhitespace,
...(currentError && { error: currentError }),
semanticDiff: await getSemanticDiffAdvert(),
callFlow: await getCallFlowAdvert(),
});
}
if (sessionVcsType && !vcsOwnsDiffType(sessionVcsType, newDiffType as string)) {
return Response.json(
{ error: `Diff type is not available in this ${sessionVcsType} session` },
{ status: 400 },
);
}
// Guard against non-string payloads — resolveBaseBranch calls
// string methods and would throw a TypeError otherwise. Mirrors
// Pi's guard so both runtimes validate identically.
const requestedBase = typeof body.base === "string" ? body.base : undefined;
// An explicit pick from the base picker is honored verbatim —
// the local/remote groups are distinct choices, so "main" must
// not be canonicalized to "origin/main" when the user chose the
// local ref on purpose. Sticky: later echoes of that choice
// (diff-type switches, refreshes) must not re-canonicalize it.
const nextBaseExplicitlyChosen = baseExplicitlyChosen ||
(body.explicitBase === true && !!requestedBase);
const base = resolveReviewBase(
requestedBase,
nextBaseExplicitlyChosen,
currentBase,
);
const defaultCwd = gitContext?.cwd;
// Run the new diff
const result = await runVcsDiff(newDiffType as DiffType, base, defaultCwd, {
hideWhitespace: effectiveHideWhitespace,
});
const resultContext = sessionVcsType === "gitbutler" && result.gitContext?.vcsType === "gitbutler"
? result.gitContext
: undefined;
const resultBase = resultContext?.defaultBranch ?? base;
// A newer switch started while we computed — abandon before
// touching shared state so we never clobber the latest request.
if (switchEpoch !== diffSwitchEpoch) {
return Response.json({ superseded: true });
}
// Stage every field locally. No shared review state is written
// until the final epoch guard, so a newer invalid request cannot
// strand a patch/fingerprint from this request beside the prior
// GitButler context revision.
const previousDiffType = currentDiffType;
// Recompute gitContext for the effective cwd so the client's
// sidebar (current branch, default branch, diff-mode options)
// reflects the worktree we're now reviewing — not the main
// repo's startup state. Best-effort: on failure the client
// keeps its existing context.
//
// Skipped for same-cwd commit:<sha> switches — the commit-rail
// hot path (three git enumerations dominated click latency; a
// historical commit's diff can't change any of it). The client
// keeps its existing context when the field is absent.
let updatedContext = resultContext;
let updatedContextRevision = resultContext
? getGitButlerContextRevision(resultContext) ?? ""
: undefined;
if (!updatedContext && gitContext && !isSameCwdCommitSwitch(previousDiffType as string, newDiffType as string)) {
try {
const effectiveCwd = resolveVcsCwd(newDiffType as DiffType, gitContext.cwd);
updatedContext = await getVcsContext(effectiveCwd, sessionVcsType);
updatedContextRevision = getGitButlerContextRevision(updatedContext) ?? "";
} catch {
/* best-effort */
}
}
// Base may have changed — re-evaluate behind-ness from the
// cached remote tip (cheap, local-only).
// Await (not fire-and-forget) so the switch response carries the
// freshly-recomputed baseBehindRemote — otherwise the banner lags a
// poll cycle switching INTO a base-relative mode, or lingers stale
// switching AWAY from one. Local rev-parse only; cheap.
const nextBase = updatedContext && sessionVcsType === "gitbutler"
? updatedContext.defaultBranch
: resultBase;
const nextBaseBehindRemote = await computeBaseBehindRemote(
nextBase,
newDiffType as string,
nextBaseExplicitlyChosen,
).catch(() => false);
const sections = await buildSectionsSidecar(nextBase, newDiffType as string);
const commitInfo = await buildCommitInfoSidecar(newDiffType as string);
const generatedFiles = await buildGeneratedFilesSidecar(result.patch, newDiffType as string);
const [switchSemanticDiff, switchCallFlow] = await Promise.all([
getSemanticDiffAdvert(newDiffType as DiffType),
getCallFlowAdvert(newDiffType as DiffType),
]);
// Final guard: if a newer switch took over during the trailing
// awaits, don't emit — the client would misapply our stale body
// over the newer one (which has its own response inbound).
if (switchEpoch !== diffSwitchEpoch) {
return Response.json({ superseded: true });
}
currentHideWhitespace = effectiveHideWhitespace;
currentPatch = result.patch;
currentGitRef = result.label;
currentDiffType = newDiffType;
currentBase = nextBase;
baseEverSwitched = true;
baseExplicitlyChosen = nextBaseExplicitlyChosen;
baseBehindRemote = nextBaseBehindRemote;
currentError = result.error;
draftKey = contentHash(currentPatch);
if (updatedContext && sessionVcsType === "gitbutler") {
clientGitContext = updatedContext;
currentContextRevision = updatedContextRevision ?? "";
}
captureDiffFingerprint(result.fingerprint);
return Response.json({
rawPatch: currentPatch,
// Snapshot args: robust against a future await sneaking in
// between the epoch check and this response.
aiReviewContext: buildCurrentAiReviewContext(result.patch, currentBase),
gitRef: currentGitRef,
snapshotId: currentSnapshotId(),
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,
...(sections && { sections }),
...(commitInfo && { commitInfo }),
...(generatedFiles && { generatedFiles }),
...(baseBehindRemote && { baseBehindRemote: true }),
...(updatedContext && { gitContext: updatedContext }),
...(currentError && { error: currentError }),
semanticDiff: switchSemanticDiff,
callFlow: switchCallFlow,
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to switch diff";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Switch PR diff scope between the platform layer diff and a local full-stack diff.
if (url.pathname === "/api/pr-diff-scope" && req.method === "POST") {
if (!isPRMode || !prMetadata) {
return Response.json({ error: "Not in PR mode" }, { status: 400 });
}
try {
const body = (await req.json()) as { scope?: PRDiffScope };
if (body.scope !== "layer" && body.scope !== "full-stack") {
return Response.json({ error: "Invalid PR diff scope" }, { status: 400 });
}
const scopeEpoch = ++prScopeEpoch;
// A newer scope select or pr-switch landed while this request
// was parked on an await: drop this request's writes and return
// the newest state so the client converges on it.
const supersededResponse = async () => {
const [semanticDiff, callFlow] = await Promise.all([
getSemanticDiffAdvert(),
getCallFlowAdvert(),
]);
return Response.json({
rawPatch: currentPatch,
aiReviewContext: buildCurrentAiReviewContext(),
gitRef: currentGitRef,
snapshotId: currentSnapshotId(),
prDiffScope: currentPRDiffScope,
...(layerPatchIncomplete && { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable }),
...(currentError && { error: currentError }),
semanticDiff,
callFlow,
});
};
if (body.scope === "layer") {
// Upgrade path: the platform withheld per-file content for
// this PR (too large). Once the local checkout is ready,
// recompute the exact layer diff locally and replace the
// truncated API reconstruction. Snapshot the PR before the
// await — a pr-switch landing mid-recompute must not have its
// patch overwritten with the previous PR's diff.
const upgradeMetadata = prMetadata;
let upgradeError: string | undefined;
if (layerPatchIncomplete && options.worktreePool && upgradeMetadata) {
const upgradeCwd = await ensurePRLocalCwd(upgradeMetadata);
if (upgradeCwd && prMetadata === upgradeMetadata) {
const result = await runPRLayerLocalDiff(gitRuntime, upgradeMetadata, upgradeCwd);
if (prMetadata === upgradeMetadata) {
if (!result.error) {
originalPRPatch = result.patch;
originalPRError = undefined;
layerPatchIncomplete = false;
prSwitchCache.set(upgradeMetadata.url, {
metadata: upgradeMetadata,
rawPatch: result.patch,
patchIncomplete: false,
});
} else {
upgradeError = `Could not recompute the full diff locally: ${result.error}`;
console.error(`Local PR diff recompute failed: ${result.error}`);
}
}
}
}
if (scopeEpoch !== prScopeEpoch) return supersededResponse();
currentPatch = originalPRPatch;
currentGitRef = originalPRGitRef;
currentError = originalPRError;
currentPRDiffScope = "layer";
// INVARIANT: every commit point re-keys — draftKey doubles as
// the snapshotId clients echo on freshness probes AND the
// draft-storage key, so it must always identify currentPatch.
// (This was previously conditional on !layerPatchIncomplete,
// which only stayed consistent because the full-stack branch
// never re-keyed at all.)
draftKey = contentHash(currentPatch);
captureDiffFingerprint();
return Response.json({
rawPatch: currentPatch,
aiReviewContext: buildCurrentAiReviewContext(),
gitRef: currentGitRef,
snapshotId: currentSnapshotId(),
prDiffScope: currentPRDiffScope,
...(layerPatchIncomplete && { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable }),
...((currentError ?? upgradeError) && { error: currentError ?? upgradeError }),
semanticDiff: await getSemanticDiffAdvert(),
callFlow: await getCallFlowAdvert(),
});
}
const fullStackOption = prDiffScopeOptions.find((option) => option.id === "full-stack");
if (!fullStackOption?.enabled || !(options.worktreePool || options.agentCwd)) {
return Response.json(
{ error: "Full stack diff requires a stacked PR and a local checkout" },
{ status: 400 },
);
}
// Blocks on the background checkout warmup if it's still running.
const fullStackCwd = await ensurePRLocalCwd();
if (!fullStackCwd) {
return Response.json(
{ error: "Local checkout is unavailable — full stack diff cannot run" },
{ status: 400 },
);
}
const result = await runPRFullStackDiff(gitRuntime, prMetadata, fullStackCwd);
if (result.error) {
return Response.json({ error: result.error }, { status: 400 });
}
if (scopeEpoch !== prScopeEpoch) return supersededResponse();
currentPatch = result.patch;
currentGitRef = result.label;
currentError = undefined;
currentPRDiffScope = "full-stack";
// INVARIANT: every commit point re-keys (see the layer branch).
// Skipping this advertised the LAYER snapshot id for the
// full-stack patch — stale layer tabs never got the banner and
// full-stack drafts collided with layer drafts.
draftKey = contentHash(currentPatch);
captureDiffFingerprint();
return Response.json({
rawPatch: currentPatch,
aiReviewContext: buildCurrentAiReviewContext(),
gitRef: currentGitRef,
snapshotId: currentSnapshotId(),
prDiffScope: currentPRDiffScope,
semanticDiff: await getSemanticDiffAdvert(),
callFlow: await getCallFlowAdvert(),
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to switch PR diff scope";
return Response.json({ error: message }, { status: 500 });
}
}
// API: List PRs for the current repo (cached for 30s)
if (url.pathname === "/api/pr-list" && req.method === "GET") {
if (!isPRMode || !prRef) {
return Response.json({ error: "Not in PR mode" }, { status: 400 });
}
try {
const now = Date.now();
if (prListCache && now - prListCacheTime < 30_000) {
return Response.json({ prs: prListCache });
}
const prs = await fetchPRList(prRef);
prListCache = prs;
prListCacheTime = now;
return Response.json({ prs });
} catch (err) {
return Response.json({ error: "Failed to fetch PR list" }, { status: 500 });
}
}
// API: Switch to a different PR in the stack (in-place navigation)
if (url.pathname === "/api/pr-switch" && req.method === "POST") {
if (!isPRMode || !prRef) {
return Response.json({ error: "Not in PR mode" }, { status: 400 });
}
try {
const body = (await req.json()) as { url?: string };
if (!body.url) {
return Response.json({ error: "Missing PR URL" }, { status: 400 });
}
const newRef = parsePRUrl(body.url);
if (!newRef) {
return Response.json({ error: "Invalid PR URL" }, { status: 400 });
}
if (!isSameProject(newRef, prRef!)) {
return Response.json({ error: "Cannot switch to a PR in a different repository" }, { status: 400 });
}
const cached = prSwitchCache.get(body.url);
const pr = cached ?? await fetchPR(newRef);
if (!cached) prSwitchCache.set(body.url, pr);
// Update mutable server state. Bump the scope epoch so a scope
// request parked on a long await cannot overwrite this switch.
prScopeEpoch++;
prMetadata = pr.metadata;
prRef = prRefFromMetadata(pr.metadata);
warmPRContext(pr.metadata.url, prRef);
currentPatch = pr.rawPatch;
currentGitRef = `${getMRLabel(pr.metadata)} ${getMRNumberLabel(pr.metadata)}`;
currentError = undefined;
originalPRPatch = pr.rawPatch;
originalPRGitRef = currentGitRef;
originalPRError = undefined;
currentPRDiffScope = "layer";
layerPatchIncomplete = pr.patchIncomplete ?? false;
draftKey = contentHash(pr.rawPatch);
prListCache = null;
captureDiffFingerprint();
// Recompute stack info
prStackInfo = getPRStackInfo(pr.metadata);
// Fetch stack tree (cached per PR for the session)
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);
}
// Ensure worktree for the new PR (pool creates a fresh one, no shared-state mutation)
let hasLocalForNewPR = false;
if (options.worktreePool) {
try {
await options.worktreePool.ensure(gitRuntime, pr.metadata);
hasLocalForNewPR = true;
} catch {
// Pool creation failed — full-stack will be disabled
}
} else if (options.agentCwd) {
hasLocalForNewPR = await checkoutPRHead(gitRuntime, pr.metadata, options.agentCwd);
}
prStackInfo = resolveStackInfo(pr.metadata, prStackTree, prStackInfo);
prDiffScopeOptions = prStackInfo
? getPRDiffScopeOptions(pr.metadata, hasLocalForNewPR)
: [];
// Fetch viewed files for the new PR
let switchedViewedFiles: string[] = [];
try {
const viewedMap = await fetchPRViewedFiles(prRef);
switchedViewedFiles = Object.entries(viewedMap)
.filter(([, isViewed]) => isViewed)
.map(([path]) => path);
} catch {
// Non-fatal
}
initialViewedFiles = switchedViewedFiles;
repoInfo = {
display: getDisplayRepo(pr.metadata),
branch: `${getMRLabel(pr.metadata)} ${getMRNumberLabel(pr.metadata)}`,
};
return Response.json({
rawPatch: currentPatch,
aiReviewContext: buildCurrentAiReviewContext(),
gitRef: currentGitRef,
snapshotId: currentSnapshotId(),
prMetadata: pr.metadata,
// The new PR's checkout (null while warming) so Open-in re-roots
// immediately on switch instead of waiting for the 5s probe.
agentCwd: resolvePRLocalCwd(pr.metadata) ?? null,
prStackInfo,
prStackTree,
prDiffScope: currentPRDiffScope,
prDiffScopeOptions,
...(layerPatchIncomplete && { prPatchIncomplete: true, prPatchUpgradeAvailable: layerUpgradeAvailable }),
repoInfo,
...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }),
...(currentError ? { error: currentError } : {}),
semanticDiff: await getSemanticDiffAdvert(),
callFlow: await getCallFlowAdvert(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to switch PR";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Fetch PR context (comments, checks, merge status) — PR mode only
if (url.pathname === "/api/pr-context" && req.method === "GET") {
if (!isPRMode || !prRef || !prMetadata) {
return Response.json(
{ error: "Not in PR mode" },
{ status: 400 },
);
}
try {
const context = await prContextLive.getContext(prMetadata.url, prRef);
return Response.json(context);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch PR context";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Fetch a context-referenced HTML/Markdown artifact through the
// authenticated provider session. The shared helper owns URL and
// redirect validation so this never becomes an open proxy.
if (url.pathname === "/api/pr-artifact-document" && req.method === "GET") {
if (!isPRMode || !prRef || !prMetadata) {
return Response.json({ error: "Not in PR mode" }, { status: 400 });
}
const artifactUrl = url.searchParams.get("url");
if (!artifactUrl) {
return Response.json({ error: "Missing artifact URL" }, { status: 400 });
}
try {
const context = await prContextLive.getContext(prMetadata.url, prRef);
const document = await fetchPRArtifactDocument(
prCommandRuntime,
prMetadata,
context,
artifactUrl,
);
return new Response(document.content, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "private, max-age=300",
"Content-Security-Policy": "sandbox; default-src 'none'",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
const status = error instanceof PRArtifactDocumentError ? error.status : 500;
const message = error instanceof Error ? error.message : "Failed to fetch artifact document";
return Response.json({ error: message }, { status });
}
}
// API: Serve provider media and resources derived from a referenced
// artifact document. The helper validates both the source document
// and target URL before forwarding provider credentials.
if (url.pathname === "/api/pr-artifact-content" && req.method === "GET") {
if (!isPRMode || !prRef || !prMetadata) {
return Response.json({ error: "Not in PR mode" }, { status: 400 });
}
const artifactUrl = url.searchParams.get("url");
if (!artifactUrl) {
return Response.json({ error: "Missing artifact URL" }, { status: 400 });
}
try {
const context = await prContextLive.getContext(prMetadata.url, prRef);
const content = await fetchPRArtifactContent(
prCommandRuntime,
prMetadata,
context,
artifactUrl,
{
sourceUrl: url.searchParams.get("source") ?? undefined,
range: req.headers.get("range") ?? undefined,
},
);
return new Response(Uint8Array.from(content.content).buffer, {
status: content.status,
headers: {
"Content-Type": content.contentType,
"Cache-Control": "private, max-age=300",
"Content-Security-Policy": "sandbox",
"X-Content-Type-Options": "nosniff",
"Content-Length": String(content.content.byteLength),
...(content.contentRange ? { "Content-Range": content.contentRange } : {}),
...(content.acceptRanges ? { "Accept-Ranges": content.acceptRanges } : {}),
},
});
} catch (error) {
const status = error instanceof PRArtifactDocumentError ? error.status : 500;
const message = error instanceof Error ? error.message : "Failed to fetch artifact content";
return Response.json({ error: message }, { status });
}
}
// API: Get file content for expandable diff context
if (url.pathname === "/api/file-content" && req.method === "GET") {
const filePath = url.searchParams.get("path");
if (!filePath) {
return Response.json({ error: "Missing path" }, { status: 400 });
}
try { validateFilePath(filePath); } catch {
return Response.json({ error: "Invalid path" }, { status: 400 });
}
const oldPath = url.searchParams.get("oldPath") || undefined;
if (oldPath) {
try { validateFilePath(oldPath); } catch {
return Response.json({ error: "Invalid path" }, { status: 400 });
}
}
// File expansion must describe the exact patch the requesting tab
// rendered. Reject cross-tab switches and mutable VCS changes
// instead of combining an old patch with newly-resolved contents.
const requestedSnapshot = url.searchParams.get("snapshot");
if (requestedSnapshot) {
if (requestedSnapshot !== currentSnapshotId()) {
return Response.json({ error: "Diff snapshot is stale; refresh before expanding context" }, { status: 409 });
}
const baselineGeneration = fingerprintGeneration;
let baseline = currentFingerprint;
const pendingCapture = pendingFingerprintCapture;
if (baseline == null && pendingCapture) {
baseline = await pendingCapture;
}
if (
requestedSnapshot !== currentSnapshotId() ||
baselineGeneration !== fingerprintGeneration
) {
return Response.json({ error: "Diff snapshot is stale; refresh before expanding context" }, { status: 409 });
}
if (baseline != null) {
const probe = await fileContentFingerprintProbes.run(
`${requestedSnapshot}:${baselineGeneration}`,
computeDiffFingerprint,
);
if (
requestedSnapshot !== currentSnapshotId() ||
currentFingerprint !== baseline ||
(probe != null && probe !== baseline)
) {
return Response.json({ error: "Diff snapshot is stale; refresh before expanding context" }, { status: 409 });
}
}
}
if (isBinaryPatchFile(currentPatch, filePath)) {
return Response.json({ oldContent: null, newContent: null });
}
if (workspace) {
try {
const result = await workspace.getFileContents(filePath, oldPath);
return Response.json(result);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "No file access available" },
{ status: 400 },
);
}
}
// Full-stack PR mode uses local git for file expansion because
// the patch is no longer the platform's layer diff.
const fileContentCwd = resolvePRLocalCwd();
if (
isPRMode &&
currentPRDiffScope === "full-stack" &&
fileContentCwd &&
prMetadata?.defaultBranch
) {
const baseRef = await resolvePRFullStackBaseRef(
gitRuntime,
prMetadata!.defaultBranch,
fileContentCwd,
);
if (!baseRef) {
return Response.json(
{ oldContent: null, newContent: null },
);
}
const result = await getVcsFileContentsForDiff(
"merge-base",
baseRef,
filePath,
oldPath,
fileContentCwd,
);
return Response.json(result);
}
// Local review: read file contents from local git
if (hasLocalAccess) {
const requestedBase = url.searchParams.get("base") ?? undefined;
const base = resolveReviewBase(requestedBase);
const defaultCwd = gitContext?.cwd;
const result = await getVcsFileContentsForDiff(
currentDiffType as DiffType,
base,
filePath,
oldPath,
defaultCwd,
);
return Response.json(result);
}
// PR mode: fetch from platform API using merge-base/head SHAs.
// The diff is computed against the merge-base (common ancestor), not the
// base branch tip. File contents must match the diff for hunk expansion.
if (isPRMode && prMetadata) {
const oldSha = prMetadata.mergeBaseSha ?? prMetadata.baseSha;
const [oldContent, newContent] = await Promise.all([
fetchPRFileContent(prRef!, oldSha, oldPath || filePath),
fetchPRFileContent(prRef!, prMetadata.headSha, filePath),
]);
return Response.json({ oldContent, newContent });
}
return Response.json({ error: "No file access available" }, { status: 400 });
}
// API: Code navigation (search-based symbol resolution)
if (url.pathname === "/api/code-nav/resolve" && req.method === "POST") {
if (isGitButlerCommittedView()) {
return Response.json(
{ error: "Code navigation is unavailable for committed GitButler views" },
{ status: 400 },
);
}
const hasCodeNavAccess = !!workspace || !!gitContext || !!options.agentCwd || !!options.worktreePool;
if (!hasCodeNavAccess) {
return Response.json(
{ error: "Code navigation requires local access" },
{ status: 400 },
);
}
// PR mode: the checkout must actually exist — ripgrep over a
// fallback directory returns confidently-wrong results.
const navCwd = options.worktreePool && prMetadata
? await ensurePRLocalCwd()
: await resolveAgentCwdReady();
if (!navCwd) {
return Response.json({ error: "Local checkout unavailable" }, { status: 400 });
}
const changedFiles = extractChangedFiles(currentPatch);
return handleCodeNavResolve(req, navCwd, changedFiles);
}
// API: Code navigation file preview (read file from working tree)
if (url.pathname === "/api/code-nav/file" && req.method === "GET") {
if (isGitButlerCommittedView()) {
return Response.json(
{ error: "Code navigation is unavailable for committed GitButler views" },
{ status: 400 },
);
}
const hasCodeNavAccess = !!workspace || !!gitContext || !!options.agentCwd || !!options.worktreePool;
if (!hasCodeNavAccess) {
return Response.json({ error: "Code navigation requires local access" }, { status: 400 });
}
const filePath = url.searchParams.get("path");
if (!filePath) {
return Response.json({ error: "Missing path" }, { status: 400 });
}
try { validateFilePath(filePath); } catch {
return Response.json({ error: "Invalid path" }, { status: 400 });
}
try {
const navCwd = options.worktreePool && prMetadata
? await ensurePRLocalCwd()
: await resolveAgentCwdReady();
if (!navCwd) {
return Response.json({ error: "Local checkout unavailable" }, { status: 400 });
}
const content = await Bun.file(`${navCwd}/${filePath}`).text();
return Response.json({ content });
} catch {
return Response.json({ error: "File not found" }, { status: 404 });
}
}
// API: Stage / unstage a file (disabled when VCS doesn't support it)
if (url.pathname === "/api/git-add" && req.method === "POST") {
try {
const body = (await req.json()) as { filePath?: unknown; undo?: boolean };
if (typeof body.filePath !== "string" || !body.filePath) {
return Response.json({ error: "Missing filePath" }, { status: 400 });
}
try { validateFilePath(body.filePath); } catch {
return Response.json({ error: "Invalid path" }, { status: 400 });
}
if (workspace) {
try {
await workspace.stageFile(body.filePath, body.undo);
return Response.json({ ok: true });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "Failed to stage file" },
{ status: 400 },
);
}
}
const stageCwd = resolveVcsCwd(currentDiffType as DiffType, gitContext?.cwd);
if (
isPRMode ||
(sessionVcsType && !vcsOwnsDiffType(sessionVcsType, currentDiffType as string)) ||
!(await canStageFiles(currentDiffType as DiffType, stageCwd))
) {
return Response.json(
{ error: "Staging not available" },
{ status: 400 },
);
}
if (body.undo) {
await unstageFile(currentDiffType as DiffType, body.filePath, stageCwd);
} else {
await stageFile(currentDiffType as DiffType, body.filePath, stageCwd);
}
return Response.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to stage file";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Update user config (write-back to ~/.plannotator/config.json)
if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await req.json()) as { displayName?: string; diffOptions?: Record<string, unknown>; theme?: Record<string, unknown>; favicon?: FaviconStyle; reviewAnalysis?: Record<string, unknown>; conventionalComments?: boolean; conventionalLabels?: unknown[] | null };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.theme !== undefined) toSave.theme = body.theme;
if (isFaviconStyle(body.favicon)) toSave.favicon = body.favicon;
if (body.reviewAnalysis !== undefined) {
const reviewAnalysis = parseReviewAnalysisConfig(body.reviewAnalysis);
if (!reviewAnalysis) {
return Response.json({ error: "Invalid analysis settings" }, { status: 400 });
}
toSave.reviewAnalysis = reviewAnalysis;
if (reviewAnalysis.callFlow === false) callFlowService.cancelAll();
}
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels;
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
return Response.json({ ok: true });
} catch {
return Response.json({ error: "Invalid request" }, { status: 400 });
}
}
// API: Serve images (local paths or temp uploads)
if (url.pathname === "/api/image") {
return handleImage(req);
}
// API: Upload image -> save to temp -> return path
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req);
}
// API: Get available agents (OpenCode only)
if (url.pathname === "/api/agents") {
return handleAgents(options.opencodeClient);
}
// AI-disabled review sessions expose no agent discovery or launch
// surface. The exact endpoint above is feedback routing, not a job.
if (!aiEnabled && url.pathname.startsWith("/api/agents/")) {
if (
url.pathname.slice("/api/agents/".length) === "capabilities" &&
req.method === "GET"
) {
return Response.json({ mode: "review", providers: [], available: false });
}
return Response.json({ error: "AI features disabled" }, { status: 503 });
}
// API: Review profiles (custom reviews discovery). Reloaded per
// request, no file watching. Profiles come from the user dir plus
// builtins.
if (url.pathname === "/api/agents/review-profiles" && req.method === "GET") {
// Catalog only — directory listing, no SKILL.md bodies read here.
// Bodies are read at launch, for the one selected skill.
const body: ReviewProfilesResponse = {
profiles: [
{
id: BUILTIN_DEFAULT_PROFILE.id,
label: BUILTIN_DEFAULT_PROFILE.label,
source: BUILTIN_DEFAULT_PROFILE.source,
default: BUILTIN_DEFAULT_PROFILE.default,
},
...discoverCuratedSkills().map((s) => ({
id: `skill:${s.name}`,
label: s.name,
source: "user" as const,
sourcePath: s.sourcePath,
})),
],
};
return Response.json(body);
}
// API: All discovered skills, for the "add a review" picker. Each is
// flagged with whether it is already enabled as a review.
if (url.pathname === "/api/agents/skills" && req.method === "GET") {
return Response.json({ skills: listAllSkills() });
}
// API: Enable a skill as a review (curation write to review-skills.json).
if (url.pathname === "/api/agents/review-skills" && req.method === "POST") {
let name: unknown;
try {
({ name } = (await req.json()) as { name?: unknown });
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (typeof name !== "string" || name.length === 0) {
return Response.json({ error: "`name` is required." }, { status: 400 });
}
try {
return Response.json(enableReviewSkill(name));
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : "Could not enable review." },
{ status: 400 },
);
}
}
// API: Guided Review standing instructions (#1265) — server-owned,
// stored in the data dir like review-skills.json. Guide launches
// apply the stored text when the launch body carries none.
if (url.pathname === "/api/agents/guide-instructions") {
if (req.method === "GET") {
return Response.json({ instructions: readGuideInstructions() });
}
if (req.method === "PUT") {
let instructions: unknown;
try {
({ instructions } = (await req.json()) as { instructions?: unknown });
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (typeof instructions !== "string") {
return Response.json({ error: "`instructions` must be a string." }, { status: 400 });
}
return Response.json({ instructions: writeGuideInstructions(instructions) });
}
}
// API: Annotation draft persistence
if (url.pathname === "/api/draft") {
if (req.method === "POST") return handleDraftSave(req, draftKey);
if (req.method === "DELETE") return handleDraftDelete(draftKey, req);
return handleDraftLoad(draftKey);
}
// API: Editor annotations (VS Code extension)
if (isGitButlerCommittedView() && url.pathname === "/api/editor-annotations" && req.method === "GET") {
return Response.json({ annotations: [] });
}
if (isGitButlerCommittedView() && url.pathname === "/api/editor-annotation" && req.method === "POST") {
return Response.json(
{ error: "Editor annotations are unavailable for committed GitButler views" },
{ status: 400 },
);
}
const editorResponse = await editorAnnotations.handle(req, url);
if (editorResponse) return editorResponse;
// API: Live PR context stream (comments, checks, merge state)
if (url.pathname === "/api/pr-context/stream" && req.method === "GET") {
if (!isPRMode || !prRef || !prMetadata) {
return Response.json(
{ error: "Not in PR mode" },
{ status: 400 },
);
}
server.timeout(req, 0);
const encoder = new TextEncoder();
const activeRef = prRef;
const activeUrl = prMetadata.url;
let unsubscribe: (() => void) | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
start(controller) {
unsubscribe = prContextLive.watch(activeUrl, activeRef, (event) => {
controller.enqueue(encoder.encode(serializePRContextSSEEvent(event)));
});
heartbeatTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(PR_CONTEXT_HEARTBEAT_COMMENT));
} catch {
if (heartbeatTimer) clearInterval(heartbeatTimer);
heartbeatTimer = null;
unsubscribe?.();
unsubscribe = null;
}
}, PR_CONTEXT_HEARTBEAT_INTERVAL_MS);
},
cancel() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
heartbeatTimer = null;
unsubscribe?.();
unsubscribe = null;
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// API: External annotations (SSE-based, for any external tool)
const externalResponse = await externalAnnotations.handle(req, url, {
disableIdleTimeout: () => server.timeout(req, 0),
});
if (externalResponse) return externalResponse;
// API: Agent jobs (background review agents)
const agentResponse = await agentJobs.handle(req, url, {
disableIdleTimeout: () => server.timeout(req, 0),
});
if (agentResponse) return agentResponse;
// API: Exit review session without feedback
if (url.pathname === "/api/exit" && req.method === "POST") {
deleteDraft(draftKey, readDraftGenerationFromUrl(req));
resolveDecision({ approved: false, feedback: "", annotations: [], exit: true });
return Response.json({ ok: true });
}
// API: Submit review feedback
if (url.pathname === "/api/feedback" && req.method === "POST") {
try {
const body = (await req.json()) as {
approved?: boolean;
feedback: string;
annotations: unknown[];
agentSwitch?: string;
draftGeneration?: number;
};
deleteDraft(draftKey, readDraftGenerationFromBody(body));
resolveDecision({
approved: body.approved ?? false,
feedback: body.feedback || "",
annotations: body.annotations || [],
agentSwitch: body.agentSwitch,
});
return Response.json({ ok: true });
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to process feedback";
return Response.json({ error: message }, { status: 500 });
}
}
// API: Submit PR review directly to GitHub (PR mode only)
if (url.pathname === "/api/pr-action" && req.method === "POST") {
if (!isPRMode || !prMetadata) {
return Response.json({ error: "Not in PR mode" }, { status: 400 });
}
try {
const body = (await req.json()) as {
action: "approve" | "comment";
body: string;
fileComments: PRReviewFileComment[];
targetPrUrl?: string;
};
// Resolve target PR — either explicit target or current.
// When targetPrUrl is provided, the client has already filtered
// annotations by diffScope, so we skip the server-side scope guard.
let targetRef = prRef!;
let targetHeadSha = prMetadata.headSha;
let targetUrl = prMetadata.url;
if (body.targetPrUrl) {
const cached = prSwitchCache.get(body.targetPrUrl);
if (!cached) {
return Response.json({ error: "Target PR not found in session" }, { status: 400 });
}
targetRef = prRefFromMetadata(cached.metadata);
targetHeadSha = cached.metadata.headSha;
targetUrl = cached.metadata.url;
} else if (currentPRDiffScope !== "layer") {
return Response.json(
{ error: "Switch to Layer diff before posting a platform review" },
{ status: 400 },
);
}
console.error(`[pr-action] ${body.action} with ${body.fileComments.length} file comment(s), target=${targetUrl}, headSha=${targetHeadSha}`);
const submission = await submitPlatformReview(
targetRef,
targetHeadSha,
body.action,
body.body,
body.fileComments,
);
console.error(`[pr-action] ${submission.status === "complete" ? "Success" : "Partial success"}`);
prContextLive.refreshAfterWrite(targetUrl, targetRef);
return Response.json({ ok: true, prUrl: targetUrl, submission });
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to submit PR review";
console.error(`[pr-action] Failed: ${message}`);
return Response.json({ error: message }, { status: 500 });
}
}
// API: Mark/unmark PR files as viewed on GitHub (PR mode, GitHub only)
if (url.pathname === "/api/pr-viewed" && req.method === "POST") {
if (!isPRMode || !prMetadata) {
return Response.json({ error: "Not in PR mode" }, { status: 400 });
}
if (prMetadata.platform !== "github") {
return Response.json({ error: "Viewed sync only supported for GitHub" }, { status: 400 });
}
const prNodeId = prMetadata.prNodeId;
if (!prNodeId) {
return Response.json({ error: "PR node ID not available" }, { status: 400 });
}
try {
const body = (await req.json()) as {
filePaths: string[];
viewed: boolean;
};
await markPRFilesViewed(prRef!, prNodeId, body.filePaths, body.viewed);
return Response.json({ ok: true });
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to update viewed state";
console.error("[plannotator] /api/pr-viewed error:", message);
return Response.json({ error: message }, { status: 500 });
}
}
// AI endpoints
if (url.pathname.startsWith("/api/ai/")) {
if (!aiRuntime) {
if (!isAIEndpointPath(url.pathname)) {
return handleApiNotFound(url.pathname);
}
if (url.pathname.slice("/api/ai/".length) === "capabilities" && req.method === "GET") {
return Response.json({ available: false, providers: [] });
}
return Response.json({ error: "AI backend not available" }, { status: 503 });
}
const handler = aiRuntime.endpoints[url.pathname as keyof AIEndpoints];
if (handler) {
// AI sessions pin their cwd at creation — wait out the PR
// checkout warmup so a session opened in the first seconds
// isn't rooted in a transient fallback directory for life.
// If the checkout can't be produced (warmup failed), refuse
// instead of starting a session in the wrong directory.
if (req.method === "POST" && url.pathname === "/api/ai/session" && options.worktreePool && prMetadata) {
const checkout = await ensurePRLocalCwd();
if (!checkout) {
return Response.json(
{ error: "Local PR checkout unavailable — Ask AI can't read the PR files right now. Retry shortly." },
{ status: 503 },
);
}
}
if (url.pathname === AI_QUERY_ENDPOINT) {
server.timeout(req, 0);
}
return handler(req);
}
return handleApiNotFound(url.pathname);
}
// Favicon
if (url.pathname === "/favicon.png") return handleFavicon();
// API 404 guard: unknown /api/* routes should return JSON, not HTML
if (url.pathname.startsWith("/api/")) {
return handleApiNotFound(url.pathname);
}
// Serve embedded HTML for all other routes (SPA)
return new Response(htmlContent, {
headers: { "Content-Type": "text/html" },
});
},
error(err) {
console.error("[plannotator] Server error:", err);
return new Response(
`Internal Server Error: ${err instanceof Error ? err.message : String(err)}`,
{ status: 500, headers: { "Content-Type": "text/plain" } },
);
},
}),
);
const port = server.port!;
serverUrl = buildAdvertisedUrl(port);
agentApiUrl = `http://127.0.0.1:${port}`;
const exitHandler = () => agentJobs.killAll();
process.once("exit", exitHandler);
const stop = () => {
process.removeListener("exit", exitHandler);
agentJobs.killAll();
callFlowService.cancelAll();
aiRuntime?.dispose();
server.stop();
// 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 */ }
}
};
// Notify caller that server is ready. An async ready handler that rejects
// (e.g. --tailscale publishing failed) must stop the server and propagate:
// firing-and-forgetting it would leave an unhandled rejection while the
// loopback server keeps listening and the session hangs forever.
if (onReady) {
try {
await onReady(serverUrl, isRemote, port);
} catch (error) {
stop();
throw error;
}
}
return {
port,
url: serverUrl,
isRemote,
waitForDecision: () => decisionPromise,
stop,
};
}