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