diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ebc23acd..8d5571467 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - name: Install dependencies run: bun install - - name: Generate Pi extension shared copies + - name: Generate Pi extension shared helpers run: bash apps/pi-extension/vendor.sh - name: Type check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d319f9401..02383eed3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: bun install - - name: Generate Pi extension shared copies + - name: Generate Pi extension shared helpers run: bash apps/pi-extension/vendor.sh - name: Type check diff --git a/AGENTS.md b/AGENTS.md index 59af5f634..de0f32123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,10 @@ plannotator/ │ ├── shared/ # Shared types, utilities, and cross-runtime logic │ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) -│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) +│ │ ├── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) +│ │ ├── plugin-protocol.ts # JSON protocol for binary-owned plugin commands +│ │ ├── plugin-client.ts # Shared OpenCode/Pi subprocess client for plannotator plugin commands +│ │ └── plugin-binary.ts # Binary discovery, compatibility checks, and installer bridge │ ├── editor/ # Plan review app │ │ ├── App.tsx # Main plan review app │ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries @@ -90,12 +93,11 @@ plannotator/ ## Server Runtimes -There are two separate server implementations with the same API surface: +Plannotator has one server implementation: -- **Bun server** (`packages/server/`) — used by both Claude Code (`apps/hook/`) and OpenCode (`apps/opencode-plugin/`). These plugins import directly from `@plannotator/server`. -- **Pi server** (`apps/pi-extension/server/`) — a standalone Node.js server for the Pi extension. It mirrors the Bun server's API but uses `node:http` primitives instead of Bun's `Request`/`Response` APIs. +- **Bun server** (`packages/server/`) — owns plan review, code review, annotate, archive, and shared browser APIs. -When adding or modifying server endpoints, both implementations must be updated. Runtime-agnostic logic (store, validation, types) lives in `packages/shared/` and is imported by both. +Claude Code runs this server through the released `plannotator` binary entrypoint. OpenCode and Pi do not package their own server implementations; they call the same binary through the plugin protocol in `packages/shared/plugin-protocol.ts`. Runtime-agnostic logic (store, validation, types) lives in `packages/shared/`. ## Installation @@ -118,6 +120,8 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_REMOTE` | Set to `1` / `true` for remote mode, `0` / `false` for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. | | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | +| `PLANNOTATOR_BIN` | Explicit `plannotator` binary path for OpenCode/Pi plugin clients. Overrides PATH and standard install locations. | +| `PLANNOTATOR_DISABLE_AUTO_INSTALL` | Set to `1` / `true` to make OpenCode/Pi fail clearly instead of running the official installer when no compatible binary is found. | | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | diff --git a/apps/copilot/plugin.json b/apps/copilot/plugin.json index 908a83b60..d8be2de53 100644 --- a/apps/copilot/plugin.json +++ b/apps/copilot/plugin.json @@ -1,7 +1,7 @@ { "name": "plannotator-copilot", "description": "Interactive Plan & Code Review for GitHub Copilot CLI. Visual annotations, team sharing, structured feedback.", - "version": "0.19.18", + "version": "0.19.17", "author": { "name": "backnotprop" }, "repository": "https://github.com/backnotprop/plannotator", "license": "MIT OR Apache-2.0", diff --git a/apps/hook/.claude-plugin/plugin.json b/apps/hook/.claude-plugin/plugin.json index 5e0f71afe..8efddaf5b 100644 --- a/apps/hook/.claude-plugin/plugin.json +++ b/apps/hook/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "plannotator", "description": "Interactive Plan Review: Mark up and refine your plans using a UI, easily share for team collaboration, automatically integrates with plan mode hooks.", - "version": "0.19.18", + "version": "0.19.17", "author": { "name": "backnotprop" }, diff --git a/apps/hook/README.md b/apps/hook/README.md index 1606da5dd..7336fdec0 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -23,6 +23,8 @@ curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && d Released binaries ship with SHA256 sidecars and [SLSA build provenance](https://slsa.dev/) attestations from v0.17.2 onwards. See the [installation docs](https://plannotator.ai/docs/getting-started/installation/#verifying-your-install) for version pinning and verification commands. +The released binary owns Plannotator's browser server runtime for Claude Code, OpenCode, and Pi. See [Single Binary Runtime](../../docs/single-binary-runtime.md) for the plugin client boundary and daemon-next design. + --- [Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) · [Obsidian Integration](#obsidian-integration) diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index 88b81958d..802cdaaef 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -32,6 +32,7 @@ export function formatTopLevelHelp(): string { " plannotator archive", " plannotator sessions", " plannotator improve-context", + " plannotator plugin capabilities", "", "Note:", " running 'plannotator' without arguments is for hook integration and expects JSON on stdin", @@ -50,6 +51,7 @@ export function formatInteractiveNoArgClarification(): string { " plannotator last", " plannotator archive", " plannotator sessions", + " plannotator plugin capabilities", "", "Run 'plannotator --help' for top-level usage.", ].join("\n"); diff --git a/apps/hook/server/codex-session.test.ts b/apps/hook/server/codex-session.test.ts index 3884446db..c9d749953 100644 --- a/apps/hook/server/codex-session.test.ts +++ b/apps/hook/server/codex-session.test.ts @@ -310,43 +310,6 @@ describe("getLastCodexMessage", () => { expect(result).not.toBeNull(); expect(result!.text).toBe("Valid message"); }); - - test("can ignore assistant messages from the active Codex turn", () => { - const previousTurnId = "turn-previous"; - const activeTurnId = "turn-active"; - const path = writeTempRollout( - buildRollout( - sessionMeta(), - turnStarted(previousTurnId), - userMessage("Explain the thing"), - assistantMessage("Substantive final answer"), - turnCompleted(previousTurnId), - turnStarted(activeTurnId), - userMessage("[$plannotator-last]"), - assistantMessage("I’ll open Plannotator on my last response.") - ) - ); - - const result = getLastCodexMessage(path, { beforeActiveTurn: true }); - expect(result).not.toBeNull(); - expect(result!.text).toBe("Substantive final answer"); - }); - - test("keeps default latest-message behavior inside an active turn", () => { - const turnId = "turn-active"; - const path = writeTempRollout( - buildRollout( - sessionMeta(), - assistantMessage("Previous answer"), - turnStarted(turnId), - assistantMessage("Current status update") - ) - ); - - const result = getLastCodexMessage(path); - expect(result).not.toBeNull(); - expect(result!.text).toBe("Current status update"); - }); }); describe("getLatestCodexPlan", () => { diff --git a/apps/hook/server/codex-session.ts b/apps/hook/server/codex-session.ts index 585e5f2d5..a3e625d31 100644 --- a/apps/hook/server/codex-session.ts +++ b/apps/hook/server/codex-session.ts @@ -48,17 +48,12 @@ export interface CodexPlanResult { source: CodexPlanSource; } -export interface GetLastCodexMessageOptions { - beforeActiveTurn?: boolean; -} - export interface GetLatestCodexPlanOptions { turnId?: string; stopHookActive?: boolean; } const TURN_START_TYPES = new Set(["task_started", "turn_started"]); -const TURN_COMPLETE_TYPES = new Set(["task_complete", "turn_completed"]); const PROPOSED_PLAN_RE = /([\s\S]*?)<\/proposed_plan>/gi; // --- Rollout File Discovery --- @@ -205,24 +200,6 @@ function findTurnStartIndex(entries: RolloutEntry[], turnId?: string): number { return lastTurnContext === -1 ? 0 : lastTurnContext; } -function findActiveTurnStartIndex(entries: RolloutEntry[]): number { - const latestTurnStart = findLastIndex( - entries, - (entry) => - entry.type === "event_msg" && - TURN_START_TYPES.has(entry.payload?.type || "") - ); - if (latestTurnStart === -1) return -1; - - const latestTurnComplete = findLastIndex( - entries, - (entry) => - entry.type === "event_msg" && - TURN_COMPLETE_TYPES.has(entry.payload?.type || "") - ); - return latestTurnStart > latestTurnComplete ? latestTurnStart : -1; -} - function isHookPromptMessage(entry: RolloutEntry): boolean { if (entry.type !== "response_item") return false; if (entry.payload?.type !== "message") return false; @@ -318,17 +295,12 @@ function pickLatestPreferredPlan( * Extracts output_text blocks from payload.content. */ export function getLastCodexMessage( - rolloutPath: string, - options: GetLastCodexMessageOptions = {} + rolloutPath: string ): { text: string } | null { const entries = parseRolloutEntries(rolloutPath); - const activeTurnStart = options.beforeActiveTurn - ? findActiveTurnStartIndex(entries) - : -1; - const endIndex = activeTurnStart === -1 ? entries.length - 1 : activeTurnStart - 1; // Walk backward - for (let i = endIndex; i >= 0; i--) { + for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type !== "response_item") continue; if (entry.payload?.type !== "message") continue; diff --git a/apps/hook/server/html-assets.ts b/apps/hook/server/html-assets.ts new file mode 100644 index 000000000..8c01d7627 --- /dev/null +++ b/apps/hook/server/html-assets.ts @@ -0,0 +1,10 @@ +// Keep text imports isolated so protocol-only commands can run from source +// before apps/hook/dist has been built. +// @ts-ignore - Bun import attribute for text +import planHtml from "../dist/index.html" with { type: "text" }; + +// @ts-ignore - Bun import attribute for text +import reviewHtml from "../dist/review.html" with { type: "text" }; + +export const planHtmlContent = planHtml as unknown as string; +export const reviewHtmlContent = reviewHtml as unknown as string; diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index be654dc8d..c825570e1 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -1,7 +1,7 @@ /** * Plannotator CLI for Claude Code, Codex, Gemini CLI, and Copilot CLI * - * Supports nine modes: + * Supports eight modes: * * 1. Plan Review (default, no args): * - Spawned by Claude/Gemini/Codex hook entrypoints @@ -37,11 +37,7 @@ * - Annotate the last assistant message from a Copilot CLI session * - Parses events.jsonl from session state * - * 8. Goal Setup (`plannotator setup-goal interview|facts `): - * - Opens the bundled question or facts acceptance UI - * - Outputs structured JSON for setup-goal workflows - * - * 9. Improve Context (`plannotator improve-context`): + * 8. Improve Context (`plannotator improve-context`): * - Spawned by PreToolUse hook on EnterPlanMode * - Reads improvement hook file from ~/.plannotator/hooks/ * - Returns additionalContext or silently passes through @@ -75,6 +71,7 @@ import { import { type DiffType, prepareLocalReviewDiff, gitRuntime } from "@plannotator/server/vcs"; import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; import { parseReviewArgs } from "@plannotator/shared/review-args"; +import { parseAnnotateArgs } from "@plannotator/shared/annotate-args"; import { normalizeGoalSetupBundle, type GoalSetupStage, @@ -104,6 +101,18 @@ import { hostnameOrFallback } from "@plannotator/shared/project"; import { readImprovementHook } from "@plannotator/shared/improvement-hooks"; import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; import { AGENT_CONFIG, type Origin } from "@plannotator/shared/agents"; +import { + createPluginErrorResponse, + createPluginSuccessResponse, + getPluginCapabilities, + type PluginAnnotateRequest, + type PluginArchiveRequest, + type PluginBaseRequest, + type PluginClientOrigin, + type PluginPlanRequest, + type PluginReviewRequest, + type PluginSessionInfo, +} from "@plannotator/shared/plugin-protocol"; import { findSessionLogsByAncestorWalk, findSessionLogsForCwd, @@ -125,14 +134,35 @@ import { import path from "path"; import { tmpdir } from "os"; -// Embed the built HTML at compile time -// @ts-ignore - Bun import attribute for text -import planHtml from "../dist/index.html" with { type: "text" }; -const planHtmlContent = planHtml as unknown as string; +let planHtmlContentPromise: Promise | undefined; +let reviewHtmlContentPromise: Promise | undefined; +let htmlAssetsPromise: Promise | undefined; + +function getHtmlAssets() { + htmlAssetsPromise ??= import("./html-assets"); + return htmlAssetsPromise; +} + +function getPlanHtmlContent(): Promise { + planHtmlContentPromise ??= getHtmlAssets().then((mod) => mod.planHtmlContent); + return planHtmlContentPromise; +} -// @ts-ignore - Bun import attribute for text -import reviewHtml from "../dist/review.html" with { type: "text" }; -const reviewHtmlContent = reviewHtml as unknown as string; +function getReviewHtmlContent(): Promise { + reviewHtmlContentPromise ??= getHtmlAssets().then((mod) => mod.reviewHtmlContent); + return reviewHtmlContentPromise; +} + +async function loadGoalSetupBundle( + stage: GoalSetupStage, + bundlePath: string, +) { + const raw = + bundlePath === "-" + ? await Bun.stdin.text() + : await Bun.file(path.resolve(bundlePath)).text(); + return normalizeGoalSetupBundle(JSON.parse(raw), stage); +} // Check for subcommand const args = process.argv.slice(2); @@ -207,73 +237,735 @@ function emitAnnotateOutcome(result: { } else { console.log(JSON.stringify({ decision: "annotated", feedback: result.feedback || "" })); } - return; - } - if (result.exit) return; - if (result.approved) { - console.log(APPROVED_PLAINTEXT_MARKER); - return; + return; + } + if (result.exit) return; + if (result.approved) { + console.log(APPROVED_PLAINTEXT_MARKER); + return; + } + if (result.feedback) console.log(result.feedback); +} + +if (isVersionInvocation(args)) { + console.log(formatVersion()); + process.exit(0); +} + +if (isTopLevelHelpInvocation(args)) { + console.log(formatTopLevelHelp()); + process.exit(0); +} + +if (isInteractiveNoArgInvocation(args, process.stdin.isTTY)) { + console.log(formatInteractiveNoArgClarification()); + process.exit(0); +} + +// Ensure session cleanup on exit +process.on("exit", () => unregisterSession()); + +// Check if URL sharing is enabled (default: true) +const sharingEnabled = process.env.PLANNOTATOR_SHARE !== "disabled"; + +// Custom share portal URL for self-hosting +const shareBaseUrl = process.env.PLANNOTATOR_SHARE_URL || undefined; + +// Paste service URL for short URL sharing +const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; + +// Detect calling agent from environment variables set by agent runtimes. +// Priority: +// PLANNOTATOR_ORIGIN (explicit override, validated against AGENT_CONFIG) +// > Codex (CODEX_THREAD_ID) +// > Copilot CLI (COPILOT_CLI) +// > OpenCode (OPENCODE) +// > Gemini CLI (GEMINI_CLI) +// > Claude Code (default fallback) +// +// To add a new agent, also add an entry to AGENT_CONFIG in +// packages/shared/agents.ts (see header comment there). +const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; +const detectedOrigin: Origin = + (originOverride && originOverride in AGENT_CONFIG) ? originOverride : + process.env.CODEX_THREAD_ID ? "codex" : + process.env.COPILOT_CLI ? "copilot-cli" : + process.env.OPENCODE ? "opencode" : + process.env.GEMINI_CLI ? "gemini-cli" : + "claude-code"; + +function registerProcessCleanup(cleanup: () => void): () => void { + let cleaned = false; + const run = () => { + if (cleaned) return; + cleaned = true; + cleanup(); + }; + const onSigint = () => { + run(); + process.exit(130); + }; + const onSigterm = () => { + run(); + process.exit(143); + }; + + process.once("exit", run); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + + return () => { + process.removeListener("exit", run); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + run(); + }; +} + +function cleanupWorktreeSession( + repoDir: string, + sessionDir: string, + worktreePool: WorktreePool | undefined, + fallbackWorktreePath: string, +): void { + try { + const entries = [...(worktreePool?.entries() ?? [])]; + if (entries.length > 0) { + for (const entry of entries) { + Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir }); + } + } else { + Bun.spawnSync(["git", "worktree", "remove", "--force", fallbackWorktreePath], { cwd: repoDir }); + } + } catch {} + try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} +} + +function emitPluginError(code: string, message: string, exitCode = 1): never { + console.log(JSON.stringify(createPluginErrorResponse(code, message))); + process.exit(exitCode); +} + +async function readPluginRequest(): Promise> { + try { + const raw = await Bun.stdin.text(); + return raw.trim() ? JSON.parse(raw) : {}; + } catch (err) { + emitPluginError( + "invalid-json", + err instanceof Error ? err.message : "Invalid JSON request", + ); + } +} + +function getPluginOrigin(request: Partial): PluginClientOrigin { + const originIndex = args.indexOf("--origin"); + const originArg = originIndex >= 0 ? args[originIndex + 1] : undefined; + const origin = request.origin || originArg || detectedOrigin; + if (origin !== "opencode" && origin !== "pi") { + emitPluginError( + "invalid-origin", + `Plugin origin must be "opencode" or "pi"; got ${String(origin || "")}`, + ); + } + return origin; +} + +function applyPluginCwd(request: Partial): void { + if (!request.cwd) return; + try { + process.chdir(request.cwd); + } catch (err) { + emitPluginError( + "invalid-cwd", + err instanceof Error ? err.message : `Invalid cwd: ${request.cwd}`, + ); + } +} + +function pluginSessionInfo( + mode: PluginSessionInfo["mode"], + server: { url: string; port: number; isRemote: boolean }, +): PluginSessionInfo { + return { + mode, + url: server.url, + port: server.port, + isRemote: server.isRemote, + }; +} + +function emitPluginSessionReady(session: PluginSessionInfo): void { + console.error(`PLANNOTATOR_SESSION_READY ${JSON.stringify(session)}`); +} + +async function runPluginPlanCommand(): Promise { + const request = await readPluginRequest(); + const origin = getPluginOrigin(request); + applyPluginCwd(request); + + let planContent = typeof request.plan === "string" ? request.plan : ""; + if (!planContent && request.planFilePath) { + try { + const planPath = path.isAbsolute(request.planFilePath) + ? request.planFilePath + : path.resolve(process.cwd(), request.planFilePath); + planContent = await Bun.file(planPath).text(); + } catch (err) { + emitPluginError( + "plan-read-failed", + err instanceof Error ? err.message : `Could not read plan file: ${request.planFilePath}`, + ); + } + } + + if (!planContent.trim()) { + emitPluginError( + "missing-plan", + "Plugin plan requests must include a non-empty plan or planFilePath.", + ); + } + + const effectiveSharingEnabled = request.sharingEnabled ?? sharingEnabled; + const effectiveShareBaseUrl = request.shareBaseUrl ?? shareBaseUrl; + const effectivePasteApiUrl = request.pasteApiUrl ?? pasteApiUrl; + const planProject = (await detectProjectName()) ?? "_unknown"; + + const server = await startPlannotatorServer({ + plan: planContent, + origin, + permissionMode: request.permissionMode, + sharingEnabled: effectiveSharingEnabled, + shareBaseUrl: effectiveShareBaseUrl, + pasteApiUrl: effectivePasteApiUrl, + htmlContent: await getPlanHtmlContent(), + opencodeClient: request.availableAgents + ? { app: { agents: async () => ({ data: request.availableAgents }) } } + : undefined, + onReady: async (url, isRemote, port) => { + handleServerReady(url, isRemote, port); + + if (isRemote && effectiveSharingEnabled) { + await writeRemoteShareLink(planContent, effectiveShareBaseUrl, "review the plan", "plan only").catch(() => {}); + } + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "plan", + project: planProject, + startedAt: new Date().toISOString(), + label: `plugin-plan-${origin}-${planProject}`, + }); + + const session = pluginSessionInfo("plan", server); + emitPluginSessionReady(session); + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + console.log(JSON.stringify(createPluginSuccessResponse(result, session))); +} + +async function runPluginArchiveCommand(): Promise { + const request = await readPluginRequest(); + const origin = getPluginOrigin(request); + applyPluginCwd(request); + + const effectiveSharingEnabled = request.sharingEnabled ?? sharingEnabled; + const effectiveShareBaseUrl = request.shareBaseUrl ?? shareBaseUrl; + const effectivePasteApiUrl = request.pasteApiUrl ?? pasteApiUrl; + const archiveProject = (await detectProjectName()) ?? "_unknown"; + + const server = await startPlannotatorServer({ + plan: "", + origin, + mode: "archive", + customPlanPath: request.customPlanPath, + sharingEnabled: effectiveSharingEnabled, + shareBaseUrl: effectiveShareBaseUrl, + pasteApiUrl: effectivePasteApiUrl, + htmlContent: await getPlanHtmlContent(), + onReady: (url, isRemote, port) => { + handleServerReady(url, isRemote, port); + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "archive", + project: archiveProject, + startedAt: new Date().toISOString(), + label: `plugin-archive-${origin}-${archiveProject}`, + }); + + const session = pluginSessionInfo("archive", server); + emitPluginSessionReady(session); + if (server.waitForDone) await server.waitForDone(); + await Bun.sleep(500); + server.stop(); + + console.log(JSON.stringify(createPluginSuccessResponse({ opened: true }, session))); +} + +async function runPluginAnnotateCommand(defaultMode: "annotate" | "annotate-last" = "annotate"): Promise { + const request = await readPluginRequest(); + const origin = getPluginOrigin(request); + applyPluginCwd(request); + + const directMarkdown = typeof request.markdown === "string"; + const hasRawArgs = typeof request.args === "string"; + const parsedArgs = hasRawArgs ? parseAnnotateArgs(request.args ?? "") : undefined; + const structuredFilePath = typeof request.filePath === "string" ? request.filePath : ""; + const directFilePath = structuredFilePath.trim().length > 0; + const gate = request.gate ?? parsedArgs?.gate ?? false; + const renderHtml = request.renderHtml ?? (typeof request.rawHtml === "string" ? true : parsedArgs?.renderHtml ?? false); + + let markdown = directMarkdown ? request.markdown! : ""; + let rawHtml = request.rawHtml; + let absolutePath = directFilePath ? structuredFilePath : ""; + let folderPath = request.folderPath; + let annotateMode: "annotate" | "annotate-folder" | "annotate-last" = request.mode ?? defaultMode; + let sourceInfo = request.sourceInfo; + let sourceConverted = request.sourceConverted ?? false; + + if (folderPath) { + const resolvedFolder = path.isAbsolute(folderPath) ? folderPath : resolveUserPath(folderPath, process.cwd()); + folderPath = resolvedFolder; + absolutePath = resolvedFolder; + markdown = directMarkdown ? markdown : ""; + annotateMode = "annotate-folder"; + } else if (!directMarkdown && typeof rawHtml !== "string") { + const rawFilePath = parsedArgs?.rawFilePath || structuredFilePath; + if (!rawFilePath) { + emitPluginError( + "missing-annotate-target", + "Plugin annotate requests must include args, markdown, filePath, folderPath, or rawHtml.", + ); + } + + const filePath = parsedArgs?.filePath || structuredFilePath; + const projectRoot = process.cwd(); + const isUrl = /^https?:\/\//i.test(filePath); + + if (isUrl) { + const useJina = resolveUseJina(cliNoJina, loadConfig()); + console.error(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}`); + try { + const result = await urlToMarkdown(filePath, { useJina }); + markdown = result.markdown; + sourceConverted = isConvertedSource(result.source); + } catch (err) { + emitPluginError("url-fetch-failed", `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`); + } + absolutePath = filePath; + sourceInfo = filePath; + } else { + const folderCandidate = resolveAtReference(rawFilePath, (candidate) => { + try { return statSync(resolveUserPath(candidate, projectRoot)).isDirectory(); } + catch { return false; } + }); + + if (folderCandidate !== null) { + const resolvedArg = resolveUserPath(folderCandidate, projectRoot); + if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { + emitPluginError("empty-folder", `No markdown or HTML files found in ${resolvedArg}`); + } + folderPath = resolvedArg; + absolutePath = resolvedArg; + markdown = ""; + annotateMode = "annotate-folder"; + console.error(`Folder: ${resolvedArg}`); + } else { + const htmlCandidate = resolveAtReference(rawFilePath, (candidate) => { + const abs = resolveUserPath(candidate, projectRoot); + return /\.html?$/i.test(abs) && existsSync(abs); + }); + + if (htmlCandidate !== null) { + const resolvedArg = resolveUserPath(htmlCandidate, projectRoot); + const htmlFile = Bun.file(resolvedArg); + if (htmlFile.size > 10 * 1024 * 1024) { + emitPluginError("file-too-large", `File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); + } + const html = await htmlFile.text(); + if (renderHtml) { + rawHtml = html; + markdown = ""; + } else { + markdown = htmlToMarkdown(html); + sourceConverted = true; + } + absolutePath = resolvedArg; + sourceInfo = path.basename(resolvedArg); + console.error(`${renderHtml ? "Raw HTML" : "Converted"}: ${absolutePath}`); + } else { + let resolved = resolveMarkdownFile(filePath, projectRoot); + if (resolved.kind === "not_found" && rawFilePath !== filePath) { + resolved = resolveMarkdownFile(rawFilePath, projectRoot); + } + if (resolved.kind === "ambiguous") { + emitPluginError( + "ambiguous-file", + `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:\n${resolved.matches.map((match) => ` ${match}`).join("\n")}`, + ); + } + if (resolved.kind === "not_found") { + emitPluginError("file-not-found", `File not found: ${resolved.input}`); + } + absolutePath = resolved.path; + markdown = await Bun.file(absolutePath).text(); + console.error(`Resolved: ${absolutePath}`); + } + } + } + } + + if (!absolutePath) absolutePath = annotateMode === "annotate-last" ? "last-message" : "document"; + + const effectiveSharingEnabled = request.sharingEnabled ?? sharingEnabled; + const effectiveShareBaseUrl = request.shareBaseUrl ?? shareBaseUrl; + const effectivePasteApiUrl = request.pasteApiUrl ?? pasteApiUrl; + const annotateProject = (await detectProjectName()) ?? "_unknown"; + + const server = await startAnnotateServer({ + markdown, + filePath: absolutePath, + origin, + mode: annotateMode, + folderPath, + sourceInfo, + sourceConverted, + sharingEnabled: effectiveSharingEnabled, + shareBaseUrl: effectiveShareBaseUrl, + pasteApiUrl: effectivePasteApiUrl, + gate, + rawHtml, + renderHtml, + htmlContent: await getPlanHtmlContent(), + onReady: async (url, isRemote, port) => { + handleAnnotateServerReady(url, isRemote, port); + + if (isRemote && effectiveSharingEnabled && markdown) { + await writeRemoteShareLink(markdown, effectiveShareBaseUrl, "annotate", "document only").catch(() => {}); + } + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "annotate", + project: annotateProject, + startedAt: new Date().toISOString(), + label: folderPath + ? `plugin-annotate-${origin}-${path.basename(folderPath)}` + : `plugin-annotate-${origin}-${annotateMode === "annotate-last" ? "last" : path.basename(absolutePath)}`, + }); + + const session = pluginSessionInfo("annotate", server); + emitPluginSessionReady(session); + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + console.log(JSON.stringify(createPluginSuccessResponse({ + ...result, + filePath: absolutePath, + mode: annotateMode, + }, session))); +} + +async function runPluginReviewCommand(): Promise { + const request = await readPluginRequest(); + const origin = getPluginOrigin(request); + applyPluginCwd(request); + + const reviewArgs = parseReviewArgs(request.args ?? ""); + const urlArg = request.prUrl ?? reviewArgs.prUrl; + const isPRMode = urlArg !== undefined; + const useLocal = isPRMode && (request.useLocal ?? reviewArgs.useLocal); + + let rawPatch: string; + let gitRef: string; + let diffError: string | undefined; + let gitContext: Awaited>["gitContext"] | undefined; + let prMetadata: Awaited>["metadata"] | undefined; + let initialDiffType: DiffType | undefined; + let initialBase: string | undefined; + let agentCwd: string | undefined; + let worktreePool: WorktreePool | undefined; + let worktreeCleanup: (() => void | Promise) | undefined; + + if (isPRMode) { + const prRef = parsePRUrl(urlArg); + if (!prRef) { + emitPluginError( + "invalid-pr-url", + `Invalid PR/MR URL: ${urlArg}\nSupported formats:\n GitHub: https://github.com/owner/repo/pull/123\n GitLab: https://gitlab.com/group/project/-/merge_requests/42`, + ); + } + + const cliName = getCliName(prRef); + const cliUrl = getCliInstallUrl(prRef); + + try { + await checkPRAuth(prRef); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("not found") || msg.includes("ENOENT")) { + emitPluginError( + "pr-auth-failed", + `${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed. Install it from ${cliUrl}`, + ); + } + emitPluginError("pr-auth-failed", msg); + } + + console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); + try { + const pr = await fetchPR(prRef); + rawPatch = pr.rawPatch; + gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; + prMetadata = pr.metadata; + } catch (err) { + emitPluginError("pr-fetch-failed", err instanceof Error ? err.message : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`); + } + + if (useLocal && prMetadata) { + let localPath: string | undefined; + let sessionDir: string | undefined; + try { + const repoDir = process.cwd(); + const identifier = prMetadata.platform === "github" + ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` + : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; + const suffix = Math.random().toString(36).slice(2, 8); + sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); + const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; + localPath = path.join(sessionDir, "pool", `pr-${prNumber}`); + const fetchRefStr = prMetadata.platform === "github" + ? `refs/pull/${prMetadata.number}/head` + : `refs/merge-requests/${prMetadata.iid}/head`; + + if (prMetadata.baseBranch.includes("..") || prMetadata.baseBranch.startsWith("-")) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); + if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); + + let isSameRepo = false; + try { + const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]); + if (remoteResult.exitCode === 0) { + const remoteUrl = remoteResult.stdout.trim(); + const currentRepo = parseRemoteUrl(remoteUrl); + const prRepo = prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; + const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); + const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; + const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); + const remoteHost = (sshHost || httpsHost || "").toLowerCase(); + const prHost = prMetadata.host.toLowerCase(); + isSameRepo = repoMatches && remoteHost === prHost; + } + } catch {} + + if (isSameRepo) { + console.error("Fetching PR branch and creating local worktree..."); + await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir }); + await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir }); + await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir }); + + await createWorktree(gitRuntime, { + ref: "FETCH_HEAD", + path: localPath, + detach: true, + cwd: repoDir, + }); + + // worktreePool is assigned after registration; read it at cleanup + // time so early exits still fall back to removing localPath. + worktreeCleanup = registerProcessCleanup(() => cleanupWorktreeSession( + repoDir, + sessionDir, + worktreePool, + localPath, + )); + } else { + const prRepo = prMetadata.platform === "github" + ? `${prMetadata.owner}/${prMetadata.repo}` + : prMetadata.projectPath; + if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); + const cli = prMetadata.platform === "github" ? "gh" : "glab"; + const host = prMetadata.host; + const isDefaultHost = host === "github.com" || host === "gitlab.com"; + const cloneEnv = isDefaultHost ? undefined : { + ...process.env, + ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), + }; + + console.error(`Cloning ${prRepo} (shallow)...`); + const cloneResult = Bun.spawnSync( + [cli, "repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], + { stderr: "pipe", env: cloneEnv }, + ); + if (cloneResult.exitCode !== 0) { + throw new Error(`${cli} repo clone failed: ${new TextDecoder().decode(cloneResult.stderr).trim()}`); + } + + console.error("Fetching PR branch..."); + const fetchResult = Bun.spawnSync( + ["git", "fetch", "--depth=200", "origin", fetchRefStr], + { cwd: localPath, stderr: "pipe" }, + ); + if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${new TextDecoder().decode(fetchResult.stderr).trim()}`); + + const checkoutResult = Bun.spawnSync(["git", "checkout", "FETCH_HEAD"], { cwd: localPath, stderr: "pipe" }); + if (checkoutResult.exitCode !== 0) { + throw new Error(`git checkout FETCH_HEAD failed: ${new TextDecoder().decode(checkoutResult.stderr).trim()}`); + } + + const baseFetch = Bun.spawnSync(["git", "fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); + Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); + + worktreeCleanup = registerProcessCleanup(() => { + try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + }); + } + + agentCwd = localPath; + worktreePool = createWorktreePool( + { sessionDir, repoDir, isSameRepo }, + { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, + ); + + console.error(`Local checkout ready at ${localPath}`); + } catch (err) { + console.error("Warning: --local failed, falling back to remote diff"); + console.error(err instanceof Error ? err.message : String(err)); + if (worktreeCleanup) { + worktreeCleanup(); + } else if (sessionDir) { + try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + } + agentCwd = undefined; + worktreePool = undefined; + worktreeCleanup = undefined; + } + } + } else { + const config = loadConfig(); + const diffResult = await prepareLocalReviewDiff({ + vcsType: request.vcsType ?? reviewArgs.vcsType, + requestedDiffType: request.diffType as DiffType | undefined, + requestedBase: request.defaultBranch, + configuredDiffType: resolveDefaultDiffType(config), + hideWhitespace: config.diffOptions?.hideWhitespace ?? false, + }); + gitContext = diffResult.gitContext; + initialDiffType = diffResult.diffType; + initialBase = diffResult.base; + rawPatch = diffResult.rawPatch; + gitRef = diffResult.gitRef; + diffError = diffResult.error; } - if (result.feedback) console.log(result.feedback); -} -async function loadGoalSetupBundle( - stage: GoalSetupStage, - bundlePath: string -) { - const raw = - bundlePath === "-" - ? await Bun.stdin.text() - : await Bun.file(path.resolve(bundlePath)).text(); - return normalizeGoalSetupBundle(JSON.parse(raw), stage); -} + const effectiveSharingEnabled = request.sharingEnabled ?? sharingEnabled; + const effectiveShareBaseUrl = request.shareBaseUrl ?? shareBaseUrl; + const reviewProject = (await detectProjectName()) ?? "_unknown"; -if (isVersionInvocation(args)) { - console.log(formatVersion()); - process.exit(0); -} + const server = await startReviewServer({ + rawPatch, + gitRef, + error: diffError, + origin, + diffType: gitContext ? (initialDiffType ?? "unstaged") : undefined, + gitContext, + initialBase, + prMetadata, + agentCwd, + worktreePool, + sharingEnabled: effectiveSharingEnabled, + shareBaseUrl: effectiveShareBaseUrl, + htmlContent: await getReviewHtmlContent(), + opencodeClient: request.availableAgents + ? { app: { agents: async () => ({ data: request.availableAgents }) } } + : undefined, + onCleanup: worktreeCleanup, + onReady: async (url, isRemote, port) => { + handleReviewServerReady(url, isRemote, port); -if (isTopLevelHelpInvocation(args)) { - console.log(formatTopLevelHelp()); - process.exit(0); -} + if (isRemote && effectiveSharingEnabled && rawPatch) { + await writeRemoteShareLink(rawPatch, effectiveShareBaseUrl, "review changes", "diff only").catch(() => {}); + } + }, + }); -if (isInteractiveNoArgInvocation(args, process.stdin.isTTY)) { - console.log(formatInteractiveNoArgClarification()); - process.exit(0); + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "review", + project: reviewProject, + startedAt: new Date().toISOString(), + label: isPRMode && prMetadata + ? `plugin-${getMRLabel(prMetadata).toLowerCase()}-review-${getDisplayRepo(prMetadata)}${getMRNumberLabel(prMetadata)}` + : `plugin-review-${origin}-${reviewProject}`, + }); + + const session = pluginSessionInfo("review", server); + emitPluginSessionReady(session); + const result = await server.waitForDecision(); + await Bun.sleep(1500); + server.stop(); + + console.log(JSON.stringify(createPluginSuccessResponse(result, session))); } -// Ensure session cleanup on exit -process.on("exit", () => unregisterSession()); +if (args[0] === "plugin") { + const command = args[1]; + if (command === "capabilities") { + console.log(JSON.stringify(getPluginCapabilities())); + process.exit(0); + } -// Check if URL sharing is enabled (default: true) -const sharingEnabled = process.env.PLANNOTATOR_SHARE !== "disabled"; + if (command === "plan") { + await runPluginPlanCommand(); + process.exit(0); + } -// Custom share portal URL for self-hosting -const shareBaseUrl = process.env.PLANNOTATOR_SHARE_URL || undefined; + if (command === "review") { + await runPluginReviewCommand(); + process.exit(0); + } -// Paste service URL for short URL sharing -const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined; + if (command === "annotate" || command === "annotate-last") { + await runPluginAnnotateCommand(command === "annotate-last" ? "annotate-last" : "annotate"); + process.exit(0); + } -// Detect calling agent from environment variables set by agent runtimes. -// Priority: -// PLANNOTATOR_ORIGIN (explicit override, validated against AGENT_CONFIG) -// > Codex (CODEX_THREAD_ID) -// > Copilot CLI (COPILOT_CLI) -// > OpenCode (OPENCODE) -// > Gemini CLI (GEMINI_CLI) -// > Claude Code (default fallback) -// -// To add a new agent, also add an entry to AGENT_CONFIG in -// packages/shared/agents.ts (see header comment there). -const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined; -const detectedOrigin: Origin = - (originOverride && originOverride in AGENT_CONFIG) ? originOverride : - process.env.CODEX_THREAD_ID ? "codex" : - process.env.COPILOT_CLI ? "copilot-cli" : - process.env.OPENCODE ? "opencode" : - process.env.GEMINI_CLI ? "gemini-cli" : - "claude-code"; + if (command === "archive") { + await runPluginArchiveCommand(); + process.exit(0); + } + + console.log( + JSON.stringify( + createPluginErrorResponse( + "unknown-plugin-command", + command ? `Unknown plugin command: ${command}` : "Missing plugin command", + ), + ), + ); + process.exit(1); +} if (args[0] === "sessions") { // ============================================ @@ -320,68 +1012,6 @@ if (args[0] === "sessions") { console.error(`\nReopen with: plannotator sessions --open [N]`); process.exit(0); -} else if (args[0] === "setup-goal") { - // ============================================ - // GOAL SETUP MODE - // ============================================ - - const stage = args[1] as GoalSetupStage | undefined; - const bundlePath = args[2]; - - if ((stage !== "interview" && stage !== "facts") || !bundlePath) { - console.error( - "Usage: plannotator setup-goal [--json]" - ); - process.exit(1); - } - - let bundle: Awaited>; - try { - bundle = await loadGoalSetupBundle(stage, bundlePath); - } catch (err) { - console.error( - `Failed to load goal setup bundle: ${err instanceof Error ? err.message : String(err)}` - ); - process.exit(1); - } - - const goalProject = (await detectProjectName()) ?? "_unknown"; - - const server = await startGoalSetupServer({ - bundle, - origin: detectedOrigin, - htmlContent: planHtmlContent, - onReady: (url, isRemote, port) => { - handleGoalSetupServerReady(url, isRemote, port); - }, - }); - - registerSession({ - pid: process.pid, - port: server.port, - url: server.url, - mode: "goal-setup", - project: goalProject, - startedAt: new Date().toISOString(), - label: `goal-setup-${bundle.stage}-${bundle.goalSlug || goalProject}`, - }); - - const result = await server.waitForDecision(); - await Bun.sleep(800); - server.stop(); - - if (result.exit) { - console.log(JSON.stringify({ decision: "dismissed", stage: bundle.stage })); - } else if (result.result) { - const output = { - decision: "submitted", - stage: result.result.stage, - result: result.result, - }; - console.log(jsonFlag ? JSON.stringify(output) : JSON.stringify(output, null, 2)); - } - process.exit(0); - } else if (args[0] === "review") { // ============================================ // CODE REVIEW MODE @@ -503,19 +1133,12 @@ if (args[0] === "sessions") { cwd: repoDir, }); - worktreeCleanup = async () => { - if (worktreePool) await worktreePool.cleanup(gitRuntime); - try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} - }; - process.once("exit", () => { - // Best-effort sync cleanup: remove each pool worktree from git, then rm session dir - try { - for (const entry of worktreePool?.entries() ?? []) { - Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir }); - } - } catch {} - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} - }); + worktreeCleanup = registerProcessCleanup(() => cleanupWorktreeSession( + repoDir, + sessionDir, + worktreePool, + localPath, + )); } else { // ── Cross-repo: shallow clone + fetch PR head ── const prRepo = prMetadata.platform === "github" @@ -562,9 +1185,8 @@ if (args[0] === "sessions") { Bun.spawnSync(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); Bun.spawnSync(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath, stderr: "pipe" }); - worktreeCleanup = () => { try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }; - process.once("exit", () => { - try { Bun.spawnSync(["rm", "-rf", sessionDir]); } catch {} + worktreeCleanup = registerProcessCleanup(() => { + try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} }); } @@ -582,7 +1204,11 @@ if (args[0] === "sessions") { } catch (err) { console.error(`Warning: --local failed, falling back to remote diff`); console.error(err instanceof Error ? err.message : String(err)); - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + if (worktreeCleanup) { + worktreeCleanup(); + } else if (sessionDir) { + try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + } agentCwd = undefined; worktreePool = undefined; worktreeCleanup = undefined; @@ -618,7 +1244,7 @@ if (args[0] === "sessions") { worktreePool, sharingEnabled, shareBaseUrl, - htmlContent: reviewHtmlContent, + htmlContent: await getReviewHtmlContent(), onCleanup: worktreeCleanup, onReady: async (url, isRemote, port) => { handleReviewServerReady(url, isRemote, port); @@ -800,7 +1426,7 @@ if (args[0] === "sessions") { gate: gateFlag, rawHtml, renderHtml: renderHtmlFlag, - htmlContent: planHtmlContent, + htmlContent: await getPlanHtmlContent(), onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -856,7 +1482,7 @@ if (args[0] === "sessions") { if (process.env.PLANNOTATOR_DEBUG) { console.error(`[DEBUG] Rollout: ${rolloutPath}`); } - const msg = getLastCodexMessage(rolloutPath, { beforeActiveTurn: true }); + const msg = getLastCodexMessage(rolloutPath); if (msg) { lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] }; } @@ -931,7 +1557,7 @@ if (args[0] === "sessions") { shareBaseUrl, pasteApiUrl, gate: gateFlag, - htmlContent: planHtmlContent, + htmlContent: await getPlanHtmlContent(), onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -973,7 +1599,7 @@ if (args[0] === "sessions") { mode: "archive", sharingEnabled, shareBaseUrl, - htmlContent: planHtmlContent, + htmlContent: await getPlanHtmlContent(), onReady: (url, isRemote, port) => { handleServerReady(url, isRemote, port); }, @@ -995,6 +1621,68 @@ if (args[0] === "sessions") { server.stop(); process.exit(0); +} else if (args[0] === "setup-goal") { + // ============================================ + // GOAL SETUP MODE + // ============================================ + + const stage = args[1] as GoalSetupStage | undefined; + const bundlePath = args[2]; + + if ((stage !== "interview" && stage !== "facts") || !bundlePath) { + console.error( + "Usage: plannotator setup-goal [--json]" + ); + process.exit(1); + } + + let bundle: Awaited>; + try { + bundle = await loadGoalSetupBundle(stage, bundlePath); + } catch (err) { + console.error( + `Failed to load goal setup bundle: ${err instanceof Error ? err.message : String(err)}` + ); + process.exit(1); + } + + const goalProject = (await detectProjectName()) ?? "_unknown"; + + const server = await startGoalSetupServer({ + bundle, + origin: detectedOrigin, + htmlContent: await getPlanHtmlContent(), + onReady: (url, isRemote, port) => { + handleGoalSetupServerReady(url, isRemote, port); + }, + }); + + registerSession({ + pid: process.pid, + port: server.port, + url: server.url, + mode: "goal-setup", + project: goalProject, + startedAt: new Date().toISOString(), + label: `goal-setup-${bundle.stage}-${bundle.goalSlug || goalProject}`, + }); + + const result = await server.waitForDecision(); + await Bun.sleep(800); + server.stop(); + + if (result.exit) { + console.log(JSON.stringify({ decision: "dismissed", stage: bundle.stage })); + } else if (result.result) { + const output = { + decision: "submitted", + stage: result.result.stage, + result: result.result, + }; + console.log(jsonFlag ? JSON.stringify(output) : JSON.stringify(output, null, 2)); + } + process.exit(0); + } else if (args[0] === "copilot-plan") { // ============================================ // COPILOT CLI PLAN INTERCEPTION MODE @@ -1035,7 +1723,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: await getPlanHtmlContent(), onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); @@ -1120,7 +1808,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, gate: gateFlag, - htmlContent: planHtmlContent, + htmlContent: await getPlanHtmlContent(), onReady: async (url, isRemote, port) => { handleAnnotateServerReady(url, isRemote, port); @@ -1224,7 +1912,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: await getPlanHtmlContent(), onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); @@ -1303,7 +1991,7 @@ if (args[0] === "sessions") { sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: planHtmlContent, + htmlContent: await getPlanHtmlContent(), onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); diff --git a/apps/marketing/src/content/blog/plannotator-meets-pi.md b/apps/marketing/src/content/blog/plannotator-meets-pi.md index 732f092ff..faeb271f8 100644 --- a/apps/marketing/src/content/blog/plannotator-meets-pi.md +++ b/apps/marketing/src/content/blog/plannotator-meets-pi.md @@ -6,7 +6,7 @@ author: "backnotprop" tags: ["pi", "integration", "plan-mode"] --- -**Plannotator now supports [Pi](https://github.com/earendil-works/pi), the minimal terminal coding agent originally created by Mario Zechner (now part of Earendil Works).** Install it as a Pi extension and you get file-based plan mode, visual plan review, code review, and markdown annotation — all through the same browser UI that Claude Code and OpenCode users already know. +**Plannotator now supports [Pi](https://github.com/badlogic/pi-mono), the minimal terminal coding agent from Mario Zechner.** Install it as a Pi extension and you get file-based plan mode, visual plan review, code review, and markdown annotation — all through the same browser UI that Claude Code and OpenCode users already know. ## Watch the Demo diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index 23dd048af..38748e258 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -34,7 +34,19 @@ Restart OpenCode. By default, the `submit_plan` tool is available to OpenCode's > ```bash > curl -fsSL https://plannotator.ai/install.sh | bash > ``` -> This also clears any cached plugin versions. +> This also installs or updates the `plannotator` binary and clears any cached plugin versions. + +## Runtime Model + +The OpenCode plugin is a client of the installed `plannotator` binary. It keeps OpenCode-specific behavior such as `submit_plan`, prompt transforms, slash-command interception, feedback injection, and agent switching, but the browser UI and HTTP server are owned by the Bun binary. + +Binary discovery order: + +1. `PLANNOTATOR_BIN` +2. `plannotator` on `PATH` +3. Standard install locations such as `~/.local/bin/plannotator` + +If the binary is missing or too old for the plugin protocol, the plugin runs the official installer. Set `PLANNOTATOR_DISABLE_AUTO_INSTALL=1` to turn that off in controlled environments. ## Workflow Modes @@ -144,6 +156,8 @@ Register the tool but manage prompts and permissions yourself: | `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Custom paste service URL for self-hosting. Default: `https://plannotator-paste.plannotator.workers.dev`. | | `PLANNOTATOR_PLAN_TIMEOUT_SECONDS` | Timeout for `submit_plan` review wait. Default: `345600` (96h). Set `0` to disable timeout. | +| `PLANNOTATOR_BIN` | Explicit path to the installed `plannotator` binary used by the plugin client. | +| `PLANNOTATOR_DISABLE_AUTO_INSTALL` | Set to `1`, `true`, or `yes` to prevent the plugin from running the official installer when the binary is missing or incompatible. | ## Devcontainer / Docker diff --git a/apps/opencode-plugin/binary-client.test.ts b/apps/opencode-plugin/binary-client.test.ts new file mode 100644 index 000000000..5f3b710dd --- /dev/null +++ b/apps/opencode-plugin/binary-client.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, test } from "bun:test"; +import { ensurePlannotatorBinary, runPluginPlan, type CommandRunner } from "./binary-client"; +import { + createPluginSuccessResponse, + getPluginCapabilities, +} from "../../packages/shared/plugin-protocol"; + +function existsFrom(set: Set) { + return (candidate: string) => set.has(candidate); +} + +describe("OpenCode binary client", () => { + test("returns a compatible discovered binary", () => { + const existing = new Set(["/bin/plannotator"]); + const commands: Array<[string, string[]]> = []; + let timeoutMs: number | null | undefined; + const run: CommandRunner = (command, args, _input, options) => { + commands.push([command, args]); + timeoutMs = options?.timeoutMs; + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/bin/plannotator", + source: "path", + installed: false, + }); + expect(commands).toEqual([["/bin/plannotator", ["plugin", "capabilities"]]]); + expect(timeoutMs).toBe(5000); + }); + + test("skips candidates missing required plugin features", () => { + const existing = new Set(["/old/plannotator", "/current/plannotator"]); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + return { + exitCode: 0, + stdout: JSON.stringify({ + ...getPluginCapabilities(), + features: command === "/old/plannotator" ? ["capabilities", "plan-review"] : getPluginCapabilities().features, + }), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/old:/current" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + requiredFeatures: ["archive"], + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/current/plannotator", + source: "path", + installed: false, + }); + expect(commands).toEqual([ + ["/old/plannotator", ["plugin", "capabilities"]], + ["/current/plannotator", ["plugin", "capabilities"]], + ]); + }); + + test("reports a missing binary when auto-install is disabled", () => { + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin", PLANNOTATOR_DISABLE_AUTO_INSTALL: "1" }, + homeDir: "/home/test", + exists: existsFrom(new Set()), + platform: "linux", + pathDelimiter: ":", + run: () => { + throw new Error("runner should not be called"); + }, + }); + + expect(result).toMatchObject({ + ok: false, + code: "missing-binary", + }); + }); + + test("runs the official installer and rediscovers the binary", () => { + const existing = new Set(); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + if (command === "bash") { + existing.add("/home/test/.local/bin/plannotator"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/home/test/.local/bin/plannotator", + source: "standard", + installed: true, + }); + expect(commands[0][0]).toBe("bash"); + expect(commands[0][1][1]).toBe("curl -fsSL https://plannotator.ai/install.sh | bash"); + expect(commands[1]).toEqual(["/home/test/.local/bin/plannotator", ["plugin", "capabilities"]]); + }); + + test("uses the local source shim before auto-installing", () => { + const existing = new Set(["/repo/plannotator/bin/plannotator.js"]); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + if (command === "bash") { + throw new Error("installer should not run"); + } + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin" }, + homeDir: "/home/test", + sourceRoot: "/repo/plannotator", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/repo/plannotator/bin/plannotator.js", + source: "source", + installed: false, + }); + expect(commands).toEqual([["/repo/plannotator/bin/plannotator.js", ["plugin", "capabilities"]]]); + }); + + + test("only pins the installer when an explicit install version is provided", () => { + const existing = new Set(); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + if (command === "bash") { + existing.add("/home/test/.local/bin/plannotator"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + installVersion: "1.2.3", + run, + }); + + expect(result).toMatchObject({ ok: true, installed: true }); + expect(commands[0]).toEqual([ + "bash", + ["-c", "curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version 'v1.2.3'"], + ]); + }); + + test("rediscovers the standard install after an incompatible PATH binary", () => { + const existing = new Set(["/old/plannotator"]); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + if (command === "/old/plannotator") { + return { exitCode: 0, stdout: JSON.stringify({ protocol: "old" }), stderr: "" }; + } + if (command === "bash") { + existing.add("/home/test/.local/bin/plannotator"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/old" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/home/test/.local/bin/plannotator", + source: "standard", + installed: true, + }); + expect(commands[0]).toEqual(["/old/plannotator", ["plugin", "capabilities"]]); + expect(commands[1][0]).toBe("bash"); + expect(commands[1][1][1]).toBe("curl -fsSL https://plannotator.ai/install.sh | bash"); + expect(commands[2]).toEqual(["/home/test/.local/bin/plannotator", ["plugin", "capabilities"]]); + }); + + test("uses an existing standard install after an incompatible PATH binary", () => { + const existing = new Set([ + "/old/plannotator", + "/home/test/.local/bin/plannotator", + ]); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + if (command === "/old/plannotator") { + return { exitCode: 0, stdout: JSON.stringify({ protocol: "old" }), stderr: "" }; + } + if (command === "bash") { + throw new Error("installer should not run"); + } + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/old" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/home/test/.local/bin/plannotator", + source: "standard", + installed: false, + }); + expect(commands).toEqual([ + ["/old/plannotator", ["plugin", "capabilities"]], + ["/home/test/.local/bin/plannotator", ["plugin", "capabilities"]], + ]); + }); + + test("reports incompatible binaries when capabilities are missing", () => { + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin", PLANNOTATOR_DISABLE_AUTO_INSTALL: "1" }, + homeDir: "/home/test", + exists: existsFrom(new Set(["/bin/plannotator"])), + platform: "linux", + pathDelimiter: ":", + run: () => ({ exitCode: 1, stdout: "", stderr: "unknown command" }), + }); + + expect(result).toMatchObject({ + ok: false, + code: "incompatible-binary", + }); + }); + + test("runs plugin plan with JSON stdin and parses the response", async () => { + const response = createPluginSuccessResponse({ approved: true }); + const calls: Array<{ command: string; args: string[]; input?: string }> = []; + const run: CommandRunner = (command, args, input) => { + calls.push({ command, args, input }); + return { exitCode: 0, stdout: JSON.stringify(response), stderr: "" }; + }; + + expect( + await runPluginPlan( + "/bin/plannotator", + { + origin: "opencode", + plan: "# Plan", + cwd: "/repo", + }, + run, + ), + ).toEqual(response); + expect(calls).toEqual([ + { + command: "/bin/plannotator", + args: ["plugin", "plan", "--origin", "opencode"], + input: JSON.stringify({ origin: "opencode", plan: "# Plan", cwd: "/repo" }), + }, + ]); + }); + + test("turns malformed plugin plan output into a protocol error", async () => { + const result = await runPluginPlan( + "/bin/plannotator", + { origin: "opencode", plan: "# Plan" }, + () => ({ exitCode: 0, stdout: "not-json", stderr: "" }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "invalid-plugin-response" }, + }); + }); + + test("preserves plugin runner errors when stderr only contains progress", async () => { + const result = await runPluginPlan( + "/bin/plannotator", + { origin: "opencode", plan: "# Plan" }, + () => ({ + exitCode: 1, + stdout: "", + stderr: "Open this forwarded Plannotator session URL: http://localhost:19432/s/s1\n", + error: "Command timed out after 1000ms.", + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "plugin-command-failed", message: "Command timed out after 1000ms." }, + }); + }); +}); diff --git a/apps/opencode-plugin/binary-client.ts b/apps/opencode-plugin/binary-client.ts new file mode 100644 index 000000000..a9621e074 --- /dev/null +++ b/apps/opencode-plugin/binary-client.ts @@ -0,0 +1 @@ +export * from "../../packages/shared/plugin-client"; diff --git a/apps/opencode-plugin/commands.test.ts b/apps/opencode-plugin/commands.test.ts index 678e0fbe6..5334f1c8e 100644 --- a/apps/opencode-plugin/commands.test.ts +++ b/apps/opencode-plugin/commands.test.ts @@ -1,106 +1,169 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import path from "path"; - -const startAnnotateServerMock = mock(async (_options: any) => ({ - waitForDecision: async () => ({ feedback: "", annotations: [] }), - stop: () => {}, -})); - -mock.module("@plannotator/server/annotate", () => ({ - startAnnotateServer: startAnnotateServerMock, - handleAnnotateServerReady: () => {}, +import { + createPluginSuccessResponse, + getPluginCapabilities, +} from "../../packages/shared/plugin-protocol"; + +const ensurePlannotatorBinaryMock = mock(() => ({ + ok: true, + path: "/bin/plannotator", + source: "path", + installed: false, + capabilities: getPluginCapabilities(), })); -const { handleAnnotateCommand, handleAnnotateLastCommand } = await import("./commands"); +const runPluginAnnotateMock = mock(async (_binaryPath: string, _request: unknown) => + createPluginSuccessResponse({ feedback: "", filePath: "/repo/docs/Design Spec.html", mode: "annotate" }), +); +const runPluginReviewMock = mock(async (_binaryPath: string, _request: unknown) => + createPluginSuccessResponse({ approved: false, feedback: "", annotations: [] }), +); +const runPluginArchiveMock = mock(async (_binaryPath: string, _request: unknown) => + createPluginSuccessResponse({ opened: true }), +); -const tempDirs: string[] = []; - -function makeTempDir(): string { - const dir = mkdtempSync(path.join(tmpdir(), "plannotator-opencode-commands-")); - tempDirs.push(dir); - return dir; -} +const { handleAnnotateCommand, handleAnnotateLastCommand, handleArchiveCommand, handleReviewCommand } = await import("./commands"); function makeDeps() { return { client: { app: { log: mock((_entry: unknown) => {}), + agents: mock(async (_input: unknown) => ({ + data: [{ name: "build", mode: "primary" }], + })), }, session: { prompt: mock(async (_input: unknown) => {}), messages: mock(async (_input: unknown) => ({ data: [] })), }, }, - htmlContent: "", - reviewHtmlContent: "", getSharingEnabled: async () => true, getShareBaseUrl: () => "https://share.example.test", getPasteApiUrl: () => "https://paste.example.test", - directory: undefined as string | undefined, + directory: "/repo" as string | undefined, + binaryClient: { + ensurePlannotatorBinary: ensurePlannotatorBinaryMock, + runPluginAnnotate: runPluginAnnotateMock, + runPluginReview: runPluginReviewMock, + runPluginArchive: runPluginArchiveMock, + }, }; } afterEach(() => { - startAnnotateServerMock.mockClear(); - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } + ensurePlannotatorBinaryMock.mockClear(); + runPluginAnnotateMock.mockClear(); + runPluginReviewMock.mockClear(); + runPluginArchiveMock.mockClear(); }); describe("handleAnnotateCommand", () => { - test("strips wrapping quotes from HTML paths and forwards pasteApiUrl", async () => { - const projectRoot = makeTempDir(); - const docsDir = path.join(projectRoot, "docs"); - mkdirSync(docsDir, { recursive: true }); - const htmlPath = path.join(docsDir, "Design Spec.html"); - writeFileSync(htmlPath, "

Design Spec

Body

"); - + test("forwards raw annotate arguments and sharing settings to the binary", async () => { const deps = makeDeps(); - deps.directory = projectRoot; await handleAnnotateCommand( { properties: { arguments: "\"docs/Design Spec.html\"" } }, deps, ); - expect(startAnnotateServerMock).toHaveBeenCalledTimes(1); - const options = startAnnotateServerMock.mock.calls[0]?.[0]; - expect(options.filePath).toBe(htmlPath); - expect(options.mode).toBe("annotate"); - expect(options.pasteApiUrl).toBe("https://paste.example.test"); - expect(options.shareBaseUrl).toBe("https://share.example.test"); - expect(options.markdown).toContain("Design Spec"); + expect(runPluginAnnotateMock).toHaveBeenCalledTimes(1); + expect(runPluginAnnotateMock.mock.calls[0]?.[0]).toBe("/bin/plannotator"); + expect(runPluginAnnotateMock.mock.calls[0]?.[1]).toEqual({ + origin: "opencode", + cwd: "/repo", + args: "\"docs/Design Spec.html\"", + sharingEnabled: true, + shareBaseUrl: "https://share.example.test", + pasteApiUrl: "https://paste.example.test", + }); + const options = runPluginAnnotateMock.mock.calls[0]?.[3] as any; + options.onSession({ url: "http://127.0.0.1:1234/s/s1" }); + expect(deps.client.app.log).toHaveBeenCalledWith({ + level: "info", + message: "[Plannotator] Open in browser: http://127.0.0.1:1234/s/s1", + }); }); - test("supports quoted folder paths and opens annotate-folder mode", async () => { - const projectRoot = makeTempDir(); - const folderPath = path.join(projectRoot, "docs", "Specs Folder"); - mkdirSync(folderPath, { recursive: true }); - writeFileSync(path.join(folderPath, "plan.md"), "# Plan\n"); - + test("injects folder feedback using file metadata returned by the binary", async () => { + runPluginAnnotateMock.mockImplementationOnce(async (_binaryPath: string, _request: unknown) => + createPluginSuccessResponse({ + feedback: "Please revise this section.", + filePath: "/repo/docs/Specs Folder", + mode: "annotate-folder", + }), + ); const deps = makeDeps(); - deps.directory = projectRoot; await handleAnnotateCommand( - { properties: { arguments: "\"docs/Specs Folder\"" } }, + { properties: { arguments: "\"docs/Specs Folder\"", sessionID: "session-123" } }, deps, ); - expect(startAnnotateServerMock).toHaveBeenCalledTimes(1); - const options = startAnnotateServerMock.mock.calls[0]?.[0]; - expect(options.filePath).toBe(folderPath); - expect(options.folderPath).toBe(folderPath); - expect(options.mode).toBe("annotate-folder"); - expect(options.pasteApiUrl).toBe("https://paste.example.test"); - expect(options.markdown).toBe(""); + expect(deps.client.session.prompt).toHaveBeenCalledTimes(1); + const prompt = deps.client.session.prompt.mock.calls[0]?.[0] as any; + expect(prompt.body.parts[0].text).toContain("Folder: /repo/docs/Specs Folder"); + expect(prompt.body.parts[0].text).toContain("Please revise this section."); + }); +}); + +describe("handleReviewCommand", () => { + test("forwards available OpenCode agents to the binary", async () => { + const deps = makeDeps(); + + await handleReviewCommand( + { properties: { arguments: "--base main" } }, + deps, + ); + + expect(deps.client.app.agents).toHaveBeenCalledWith({ + query: { directory: "/repo" }, + }); + expect(runPluginReviewMock).toHaveBeenCalledTimes(1); + expect(runPluginReviewMock.mock.calls[0]?.[1]).toEqual({ + origin: "opencode", + cwd: "/repo", + args: "--base main", + sharingEnabled: true, + shareBaseUrl: "https://share.example.test", + pasteApiUrl: "https://paste.example.test", + availableAgents: [{ name: "build", mode: "primary" }], + }); + const options = runPluginReviewMock.mock.calls[0]?.[3] as any; + options.onSession({ url: "http://127.0.0.1:1234/s/review" }); + expect(deps.client.app.log).toHaveBeenCalledWith({ + level: "info", + message: "[Plannotator] Open in browser: http://127.0.0.1:1234/s/review", + }); + }); + + test("logs when OpenCode agents cannot be loaded", async () => { + const deps = makeDeps(); + deps.client.app.agents = mock(async () => { + throw new Error("agent API unavailable"); + }); + + await handleReviewCommand( + { properties: { arguments: "--base main" } }, + deps, + ); + + expect(deps.client.app.agents).toHaveBeenCalledTimes(3); + expect(deps.client.app.log).toHaveBeenCalledWith({ + level: "info", + message: "[Plannotator] OpenCode agent list unavailable; agent switching is disabled for this session. agent API unavailable", + }); + expect(runPluginReviewMock.mock.calls[0]?.[1]).toMatchObject({ + availableAgents: undefined, + }); }); }); describe("handleAnnotateLastCommand", () => { - test("forwards pasteApiUrl for annotate-last sessions", async () => { + test("passes the last assistant message through annotate-last binary mode", async () => { + runPluginAnnotateMock.mockImplementationOnce(async (_binaryPath: string, _request: unknown) => + createPluginSuccessResponse({ feedback: "Tighten the conclusion.", mode: "annotate-last" }), + ); const deps = makeDeps(); deps.client.session.messages = mock(async (_input: unknown) => ({ data: [ @@ -111,16 +174,64 @@ describe("handleAnnotateLastCommand", () => { ], })); - await handleAnnotateLastCommand( + const feedback = await handleAnnotateLastCommand( { properties: { sessionID: "session-123" } }, deps, ); - expect(startAnnotateServerMock).toHaveBeenCalledTimes(1); - const options = startAnnotateServerMock.mock.calls[0]?.[0]; - expect(options.mode).toBe("annotate-last"); - expect(options.filePath).toBe("last-message"); - expect(options.pasteApiUrl).toBe("https://paste.example.test"); - expect(options.markdown).toBe("Latest assistant message"); + expect(feedback).toBe("Tighten the conclusion."); + expect(runPluginAnnotateMock).toHaveBeenCalledTimes(1); + expect(runPluginAnnotateMock.mock.calls[0]?.[1]).toEqual({ + origin: "opencode", + cwd: "/repo", + markdown: "Latest assistant message", + filePath: "last-message", + mode: "annotate-last", + sharingEnabled: true, + shareBaseUrl: "https://share.example.test", + pasteApiUrl: "https://paste.example.test", + gate: false, + }); + const options = runPluginAnnotateMock.mock.calls[0]?.[3] as any; + options.onSession({ url: "http://127.0.0.1:1234/s/last" }); + expect(deps.client.app.log).toHaveBeenCalledWith({ + level: "info", + message: "[Plannotator] Open in browser: http://127.0.0.1:1234/s/last", + }); + }); + + test("handles missing session messages without throwing", async () => { + const deps = makeDeps(); + deps.client.session.messages = mock(async () => { + throw new Error("session unavailable"); + }); + + const feedback = await handleAnnotateLastCommand( + { properties: { sessionID: "session-123" } }, + deps, + ); + + expect(feedback).toBeNull(); + expect(runPluginAnnotateMock).not.toHaveBeenCalled(); + expect(deps.client.app.log).toHaveBeenCalledWith({ + level: "error", + message: "[Plannotator] Could not read the current session messages. session unavailable", + }); + }); +}); + +describe("handleArchiveCommand", () => { + test("surfaces the archive browser URL through OpenCode logs", async () => { + const deps = makeDeps(); + + await handleArchiveCommand({}, deps); + + expect(runPluginArchiveMock).toHaveBeenCalledTimes(1); + const options = runPluginArchiveMock.mock.calls[0]?.[3] as any; + options.onSession({ url: "http://127.0.0.1:1234/s/archive" }); + expect(deps.client.app.log).toHaveBeenCalledWith({ + level: "info", + message: "[Plannotator] Open in browser: http://127.0.0.1:1234/s/archive", + }); }); }); diff --git a/apps/opencode-plugin/commands.ts b/apps/opencode-plugin/commands.ts index 7a50c75a4..88cf555d9 100644 --- a/apps/opencode-plugin/commands.ts +++ b/apps/opencode-plugin/commands.ts @@ -6,137 +6,163 @@ * for modularity. */ -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; -import { - startAnnotateServer, - handleAnnotateServerReady, -} from "@plannotator/server/annotate"; -import { type DiffType, prepareLocalReviewDiff } from "@plannotator/server/vcs"; -import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; -import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; import { getReviewApprovedPrompt, getReviewDeniedSuffix, getAnnotateFileFeedbackPrompt, } from "@plannotator/shared/prompts"; -import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; -import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; import { parseAnnotateArgs } from "@plannotator/shared/annotate-args"; import { parseReviewArgs } from "@plannotator/shared/review-args"; -import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown"; -import { statSync } from "fs"; -import path from "path"; +import type { PluginAgentInfo, PluginFeature } from "@plannotator/shared/plugin-protocol"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type CommandRunOptions, + type EnsurePlannotatorBinaryResult, + ensurePlannotatorBinary, + findPlannotatorSourceRoot, + runPluginAnnotate, + runPluginArchive, + runPluginReview, +} from "./binary-client"; /** Shared dependencies injected by the plugin */ +interface OpenCodeCommandEvent { + arguments?: string; + properties?: { + arguments?: string; + sessionID?: string; + }; +} + +interface OpenCodeMessagePart { + type: string; + text?: string; +} + +interface OpenCodeMessage { + info: { + role: string; + }; + parts: OpenCodeMessagePart[]; +} + +interface OpenCodeClient { + app: { + log: (entry: { level: "error" | "info"; message: string }) => void; + agents: (options?: { query?: { directory?: string } }) => Promise<{ data?: PluginAgentInfo[] }>; + }; + session: { + prompt: (request: { + path: { id: string }; + body: { + agent?: string; + parts: Array<{ type: "text"; text: string }>; + }; + }) => Promise; + messages: (request: { path: { id: string } }) => Promise<{ data?: OpenCodeMessage[] }>; + }; +} + export interface CommandDeps { - client: any; - htmlContent: string; - reviewHtmlContent: string; + client: OpenCodeClient; getSharingEnabled: () => Promise; getShareBaseUrl: () => string | undefined; getPasteApiUrl: () => string | undefined; directory?: string; + binaryClient?: { + ensurePlannotatorBinary?: typeof ensurePlannotatorBinary; + runPluginAnnotate?: typeof runPluginAnnotate; + runPluginArchive?: typeof runPluginArchive; + runPluginReview?: typeof runPluginReview; + }; } -export async function handleReviewCommand( - event: any, - deps: CommandDeps -) { - const { client, reviewHtmlContent, getSharingEnabled, getShareBaseUrl, directory } = deps; - - // @ts-ignore - Event properties contain arguments - const reviewArgs = parseReviewArgs(event.properties?.arguments || ""); - const urlArg = reviewArgs.prUrl; - const isPRMode = urlArg !== undefined; - - let rawPatch: string; - let gitRef: string; - let diffError: string | undefined; - let userDiffType: DiffType | undefined; - let gitContext: Awaited>["gitContext"] | undefined; - let prMetadata: Awaited>["metadata"] | undefined; - - if (isPRMode) { - const prRef = parsePRUrl(urlArg); - if (!prRef) { - client.app.log({ level: "error", message: `Invalid PR/MR URL: ${urlArg}` }); - return; - } +function logBinaryError(client: OpenCodeClient, message: string): void { + client.app.log({ level: "error", message: `[Plannotator] ${message}` }); +} - client.app.log({ level: "info", message: `Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...` }); +function logSessionReady(client: OpenCodeClient, url: string): void { + client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); +} - try { - await checkPRAuth(prRef); - } catch (err) { - const cliName = getCliName(prRef); - client.app.log({ level: "error", message: err instanceof Error ? err.message : `${cliName} auth check failed` }); - return; - } +function sessionReadyOptions(client: OpenCodeClient): CommandRunOptions { + return { + onSession: (session) => logSessionReady(client, session.url), + }; +} +function ensureBinaryForCommand( + client: OpenCodeClient, + binaryClient?: CommandDeps["binaryClient"], + requiredFeatures?: readonly PluginFeature[], +): EnsurePlannotatorBinaryResult { + const binary = (binaryClient?.ensurePlannotatorBinary ?? ensurePlannotatorBinary)({ + requiredFeatures, + sourceRoot: findPlannotatorSourceRoot(dirname(fileURLToPath(import.meta.url))), + }); + if (!binary.ok) logBinaryError(client, binary.message); + return binary; +} + +export async function loadAvailableAgents(client: OpenCodeClient, directory?: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt += 1) { try { - const pr = await fetchPR(prRef); - rawPatch = pr.rawPatch; - gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; - prMetadata = pr.metadata; + const response = await client.app.agents({ + query: { directory }, + }); + return response.data ?? undefined; } catch (err) { - client.app.log({ level: "error", message: err instanceof Error ? err.message : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}` }); - return; + lastError = err; + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 100)); } - } else { - client.app.log({ level: "info", message: "Opening code review UI..." }); - - const config = loadConfig(); - const diffResult = await prepareLocalReviewDiff({ - cwd: directory, - vcsType: reviewArgs.vcsType, - configuredDiffType: resolveDefaultDiffType(config), - hideWhitespace: config.diffOptions?.hideWhitespace ?? false, - }); - gitContext = diffResult.gitContext; - userDiffType = diffResult.diffType; - rawPatch = diffResult.rawPatch; - gitRef = diffResult.gitRef; - diffError = diffResult.error; } + client.app.log({ + level: "info", + message: `[Plannotator] OpenCode agent list unavailable; agent switching is disabled for this session.${lastError instanceof Error ? ` ${lastError.message}` : ""}`, + }); + return undefined; +} + +export async function handleReviewCommand( + event: OpenCodeCommandEvent, + deps: CommandDeps +) { + const { client, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory, binaryClient } = deps; + + const rawArgs = event.properties?.arguments || ""; + const reviewArgs = parseReviewArgs(rawArgs); + const isPRMode = reviewArgs.prUrl !== undefined; + + client.app.log({ level: "info", message: isPRMode ? "Opening PR review UI..." : "Opening code review UI..." }); - const server = await startReviewServer({ - rawPatch, - gitRef, - error: diffError, + const binary = ensureBinaryForCommand(client, binaryClient, ["code-review"]); + if (!binary.ok) return; + + const availableAgents = await loadAvailableAgents(client, directory); + const response = await (binaryClient?.runPluginReview ?? runPluginReview)(binary.path, { origin: "opencode", - diffType: isPRMode ? undefined : userDiffType, - gitContext, - prMetadata, + cwd: directory, + args: rawArgs, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), - htmlContent: reviewHtmlContent, - opencodeClient: client, - onReady: (url, isRemote, port) => { - handleReviewServerReady(url, isRemote, port); - if (isRemote) { - client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } - }, - }); + pasteApiUrl: getPasteApiUrl(), + availableAgents, + }, undefined, sessionReadyOptions(client)); + + if (!response.ok) { + logBinaryError(client, response.error.message); + return; + } - const result = await server.waitForDecision(); - await Bun.sleep(1500); - server.stop(); + const result = response.result; if (result.exit) { return; } if (result.feedback) { - // @ts-ignore - Event properties contain sessionID const sessionId = event.properties?.sessionID; if (sessionId) { @@ -165,145 +191,39 @@ export async function handleReviewCommand( } export async function handleAnnotateCommand( - event: any, + event: OpenCodeCommandEvent, deps: CommandDeps ) { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory } = deps; + const { client, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory, binaryClient } = deps; - // @ts-ignore - Event properties contain arguments const rawArgs = event.properties?.arguments || event.arguments || ""; - // #570: split --gate / --json out of the args; rest is the file path. - // --json is accepted silently (OpenCode writes to session, not stdout). - // parseAnnotateArgs strips leading @ on filePath (reference-mode convention). - // `rawFilePath` preserves it for the scoped-package markdown fallback. - const { filePath, rawFilePath, gate, renderHtml: renderHtmlFlag } = parseAnnotateArgs(rawArgs); + const { filePath } = parseAnnotateArgs(rawArgs); if (!filePath) { client.app.log({ level: "error", message: "Usage: /plannotator-annotate [--gate] [--json]" }); return; } - let markdown: string; - let rawHtml: string | undefined; - let absolutePath: string; - let folderPath: string | undefined; - let annotateMode: "annotate" | "annotate-folder" = "annotate"; - let isFolder = false; - let sourceInfo: string | undefined; - let sourceConverted = false; - - // --- URL annotation --- - const isUrl = /^https?:\/\//i.test(filePath); - - if (isUrl) { - const useJina = resolveUseJina(false, loadConfig()); - client.app.log({ level: "info", message: `Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...` }); - try { - const result = await urlToMarkdown(filePath, { useJina }); - markdown = result.markdown; - sourceConverted = isConvertedSource(result.source); - } catch (err) { - client.app.log({ level: "error", message: `Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}` }); - return; - } - absolutePath = filePath; - sourceInfo = filePath; - } else { - const projectRoot = directory || process.cwd(); - const resolvedArg = resolveUserPath(filePath, projectRoot); - - try { - isFolder = statSync(resolvedArg).isDirectory(); - } catch { - // Not a directory, fall through to file resolution. - } - - if (isFolder) { - if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { - client.app.log({ level: "error", message: `No markdown or HTML files found in ${resolvedArg}` }); - return; - } - folderPath = resolvedArg; - absolutePath = resolvedArg; - markdown = ""; - annotateMode = "annotate-folder"; - client.app.log({ level: "info", message: `Opening annotation UI for folder ${resolvedArg}...` }); - } else if (/\.html?$/i.test(resolvedArg)) { - let fileSize: number; - try { - fileSize = statSync(resolvedArg).size; - } catch { - client.app.log({ level: "error", message: `File not found: ${filePath}` }); - return; - } - if (fileSize > 10 * 1024 * 1024) { - client.app.log({ level: "error", message: `File too large (${Math.round(fileSize / 1024 / 1024)}MB, max 10MB)` }); - return; - } - const html = await Bun.file(resolvedArg).text(); - if (renderHtmlFlag) { - rawHtml = html; - markdown = ""; - } else { - markdown = htmlToMarkdown(html); - sourceConverted = true; - } - absolutePath = resolvedArg; - sourceInfo = path.basename(resolvedArg); - client.app.log({ level: "info", message: `${renderHtmlFlag ? "Raw HTML" : "Converted"}: ${absolutePath}` }); - } else { - // Markdown file annotation - client.app.log({ level: "info", message: `Opening annotation UI for ${filePath}...` }); - // Strip-first with literal-@ fallback (scoped-package-style names). - let resolved = await resolveMarkdownFile(filePath, projectRoot); - if (resolved.kind === "not_found" && rawFilePath !== filePath) { - resolved = await resolveMarkdownFile(rawFilePath, projectRoot); - } - - if (resolved.kind === "ambiguous") { - client.app.log({ - level: "error", - message: `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:\n${resolved.matches.map((m) => ` ${m}`).join("\n")}`, - }); - return; - } - if (resolved.kind === "not_found") { - client.app.log({ level: "error", message: `File not found: ${resolved.input}` }); - return; - } + client.app.log({ level: "info", message: `Opening annotation UI for ${filePath}...` }); - absolutePath = resolved.path; - client.app.log({ level: "info", message: `Resolved: ${absolutePath}` }); - markdown = await Bun.file(absolutePath).text(); - } - } + const binary = ensureBinaryForCommand(client, binaryClient, ["annotate"]); + if (!binary.ok) return; - const server = await startAnnotateServer({ - markdown, - filePath: absolutePath, + const response = await (binaryClient?.runPluginAnnotate ?? runPluginAnnotate)(binary.path, { origin: "opencode", - mode: annotateMode, - folderPath, - sourceInfo, - sourceConverted, - rawHtml, - renderHtml: renderHtmlFlag, + cwd: directory, + args: rawArgs, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), pasteApiUrl: getPasteApiUrl(), - gate, - htmlContent, - onReady: (url, isRemote, port) => { - handleAnnotateServerReady(url, isRemote, port); - if (isRemote) { - client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } - }, - }); + }, undefined, sessionReadyOptions(client)); + + if (!response.ok) { + logBinaryError(client, response.error.message); + return; + } - const result = await server.waitForDecision(); - await Bun.sleep(1500); - server.stop(); + const result = response.result; // Both exit and approve are "no-op for the agent" — skip session injection. if (result.exit || result.approved) { @@ -311,7 +231,6 @@ export async function handleAnnotateCommand( } if (result.feedback) { - // @ts-ignore - Event properties contain sessionID const sessionId = event.properties?.sessionID; if (sessionId) { @@ -322,8 +241,8 @@ export async function handleAnnotateCommand( parts: [{ type: "text", text: getAnnotateFileFeedbackPrompt("opencode", undefined, { - fileHeader: isFolder ? "Folder" : "File", - filePath: absolutePath, + fileHeader: result.mode === "annotate-folder" ? "Folder" : "File", + filePath: result.filePath ?? filePath, feedback: result.feedback, }), }], @@ -342,17 +261,15 @@ export async function handleAnnotateCommand( * so the caller can set it as output.parts for the agent to see. */ export async function handleAnnotateLastCommand( - event: any, + event: OpenCodeCommandEvent, deps: CommandDeps ): Promise { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; + const { client, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory, binaryClient } = deps; - // @ts-ignore - Event properties contain arguments const rawArgs = event.properties?.arguments || event.arguments || ""; // #570: support --gate on /plannotator-last (Stop-hook review-gate pattern). const { gate } = parseAnnotateArgs(rawArgs); - // @ts-ignore - Event properties contain sessionID const sessionId = event.properties?.sessionID; if (!sessionId) { client.app.log({ level: "error", message: "No active session." }); @@ -360,9 +277,18 @@ export async function handleAnnotateLastCommand( } // Fetch messages from session - const messagesResponse = await client.session.messages({ - path: { id: sessionId }, - }); + let messagesResponse: Awaited>; + try { + messagesResponse = await client.session.messages({ + path: { id: sessionId }, + }); + } catch (err) { + client.app.log({ + level: "error", + message: `[Plannotator] Could not read the current session messages.${err instanceof Error ? ` ${err.message}` : ""}`, + }); + return null; + } const messages = messagesResponse.data; // Walk backward, find last assistant message with text @@ -372,8 +298,8 @@ export async function handleAnnotateLastCommand( const msg = messages[i]; if (msg.info.role === "assistant") { const textParts = msg.parts - .filter((p: any) => p.type === "text" && p.text?.trim()) - .map((p: any) => p.text); + .filter((p) => p.type === "text" && p.text?.trim()) + .map((p) => p.text!); if (textParts.length > 0) { lastText = textParts.join("\n"); break; @@ -389,27 +315,27 @@ export async function handleAnnotateLastCommand( client.app.log({ level: "info", message: "Opening annotation UI for last message..." }); - const server = await startAnnotateServer({ + const binary = ensureBinaryForCommand(client, binaryClient, ["annotate-last"]); + if (!binary.ok) return null; + + const response = await (binaryClient?.runPluginAnnotate ?? runPluginAnnotate)(binary.path, { markdown: lastText, filePath: "last-message", origin: "opencode", + cwd: directory, mode: "annotate-last", sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), pasteApiUrl: getPasteApiUrl(), gate, - htmlContent, - onReady: (url, isRemote, port) => { - handleAnnotateServerReady(url, isRemote, port); - if (isRemote) { - client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } - }, - }); + }, undefined, sessionReadyOptions(client)); + + if (!response.ok) { + logBinaryError(client, response.error.message); + return null; + } - const result = await server.waitForDecision(); - await Bun.sleep(1500); - server.stop(); + const result = response.result; // Both exit and approve signal "don't inject feedback" — return null. if (result.exit || result.approved) { @@ -420,32 +346,25 @@ export async function handleAnnotateLastCommand( } export async function handleArchiveCommand( - event: any, + _event: OpenCodeCommandEvent, deps: CommandDeps ) { - const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps; + const { client, getSharingEnabled, getShareBaseUrl, getPasteApiUrl, directory, binaryClient } = deps; client.app.log({ level: "info", message: "Opening plan archive..." }); - const server = await startPlannotatorServer({ - plan: "", + const binary = ensureBinaryForCommand(client, binaryClient, ["archive"]); + if (!binary.ok) return; + + const response = await (binaryClient?.runPluginArchive ?? runPluginArchive)(binary.path, { origin: "opencode", - mode: "archive", + cwd: directory, sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), pasteApiUrl: getPasteApiUrl(), - htmlContent, - onReady: (url, isRemote, port) => { - handleServerReady(url, isRemote, port); - if (isRemote) { - client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } - }, - }); + }, undefined, sessionReadyOptions(client)); - if (server.waitForDone) { - await server.waitForDone(); + if (!response.ok) { + logBinaryError(client, response.error.message); } - await Bun.sleep(1500); - server.stop(); } diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index 74fb02bcc..01a72ebc9 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -35,23 +35,12 @@ if (_proto?.constructor && _proto.constructor !== Response && _proto.constructor globalThis.Request = _reqProto.constructor; } } -import { - startPlannotatorServer, - handleServerReady, -} from "@plannotator/server"; -import { - startReviewServer, - handleReviewServerReady, -} from "@plannotator/server/review"; -import { - startAnnotateServer, - handleAnnotateServerReady, -} from "@plannotator/server/annotate"; import { handleReviewCommand, handleAnnotateCommand, handleAnnotateLastCommand, handleArchiveCommand, + loadAvailableAgents, type CommandDeps, } from "./commands"; import { @@ -79,44 +68,15 @@ import { shouldRejectSubmitPlanForAgent, type PlannotatorOpenCodeOptions, } from "./workflow"; - -// Lazy-load HTML at first use instead of embedding in the bundle. -// The two SPA files are ~20 MB combined — inlining them as string literals -// adds ~160ms to module parse time (see GitHub issue #410). -let _planHtml: string | null = null; -let _reviewHtml: string | null = null; - -function resolveBundledHtmlPath(filename: string): string { - const candidates = [ - path.join(import.meta.dir, filename), - path.join(import.meta.dir, "..", filename), - ]; - - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; - } - } - - throw new Error(`Could not find bundled HTML asset: ${filename}`); -} - -function readBundledHtml(filename: string): string { - return readFileSync(resolveBundledHtmlPath(filename), "utf-8"); -} - -function getPlanHtml(): string { - if (!_planHtml) _planHtml = readBundledHtml("plannotator.html"); - return _planHtml; -} - -function getReviewHtml(): string { - if (!_reviewHtml) _reviewHtml = readBundledHtml("review-editor.html"); - return _reviewHtml; -} +import { + findPlannotatorSourceRoot, + ensurePlannotatorBinary, + runPluginPlan, +} from "./binary-client"; const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours const MAX_PLAN_SIZE = 5 * 1024 * 1024; // 5MB +const SOURCE_ROOT = findPlannotatorSourceRoot(import.meta.dir); // ── Edit-based plan management ──────────────────────────────────────────── @@ -290,10 +250,6 @@ function getLastUserAgentFromMessages(messages: any[] | undefined): string | und export const PlannotatorPlugin: Plugin = async (ctx, rawOptions?: PlannotatorOpenCodeOptions) => { const workflowOptions = normalizeWorkflowOptions(rawOptions); - // Preload HTML in background — populates the sync cache before first use - Bun.file(resolveBundledHtmlPath("plannotator.html")).text().then(h => { _planHtml = h; }); - Bun.file(resolveBundledHtmlPath("review-editor.html")).text().then(h => { _reviewHtml = h; }); - let cachedAgents: any[] | null = null; async function getSharingEnabled(): Promise { @@ -318,6 +274,10 @@ export const PlannotatorPlugin: Plugin = async (ctx, rawOptions?: PlannotatorOpe return process.env.PLANNOTATOR_PASTE_URL || undefined; } + function logSessionReady(url: string): void { + ctx.client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); + } + function getPlanTimeoutSeconds(): number | null { const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim(); if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS; @@ -491,8 +451,6 @@ Do NOT proceed with implementation until your plan is approved.`); const deps: CommandDeps = { client: ctx.client, - htmlContent: getPlanHtml(), - reviewHtmlContent: getReviewHtml(), getSharingEnabled, getShareBaseUrl, getPasteApiUrl, @@ -595,45 +553,48 @@ Use /plannotator-last or /plannotator-annotate for manual review, or set workflo // Write backing file writeFileSync(backingPath, planContent, "utf-8"); - const sharingEnabled = await getSharingEnabled(); - const server = await startPlannotatorServer({ - plan: planContent, - origin: "opencode", - sharingEnabled, - shareBaseUrl: getShareBaseUrl(), - pasteApiUrl: getPasteApiUrl(), - htmlContent: getPlanHtml(), - opencodeClient: ctx.client, - onReady: async (url, isRemote, port) => { - handleServerReady(url, isRemote, port); - if (isRemote) { - ctx.client.app.log({ level: "info", message: `[Plannotator] Open in browser: ${url}` }); - } - }, + const binary = ensurePlannotatorBinary({ + requiredFeatures: ["plan-review"], + sourceRoot: SOURCE_ROOT, }); + if (!binary.ok) { + return `[Plannotator] ${binary.message}`; + } + const sharingEnabled = await getSharingEnabled(); const timeoutSeconds = getPlanTimeoutSeconds(); const timeoutMs = timeoutSeconds === null ? null : timeoutSeconds * 1000; + const availableAgents = await loadAvailableAgents(ctx.client, ctx.directory); + const response = await runPluginPlan( + binary.path, + { + plan: planContent, + planFilePath: backingPath, + cwd: ctx.directory, + origin: "opencode", + sharingEnabled, + shareBaseUrl: getShareBaseUrl(), + pasteApiUrl: getPasteApiUrl(), + availableAgents, + }, + undefined, + { + timeoutMs, + onSession: (session) => logSessionReady(session.url), + }, + ); + + if (!response.ok) { + if ( + timeoutSeconds !== null && + /etimedout|timed out|timeout/i.test(response.error.message) + ) { + return `[Plannotator] No response within ${timeoutSeconds} seconds. Please call submit_plan again.`; + } + return `[Plannotator] ${response.error.message}`; + } - const result = timeoutMs === null - ? await server.waitForDecision() - : await new Promise>>((resolve) => { - const timeoutId = setTimeout( - () => - resolve({ - approved: false, - feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`, - }), - timeoutMs - ); - - server.waitForDecision().then((r) => { - clearTimeout(timeoutId); - resolve(r); - }); - }); - await Bun.sleep(1500); - server.stop(); + const result = response.result; if (result.approved) { const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled'; diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json index 5a8e046d7..edb651200 100644 --- a/apps/opencode-plugin/package.json +++ b/apps/opencode-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/opencode", - "version": "0.19.18", + "version": "0.19.17", "description": "Plannotator plugin for OpenCode - interactive plan review with visual annotation", "author": "backnotprop", "license": "MIT OR Apache-2.0", @@ -25,12 +25,10 @@ "files": [ "dist", "commands", - "README.md", - "plannotator.html", - "review-editor.html" + "README.md" ], "scripts": { - "build": "cp ../hook/dist/index.html ./plannotator.html && cp ../review/dist/index.html ./review-editor.html && bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", + "build": "bun build index.ts --outfile dist/index.js --target bun --external @opencode-ai/plugin", "postinstall": "mkdir -p ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands && cp ./commands/*.md ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands/ 2>/dev/null || true", "prepublishOnly": "bun run build" }, @@ -38,7 +36,6 @@ "@opencode-ai/plugin": "^1.1.10" }, "devDependencies": { - "@plannotator/server": "workspace:*", "@plannotator/shared": "workspace:*" }, "peerDependencies": { diff --git a/apps/opencode-plugin/packaging.test.ts b/apps/opencode-plugin/packaging.test.ts new file mode 100644 index 000000000..250b6f17f --- /dev/null +++ b/apps/opencode-plugin/packaging.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const appDir = import.meta.dir; + +function listRuntimeTsFiles(dir: string): string[] { + const result: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "dist" || entry.name === "node_modules") continue; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + result.push(...listRuntimeTsFiles(fullPath)); + } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) { + result.push(fullPath); + } + } + return result; +} + +describe("OpenCode package boundary", () => { + test("does not package browser HTML assets", () => { + const pkg = JSON.parse(readFileSync(path.join(appDir, "package.json"), "utf-8")) as { files?: string[] }; + expect(pkg.files ?? []).not.toContain("plannotator.html"); + expect(pkg.files ?? []).not.toContain("review-editor.html"); + }); + + test("does not import or start Plannotator servers in runtime code", () => { + const runtimeSource = listRuntimeTsFiles(appDir) + .map((file) => readFileSync(file, "utf-8")) + .join("\n"); + + expect(runtimeSource).not.toMatch(/@plannotator\/server/); + expect(runtimeSource).not.toMatch(/startPlannotatorServer|startReviewServer|startAnnotateServer/); + }); +}); diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index df166d3c4..627be4fd9 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -1,6 +1,6 @@ # Plannotator for Pi -Plannotator integration for the [Pi coding agent](https://github.com/earendil-works/pi). Adds file-based plan mode with a visual browser UI for reviewing, annotating, and approving agent plans. +Plannotator integration for the [Pi coding agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Adds file-based plan mode with a visual browser UI for reviewing, annotating, and approving agent plans. ## Install @@ -25,7 +25,7 @@ pi -e npm:@plannotator/pi-extension ## Build from source -If installing from a local clone, build the HTML assets first: +If installing from a local clone, build the extension package helpers first: ```bash cd plannotator @@ -33,7 +33,19 @@ bun install bun run build:pi ``` -This builds the plan review and code review UIs and copies them into `apps/pi-extension/`. +The Pi extension does not package browser HTML or a server implementation. It delegates Plannotator UI sessions to the installed `plannotator` Bun binary. + +## Runtime Model + +The Pi extension is a client of the installed `plannotator` binary. Pi keeps phase state, tool gating, slash commands, current-session fallback, checklist progress, and the shared event channel. The binary owns plan review, code review, annotation, archive browser sessions, and the HTTP routes behind those UIs. + +Binary discovery order: + +1. `PLANNOTATOR_BIN` +2. `plannotator` on `PATH` +3. Standard install locations such as `~/.local/bin/plannotator` + +If the binary is missing or too old for the plugin protocol, the extension runs the official installer. Set `PLANNOTATOR_DISABLE_AUTO_INSTALL=1` to turn that off in controlled environments. ## Usage @@ -180,7 +192,7 @@ Run `/plannotator-last` to annotate the agent's most recent response. The messag ### Archive browser -The Plannotator archive browser is available through the shared event API as `archive`, which opens the saved plan/decision browser for future callers. The orchestrator does not expose a dedicated archive command yet. +Run `/plannotator-archive` to open the saved plan/decision browser. The same browser is available through the shared event API as `archive`. ### Progress tracking @@ -195,6 +207,7 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr | `/plannotator-review` | Open code review UI for current changes | | `/plannotator-annotate ` | Open markdown file in annotation UI | | `/plannotator-last` | Annotate the last assistant message | +| `/plannotator-archive` | Browse saved plan decisions | ## Flags @@ -227,3 +240,16 @@ State persists across session restarts via Pi's `appendEntry` API. ## Requirements - [Pi](https://github.com/earendil-works/pi) >= 0.74.0 +- Installed `plannotator` binary, or permission for the extension to install it automatically + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `PLANNOTATOR_BIN` | Explicit path to the installed `plannotator` binary used by the extension. | +| `PLANNOTATOR_DISABLE_AUTO_INSTALL` | Set to `1`, `true`, or `yes` to prevent automatic binary installation. | +| `PLANNOTATOR_REMOTE` | Set to `1` / `true` for remote mode, `0` / `false` for local mode, or leave unset for SSH auto-detection. | +| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | +| `PLANNOTATOR_BROWSER` | Custom browser to open Plannotator sessions. | +| `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. | +| `PLANNOTATOR_PASTE_URL` | Custom paste service URL for self-hosting. | diff --git a/apps/pi-extension/binary-client.test.ts b/apps/pi-extension/binary-client.test.ts new file mode 100644 index 000000000..02720c11a --- /dev/null +++ b/apps/pi-extension/binary-client.test.ts @@ -0,0 +1,335 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { ensurePlannotatorBinary, runPluginPlan, type CommandRunner } from "./binary-client"; +import { + createPluginSuccessResponse, + getPluginCapabilities, +} from "../../packages/shared/plugin-protocol"; + +function existsFrom(set: Set) { + return (candidate: string) => set.has(candidate); +} + +let dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +describe("Pi binary client", () => { + test("returns a compatible discovered binary", () => { + const commands: Array<[string, string[]]> = []; + let timeoutMs: number | null | undefined; + const run: CommandRunner = (command, args, _input, options) => { + commands.push([command, args]); + timeoutMs = options?.timeoutMs; + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PLANNOTATOR_BIN: "/opt/plannotator", PATH: "/bin" }, + homeDir: "/home/test", + exists: existsFrom(new Set(["/opt/plannotator"])), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/opt/plannotator", + source: "env", + installed: false, + }); + expect(commands).toEqual([["/opt/plannotator", ["plugin", "capabilities"]]]); + expect(timeoutMs).toBe(5000); + }); + + test("skips candidates missing required plugin features", () => { + const existing = new Set(["/old/plannotator", "/current/plannotator"]); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + return { + exitCode: 0, + stdout: JSON.stringify({ + ...getPluginCapabilities(), + features: command === "/old/plannotator" ? ["capabilities", "plan-review"] : getPluginCapabilities().features, + }), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/old:/current" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + requiredFeatures: ["archive"], + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/current/plannotator", + source: "path", + installed: false, + }); + expect(commands).toEqual([ + ["/old/plannotator", ["plugin", "capabilities"]], + ["/current/plannotator", ["plugin", "capabilities"]], + ]); + }); + + test("does not install when auto-install is disabled", () => { + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin", PLANNOTATOR_DISABLE_AUTO_INSTALL: "true" }, + homeDir: "/home/test", + exists: existsFrom(new Set()), + platform: "linux", + pathDelimiter: ":", + run: () => { + throw new Error("runner should not be called"); + }, + }); + + expect(result).toMatchObject({ + ok: false, + code: "missing-binary", + }); + }); + + test("runs the official installer and validates the installed binary", () => { + const existing = new Set(); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + if (command === "bash") { + existing.add("/home/test/.local/bin/plannotator"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/home/test/.local/bin/plannotator", + source: "standard", + installed: true, + }); + expect(commands[0][0]).toBe("bash"); + expect(commands[0][1][1]).toBe("curl -fsSL https://plannotator.ai/install.sh | bash"); + expect(commands[1]).toEqual(["/home/test/.local/bin/plannotator", ["plugin", "capabilities"]]); + }); + + test("rediscovers the standard install after an incompatible env override", () => { + const existing = new Set(["/opt/old-plannotator"]); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + if (command === "/opt/old-plannotator") { + return { exitCode: 0, stdout: JSON.stringify({ protocol: "old" }), stderr: "" }; + } + if (command === "bash") { + existing.add("/home/test/.local/bin/plannotator"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PLANNOTATOR_BIN: "/opt/old-plannotator", PATH: "/bin" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/home/test/.local/bin/plannotator", + source: "standard", + installed: true, + }); + expect(commands[0]).toEqual(["/opt/old-plannotator", ["plugin", "capabilities"]]); + expect(commands[1][0]).toBe("bash"); + expect(commands[1][1][1]).toBe("curl -fsSL https://plannotator.ai/install.sh | bash"); + expect(commands[2]).toEqual(["/home/test/.local/bin/plannotator", ["plugin", "capabilities"]]); + }); + + test("uses an existing standard install after an incompatible env override", () => { + const existing = new Set([ + "/opt/old-plannotator", + "/home/test/.local/bin/plannotator", + ]); + const commands: Array<[string, string[]]> = []; + const run: CommandRunner = (command, args) => { + commands.push([command, args]); + if (command === "/opt/old-plannotator") { + return { exitCode: 0, stdout: JSON.stringify({ protocol: "old" }), stderr: "" }; + } + if (command === "bash") { + throw new Error("installer should not run"); + } + return { + exitCode: 0, + stdout: JSON.stringify(getPluginCapabilities()), + stderr: "", + }; + }; + + const result = ensurePlannotatorBinary({ + env: { PLANNOTATOR_BIN: "/opt/old-plannotator", PATH: "/bin" }, + homeDir: "/home/test", + exists: existsFrom(existing), + platform: "linux", + pathDelimiter: ":", + run, + }); + + expect(result).toMatchObject({ + ok: true, + path: "/home/test/.local/bin/plannotator", + source: "standard", + installed: false, + }); + expect(commands).toEqual([ + ["/opt/old-plannotator", ["plugin", "capabilities"]], + ["/home/test/.local/bin/plannotator", ["plugin", "capabilities"]], + ]); + }); + + test("reports old binaries as incompatible when install is disabled", () => { + const result = ensurePlannotatorBinary({ + env: { PATH: "/bin", PLANNOTATOR_DISABLE_AUTO_INSTALL: "yes" }, + homeDir: "/home/test", + exists: existsFrom(new Set(["/bin/plannotator"])), + platform: "linux", + pathDelimiter: ":", + run: () => ({ exitCode: 0, stdout: JSON.stringify({ protocol: "old" }), stderr: "" }), + }); + + expect(result).toMatchObject({ + ok: false, + code: "incompatible-binary", + }); + }); + + test("runs plugin plan with JSON stdin and parses the response", async () => { + const response = createPluginSuccessResponse({ approved: false, feedback: "Revise" }); + const calls: Array<{ command: string; args: string[]; input?: string }> = []; + const run: CommandRunner = (command, args, input) => { + calls.push({ command, args, input }); + return { exitCode: 0, stdout: JSON.stringify(response), stderr: "" }; + }; + + expect( + await runPluginPlan( + "/bin/plannotator", + { + origin: "pi", + planFilePath: "PLAN.md", + cwd: "/repo", + }, + run, + ), + ).toEqual(response); + expect(calls).toEqual([ + { + command: "/bin/plannotator", + args: ["plugin", "plan", "--origin", "pi"], + input: JSON.stringify({ origin: "pi", planFilePath: "PLAN.md", cwd: "/repo" }), + }, + ]); + }); + + test("turns plugin plan command failures into protocol errors", async () => { + const result = await runPluginPlan( + "/bin/plannotator", + { origin: "pi", plan: "# Plan" }, + () => ({ exitCode: 1, stdout: "", stderr: "failed" }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "plugin-command-failed", message: "failed" }, + }); + }); + + test("preserves plugin runner errors when stderr only contains progress", async () => { + const result = await runPluginPlan( + "/bin/plannotator", + { origin: "pi", plan: "# Plan" }, + () => ({ + exitCode: 1, + stdout: "", + stderr: "Open this forwarded Plannotator session URL: http://localhost:19432/s/s1\n", + error: "Command timed out after 1000ms.", + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "plugin-command-failed", message: "Command timed out after 1000ms." }, + }); + }); + + test("aborts a running plugin command", async () => { + const dir = mkdtempSync(join(tmpdir(), "plannotator-pi-abort-")); + dirs.push(dir); + const binary = join(dir, "plannotator"); + writeFileSync(binary, `#!/usr/bin/env bash +echo 'PLANNOTATOR_SESSION_READY {"mode":"plan","url":"http://127.0.0.1:4321/s/s1","port":4321,"isRemote":false}' >&2 +trap 'exit 143' TERM +while true; do sleep 1; done +`, "utf-8"); + chmodSync(binary, 0o755); + + const controller = new AbortController(); + let sawSession = false; + const result = await runPluginPlan( + binary, + { origin: "pi", plan: "# Plan" }, + undefined, + { + signal: controller.signal, + onSession: () => { + sawSession = true; + controller.abort(); + }, + }, + ); + + expect(sawSession).toBe(true); + expect(result).toMatchObject({ + ok: false, + error: { code: "plugin-command-failed", message: "Command aborted." }, + }); + }); +}); diff --git a/apps/pi-extension/binary-client.ts b/apps/pi-extension/binary-client.ts new file mode 100644 index 000000000..527661445 --- /dev/null +++ b/apps/pi-extension/binary-client.ts @@ -0,0 +1 @@ +export * from "./generated/plugin-client.js"; diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 441c3fb34..43fa2e650 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -54,8 +54,6 @@ import { parseAnnotateArgs } from "./generated/annotate-args.js"; import { parseReviewArgs } from "./generated/review-args.js"; import { resolveAtReference } from "./generated/at-reference.js"; import { - hasPlanBrowserHtml, - hasReviewBrowserHtml, getStartupErrorMessage, openArchiveBrowserAction, startCodeReviewBrowserSession, @@ -101,16 +99,9 @@ type PersistedPlannotatorState = { savedState?: SavedPhaseState; }; -function getPlanReviewAvailabilityWarning(options: { hasUI: boolean; hasPlanHtml: boolean }): string | null { - const { hasUI, hasPlanHtml } = options; - if (hasUI && hasPlanHtml) return null; - if (!hasUI && !hasPlanHtml) { - return "Plannotator: interactive plan review is unavailable in this session (no UI support and missing built assets). Plans will auto-approve on exit_plan_mode."; - } - if (!hasUI) { - return "Plannotator: interactive plan review is unavailable in this session (no UI support). Plans will auto-approve on exit_plan_mode."; - } - return "Plannotator: interactive plan review assets are missing. Rebuild the extension to restore the browser UI. Plans will auto-approve on exit_plan_mode."; +function getPlanReviewAvailabilityWarning(options: { hasUI: boolean }): string | null { + if (options.hasUI) return null; + return "Plannotator: interactive plan review is unavailable in this session (no UI support). Plans will auto-approve on exit_plan_mode."; } function safeNotify( @@ -355,7 +346,7 @@ export default function plannotator(pi: ExtensionAPI): void { ctx.ui.notify( "Plannotator: planning mode enabled.", ); - const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: hasPlanBrowserHtml() }); + const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI }); if (warning) { ctx.ui.notify(warning, "warning"); } @@ -409,14 +400,6 @@ export default function plannotator(pi: ExtensionAPI): void { pi.registerCommand("plannotator-review", { description: "Open interactive code review for current changes or a PR URL; pass --git to force Git in JJ workspaces", handler: async (args, ctx) => { - if (!hasReviewBrowserHtml()) { - ctx.ui.notify( - "Code review UI not available. Run 'bun run build' in the pi-extension directory.", - "error", - ); - return; - } - currentPiSession.update(ctx); const origin = getPiSessionIdentity(ctx); @@ -498,13 +481,6 @@ export default function plannotator(pi: ExtensionAPI): void { ctx.ui.notify("Usage: /plannotator-annotate [--gate] [--json]", "error"); return; } - if (!hasPlanBrowserHtml()) { - ctx.ui.notify( - "Annotation UI not available. Run 'bun run build' in the pi-extension directory.", - "error", - ); - return; - } let markdown: string; let rawHtml: string | undefined; @@ -652,14 +628,6 @@ export default function plannotator(pi: ExtensionAPI): void { // #570: support --gate on /plannotator-last for Stop-hook review gate. const { gate } = parseAnnotateArgs(args ?? ""); - if (!hasPlanBrowserHtml()) { - ctx.ui.notify( - "Annotation UI not available. Run 'bun run build' in the pi-extension directory.", - "error", - ); - return; - } - currentPiSession.update(ctx); const origin = getPiSessionIdentity(ctx); @@ -721,14 +689,6 @@ export default function plannotator(pi: ExtensionAPI): void { pi.registerCommand("plannotator-archive", { description: "Browse saved plan decisions", handler: async (_args, ctx) => { - if (!hasPlanBrowserHtml()) { - ctx.ui.notify( - "Archive UI not available. Run 'bun run build' in the pi-extension directory.", - "error", - ); - return; - } - ctx.ui.notify("Opening plan archive...", "info"); try { @@ -863,8 +823,9 @@ export default function plannotator(pi: ExtensionAPI): void { lastSubmittedPath = inputPath; checklistItems = parseChecklist(planContent); - // Non-interactive or no HTML: auto-approve - if (!ctx.hasUI || !hasPlanBrowserHtml()) { + // Non-interactive Pi sessions cannot show the review UI, so keep the + // existing headless fallback. Runtime startup failures are handled below. + if (!ctx.hasUI) { phase = "executing"; await applyPhaseConfig(ctx, { restoreSavedState: true }); pi.appendEntry("plannotator-execute", { lastSubmittedPath }); @@ -1265,7 +1226,7 @@ Execute each step in order. After completing a step, include [DONE:n] in your re if (phase === "planning") { checklistItems = []; - const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI, hasPlanHtml: hasPlanBrowserHtml() }); + const warning = getPlanReviewAvailabilityWarning({ hasUI: ctx.hasUI }); if (warning) { ctx.ui.notify(warning, "warning"); } diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 21d4cd470..0cd6bc973 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/pi-extension", - "version": "0.19.18", + "version": "0.19.17", "type": "module", "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs", "author": "backnotprop", @@ -22,22 +22,19 @@ "files": [ "index.ts", "assistant-message.ts", + "binary-client.ts", "current-pi-session.ts", "plannotator-browser.ts", "plannotator-events.ts", - "server.ts", "tool-scope.ts", "config.ts", "plannotator.json", - "server/", "generated/", "README.md", - "plannotator.html", - "review-editor.html", "skills/" ], "scripts": { - "build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && rm -rf skills && cp -r ../skills skills && bash vendor.sh", + "build": "rm -rf skills && cp -r ../skills skills && bash vendor.sh", "prepublishOnly": "cd ../.. && bun run build:pi" }, "dependencies": { diff --git a/apps/pi-extension/packaging.test.ts b/apps/pi-extension/packaging.test.ts new file mode 100644 index 000000000..4c0ad0ede --- /dev/null +++ b/apps/pi-extension/packaging.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const appDir = import.meta.dir; + +describe("Pi package boundary", () => { + test("does not ship the mirrored Node server implementation", () => { + expect(existsSync(path.join(appDir, "server.ts"))).toBe(false); + expect(existsSync(path.join(appDir, "server"))).toBe(false); + }); + + test("does not package browser HTML assets or server folders", () => { + const pkg = JSON.parse(readFileSync(path.join(appDir, "package.json"), "utf-8")) as { files?: string[] }; + const files = pkg.files ?? []; + + expect(files).not.toContain("server.ts"); + expect(files).not.toContain("server/"); + expect(files).not.toContain("plannotator.html"); + expect(files).not.toContain("review-editor.html"); + }); + + test("does not keep generated AI/server payloads", () => { + expect(existsSync(path.join(appDir, "generated", "ai"))).toBe(false); + expect(existsSync(path.join(appDir, "generated", "agent-review-message.ts"))).toBe(false); + expect(existsSync(path.join(appDir, "generated", "tour-review.ts"))).toBe(false); + }); +}); diff --git a/apps/pi-extension/plannotator-browser.test.ts b/apps/pi-extension/plannotator-browser.test.ts index 25450df7b..ef6df8d04 100644 --- a/apps/pi-extension/plannotator-browser.test.ts +++ b/apps/pi-extension/plannotator-browser.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { shouldUseLocalPrCheckout } from "./plannotator-browser"; +import { + normalizeAnnotationMarkdownForBinary, + shouldUseLocalPrCheckout, + startBinarySession, +} from "./plannotator-browser"; describe("shouldUseLocalPrCheckout", () => { test("uses local PR checkout by default", () => { @@ -11,3 +15,78 @@ describe("shouldUseLocalPrCheckout", () => { expect(shouldUseLocalPrCheckout({ useLocal: false })).toBe(false); }); }); + +describe("normalizeAnnotationMarkdownForBinary", () => { + test("omits blank markdown so the binary can load filePath content", () => { + expect(normalizeAnnotationMarkdownForBinary(undefined)).toBeUndefined(); + expect(normalizeAnnotationMarkdownForBinary("")).toBeUndefined(); + expect(normalizeAnnotationMarkdownForBinary(" \n\t")).toBeUndefined(); + expect(normalizeAnnotationMarkdownForBinary("# Notes")).toBe("# Notes"); + }); +}); + +describe("startBinarySession", () => { + test("rejects launch errors that happen before a session URL is ready", async () => { + await expect(startBinarySession(async () => { + throw new Error("startup failed"); + })).rejects.toThrow("startup failed"); + }); + + test("returns a session once a URL is ready and leaves later failures on waitForDecision", async () => { + const session = await startBinarySession(async (onSession) => { + onSession({ mode: "plan", url: "http://localhost:1234", port: 1234, isRemote: false }); + throw new Error("decision failed"); + }); + + expect(session.url).toBe("http://localhost:1234"); + await expect(session.waitForDecision()).rejects.toThrow("decision failed"); + }); + + test("waits for slow session readiness when no startup timeout is provided", async () => { + const session = await startBinarySession(async (onSession) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + onSession({ mode: "review", url: "http://localhost:5678", port: 5678, isRemote: false }); + return { approved: true }; + }); + + expect(session.url).toBe("http://localhost:5678"); + await expect(session.waitForDecision()).resolves.toEqual({ approved: true }); + }); + + test("can return before a slow session URL is ready", async () => { + const session = await startBinarySession(async (onSession) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + onSession({ mode: "plan", url: "http://localhost:9999", port: 9999, isRemote: false }); + return { approved: true }; + }, undefined, { waitForReady: false }); + + expect(session.url).toBe("plannotator://pending"); + await expect(session.waitForDecision()).resolves.toEqual({ approved: true }); + expect(session.url).toBe("http://localhost:9999"); + }); + + test("surfaces deferred startup failures through waitForDecision", async () => { + const session = await startBinarySession(async () => { + throw new Error("startup failed later"); + }, undefined, { waitForReady: false }); + + expect(session.url).toBe("plannotator://pending"); + await expect(session.waitForDecision()).rejects.toThrow("startup failed later"); + }); + + test("rejects if the binary exits without reporting a session URL", async () => { + await expect(startBinarySession(async () => ({ approved: true }))).rejects.toThrow( + "Plannotator exited before reporting a browser session URL.", + ); + }); + + test("rejects when no session URL is reported before an explicit startup timeout", async () => { + await expect(startBinarySession( + (_onSession, signal) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + undefined, + { readyTimeoutMs: 1 }, + )).rejects.toThrow("Timed out waiting for Plannotator session URL."); + }); +}); diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index e71b20a24..6abdff2c4 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -1,34 +1,23 @@ -import { existsSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { tmpdir } from "node:os"; -import { spawnSync } from "node:child_process"; -import { createWorktreePool, type WorktreePool } from "./generated/worktree-pool.js"; -import { fileURLToPath } from "node:url"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { DiffType } from "./generated/review-core.js"; +import type { VcsSelection } from "./generated/vcs-core.js"; +import type { PluginFeature, PluginSessionInfo } from "./generated/plugin-protocol.js"; import { - prepareLocalReviewDiff, - reviewRuntime, - startAnnotateServer, - startPlanReviewServer, - startReviewServer, - type DiffType, - type VcsSelection, -} from "./server.js"; -import { openBrowser, isRemoteSession } from "./server/network.js"; -import { parsePRUrl, checkPRAuth, fetchPR } from "./server/pr.js"; -import { - getMRLabel, - getMRNumberLabel, - getDisplayRepo, - getCliName, - getCliInstallUrl, -} from "./generated/pr-provider.js"; -import { parseRemoteUrl } from "./generated/repo.js"; -import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "./generated/worktree.js"; -import { loadConfig, resolveDefaultDiffType } from "./generated/config.js"; + ensurePlannotatorBinary, + findPlannotatorSourceRoot, + runPluginAnnotate, + runPluginArchive, + runPluginPlan, + runPluginReview, +} from "./binary-client.js"; +import { getLastAssistantMessageText } from "./assistant-message.js"; + export { getLastAssistantMessageText } from "./assistant-message.js"; export type AnnotateMode = "annotate" | "annotate-folder" | "annotate-last"; + export interface PlanReviewDecision { approved: boolean; feedback?: string; @@ -48,137 +37,210 @@ export interface PlanReviewBrowserSession extends BrowserDecisionSession void | Promise) => () => void; } -const __dirname = dirname(fileURLToPath(import.meta.url)); -let planHtmlContent = ""; -let reviewHtmlContent = ""; - -try { - planHtmlContent = readFileSync(resolve(__dirname, "plannotator.html"), "utf-8"); -} catch { - // built assets unavailable -} - -try { - reviewHtmlContent = readFileSync(resolve(__dirname, "review-editor.html"), "utf-8"); -} catch { - // built assets unavailable +export function getStartupErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : "Unknown error"; } -function delay(ms: number): Promise { - return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +export function shouldUseLocalPrCheckout(options: { useLocal?: boolean }): boolean { + return options.useLocal !== false; } -export function hasPlanBrowserHtml(): boolean { - return Boolean(planHtmlContent); +export function normalizeAnnotationMarkdownForBinary(markdown: string | undefined): string | undefined { + return markdown !== undefined && markdown.trim().length > 0 ? markdown : undefined; } -export function hasReviewBrowserHtml(): boolean { - return Boolean(reviewHtmlContent); -} +const SOURCE_ROOT = findPlannotatorSourceRoot(dirname(fileURLToPath(import.meta.url))); -export function getStartupErrorMessage(err: unknown): string { - return err instanceof Error ? err.message : "Unknown error"; +function sharingRequest(ctx: ExtensionContext) { + return { + cwd: ctx.cwd, + sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", + shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, + pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, + }; } -function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): void { - const browserResult = openBrowser(serverUrl); - if (isRemoteSession()) { - ctx.ui.notify(`[Plannotator] ${serverUrl}`, "info"); - } else if (!browserResult.opened) { - ctx.ui.notify(`Open this URL to review: ${serverUrl}`, "info"); +function getBinaryPath(requiredFeatures?: readonly PluginFeature[]): string { + const binary = ensurePlannotatorBinary({ requiredFeatures, sourceRoot: SOURCE_ROOT }); + if (!binary.ok) { + throw new Error(binary.message); } + return binary.path; } -async function openBrowserAndWait( - server: { url: string; stop: () => void }, - ctx: ExtensionContext, - waitForResult: () => Promise, -): Promise { - openBrowserForServer(server.url, ctx); - return waitForDecisionWithCleanup(server, waitForResult); +function createReviewId(): string { + return `plannotator-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } -async function waitForDecisionWithCleanup( - server: { url: string; stop: () => void }, - waitForResult: () => Promise, -): Promise { +function notifySessionReady(ctx: ExtensionContext, session: PluginSessionInfo): void { try { - const result = await waitForResult(); - await delay(1500); - return result; - } finally { - server.stop(); + ctx.ui.notify(`Plannotator: ${session.url}`, "info"); + } catch { + // Pi may be running headless or between UI sessions; the binary runner still writes stderr. } } -function startBrowserDecisionSession( - server: { url: string; stop: () => void }, - ctx: ExtensionContext, - waitForResult: () => Promise, -): BrowserDecisionSession { - openBrowserForServer(server.url, ctx); +export async function startBinarySession( + run: (onSession: (session: PluginSessionInfo) => void, signal: AbortSignal) => T | Promise, + onReady?: (session: PluginSessionInfo) => void, + options: { readyTimeoutMs?: number; waitForReady?: boolean } = {}, +): Promise> { let stopped = false; + let sessionInfo: PluginSessionInfo | undefined; let stopReject: ((err: Error) => void) | undefined; - let decisionPromise: Promise | undefined; + let resolveReady: (() => void) | undefined; + let rejectReady: ((err: Error) => void) | undefined; + const controller = new AbortController(); + const createStoppedError = () => new Error("Plannotator browser session was stopped."); - const stop = () => { - if (stopped) return; - stopped = true; - server.stop(); - stopReject?.(createStoppedError()); - stopReject = undefined; + const readyPromise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const settleReady = () => { + if (!resolveReady) return; + resolveReady(); + resolveReady = undefined; + rejectReady = undefined; }; - - return { - url: server.url, - waitForDecision: () => { - if (decisionPromise) return decisionPromise; - if (stopped) return Promise.reject(createStoppedError()); - decisionPromise = (async () => { - const stoppedPromise = new Promise((_, reject) => { - stopReject = reject; - }); - try { - const result = await Promise.race([waitForResult(), stoppedPromise]); - stopReject = undefined; - await delay(1500); - return result; - } finally { - stop(); + const failReady = (err: Error) => { + if (!rejectReady) return; + rejectReady(err); + resolveReady = undefined; + rejectReady = undefined; + }; + const onSession = (session: PluginSessionInfo) => { + sessionInfo = session; + onReady?.(session); + settleReady(); + }; + const decisionPromise = new Promise((resolve, reject) => { + stopReject = reject; + void (async () => { + try { + const result = await run(onSession, controller.signal); + if (!sessionInfo) { + const err = new Error("Plannotator exited before reporting a browser session URL."); + reject(err); + failReady(err); + return; + } + resolve(result); + } catch (err) { + reject(err); + if (!sessionInfo) { + failReady(err instanceof Error ? err : new Error(String(err))); } - })(); - return decisionPromise; + } finally { + stopReject = undefined; + if (sessionInfo) settleReady(); + } + })(); + }); + + const session = { + get url() { + return sessionInfo?.url ?? "plannotator://pending"; + }, + waitForDecision: () => decisionPromise, + stop: () => { + if (stopped) return; + stopped = true; + controller.abort(); + const err = createStoppedError(); + stopReject?.(err); + stopReject = undefined; + failReady(err); }, - stop, }; + + try { + if (options.waitForReady === false) { + // The caller will observe startup failures through waitForDecision(). + void readyPromise.catch(() => {}); + void decisionPromise.catch(() => {}); + } else if (options.readyTimeoutMs === undefined) { + await readyPromise; + } else { + let readyTimer: ReturnType | undefined; + await Promise.race([ + readyPromise, + new Promise((_, reject) => { + readyTimer = setTimeout( + () => reject(new Error("Timed out waiting for Plannotator session URL.")), + options.readyTimeoutMs, + ); + }), + ]).finally(() => { + if (readyTimer) clearTimeout(readyTimer); + }); + } + } catch (err) { + session.stop(); + void decisionPromise.catch(() => {}); + throw err; + } + + return session; } export async function startPlanReviewBrowserSession( ctx: ExtensionContext, planContent: string, + options: { waitForReady?: boolean } = {}, ): Promise { - if (!ctx.hasUI || !planHtmlContent) { + if (!ctx.hasUI) { throw new Error("Plannotator browser review is unavailable in this session."); } - const server = await startPlanReviewServer({ - plan: planContent, - htmlContent: planHtmlContent, - origin: "pi", - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, + const reviewId = createReviewId(); + const listeners = new Set<(result: PlanReviewDecision) => void | Promise>(); + const session = await startBinarySession(async (onSession, signal) => { + const binaryPath = getBinaryPath(["plan-review"]); + const response = await runPluginPlan(binaryPath, { + origin: "pi", + plan: planContent, + ...sharingRequest(ctx), + }, undefined, { onSession, signal }); + if (!response.ok) throw new Error(response.error.message); + return response.result; + }, (sessionInfo) => notifySessionReady(ctx, sessionInfo), { + waitForReady: options.waitForReady, }); - const session = startBrowserDecisionSession(server, ctx, server.waitForDecision); - server.onDecision(() => { - setTimeout(() => session.stop(), 1500); - }); + const originalWait = session.waitForDecision; + let notified = false; + let completedResult: PlanReviewDecision | undefined; + const waitForDecision = async () => { + const result = await originalWait(); + if (!notified) { + notified = true; + completedResult = result; + for (const listener of listeners) { + try { + await listener(result); + } catch { + // Listener failures should not turn the browser decision into a failed review. + } + } + } + return result; + }; + + void waitForDecision().catch(() => {}); return { - ...session, - reviewId: server.reviewId, - onDecision: server.onDecision, + get url() { + return session.url === "plannotator://pending" ? `plannotator://pending/${reviewId}` : session.url; + }, + reviewId, + waitForDecision, + stop: session.stop, + onDecision: (listener) => { + listeners.add(listener); + if (completedResult) void Promise.resolve(listener(completedResult)).catch(() => {}); + return () => listeners.delete(listener); + }, }; } @@ -190,10 +252,6 @@ export async function openPlanReviewBrowser( return session.waitForDecision(); } -export function shouldUseLocalPrCheckout(options: { useLocal?: boolean }): boolean { - return options.useLocal !== false; -} - export async function openCodeReview( ctx: ExtensionContext, options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string; vcsType?: VcsSelection; useLocal?: boolean } = {}, @@ -205,244 +263,32 @@ export async function openCodeReview( export async function startCodeReviewBrowserSession( ctx: ExtensionContext, options: { cwd?: string; defaultBranch?: string; diffType?: DiffType; prUrl?: string; vcsType?: VcsSelection; useLocal?: boolean } = {}, -): Promise< - BrowserDecisionSession<{ - approved: boolean; - feedback?: string; - annotations?: unknown[]; - agentSwitch?: string; - exit?: boolean; - }> -> { - if (!ctx.hasUI || !reviewHtmlContent) { +): Promise> { + if (!ctx.hasUI) { throw new Error("Plannotator code review browser is unavailable in this session."); } - const urlArg = options.prUrl; - const isPRMode = urlArg?.startsWith("http://") || urlArg?.startsWith("https://"); - - let rawPatch: string; - let gitRef: string; - let diffError: string | undefined; - let gitCtx: Awaited>["gitContext"] | undefined; - let prMetadata: Awaited>["metadata"] | undefined; - let diffType: DiffType | undefined; - let agentCwd: string | undefined; - let initialBase: string | undefined; - let worktreeCleanup: (() => void | Promise) | undefined; - let worktreePool: WorktreePool | undefined; - let exitHandler: (() => void) | undefined; - - if (isPRMode && urlArg) { - // --- PR Review Mode --- - const prRef = parsePRUrl(urlArg); - if (!prRef) { - throw new Error( - `Invalid PR/MR URL: ${urlArg}\n` + - "Supported formats:\n" + - " GitHub: https://github.com/owner/repo/pull/123\n" + - " GitLab: https://gitlab.com/group/project/-/merge_requests/42", - ); - } - - const cliName = getCliName(prRef); - const cliUrl = getCliInstallUrl(prRef); - - try { - await checkPRAuth(prRef); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("not found") || msg.includes("ENOENT")) { - throw new Error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed. Install it from ${cliUrl}`); - } - throw err; - } - - console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`); - const pr = await fetchPR(prRef); - rawPatch = pr.rawPatch; - gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; - prMetadata = pr.metadata; - - if (shouldUseLocalPrCheckout(options)) { - // Create local worktree for agent file access (--local is the default for PR reviews) - let localPath: string | undefined; - let sessionDir: string | undefined; - try { - const repoDir = options.cwd ?? ctx.cwd; - const identifier = prMetadata.platform === "github" - ? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}` - : `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`; - const suffix = Math.random().toString(36).slice(2, 8); - const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; - sessionDir = join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); - localPath = join(sessionDir, "pool", `pr-${prNumber}`); - const fetchRefStr = prMetadata.platform === "github" - ? `refs/pull/${prMetadata.number}/head` - : `refs/merge-requests/${prMetadata.iid}/head`; - - // Validate inputs from platform API to prevent git flag/path injection - if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`); - if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`); - - // Detect same-repo vs cross-repo (must match both owner/repo AND host) - let isSameRepo = false; - try { - const remoteResult = await reviewRuntime.runGit(["remote", "get-url", "origin"], { cwd: repoDir }); - if (remoteResult.exitCode === 0) { - const remoteUrl = remoteResult.stdout.trim(); - const currentRepo = parseRemoteUrl(remoteUrl); - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase(); - const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1]; - const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })(); - const remoteHost = (sshHost || httpsHost || "").toLowerCase(); - const prHost = prMetadata.host.toLowerCase(); - isSameRepo = repoMatches && remoteHost === prHost; - } - } catch { /* not in a git repo — cross-repo path */ } - - if (isSameRepo) { - // ── Same-repo: fast worktree path ── - console.error("Fetching PR branch and creating local worktree..."); - await fetchRef(reviewRuntime, prMetadata.baseBranch, { cwd: repoDir }); - await ensureObjectAvailable(reviewRuntime, prMetadata.baseSha, { cwd: repoDir }); - await fetchRef(reviewRuntime, fetchRefStr, { cwd: repoDir }); - - await createWorktree(reviewRuntime, { - ref: "FETCH_HEAD", - path: localPath, - detach: true, - cwd: repoDir, - }); - - const wtRepoDir = repoDir; - exitHandler = () => { - try { - for (const entry of worktreePool?.entries() ?? []) { - spawnSync("git", ["worktree", "remove", "--force", entry.path], { cwd: wtRepoDir }); - } - } catch {} - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} - }; - worktreeCleanup = async () => { - if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; } - if (worktreePool) await worktreePool.cleanup(reviewRuntime); - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} - }; - process.once("exit", exitHandler); - } else { - // ── Cross-repo: shallow clone + fetch PR head ── - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`); - const cli = prMetadata.platform === "github" ? "gh" : "glab"; - const host = prMetadata.host; - // gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead - const isDefaultHost = host === "github.com" || host === "gitlab.com"; - const cloneEnv = isDefaultHost ? undefined : { - ...process.env, - ...(prMetadata.platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }), - }; - - console.error(`Cloning ${prRepo} (shallow)...`); - const cloneResult = spawnSync(cli, ["repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], { encoding: "utf-8", env: cloneEnv }); - if ((cloneResult.status ?? 1) !== 0) { - throw new Error(`${cli} repo clone failed: ${(cloneResult.stderr ?? "").trim()}`); - } - - console.error("Fetching PR branch..."); - const fetchResult = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", fetchRefStr], { cwd: localPath }); - if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${fetchResult.stderr.trim()}`); - - const checkoutResult = await reviewRuntime.runGit(["checkout", "FETCH_HEAD"], { cwd: localPath }); - if (checkoutResult.exitCode !== 0) { - throw new Error(`git checkout FETCH_HEAD failed: ${checkoutResult.stderr.trim()}`); - } - - // Best-effort: create base refs so agent diffs work - const baseFetch = await reviewRuntime.runGit(["fetch", "--depth=200", "origin", prMetadata.baseSha], { cwd: localPath }); - if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate"); - await reviewRuntime.runGit(["branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath }); - await reviewRuntime.runGit(["update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath }); - - exitHandler = () => { - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} - }; - worktreeCleanup = () => { - if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; } - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} - }; - process.once("exit", exitHandler); - } - - agentCwd = localPath; - worktreePool = createWorktreePool( - { sessionDir: sessionDir!, repoDir, isSameRepo }, - { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, - ); - console.error(`Local checkout ready at ${localPath}`); - } catch (err) { - console.error("Warning: local worktree creation failed, falling back to remote diff"); - console.error(err instanceof Error ? err.message : String(err)); - if (exitHandler) { process.removeListener("exit", exitHandler); exitHandler = undefined; } - if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} - agentCwd = undefined; - worktreePool = undefined; - worktreeCleanup = undefined; - } - } - } else { - // --- Local Review Mode --- - const cwd = options.cwd ?? ctx.cwd; - const config = loadConfig(); - const result = await prepareLocalReviewDiff({ - cwd, + const binaryPath = getBinaryPath(["code-review"]); + return await startBinarySession(async (onSession, signal) => { + const response = await runPluginReview(binaryPath, { + origin: "pi", + ...sharingRequest(ctx), + cwd: options.cwd ?? ctx.cwd, + prUrl: options.prUrl, vcsType: options.vcsType, - requestedDiffType: options.diffType, - requestedBase: options.defaultBranch, - configuredDiffType: resolveDefaultDiffType(config), - hideWhitespace: config.diffOptions?.hideWhitespace ?? false, - }); - gitCtx = result.gitContext; - diffType = result.diffType; - rawPatch = result.rawPatch; - gitRef = result.gitRef; - diffError = result.error; - // Remember which base the initial diff was computed against so it can - // be forwarded to the server below. Only matters when the caller - // overrode the detected default; otherwise it matches gitCtx already. - initialBase = result.base; - } - - const server = await startReviewServer({ - rawPatch, - gitRef, - error: diffError, - origin: "pi", - diffType, - gitContext: gitCtx, - initialBase, - prMetadata, - agentCwd, - worktreePool, - htmlContent: reviewHtmlContent, - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, - onCleanup: worktreeCleanup, - }); - - return startBrowserDecisionSession(server, ctx, server.waitForDecision); + useLocal: shouldUseLocalPrCheckout(options), + diffType: options.diffType, + defaultBranch: options.defaultBranch, + }, undefined, { onSession, signal }); + if (!response.ok) throw new Error(response.error.message); + return response.result; + }, (sessionInfo) => notifySessionReady(ctx, sessionInfo)); } export async function openMarkdownAnnotation( ctx: ExtensionContext, filePath: string, - markdown: string, + markdown: string | undefined, mode: AnnotateMode, folderPath?: string, sourceInfo?: string, @@ -465,7 +311,7 @@ export async function openMarkdownAnnotation( export async function startMarkdownAnnotationSession( ctx: ExtensionContext, filePath: string, - markdown: string, + markdown: string | undefined, mode: AnnotateMode, folderPath?: string, sourceInfo?: string, @@ -474,40 +320,29 @@ export async function startMarkdownAnnotationSession( rawHtml?: string, renderHtml?: boolean, ): Promise> { - if (!ctx.hasUI || !planHtmlContent) { + if (!ctx.hasUI) { throw new Error("Plannotator annotation browser is unavailable in this session."); } - let resolvedMarkdown = markdown; - if (!renderHtml && !resolvedMarkdown.trim() && existsSync(filePath)) { - try { - const fileStat = statSync(filePath); - if (!fileStat.isDirectory()) { - resolvedMarkdown = readFileSync(filePath, "utf-8"); - } - } catch { - // fall back to provided markdown - } - } - - const server = await startAnnotateServer({ - markdown: resolvedMarkdown, - filePath, - origin: "pi", - mode, - folderPath, - sourceInfo, - sourceConverted, - gate, - rawHtml, - renderHtml, - htmlContent: planHtmlContent, - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, - }); - - return startBrowserDecisionSession(server, ctx, server.waitForDecision); + const binaryPath = getBinaryPath([mode === "annotate-last" ? "annotate-last" : "annotate"]); + const requestMarkdown = normalizeAnnotationMarkdownForBinary(markdown); + return await startBinarySession(async (onSession, signal) => { + const response = await runPluginAnnotate(binaryPath, { + origin: "pi", + ...sharingRequest(ctx), + filePath, + ...(requestMarkdown !== undefined && { markdown: requestMarkdown }), + mode, + folderPath, + sourceInfo, + sourceConverted, + gate, + rawHtml, + renderHtml, + }, undefined, { onSession, signal }); + if (!response.ok) throw new Error(response.error.message); + return response.result; + }, (sessionInfo) => notifySessionReady(ctx, sessionInfo)); } export async function openLastMessageAnnotation( @@ -539,25 +374,19 @@ export async function openArchiveBrowserAction( ctx: ExtensionContext, customPlanPath?: string, ): Promise<{ opened: boolean }> { - if (!ctx.hasUI || !planHtmlContent) { + if (!ctx.hasUI) { throw new Error("Plannotator archive browser is unavailable in this session."); } - const server = await startPlanReviewServer({ - plan: "", - htmlContent: planHtmlContent, - origin: "pi", - mode: "archive", - customPlanPath, - sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled", - shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined, - pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, - }); - - return openBrowserAndWait(server, ctx, async () => { - if (server.waitForDone) { - await server.waitForDone(); - } - return { opened: true }; - }); + const binaryPath = getBinaryPath(["archive"]); + const session = await startBinarySession(async (onSession, signal) => { + const response = await runPluginArchive(binaryPath, { + origin: "pi", + ...sharingRequest(ctx), + customPlanPath, + }, undefined, { onSession, signal }); + if (!response.ok) throw new Error(response.error.message); + return response.result; + }, (sessionInfo) => notifySessionReady(ctx, sessionInfo)); + return session.waitForDecision(); } diff --git a/apps/pi-extension/plannotator-events.ts b/apps/pi-extension/plannotator-events.ts index 84d09d744..5a154fa6b 100644 --- a/apps/pi-extension/plannotator-events.ts +++ b/apps/pi-extension/plannotator-events.ts @@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -import type { DiffType, VcsSelection } from "./server.js"; +import type { DiffType } from "./generated/review-core.js"; +import type { VcsSelection } from "./generated/vcs-core.js"; import { getLastAssistantMessageText, getStartupErrorMessage, @@ -82,6 +83,7 @@ export interface PlannotatorReviewStatusPayload { export type PlannotatorReviewStatusResult = | { status: "pending" } | ({ status: "completed" } & PlannotatorReviewResultEvent) + | { status: "error"; error: string } | { status: "missing" }; export interface PlannotatorCodeReviewPayload { @@ -238,7 +240,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { request.respond({ status: "error", error: "Missing planContent for plan-review request." }); return; } - const session = await startPlanReviewBrowserSession(ctx, planContent); + const session = await startPlanReviewBrowserSession(ctx, planContent, { waitForReady: false }); setStoredReviewStatus(session.reviewId, { status: "pending" }); session.onDecision((result) => { const reviewResult = { @@ -252,6 +254,12 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { setStoredReviewStatus(session.reviewId, { status: "completed", ...reviewResult }); pi.events.emit(PLANNOTATOR_REVIEW_RESULT_CHANNEL, reviewResult); }); + void session.waitForDecision().catch((err) => { + setStoredReviewStatus(session.reviewId, { + status: "error", + error: getStartupErrorMessage(err), + }); + }); request.respond({ status: "handled", result: { @@ -283,7 +291,7 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { const result = await openMarkdownAnnotation( ctx, payload.filePath, - payload.markdown ?? "", + payload.markdown, payload.mode ?? "annotate", payload.folderPath, undefined, @@ -323,8 +331,6 @@ export function registerPlannotatorEventListeners(pi: ExtensionAPI): void { export { getLastAssistantMessageText, - hasPlanBrowserHtml, - hasReviewBrowserHtml, startCodeReviewBrowserSession, startLastMessageAnnotationSession, startMarkdownAnnotationSession, diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts deleted file mode 100644 index bbfafaff9..000000000 --- a/apps/pi-extension/server.test.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { createServer as createNetServer } from "node:net"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - getGitContext, - getVcsContext, - prepareLocalReviewDiff, - runGitDiff, - startReviewServer, -} from "./server"; - -const tempDirs: string[] = []; -const originalCwd = process.cwd(); -const originalHome = process.env.HOME; -const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; -const originalPort = process.env.PLANNOTATOR_PORT; - -function makeTempDir(prefix: string): string { - const dir = mkdtempSync(join(tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -function childEnv(): NodeJS.ProcessEnv { - return { ...process.env }; -} - -function git(cwd: string, args: string[]): string { - const result = spawnSync("git", args, { cwd, encoding: "utf-8", env: childEnv() }); - if (result.status !== 0) { - throw new Error(result.stderr || `git ${args.join(" ")} failed`); - } - return result.stdout.trim(); -} - -function hasJj(): boolean { - return spawnSync("jj", ["--version"], { encoding: "utf-8", env: childEnv() }).status === 0; -} - -function jj(cwd: string, args: string[]): string { - const result = spawnSync("jj", ["-R", cwd, ...args], { encoding: "utf-8", env: childEnv() }); - if (result.status !== 0) { - throw new Error(result.stderr || `jj ${args.join(" ")} failed`); - } - return result.stdout.trim(); -} - -function initRepo(): string { - const repoDir = makeTempDir("plannotator-pi-review-"); - git(repoDir, ["init"]); - git(repoDir, ["branch", "-M", "main"]); - git(repoDir, ["config", "user.email", "pi-review@example.com"]); - git(repoDir, ["config", "user.name", "Pi Review"]); - - writeFileSync(join(repoDir, "tracked.txt"), "before\n", "utf-8"); - git(repoDir, ["add", "tracked.txt"]); - git(repoDir, ["commit", "-m", "initial"]); - - return repoDir; -} - -function initJjRepo(): string { - const repoDir = initRepo(); - writeFileSync(join(repoDir, "spacey.ts"), "const x = 1;\n", "utf-8"); - git(repoDir, ["add", "spacey.ts"]); - git(repoDir, ["commit", "-m", "add spacey file"]); - - const init = spawnSync("jj", ["git", "init", "--colocate", repoDir], { encoding: "utf-8", env: childEnv() }); - if (init.status !== 0) { - throw new Error(init.stderr || "jj git init --colocate failed"); - } - jj(repoDir, ["config", "set", "--repo", "user.name", "Pi Review"]); - jj(repoDir, ["config", "set", "--repo", "user.email", "pi-review@example.com"]); - - writeFileSync(join(repoDir, "last.txt"), "last\n", "utf-8"); - jj(repoDir, ["commit", "-m", "add last change"]); - - writeFileSync(join(repoDir, "tracked.txt"), "after\n", "utf-8"); - writeFileSync(join(repoDir, "spacey.ts"), "const x = 1;\n", "utf-8"); - - return repoDir; -} - -function reservePort(): Promise { - return new Promise((resolve, reject) => { - const server = createNetServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to reserve test port")); - return; - } - - const { port } = address; - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(port); - }); - }); - }); -} - -afterEach(() => { - process.chdir(originalCwd); - if (originalHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = originalHome; - } - if (originalXdgConfigHome === undefined) { - delete process.env.XDG_CONFIG_HOME; - } else { - process.env.XDG_CONFIG_HOME = originalXdgConfigHome; - } - if (originalPort === undefined) { - delete process.env.PLANNOTATOR_PORT; - } else { - process.env.PLANNOTATOR_PORT = originalPort; - } - - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("pi review server", () => { - const testIfJj = hasJj() ? test : test.skip; - - test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = initRepo(); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - writeFileSync(join(repoDir, "tracked.txt"), "after\n", "utf-8"); - writeFileSync(join(repoDir, "untracked.txt"), "brand new\n", "utf-8"); - - const gitContext = await getGitContext(); - const diff = await runGitDiff("uncommitted", gitContext.defaultBranch); - - const server = await startReviewServer({ - rawPatch: diff.patch, - gitRef: diff.label, - error: diff.error, - diffType: "uncommitted", - gitContext, - origin: "pi", - htmlContent: "review", - }); - - try { - const diffResponse = await fetch(`${server.url}/api/diff`); - expect(diffResponse.status).toBe(200); - const diffPayload = await diffResponse.json() as { - rawPatch: string; - gitContext?: { diffOptions: Array<{ id: string }> }; - origin?: string; - repoInfo?: { display: string }; - }; - expect(diffPayload.origin).toBe("pi"); - expect(diffPayload.rawPatch).toContain("diff --git a/untracked.txt b/untracked.txt"); - expect(diffPayload.gitContext?.diffOptions.map((option) => option.id)).toEqual( - expect.arrayContaining(["uncommitted", "staged", "unstaged", "last-commit"]), - ); - expect(diffPayload.repoInfo?.display).toBeTruthy(); - - const fileContentResponse = await fetch(`${server.url}/api/file-content?path=tracked.txt`); - const fileContent = await fileContentResponse.json() as { - oldContent: string | null; - newContent: string | null; - }; - expect(fileContent.oldContent).toBe("before\n"); - expect(fileContent.newContent).toBe("after\n"); - - const draftBody = { annotations: [{ id: "draft-1" }] }; - const draftSave = await fetch(`${server.url}/api/draft`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(draftBody), - }); - expect(draftSave.status).toBe(200); - - const draftLoad = await fetch(`${server.url}/api/draft`); - expect(draftLoad.status).toBe(200); - expect(await draftLoad.json()).toEqual(draftBody); - - const annotationCreate = await fetch(`${server.url}/api/editor-annotation`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filePath: "tracked.txt", - selectedText: "after", - lineStart: 1, - lineEnd: 1, - comment: "Check wording", - }), - }); - expect(annotationCreate.status).toBe(200); - const createdAnnotation = await annotationCreate.json() as { id: string }; - expect(createdAnnotation.id).toBeTruthy(); - - const annotationsList = await fetch(`${server.url}/api/editor-annotations`); - const annotationsPayload = await annotationsList.json() as { annotations: Array<{ id: string }> }; - expect(annotationsPayload.annotations).toHaveLength(1); - expect(annotationsPayload.annotations[0].id).toBe(createdAnnotation.id); - - const annotationDelete = await fetch( - `${server.url}/api/editor-annotation?id=${encodeURIComponent(createdAnnotation.id)}`, - { method: "DELETE" }, - ); - expect(annotationDelete.status).toBe(200); - - const agentsResponse = await fetch(`${server.url}/api/agents`); - expect(await agentsResponse.json()).toEqual({ agents: [] }); - - const formData = new FormData(); - formData.append("file", new File(["png-bytes"], "diagram.png", { type: "image/png" })); - const uploadResponse = await fetch(`${server.url}/api/upload`, { - method: "POST", - body: formData, - }); - expect(uploadResponse.status).toBe(200); - const uploadPayload = await uploadResponse.json() as { path: string; originalName: string }; - expect(uploadPayload.originalName).toBe("diagram.png"); - - const imageResponse = await fetch( - `${server.url}/api/image?path=${encodeURIComponent(uploadPayload.path)}`, - ); - expect(imageResponse.status).toBe(200); - expect(await imageResponse.text()).toBe("png-bytes"); - - const draftDelete = await fetch(`${server.url}/api/draft`, { method: "DELETE" }); - expect(draftDelete.status).toBe(200); - - const draftMissing = await fetch(`${server.url}/api/draft`); - expect(draftMissing.status).toBe(404); - - const feedbackResponse = await fetch(`${server.url}/api/feedback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - approved: false, - feedback: "Please update the diff", - annotations: [{ id: "note-1" }], - }), - }); - expect(feedbackResponse.status).toBe(200); - - await expect(server.waitForDecision()).resolves.toEqual({ - approved: false, - feedback: "Please update the diff", - annotations: [{ id: "note-1" }], - agentSwitch: undefined, - }); - } finally { - server.stop(); - } - }); - - test("exit endpoint resolves decision with exit flag", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = initRepo(); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - const gitContext = await getGitContext(); - const diff = await runGitDiff("uncommitted", gitContext.defaultBranch); - - const server = await startReviewServer({ - rawPatch: diff.patch, - gitRef: diff.label, - error: diff.error, - diffType: "uncommitted", - gitContext, - origin: "pi", - htmlContent: "review", - }); - - try { - const exitResponse = await fetch(`${server.url}/api/exit`, { method: "POST" }); - expect(exitResponse.status).toBe(200); - expect(await exitResponse.json()).toEqual({ ok: true }); - - await expect(server.waitForDecision()).resolves.toEqual({ - exit: true, - approved: false, - feedback: "", - annotations: [], - agentSwitch: undefined, - }); - } finally { - server.stop(); - } - }); - - test("git-add endpoint stages and unstages files in review mode", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = initRepo(); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - writeFileSync(join(repoDir, "stage-me.txt"), "new file\n", "utf-8"); - - const gitContext = await getGitContext(); - const diff = await runGitDiff("uncommitted", gitContext.defaultBranch); - - const server = await startReviewServer({ - rawPatch: diff.patch, - gitRef: diff.label, - error: diff.error, - diffType: "uncommitted", - gitContext, - origin: "pi", - htmlContent: "review", - }); - - try { - const stageResponse = await fetch(`${server.url}/api/git-add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ filePath: "stage-me.txt" }), - }); - expect(stageResponse.status).toBe(200); - expect(git(repoDir, ["diff", "--staged", "--name-only"])).toContain("stage-me.txt"); - - const unstageResponse = await fetch(`${server.url}/api/git-add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ filePath: "stage-me.txt", undo: true }), - }); - expect(unstageResponse.status).toBe(200); - expect(git(repoDir, ["diff", "--staged", "--name-only"])).not.toContain("stage-me.txt"); - expect(git(repoDir, ["status", "--short"])).toContain("?? stage-me.txt"); - - await fetch(`${server.url}/api/feedback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - approved: true, - feedback: "LGTM - no changes requested.", - annotations: [], - }), - }); - await server.waitForDecision(); - } finally { - server.stop(); - } - }, 15_000); - - test("round-trips the active base branch through /api/diff and /api/diff/switch", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = initRepo(); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - // Create a second branch the picker can switch to, then branch off it so - // currentBranch !== defaultBranch and the branch/merge-base options appear. - git(repoDir, ["checkout", "-b", "develop"]); - writeFileSync(join(repoDir, "develop-file.txt"), "develop\n", "utf-8"); - git(repoDir, ["add", "develop-file.txt"]); - git(repoDir, ["commit", "-m", "develop commit"]); - git(repoDir, ["checkout", "-b", "feature/x"]); - writeFileSync(join(repoDir, "feature-file.txt"), "feature\n", "utf-8"); - git(repoDir, ["add", "feature-file.txt"]); - git(repoDir, ["commit", "-m", "feature commit"]); - - const gitContext = await getGitContext(); - const diff = await runGitDiff("uncommitted", gitContext.defaultBranch); - - const server = await startReviewServer({ - rawPatch: diff.patch, - gitRef: diff.label, - error: diff.error, - diffType: "uncommitted", - gitContext, - origin: "pi", - htmlContent: "review", - }); - - try { - // Initial load: server echoes the detected default as the active base. - const initial = await fetch(`${server.url}/api/diff`).then((r) => r.json()) as { - base?: string; - gitContext?: { defaultBranch: string }; - }; - expect(initial.base).toBe(gitContext.defaultBranch); - expect(initial.base).toBe(initial.gitContext?.defaultBranch); - - // Switch to a custom base — response must echo the resolved base. - const switchResponse = await fetch(`${server.url}/api/diff/switch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ diffType: "branch", base: "develop" }), - }); - expect(switchResponse.status).toBe(200); - const switched = await switchResponse.json() as { base?: string; diffType: string }; - expect(switched.base).toBe("develop"); - expect(switched.diffType).toBe("branch"); - - const stageWhileOnBranch = await fetch(`${server.url}/api/git-add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ filePath: "feature-file.txt" }), - }); - expect(stageWhileOnBranch.status).toBe(400); - expect(await stageWhileOnBranch.json()).toEqual({ error: "Staging not available" }); - - // Subsequent /api/diff load reflects the switched base — this is what - // survives a page refresh / reconnect. - const rehydrate = await fetch(`${server.url}/api/diff`).then((r) => r.json()) as { - base?: string; - }; - expect(rehydrate.base).toBe("develop"); - - // Unknown refs pass through verbatim — the resolver trusts callers so - // unusual-but-valid refs (tags, SHAs, non-origin remotes) work. Truly - // invalid refs surface via the diff error, not via a silent swap. - const unknownResponse = await fetch(`${server.url}/api/diff/switch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ diffType: "branch", base: "nope-does-not-exist" }), - }); - const unknown = await unknownResponse.json() as { base?: string; error?: string }; - expect(unknown.base).toBe("nope-does-not-exist"); - expect(unknown.error).toBeTruthy(); - - // Feedback to clean up the waitForDecision promise. - await fetch(`${server.url}/api/feedback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ approved: false, feedback: "done", annotations: [] }), - }); - await server.waitForDecision(); - } finally { - server.stop(); - } - }, 15_000); - - test("initialBase overrides gitContext.defaultBranch in server state", async () => { - // Simulates a programmatic caller (Pi event bus, other extensions) that - // opens a review against a non-default base. The server's currentBase — - // which drives /api/diff, agent prompts, and file-content fetches — must - // honor that override instead of falling back to the detected default. - const homeDir = makeTempDir("plannotator-pi-home-"); - const repoDir = initRepo(); - process.env.HOME = homeDir; - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - git(repoDir, ["checkout", "-b", "develop"]); - writeFileSync(join(repoDir, "develop-file.txt"), "develop\n", "utf-8"); - git(repoDir, ["add", "develop-file.txt"]); - git(repoDir, ["commit", "-m", "develop commit"]); - git(repoDir, ["checkout", "-b", "feature/x"]); - - const gitContext = await getGitContext(); - // Detected default is "main"; caller explicitly wants "develop". - expect(gitContext.defaultBranch).toBe("main"); - const diff = await runGitDiff("branch", "develop"); - - const server = await startReviewServer({ - rawPatch: diff.patch, - gitRef: diff.label, - error: diff.error, - diffType: "branch", - gitContext, - initialBase: "develop", - origin: "pi", - htmlContent: "review", - }); - - try { - const payload = await fetch(`${server.url}/api/diff`).then((r) => r.json()) as { - base?: string; - gitContext?: { defaultBranch: string }; - }; - // The server must echo the caller's override, not the detected default. - expect(payload.base).toBe("develop"); - expect(payload.gitContext?.defaultBranch).toBe("main"); - - await fetch(`${server.url}/api/feedback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ approved: false, feedback: "done", annotations: [] }), - }); - await server.waitForDecision(); - } finally { - server.stop(); - } - }, 15_000); - - testIfJj("supports JJ local review modes through the Pi server", async () => { - const homeDir = makeTempDir("plannotator-pi-home-"); - process.env.HOME = homeDir; - process.env.XDG_CONFIG_HOME = join(homeDir, ".config"); - const repoDir = initJjRepo(); - process.chdir(repoDir); - process.env.PLANNOTATOR_PORT = String(await reservePort()); - - const vcsContext = await getVcsContext(repoDir); - expect(vcsContext.vcsType).toBe("jj"); - const prepared = await prepareLocalReviewDiff({ - cwd: repoDir, - requestedDiffType: "merge-base", - requestedBase: "main", - configuredDiffType: "unstaged", - }); - expect(prepared.gitContext.vcsType).toBe("jj"); - expect(prepared.diffType).toBe("jj-current"); - expect(prepared.base).toBe("main@git"); - - const forcedGit = await prepareLocalReviewDiff({ - cwd: repoDir, - vcsType: "git", - requestedDiffType: "unstaged", - configuredDiffType: "unstaged", - }); - expect(forcedGit.gitContext.vcsType).toBe("git"); - expect(forcedGit.diffType).toBe("unstaged"); - expect(forcedGit.rawPatch).toContain("tracked.txt"); - - const forcedGitServer = await startReviewServer({ - rawPatch: forcedGit.rawPatch, - gitRef: forcedGit.gitRef, - error: forcedGit.error, - diffType: forcedGit.diffType, - gitContext: forcedGit.gitContext, - initialBase: forcedGit.base, - origin: "pi", - htmlContent: "review", - }); - try { - const switchResponse = await fetch(`${forcedGitServer.url}/api/diff/switch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ diffType: "merge-base", base: "main" }), - }); - expect(switchResponse.status).toBe(200); - const switched = await switchResponse.json() as { - gitContext?: { vcsType?: string; diffOptions: Array<{ id: string }> }; - }; - expect(switched.gitContext?.vcsType).toBe("git"); - expect(switched.gitContext?.diffOptions.map((option) => option.id)).toContain("merge-base"); - expect(switched.gitContext?.diffOptions.map((option) => option.id)).not.toContain("jj-current"); - } finally { - forcedGitServer.stop(); - } - - process.env.PLANNOTATOR_PORT = String(await reservePort()); - const server = await startReviewServer({ - rawPatch: prepared.rawPatch, - gitRef: prepared.gitRef, - error: prepared.error, - diffType: prepared.diffType, - gitContext: prepared.gitContext, - initialBase: prepared.base, - origin: "pi", - htmlContent: "review", - }); - - try { - const initial = await fetch(`${server.url}/api/diff`).then((r) => r.json()) as { - diffType: string; - rawPatch: string; - base?: string; - gitContext?: { vcsType?: string; diffOptions: Array<{ id: string }> }; - }; - expect(initial.diffType).toBe("jj-current"); - expect(initial.base).toBe("main@git"); - expect(initial.gitContext?.vcsType).toBe("jj"); - expect(initial.gitContext?.diffOptions.map((option) => option.id)).toEqual([ - "jj-current", - "jj-last", - "jj-line", - "jj-all", - ]); - expect(initial.rawPatch).toContain("tracked.txt"); - expect(initial.rawPatch).toContain("+after"); - - const lastResponse = await fetch(`${server.url}/api/diff/switch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ diffType: "jj-last" }), - }); - expect(lastResponse.status).toBe(200); - const last = await lastResponse.json() as { rawPatch: string; diffType: string }; - expect(last.diffType).toBe("jj-last"); - expect(last.rawPatch).toContain("last.txt"); - - for (const nextType of ["jj-line", "jj-all"] as const) { - const response = await fetch(`${server.url}/api/diff/switch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ diffType: nextType }), - }); - expect(response.status).toBe(200); - const payload = await response.json() as { diffType: string; rawPatch: string }; - expect(payload.diffType).toBe(nextType); - expect(payload.rawPatch).toContain("tracked.txt"); - } - - const hideWhitespaceResponse = await fetch(`${server.url}/api/diff/switch`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ diffType: "jj-current", hideWhitespace: true }), - }); - expect(hideWhitespaceResponse.status).toBe(200); - const hidden = await hideWhitespaceResponse.json() as { rawPatch: string }; - expect(hidden.rawPatch).toContain("+after"); - expect(hidden.rawPatch).not.toContain("+const x = 1;"); - - const fileContentResponse = await fetch(`${server.url}/api/file-content?path=tracked.txt`); - expect(fileContentResponse.status).toBe(200); - const fileContent = await fileContentResponse.json() as { - oldContent: string | null; - newContent: string | null; - }; - expect(fileContent.oldContent).toBe("before\n"); - expect(fileContent.newContent).toBe("after\n"); - - const stageResponse = await fetch(`${server.url}/api/git-add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ filePath: "tracked.txt" }), - }); - expect(stageResponse.status).toBe(400); - expect(await stageResponse.json()).toEqual({ error: "Staging not available" }); - - await fetch(`${server.url}/api/feedback`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ approved: true, feedback: "LGTM", annotations: [] }), - }); - await server.waitForDecision(); - } finally { - server.stop(); - } - }, 20_000); -}); diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts deleted file mode 100644 index bbdc3c83a..000000000 --- a/apps/pi-extension/server.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Node-compatible servers for Plannotator Pi extension. - * - * Pi loads extensions via jiti (Node.js), so we can't use Bun.serve(). - * These are lightweight node:http servers implementing just the routes - * each UI needs — plan review, code review, and markdown annotation. - */ - -export type { - DiffOption, - DiffType, - GitContext, -} from "./generated/review-core.js"; -export type { VcsSelection } from "./server/vcs.js"; -export { - type AnnotateServerResult, - startAnnotateServer, -} from "./server/serverAnnotate.js"; -export { - type PlanServerResult, - startPlanReviewServer, -} from "./server/serverPlan.js"; -export { - type ReviewServerResult, - startReviewServer, -} from "./server/serverReview.js"; -export { - canStageFiles, - detectRemoteDefaultCompareTarget, - detectVcs, - getGitContext, - getVcsContext, - getVcsFileContentsForDiff, - prepareLocalReviewDiff, - resolveInitialDiffType, - resolveVcsCwd, - reviewRuntime, - runGitDiff, - runVcsDiff, - stageFile, - unstageFile, -} from "./server/vcs.js"; diff --git a/apps/pi-extension/server/agent-jobs.ts b/apps/pi-extension/server/agent-jobs.ts deleted file mode 100644 index 8711189a2..000000000 --- a/apps/pi-extension/server/agent-jobs.ts +++ /dev/null @@ -1,515 +0,0 @@ -/** - * Agent Jobs — Pi (node:http) server handler. - * - * Manages background agent processes (spawn, monitor, kill) and exposes - * HTTP routes + SSE broadcasting for job status updates. - * - * Mirrors packages/server/agent-jobs.ts but uses node:http primitives. - */ - -import type { IncomingMessage, ServerResponse } from "node:http"; -import { spawn, execFileSync, type ChildProcess } from "node:child_process"; -import { - type AgentJobInfo, - type AgentJobEvent, - type AgentCapability, - type AgentCapabilities, - isTerminalStatus, - jobSource, - serializeAgentSSEEvent, - AGENT_HEARTBEAT_COMMENT, - AGENT_HEARTBEAT_INTERVAL_MS, -} from "../generated/agent-jobs.js"; -import { formatClaudeLogEvent } from "../generated/claude-review.js"; -import { json, parseBody } from "./helpers.js"; - -// --------------------------------------------------------------------------- -// Route prefixes -// --------------------------------------------------------------------------- - -const BASE = "/api/agents"; -const JOBS = `${BASE}/jobs`; -const JOBS_STREAM = `${JOBS}/stream`; -const CAPABILITIES = `${BASE}/capabilities`; - -// --------------------------------------------------------------------------- -// which() helper for Node.js -// --------------------------------------------------------------------------- - -function whichCmd(cmd: string): boolean { - try { - const bin = process.platform === "win32" ? "where" : "which"; - execFileSync(bin, [cmd], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); - return true; - } catch { - return false; - } -} - -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- - -export interface AgentJobHandlerOptions { - mode: "plan" | "review" | "annotate"; - getServerUrl: () => string; - getCwd: () => string; - /** Server-side command builder for known providers (codex, claude, tour). */ - buildCommand?: (provider: string, config?: Record) => Promise<{ - command: string[]; - outputPath?: string; - captureStdout?: boolean; - stdinPrompt?: string; - cwd?: string; - prompt?: string; - label?: string; - /** Underlying engine used (e.g., "claude" or "codex"). Stored on AgentJobInfo for UI display. */ - engine?: string; - /** Model used (e.g., "sonnet", "opus"). Stored on AgentJobInfo for UI display. */ - model?: string; - /** Claude --effort level. */ - effort?: string; - /** Codex reasoning effort level. */ - reasoningEffort?: string; - /** Whether Codex fast mode was enabled. */ - fastMode?: boolean; - /** PR URL at launch time. */ - prUrl?: string; - /** PR diff scope at launch time. */ - diffScope?: string; - /** Diff context snapshot at launch (stored on AgentJobInfo for per-job "Copy All"). */ - diffContext?: AgentJobInfo["diffContext"]; - } | null>; - /** Called when a job completes successfully — parse results and push annotations. */ - onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string }) => void | Promise; -} - -export function createAgentJobHandler(options: AgentJobHandlerOptions) { - const { mode, getServerUrl, getCwd } = options; - - // --- State --- - const jobs = new Map(); - const jobOutputPaths = new Map(); - const subscribers = new Set(); - let version = 0; - - // --- Capability detection (run once) --- - const capabilities: AgentCapability[] = [ - { id: "claude", name: "Claude Code", available: whichCmd("claude") }, - { id: "codex", name: "Codex CLI", available: whichCmd("codex") }, - { id: "tour", name: "Code Tour", available: whichCmd("claude") || whichCmd("codex") }, - ]; - const capabilitiesResponse: AgentCapabilities = { - mode, - providers: capabilities, - available: capabilities.some((c) => c.available), - }; - - // --- SSE broadcasting --- - function broadcast(event: AgentJobEvent): void { - version++; - const data = serializeAgentSSEEvent(event); - for (const res of subscribers) { - try { - res.write(data); - } catch { - subscribers.delete(res); - } - } - } - - // --- Process lifecycle --- - function spawnJob( - provider: string, - command: string[], - label: string, - outputPath?: string, - spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean; prUrl?: string; diffScope?: string; diffContext?: AgentJobInfo["diffContext"] }, - ): AgentJobInfo { - const id = crypto.randomUUID(); - const source = jobSource(id); - - const info: AgentJobInfo = { - id, - source, - provider, - label, - status: "starting", - startedAt: Date.now(), - command, - cwd: getCwd(), - ...(spawnOptions?.engine && { engine: spawnOptions.engine }), - ...(spawnOptions?.model && { model: spawnOptions.model }), - ...(spawnOptions?.effort && { effort: spawnOptions.effort }), - ...(spawnOptions?.reasoningEffort && { reasoningEffort: spawnOptions.reasoningEffort }), - ...(spawnOptions?.fastMode && { fastMode: spawnOptions.fastMode }), - ...(spawnOptions?.prUrl && { prUrl: spawnOptions.prUrl }), - ...(spawnOptions?.diffScope && { diffScope: spawnOptions.diffScope }), - ...(spawnOptions?.diffContext && { diffContext: spawnOptions.diffContext }), - }; - - let proc: ChildProcess | null = null; - - try { - const spawnCwd = spawnOptions?.cwd ?? getCwd(); - const captureStdout = spawnOptions?.captureStdout ?? false; - const hasStdinPrompt = !!spawnOptions?.stdinPrompt; - - proc = spawn(command[0], command.slice(1), { - cwd: spawnCwd, - stdio: [ - hasStdinPrompt ? "pipe" : "ignore", - captureStdout ? "pipe" : "ignore", - "pipe", - ], - env: { - ...process.env, - PLANNOTATOR_AGENT_SOURCE: source, - PLANNOTATOR_API_URL: getServerUrl(), - }, - }); - - // Write prompt to stdin and close (for providers that read prompt from stdin) - if (hasStdinPrompt && proc.stdin) { - proc.stdin.write(spawnOptions!.stdinPrompt!); - proc.stdin.end(); - } - - info.status = "running"; - info.cwd = spawnCwd; - if (spawnOptions?.prompt) info.prompt = spawnOptions.prompt; - jobs.set(id, { info, proc }); - if (outputPath) jobOutputPaths.set(id, outputPath); - if (spawnOptions?.cwd) jobOutputPaths.set(`${id}:cwd`, spawnOptions.cwd); - broadcast({ type: "job:started", job: { ...info } }); - - // --- Stdout capture (Claude JSONL streaming) --- - let stdoutBuf = ""; - if (captureStdout && proc.stdout) { - proc.stdout.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - stdoutBuf += text; - - // Forward JSONL lines as log events - const lines = text.split('\n'); - for (const line of lines) { - if (!line.trim()) continue; - // Tour jobs with the Claude engine also stream Claude JSONL. - if (provider === "claude" || spawnOptions?.engine === "claude") { - const formatted = formatClaudeLogEvent(line); - if (formatted !== null) { - broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' }); - } - continue; - } - try { - const event = JSON.parse(line); - if (event.type === 'result') continue; - } catch { /* not JSON — forward as raw log */ } - broadcast({ type: "job:log", jobId: id, delta: line + '\n' }); - } - }); - } - - // --- Stderr: buffer tail for errors + live log streaming --- - let stderrBuf = ""; - let logPending = ""; - let logFlushTimer: ReturnType | null = null; - - if (proc.stderr) { - proc.stderr.on("data", (chunk: Buffer) => { - const text = chunk.toString(); - stderrBuf = (stderrBuf + text).slice(-500); - logPending += text; - - if (!logFlushTimer) { - logFlushTimer = setTimeout(() => { - if (logPending) { - broadcast({ type: "job:log", jobId: id, delta: logPending }); - logPending = ""; - } - logFlushTimer = null; - }, 200); - } - }); - } - - // Monitor process close (fires after stdio streams are fully drained, - // unlike 'exit' which fires before — critical for stdout capture) - proc.on("close", async (exitCode) => { - // Flush remaining stderr - if (logFlushTimer) { clearTimeout(logFlushTimer); logFlushTimer = null; } - if (logPending) { - broadcast({ type: "job:log", jobId: id, delta: logPending }); - logPending = ""; - } - - const entry = jobs.get(id); - if (!entry || isTerminalStatus(entry.info.status)) return; - - entry.info.endedAt = Date.now(); - entry.info.exitCode = exitCode ?? undefined; - entry.info.status = exitCode === 0 ? "done" : "failed"; - - if (exitCode !== 0 && stderrBuf) { - entry.info.error = stderrBuf; - } - - // Ingest results before broadcasting completion - const jobOutputPath = jobOutputPaths.get(id); - const jobCwd = jobOutputPaths.get(`${id}:cwd`); - if (exitCode === 0 && options.onJobComplete) { - try { - await options.onJobComplete(entry.info, { - outputPath: jobOutputPath, - stdout: captureStdout ? stdoutBuf : undefined, - cwd: jobCwd, - }); - } catch { - // Result ingestion failure shouldn't prevent job completion broadcast - } - } - jobOutputPaths.delete(id); - jobOutputPaths.delete(`${id}:cwd`); - - broadcast({ type: "job:completed", job: { ...entry.info } }); - }); - - // Handle spawn errors after process starts - proc.on("error", (err) => { - const entry = jobs.get(id); - if (!entry || isTerminalStatus(entry.info.status)) return; - - entry.info.status = "failed"; - entry.info.endedAt = Date.now(); - entry.info.error = err.message; - broadcast({ type: "job:completed", job: { ...entry.info } }); - }); - } catch (err) { - jobs.set(id, { info, proc: null }); - broadcast({ type: "job:started", job: { ...info } }); - - info.status = "failed"; - info.endedAt = Date.now(); - info.error = err instanceof Error ? err.message : String(err); - broadcast({ type: "job:completed", job: { ...info } }); - } - - return { ...info }; - } - - function killJob(id: string): boolean { - const entry = jobs.get(id); - if (!entry || isTerminalStatus(entry.info.status)) return false; - - if (entry.proc) { - try { - entry.proc.kill(); - } catch { - // Process may have already exited - } - } - - entry.info.status = "killed"; - entry.info.endedAt = Date.now(); - jobOutputPaths.delete(id); - jobOutputPaths.delete(`${id}:cwd`); - broadcast({ type: "job:completed", job: { ...entry.info } }); - return true; - } - - function killAll(): number { - let count = 0; - for (const [id, entry] of jobs) { - if (!isTerminalStatus(entry.info.status)) { - killJob(id); - count++; - } - } - return count; - } - - function getAllJobs(): AgentJobInfo[] { - return Array.from(jobs.values()).map((e) => ({ ...e.info })); - } - - // --- HTTP handler --- - return { - killAll, - - async handle( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ): Promise { - // --- GET /api/agents/capabilities --- - if (url.pathname === CAPABILITIES && req.method === "GET") { - json(res, capabilitiesResponse); - return true; - } - - // --- SSE stream --- - if (url.pathname === JOBS_STREAM && req.method === "GET") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - - res.setTimeout(0); - - // Send current state as snapshot - const snapshot: AgentJobEvent = { - type: "snapshot", - jobs: getAllJobs(), - }; - res.write(serializeAgentSSEEvent(snapshot)); - - subscribers.add(res); - - // Heartbeat to keep connection alive - const heartbeatTimer = setInterval(() => { - try { - res.write(AGENT_HEARTBEAT_COMMENT); - } catch { - clearInterval(heartbeatTimer); - subscribers.delete(res); - } - }, AGENT_HEARTBEAT_INTERVAL_MS); - - // Clean up on disconnect - res.on("close", () => { - clearInterval(heartbeatTimer); - subscribers.delete(res); - }); - - return true; - } - - // --- GET /api/agents/jobs (snapshot / polling fallback) --- - if (url.pathname === JOBS && req.method === "GET") { - const since = url.searchParams.get("since"); - if (since !== null) { - const sinceVersion = parseInt(since, 10); - if (!isNaN(sinceVersion) && sinceVersion === version) { - res.writeHead(304); - res.end(); - return true; - } - } - json(res, { jobs: getAllJobs(), version }); - return true; - } - - // --- POST /api/agents/jobs (launch) --- - if (url.pathname === JOBS && req.method === "POST") { - try { - const body = await parseBody(req); - const provider = typeof body.provider === "string" ? body.provider : ""; - let rawCommand = Array.isArray(body.command) ? body.command : []; - let command = rawCommand.filter((c: unknown): c is string => typeof c === "string"); - let label = typeof body.label === "string" ? body.label : `${provider} agent`; - let outputPath: string | undefined; - - // Validate provider is a known, available capability - const cap = capabilities.find((c) => c.id === provider); - if (!cap || !cap.available) { - json(res, { error: `Unknown or unavailable provider: ${provider}` }, 400); - return true; - } - - // Try server-side command building for known providers - let captureStdout = false; - let stdinPrompt: string | undefined; - let spawnCwd: string | undefined; - let promptText: string | undefined; - let jobEngine: string | undefined; - let jobModel: string | undefined; - let jobEffort: string | undefined; - let jobReasoningEffort: string | undefined; - let jobFastMode: boolean | undefined; - let jobPrUrl: string | undefined; - let jobDiffScope: string | undefined; - let jobDiffContext: AgentJobInfo["diffContext"] | undefined; - if (options.buildCommand) { - // Thread config from POST body to buildCommand - const config: Record = {}; - if (typeof body.engine === "string") config.engine = body.engine; - if (typeof body.model === "string") config.model = body.model; - if (typeof body.reasoningEffort === "string") config.reasoningEffort = body.reasoningEffort; - if (typeof body.effort === "string") config.effort = body.effort; - if (body.fastMode === true) config.fastMode = true; - const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined); - if (built) { - command = built.command; - outputPath = built.outputPath; - captureStdout = built.captureStdout ?? false; - stdinPrompt = built.stdinPrompt; - spawnCwd = built.cwd; - promptText = built.prompt; - if (built.label) label = built.label; - jobEngine = built.engine; - jobModel = built.model; - jobEffort = built.effort; - jobReasoningEffort = built.reasoningEffort; - jobFastMode = built.fastMode; - jobPrUrl = built.prUrl; - jobDiffScope = built.diffScope; - jobDiffContext = built.diffContext; - } - } - - if (command.length === 0) { - json(res, { error: 'Missing "command" array' }, 400); - return true; - } - - const job = spawnJob(provider, command, label, outputPath, { - captureStdout, - stdinPrompt, - cwd: spawnCwd, - prompt: promptText, - engine: jobEngine, - model: jobModel, - effort: jobEffort, - reasoningEffort: jobReasoningEffort, - fastMode: jobFastMode, - prUrl: jobPrUrl, - diffScope: jobDiffScope, - diffContext: jobDiffContext, - }); - json(res, { job }, 201); - } catch { - json(res, { error: "Invalid JSON" }, 400); - } - return true; - } - - // --- DELETE /api/agents/jobs/:id (kill one) --- - if (url.pathname.startsWith(JOBS + "/") && url.pathname !== JOBS_STREAM && req.method === "DELETE") { - const id = url.pathname.slice(JOBS.length + 1); - if (!id) { - json(res, { error: "Missing job ID" }, 400); - return true; - } - const found = killJob(id); - if (!found) { - json(res, { error: "Job not found or already terminal" }, 404); - return true; - } - json(res, { ok: true }); - return true; - } - - // --- DELETE /api/agents/jobs (kill all) --- - if (url.pathname === JOBS && req.method === "DELETE") { - const count = killAll(); - json(res, { ok: true, killed: count }); - return true; - } - - // Not handled - return false; - }, - }; -} diff --git a/apps/pi-extension/server/annotations.ts b/apps/pi-extension/server/annotations.ts deleted file mode 100644 index 193d4385f..000000000 --- a/apps/pi-extension/server/annotations.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Editor annotation handler (in-memory store for VS Code integration). - * EditorAnnotation type, createEditorAnnotationHandler - */ - -import { randomUUID } from "node:crypto"; -import type { IncomingMessage } from "node:http"; -import { json, parseBody } from "./helpers"; - -interface EditorAnnotation { - id: string; - filePath: string; - selectedText: string; - lineStart: number; - lineEnd: number; - comment?: string; - createdAt: number; -} - -export function createEditorAnnotationHandler() { - const annotations: EditorAnnotation[] = []; - - return { - async handle( - req: IncomingMessage, - res: import("node:http").ServerResponse, - url: URL, - ): Promise { - if (url.pathname === "/api/editor-annotations" && req.method === "GET") { - json(res, { annotations }); - return true; - } - - if (url.pathname === "/api/editor-annotation" && req.method === "POST") { - try { - const body = await parseBody(req); - if ( - !body.filePath || - !body.selectedText || - !body.lineStart || - !body.lineEnd - ) { - json(res, { error: "Missing required fields" }, 400); - return true; - } - - const annotation: EditorAnnotation = { - id: randomUUID(), - filePath: String(body.filePath), - selectedText: String(body.selectedText), - lineStart: Number(body.lineStart), - lineEnd: Number(body.lineEnd), - comment: typeof body.comment === "string" ? body.comment : undefined, - createdAt: Date.now(), - }; - - annotations.push(annotation); - json(res, { id: annotation.id }); - } catch { - json(res, { error: "Invalid JSON" }, 400); - } - return true; - } - - if ( - url.pathname === "/api/editor-annotation" && - req.method === "DELETE" - ) { - const id = url.searchParams.get("id"); - if (!id) { - json(res, { error: "Missing id parameter" }, 400); - return true; - } - const idx = annotations.findIndex((annotation) => annotation.id === id); - if (idx !== -1) { - annotations.splice(idx, 1); - } - json(res, { ok: true }); - return true; - } - - return false; - }, - }; -} diff --git a/apps/pi-extension/server/external-annotations.ts b/apps/pi-extension/server/external-annotations.ts deleted file mode 100644 index 4bb48aa3f..000000000 --- a/apps/pi-extension/server/external-annotations.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * External Annotations — Pi (node:http) server handler. - * - * Thin HTTP adapter over the shared annotation store. Mirrors the Bun - * handler at packages/server/external-annotations.ts but uses node:http - * IncomingMessage/ServerResponse + res.write() for SSE. - */ - -import type { IncomingMessage, ServerResponse } from "node:http"; -import { - createAnnotationStore, - transformPlanInput, - transformReviewInput, - serializeSSEEvent, - HEARTBEAT_COMMENT, - HEARTBEAT_INTERVAL_MS, - type StorableAnnotation, - type ExternalAnnotationEvent, -} from "../generated/external-annotation.js"; -import { json, parseBody } from "./helpers.js"; - -// --------------------------------------------------------------------------- -// Route prefix -// --------------------------------------------------------------------------- - -const BASE = "/api/external-annotations"; -const STREAM = `${BASE}/stream`; - -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- - -export function createExternalAnnotationHandler(mode: "plan" | "review") { - const store = createAnnotationStore(); - const subscribers = new Set(); - const transform = mode === "plan" ? transformPlanInput : transformReviewInput; - - // Wire store mutations → SSE broadcast - store.onMutation((event: ExternalAnnotationEvent) => { - const data = serializeSSEEvent(event); - for (const res of subscribers) { - try { - res.write(data); - } catch { - // Response closed — clean up - subscribers.delete(res); - } - } - }); - - return { - /** Push annotations directly into the store (bypasses HTTP, reuses same validation). */ - addAnnotations(body: unknown): { ids: string[] } | { error: string } { - const parsed = transform(body); - if ("error" in parsed) return { error: parsed.error }; - const created = store.add(parsed.annotations); - return { ids: created.map((a: { id: string }) => a.id) }; - }, - - async handle( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ): Promise { - // --- SSE stream --- - if (url.pathname === STREAM && req.method === "GET") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); - - // Disable idle timeout for SSE connections - res.setTimeout(0); - - // Send current state as snapshot - const snapshot: ExternalAnnotationEvent = { - type: "snapshot", - annotations: store.getAll(), - }; - res.write(serializeSSEEvent(snapshot)); - - subscribers.add(res); - - // Heartbeat to keep connection alive - const heartbeatTimer = setInterval(() => { - try { - res.write(HEARTBEAT_COMMENT); - } catch { - clearInterval(heartbeatTimer); - subscribers.delete(res); - } - }, HEARTBEAT_INTERVAL_MS); - - // Clean up on disconnect - res.on("close", () => { - clearInterval(heartbeatTimer); - subscribers.delete(res); - }); - - // Don't end the response — SSE stays open - return true; - } - - // --- GET snapshot (polling fallback) --- - if (url.pathname === BASE && req.method === "GET") { - const since = url.searchParams.get("since"); - if (since !== null) { - const sinceVersion = parseInt(since, 10); - if (!isNaN(sinceVersion) && sinceVersion === store.version) { - res.writeHead(304); - res.end(); - return true; - } - } - json(res, { - annotations: store.getAll(), - version: store.version, - }); - return true; - } - - // --- POST (add single or batch) --- - if (url.pathname === BASE && req.method === "POST") { - try { - const body = await parseBody(req); - const parsed = transform(body); - - if ("error" in parsed) { - json(res, { error: parsed.error }, 400); - return true; - } - - const created = store.add(parsed.annotations); - json(res, { ids: created.map((a: StorableAnnotation) => a.id) }, 201); - } catch { - json(res, { error: "Invalid JSON" }, 400); - } - return true; - } - - // --- PATCH (update fields on a single annotation) --- - if (url.pathname === BASE && req.method === "PATCH") { - const id = url.searchParams.get("id"); - if (!id) { - json(res, { error: "Missing ?id parameter" }, 400); - return true; - } - try { - const body = await parseBody(req); - const updated = store.update(id, body as Partial); - if (!updated) { - json(res, { error: "Not found" }, 404); - return true; - } - json(res, { annotation: updated }); - } catch { - json(res, { error: "Invalid JSON" }, 400); - } - return true; - } - - // --- DELETE (by id, by source, or clear all) --- - if (url.pathname === BASE && req.method === "DELETE") { - const id = url.searchParams.get("id"); - const source = url.searchParams.get("source"); - - if (id) { - store.remove(id); - json(res, { ok: true }); - return true; - } - - if (source) { - const count = store.clearBySource(source); - json(res, { ok: true, removed: count }); - return true; - } - - const count = store.clearAll(); - json(res, { ok: true, removed: count }); - return true; - } - - // Not handled — pass through - return false; - }, - }; -} diff --git a/apps/pi-extension/server/handlers.ts b/apps/pi-extension/server/handlers.ts deleted file mode 100644 index 6a87f1860..000000000 --- a/apps/pi-extension/server/handlers.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Shared request handlers reused across plan, review, and annotate servers. - * handleImageRequest, handleUploadRequest, handleDraftRequest, handleFavicon - */ - -import { randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import type { IncomingMessage } from "node:http"; -import { tmpdir } from "node:os"; -import { join, resolve as resolvePath } from "node:path"; -import { saveDraft, loadDraft, deleteDraft } from "../generated/draft.js"; -import { FAVICON_SVG } from "../generated/favicon.js"; - -import { json, parseBody, send, toWebRequest } from "./helpers"; - -type Res = import("node:http").ServerResponse; - -const ALLOWED_IMAGE_EXTENSIONS = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "svg", - "bmp", - "ico", - "tiff", - "tif", - "avif", -]); - -const IMAGE_CONTENT_TYPES: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", - bmp: "image/bmp", - ico: "image/x-icon", - tiff: "image/tiff", - tif: "image/tiff", - avif: "image/avif", -}; - -const UPLOAD_DIR = join(tmpdir(), "plannotator"); - -function getExtension(filePath: string): string { - const lastDot = filePath.lastIndexOf("."); - if (lastDot === -1) return ""; - return filePath.slice(lastDot + 1).toLowerCase(); -} - -function validateImagePath(rawPath: string): { - valid: boolean; - resolved: string; - error?: string; -} { - const resolved = resolvePath(rawPath); - const ext = getExtension(resolved); - - if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { - return { - valid: false, - resolved, - error: "Path does not point to a supported image file", - }; - } - - return { valid: true, resolved }; -} - -function validateUploadExtension(fileName: string): { - valid: boolean; - ext: string; - error?: string; -} { - const ext = getExtension(fileName) || "png"; - if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) { - return { - valid: false, - ext, - error: `File extension ".${ext}" is not a supported image type`, - }; - } - - return { valid: true, ext }; -} - -function getImageContentType(filePath: string): string { - return ( - IMAGE_CONTENT_TYPES[getExtension(filePath)] || "application/octet-stream" - ); -} - -export function handleImageRequest(res: Res, url: URL): void { - const imagePath = url.searchParams.get("path"); - if (!imagePath) { - send(res, "Missing path parameter", 400, { "Content-Type": "text/plain" }); - return; - } - - const tryServePath = (candidate: string): boolean => { - const validation = validateImagePath(candidate); - if (!validation.valid) return false; - try { - if (!existsSync(validation.resolved)) return false; - const data = readFileSync(validation.resolved); - send(res, data, 200, { - "Content-Type": getImageContentType(validation.resolved), - }); - return true; - } catch { - return false; - } - }; - - if (tryServePath(imagePath)) return; - - const base = url.searchParams.get("base"); - if ( - base && - !imagePath.startsWith("/") && - tryServePath(resolvePath(base, imagePath)) - ) { - return; - } - - const validation = validateImagePath(imagePath); - if (!validation.valid) { - send(res, validation.error || "Invalid image path", 403, { - "Content-Type": "text/plain", - }); - return; - } - - send(res, "File not found", 404, { "Content-Type": "text/plain" }); -} - -export async function handleUploadRequest( - req: IncomingMessage, - res: Res, -): Promise { - try { - const request = toWebRequest(req); - const formData = await request.formData(); - const file = formData.get("file"); - if ( - !file || - typeof file !== "object" || - !("arrayBuffer" in file) || - !("name" in file) - ) { - json(res, { error: "No file provided" }, 400); - return; - } - - const upload = file as File; - const extResult = validateUploadExtension(upload.name); - if (!extResult.valid) { - json(res, { error: extResult.error }, 400); - return; - } - - mkdirSync(UPLOAD_DIR, { recursive: true }); - const tempPath = join(UPLOAD_DIR, `${randomUUID()}.${extResult.ext}`); - const bytes = Buffer.from(await upload.arrayBuffer()); - writeFileSync(tempPath, bytes); - json(res, { path: tempPath, originalName: upload.name }); - } catch (err) { - const message = err instanceof Error ? err.message : "Upload failed"; - json(res, { error: message }, 500); - } -} - -export function handleDraftRequest( - req: IncomingMessage, - res: Res, - draftKey: string, -): Promise | void { - if (req.method === "POST") { - return parseBody(req) - .then((body) => { - saveDraft(draftKey, body); - json(res, { ok: true }); - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : "Failed to save draft"; - console.error(`[draft] save failed: ${message}`); - json(res, { error: message }, 500); - }); - } else if (req.method === "DELETE") { - deleteDraft(draftKey); - json(res, { ok: true }); - } else { - const draft = loadDraft(draftKey); - if (!draft) { - json(res, { found: false }, 404); - return; - } - json(res, draft); - } -} - -export function handleFavicon(res: Res): void { - send(res, FAVICON_SVG, 200, { - "Content-Type": "image/svg+xml", - "Cache-Control": "public, max-age=86400", - }); -} diff --git a/apps/pi-extension/server/helpers.ts b/apps/pi-extension/server/helpers.ts deleted file mode 100644 index 9bfcc785c..000000000 --- a/apps/pi-extension/server/helpers.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Core HTTP helpers for Pi extension servers. - * parseBody, json, html, send, toWebRequest - */ - -import type { IncomingMessage } from "node:http"; -import { Readable } from "node:stream"; - -export function parseBody( - req: IncomingMessage, -): Promise> { - return new Promise((resolve) => { - let data = ""; - req.on("data", (chunk: string) => (data += chunk)); - req.on("end", () => { - try { - resolve(JSON.parse(data)); - } catch { - resolve({}); - } - }); - }); -} - -export function json( - res: import("node:http").ServerResponse, - data: unknown, - status = 200, -): void { - res.writeHead(status, { "Content-Type": "application/json" }); - res.end(JSON.stringify(data)); -} - -export function html( - res: import("node:http").ServerResponse, - content: string, -): void { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(content); -} - -export function send( - res: import("node:http").ServerResponse, - body: string | Buffer, - status = 200, - headers: Record = {}, -): void { - res.writeHead(status, headers); - res.end(body); -} - -export function requestUrl(req: IncomingMessage): URL { - return new URL(req.url ?? "/", "http://localhost"); -} - -export function toWebRequest(req: IncomingMessage): Request { - const headers = new Headers(); - for (const [key, value] of Object.entries(req.headers)) { - if (value === undefined) continue; - if (Array.isArray(value)) { - for (const item of value) headers.append(key, item); - } else { - headers.set(key, value); - } - } - - const init: RequestInit & { duplex?: "half" } = { - method: req.method, - headers, - }; - - if (req.method !== "GET" && req.method !== "HEAD") { - init.body = Readable.toWeb(req) as unknown as BodyInit; - init.duplex = "half"; - } - - return new Request(`http://localhost${req.url ?? "/"}`, init); -} diff --git a/apps/pi-extension/server/ide.ts b/apps/pi-extension/server/ide.ts deleted file mode 100644 index c349e314e..000000000 --- a/apps/pi-extension/server/ide.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * IDE integration — open plan diffs in VS Code. - * Node.js equivalent of packages/server/ide.ts. - */ - -import { spawn } from "node:child_process"; - -/** Open two files in VS Code's diff viewer. Node.js equivalent of packages/server/ide.ts */ -export function openEditorDiff( - oldPath: string, - newPath: string, -): Promise<{ ok: true } | { error: string }> { - return new Promise((resolve) => { - const proc = spawn("code", ["--diff", oldPath, newPath], { - stdio: ["ignore", "ignore", "pipe"], - }); - let stderr = ""; - proc.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - proc.on("error", (err) => { - if (err.message.includes("ENOENT")) { - resolve({ - error: - "VS Code CLI not found. Run 'Shell Command: Install code command in PATH' from the VS Code command palette.", - }); - } else { - resolve({ error: err.message }); - } - }); - proc.on("close", (code) => { - if (code !== 0) { - if (stderr.includes("not found") || stderr.includes("ENOENT")) { - resolve({ - error: - "VS Code CLI not found. Run 'Shell Command: Install code command in PATH' from the VS Code command palette.", - }); - } else { - resolve({ error: `code --diff exited with ${code}: ${stderr}` }); - } - } else { - resolve({ ok: true }); - } - }); - }); -} diff --git a/apps/pi-extension/server/integrations.ts b/apps/pi-extension/server/integrations.ts deleted file mode 100644 index a68bcb303..000000000 --- a/apps/pi-extension/server/integrations.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Note-taking app integrations (Obsidian, Bear, Octarine). - * Node.js equivalents of packages/server/integrations.ts. - * Config types, save functions, tag extraction, filename generation - */ - -import { execSync, spawn } from "node:child_process"; -import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; -import { basename, join } from "node:path"; - -import { - type ObsidianConfig, - type BearConfig, - type OctarineConfig, - type IntegrationResult, - extractTitle, - generateFrontmatter, - generateFilename, - generateOctarineFrontmatter, - stripH1, - buildHashtags, - buildBearContent, - detectObsidianVaults, -} from "../generated/integrations-common.js"; -import { sanitizeTag } from "../generated/project.js"; -import { resolveUserPath } from "../generated/resolve-file.js"; - -export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult }; -export { - extractTitle, - generateFrontmatter, - generateFilename, - generateOctarineFrontmatter, - stripH1, - buildHashtags, - buildBearContent, - detectObsidianVaults, -}; - -/** Detect project name from git or cwd (sync). Used by extractTags for note integrations. */ -function detectProjectNameSync(): string | null { - try { - const toplevel = execSync("git rev-parse --show-toplevel", { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - if (toplevel) { - const name = sanitizeTag(basename(toplevel)); - if (name) return name; - } - } catch { - /* not in a git repo */ - } - try { - return sanitizeTag(basename(process.cwd())) ?? null; - } catch { - return null; - } -} - -export async function extractTags(markdown: string): Promise { - const tags = new Set(["plannotator"]); - const projectName = detectProjectNameSync(); - if (projectName) tags.add(projectName); - const stopWords = new Set([ - "the", - "and", - "for", - "with", - "this", - "that", - "from", - "into", - "plan", - "implementation", - "overview", - "phase", - "step", - "steps", - ]); - const h1Match = markdown.match( - /^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im, - ); - if (h1Match) { - h1Match[1] - .toLowerCase() - .replace(/[^\w\s-]/g, " ") - .split(/\s+/) - .filter((w) => w.length > 2 && !stopWords.has(w)) - .slice(0, 3) - .forEach((w) => tags.add(w)); - } - const seenLangs = new Set(); - let langMatch: RegExpExecArray | null; - const langRegex = /```(\w+)/g; - while ((langMatch = langRegex.exec(markdown)) !== null) { - const lang = langMatch[1]; - const n = lang.toLowerCase(); - if ( - !seenLangs.has(n) && - !["json", "yaml", "yml", "text", "txt", "markdown", "md"].includes(n) - ) { - seenLangs.add(n); - tags.add(n); - } - } - return Array.from(tags).slice(0, 7); -} - -export async function saveToObsidian( - config: ObsidianConfig, -): Promise { - try { - const { vaultPath, folder, plan } = config; - if (!vaultPath?.trim()) { - return { success: false, error: "Vault path is required" }; - } - const normalizedVault = resolveUserPath(vaultPath); - if (!existsSync(normalizedVault)) - return { - success: false, - error: `Vault path does not exist: ${normalizedVault}`, - }; - if (!statSync(normalizedVault).isDirectory()) - return { - success: false, - error: `Vault path is not a directory: ${normalizedVault}`, - }; - const folderName = folder.trim() || "plannotator"; - const targetFolder = join(normalizedVault, folderName); - if (!existsSync(targetFolder)) mkdirSync(targetFolder, { recursive: true }); - const filename = generateFilename( - plan, - config.filenameFormat, - config.filenameSeparator, - ); - const filePath = join(targetFolder, filename); - const tags = await extractTags(plan); - const frontmatter = generateFrontmatter(tags); - const content = `${frontmatter}\n\n[[Plannotator Plans]]\n\n${plan}`; - writeFileSync(filePath, content); - return { success: true, path: filePath }; - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : "Unknown error", - }; - } -} - -export async function saveToBear( - config: BearConfig, -): Promise { - try { - const { plan, customTags, tagPosition = "append" } = config; - const title = extractTitle(plan); - const body = stripH1(plan); - const tags = customTags?.trim() ? undefined : await extractTags(plan); - const hashtags = buildHashtags(customTags, tags ?? []); - const content = buildBearContent(body, hashtags, tagPosition); - const url = `bear://x-callback-url/create?title=${encodeURIComponent(title)}&text=${encodeURIComponent(content)}&open_note=no`; - spawn("open", [url], { stdio: "ignore" }); - return { success: true }; - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : "Unknown error", - }; - } -} - -export async function saveToOctarine( - config: OctarineConfig, -): Promise { - try { - const { plan } = config; - const workspace = config.workspace.trim(); - if (!workspace) return { success: false, error: "Workspace is required" }; - const folder = config.folder.trim() || "plannotator"; - const filename = generateFilename(plan); - const base = filename.replace(/\.md$/, ""); - const path = folder ? `${folder}/${base}` : base; - const tags = await extractTags(plan); - const frontmatter = generateOctarineFrontmatter(tags); - const content = `${frontmatter}\n\n${plan}`; - const url = `octarine://create?path=${encodeURIComponent(path)}&content=${encodeURIComponent(content)}&workspace=${encodeURIComponent(workspace)}&fresh=true&openAfter=false`; - spawn("open", [url], { stdio: "ignore" }); - return { success: true, path }; - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : "Unknown error", - }; - } -} diff --git a/apps/pi-extension/server/network.test.ts b/apps/pi-extension/server/network.test.ts deleted file mode 100644 index 174e7157a..000000000 --- a/apps/pi-extension/server/network.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { getServerHostname, getServerPort, isRemoteSession } from "./network"; - -const savedEnv: Record = {}; -const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION"]; - -function clearEnv() { - for (const key of envKeys) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } -} - -afterEach(() => { - for (const key of envKeys) { - if (savedEnv[key] !== undefined) { - process.env[key] = savedEnv[key]; - } else { - delete process.env[key]; - } - } -}); - -describe("pi remote detection", () => { - test("false by default", () => { - clearEnv(); - expect(isRemoteSession()).toBe(false); - }); - - test("true when PLANNOTATOR_REMOTE=1", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "1"; - expect(isRemoteSession()).toBe(true); - }); - - test("true when PLANNOTATOR_REMOTE=true", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "true"; - expect(isRemoteSession()).toBe(true); - }); - - test("false when PLANNOTATOR_REMOTE=0", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "0"; - expect(isRemoteSession()).toBe(false); - }); - - test("false when PLANNOTATOR_REMOTE=false", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "false"; - expect(isRemoteSession()).toBe(false); - }); - - test("PLANNOTATOR_REMOTE=false overrides SSH_TTY", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "false"; - process.env.SSH_TTY = "/dev/pts/0"; - expect(isRemoteSession()).toBe(false); - }); - - test("PLANNOTATOR_REMOTE=0 overrides SSH_CONNECTION", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "0"; - process.env.SSH_CONNECTION = "192.168.1.1 12345 192.168.1.2 22"; - expect(isRemoteSession()).toBe(false); - }); - - test("true when SSH_TTY is set and env var is unset", () => { - clearEnv(); - process.env.SSH_TTY = "/dev/pts/0"; - expect(isRemoteSession()).toBe(true); - }); -}); - -describe("pi port selection", () => { - test("uses random local port when false overrides SSH", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "false"; - process.env.SSH_TTY = "/dev/pts/0"; - expect(getServerPort()).toEqual({ port: 0, portSource: "random" }); - }); - - test("uses default remote port when SSH is detected", () => { - clearEnv(); - process.env.SSH_CONNECTION = "192.168.1.1 12345 192.168.1.2 22"; - expect(getServerPort()).toEqual({ port: 19432, portSource: "remote-default" }); - }); - - test("PLANNOTATOR_PORT still takes precedence", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "false"; - process.env.SSH_TTY = "/dev/pts/0"; - process.env.PLANNOTATOR_PORT = "9999"; - expect(getServerPort()).toEqual({ port: 9999, portSource: "env" }); - }); -}); - -describe("pi server hostname", () => { - test("binds local sessions to loopback", () => { - clearEnv(); - expect(getServerHostname()).toBe("127.0.0.1"); - }); - - test("binds remote sessions to all interfaces", () => { - clearEnv(); - process.env.PLANNOTATOR_REMOTE = "1"; - expect(getServerHostname()).toBe("0.0.0.0"); - }); -}); diff --git a/apps/pi-extension/server/network.ts b/apps/pi-extension/server/network.ts deleted file mode 100644 index 083c05c1a..000000000 --- a/apps/pi-extension/server/network.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Network utilities — remote detection, port binding, browser opening. - * isRemoteSession, getServerPort, listenOnPort, openBrowser - */ - -import { spawn } from "node:child_process"; -import type { Server } from "node:http"; -import { release } from "node:os"; - -const DEFAULT_REMOTE_PORT = 19432; -const LOOPBACK_HOST = "127.0.0.1"; - -/** - * Check if running in a remote session (SSH, devcontainer, etc.) - * Honors PLANNOTATOR_REMOTE as a tri-state override, or detects SSH_TTY/SSH_CONNECTION. - */ -function getRemoteOverride(): boolean | null { - const remote = process.env.PLANNOTATOR_REMOTE; - if (remote === undefined) { - return null; - } - - if (remote === "1" || remote?.toLowerCase() === "true") { - return true; - } - - if (remote === "0" || remote?.toLowerCase() === "false") { - return false; - } - - return null; -} - -export function isRemoteSession(): boolean { - const remoteOverride = getRemoteOverride(); - if (remoteOverride !== null) { - return remoteOverride; - } - // Legacy SSH detection - if (process.env.SSH_TTY || process.env.SSH_CONNECTION) { - return true; - } - return false; -} - -/** - * Get the server port to use. - * - PLANNOTATOR_PORT env var takes precedence - * - Remote sessions default to 19432 (for port forwarding) - * - Local sessions use random port - * Returns { port, portSource } so caller can notify user if needed. - */ -export function getServerPort(): { - port: number; - portSource: "env" | "remote-default" | "random"; -} { - const envPort = process.env.PLANNOTATOR_PORT; - if (envPort) { - const parsed = parseInt(envPort, 10); - if (!Number.isNaN(parsed) && parsed >= 0 && parsed < 65536) { - return { port: parsed, portSource: "env" }; - } - // Invalid port - fall back silently, caller can check env var themselves - } - if (isRemoteSession()) { - return { port: DEFAULT_REMOTE_PORT, portSource: "remote-default" }; - } - return { port: 0, portSource: "random" }; -} - -export function getServerHostname(): string { - return isRemoteSession() ? "0.0.0.0" : LOOPBACK_HOST; -} - -const MAX_RETRIES = 5; -const RETRY_DELAY_MS = 500; - -export async function listenOnPort( - server: Server, -): Promise<{ port: number; portSource: "env" | "remote-default" | "random" }> { - const result = getServerPort(); - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen( - result.port, - getServerHostname(), - () => { - server.removeListener("error", reject); - resolve(); - }, - ); - }); - const addr = server.address() as { port: number }; - return { port: addr.port, portSource: result.portSource }; - } catch (err: unknown) { - const isAddressInUse = - err instanceof Error && err.message.includes("EADDRINUSE"); - if (isAddressInUse && attempt < MAX_RETRIES) { - await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); - continue; - } - if (isAddressInUse) { - const hint = isRemoteSession() - ? " (set PLANNOTATOR_PORT to use a different port)" - : ""; - throw new Error( - `Port ${result.port} in use after ${MAX_RETRIES} retries${hint}`, - ); - } - throw err; - } - } - - // Unreachable, but satisfies TypeScript - throw new Error("Failed to bind port"); -} - -/** - * Open URL in system browser (Node-compatible, no Bun $ dependency). - * Honors PLANNOTATOR_BROWSER and BROWSER env vars. - * Returns { opened: true } if browser was opened, { opened: false, isRemote: true, url } if remote session. - */ -export function openBrowser(url: string): { - opened: boolean; - isRemote?: boolean; - url?: string; -} { - const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER; - if (isRemoteSession() && !browser) { - return { opened: false, isRemote: true, url }; - } - - try { - const platform = process.platform; - const wsl = - platform === "linux" && release().toLowerCase().includes("microsoft"); - - let cmd: string; - let args: string[]; - - if (browser) { - if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") { - cmd = "open"; - args = ["-a", browser, url]; - } else if (platform === "win32" || wsl) { - cmd = "cmd.exe"; - args = ["/c", "start", "", browser, url]; - } else { - cmd = browser; - args = [url]; - } - } else if (platform === "win32" || wsl) { - cmd = "cmd.exe"; - args = ["/c", "start", "", url]; - } else if (platform === "darwin") { - cmd = "open"; - args = [url]; - } else { - cmd = "xdg-open"; - args = [url]; - } - - const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); - child.once("error", () => {}); - child.unref(); - return { opened: true }; - } catch { - return { opened: false }; - } -} diff --git a/apps/pi-extension/server/pr.ts b/apps/pi-extension/server/pr.ts deleted file mode 100644 index 0e7d595c8..000000000 --- a/apps/pi-extension/server/pr.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * PR/MR provider for Node.js runtime. - * Node.js PRRuntime + bound dispatch functions from shared pr-provider. - */ - -import { spawn } from "node:child_process"; - -import { - type PRMetadata, - type PRRef, - type PRReviewFileComment, - type PRRuntime, - type PRStackTree, - type PRListItem, - parsePRUrl as parsePRUrlCore, -} from "../generated/pr-types.js"; -import { - checkAuth as checkAuthCore, - fetchPRContext as fetchPRContextCore, - fetchPR as fetchPRCore, - fetchPRFileContent as fetchPRFileContentCore, - fetchPRViewedFiles as fetchPRViewedFilesCore, - fetchPRStack as fetchPRStackCore, - fetchPRList as fetchPRListCore, - getUser as getUserCore, - markPRFilesViewed as markPRFilesViewedCore, - submitPRReview as submitPRReviewCore, -} from "../generated/pr-provider.js"; - -const prRuntime: PRRuntime = { - async runCommand(cmd, args) { - return new Promise((resolve, reject) => { - const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - proc.on("error", reject); - proc.on("close", (exitCode) => { - resolve({ stdout, stderr, exitCode: exitCode ?? 1 }); - }); - }); - }, - async runCommandWithInput(cmd, args, input) { - return new Promise((resolve, reject) => { - const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - proc.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - proc.on("error", reject); - proc.on("close", (exitCode) => { - resolve({ stdout, stderr, exitCode: exitCode ?? 1 }); - }); - proc.stdin?.write(input); - proc.stdin?.end(); - }); - }, -}; - -export const parsePRUrl = parsePRUrlCore; -export function checkPRAuth(ref: PRRef) { - return checkAuthCore(prRuntime, ref); -} -export function getPRUser(ref: PRRef) { - return getUserCore(prRuntime, ref); -} -export function fetchPR(ref: PRRef) { - return fetchPRCore(prRuntime, ref); -} -export function fetchPRContext(ref: PRRef) { - return fetchPRContextCore(prRuntime, ref); -} -export function fetchPRFileContent(ref: PRRef, sha: string, filePath: string) { - return fetchPRFileContentCore(prRuntime, ref, sha, filePath); -} -export function submitPRReview( - ref: PRRef, - headSha: string, - action: "approve" | "comment", - body: string, - fileComments: PRReviewFileComment[], -) { - return submitPRReviewCore( - prRuntime, - ref, - headSha, - action, - body, - fileComments, - ); -} - -export function fetchPRViewedFiles(ref: PRRef): Promise> { - return fetchPRViewedFilesCore(prRuntime, ref); -} - -export function markPRFilesViewed( - ref: PRRef, - prNodeId: string, - filePaths: string[], - viewed: boolean, -): Promise { - return markPRFilesViewedCore(prRuntime, ref, prNodeId, filePaths, viewed); -} - -export function fetchPRStack( - ref: PRRef, - metadata: PRMetadata, -): Promise { - return fetchPRStackCore(prRuntime, ref, metadata); -} - -export function fetchPRList( - ref: PRRef, -): Promise { - return fetchPRListCore(prRuntime, ref); -} diff --git a/apps/pi-extension/server/project.ts b/apps/pi-extension/server/project.ts deleted file mode 100644 index 2a05009d5..000000000 --- a/apps/pi-extension/server/project.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Project detection — repo info, project name, remote URL parsing. - * detectProjectName, getRepoInfo, parseRemoteUrl - */ - -import { execSync } from "node:child_process"; -import { basename } from "node:path"; -import { sanitizeTag } from "../generated/project.js"; -import { parseRemoteUrl, getDirName } from "../generated/repo.js"; - -/** Run a git command and return stdout (empty string on error). */ -function git(cmd: string): string { - try { - return execSync(`git ${cmd}`, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - } catch { - return ""; - } -} - -export function detectProjectName(): string { - try { - const toplevel = execSync("git rev-parse --show-toplevel", { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - const name = basename(toplevel); - return sanitizeTag(name) ?? "_unknown"; - } catch { - // Not a git repo — fall back to cwd - } - try { - const name = basename(process.cwd()); - return sanitizeTag(name) ?? "_unknown"; - } catch { - return "_unknown"; - } -} - -export function getRepoInfo(): { display: string; branch?: string } | null { - const branch = git("rev-parse --abbrev-ref HEAD"); - const safeBranch = branch && branch !== "HEAD" ? branch : undefined; - - const originUrl = git("remote get-url origin"); - const orgRepo = parseRemoteUrl(originUrl); - if (orgRepo) { - return { display: orgRepo, branch: safeBranch }; - } - - const topLevel = git("rev-parse --show-toplevel"); - const repoName = getDirName(topLevel); - if (repoName) { - return { display: repoName, branch: safeBranch }; - } - - const cwdName = getDirName(process.cwd()); - if (cwdName) { - return { display: cwdName }; - } - - return null; -} diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts deleted file mode 100644 index f71dfe6fa..000000000 --- a/apps/pi-extension/server/reference.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * Document and reference handlers (Node.js equivalents of packages/server/reference-handlers.ts). - * VaultNode, buildFileTree, walkMarkdownFiles, handleDocRequest, - * detectObsidianVaults, handleObsidian*, handleFileBrowserRequest - */ - -import { - existsSync, - readdirSync, - readFileSync, - statSync, - type Dirent, -} from "node:fs"; -import type { ServerResponse } from "node:http"; -import { join, resolve as resolvePath } from "node:path"; - -import { json, parseBody } from "./helpers"; -import type { IncomingMessage } from "node:http"; - -import { - type VaultNode, - buildFileTree, - FILE_BROWSER_EXCLUDED, -} from "../generated/reference-common.js"; -import { detectObsidianVaults } from "../generated/integrations-common.js"; -import { - isAbsoluteUserPath, - isCodeFilePath, - resolveCodeFile, - resolveMarkdownFile, - resolveUserPath, - isWithinProjectRoot, - warmFileListCache, -} from "../generated/resolve-file.js"; -import { parseCodePath } from "../generated/code-file.js"; -import { htmlToMarkdown } from "../generated/html-to-markdown.js"; -import { preloadFile } from "@pierre/diffs/ssr"; - -type Res = ServerResponse; - -/** Recursively walk a directory collecting files by extension, skipping ignored dirs. */ -function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = /\.(mdx?|html?)$/i): void { - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }) as Dirent[]; - } catch { - return; - } - for (const entry of entries) { - if (entry.isDirectory()) { - if (FILE_BROWSER_EXCLUDED.includes(entry.name + "/")) continue; - walkMarkdownFiles(join(dir, entry.name), root, results, extensions); - } else if (entry.isFile() && extensions.test(entry.name)) { - const relative = join(dir, entry.name) - .slice(root.length + 1) - .replace(/\\/g, "/"); - results.push(relative); - } - } -} - -/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */ -export async function handleDocRequest(res: Res, url: URL): Promise { - const requestedPath = url.searchParams.get("path"); - if (!requestedPath) { - json(res, { error: "Missing path parameter" }, 400); - return; - } - - // Side-channel: warm the code-file walk so /api/doc/exists POSTs land warm. - void warmFileListCache(process.cwd(), "code"); - - // Try resolving relative to base directory first (used by annotate mode). - // No isWithinProjectRoot check here — intentional, matches pre-existing - // markdown behavior. The base param is set server-side by the annotate - // server (see serverAnnotate.ts /api/doc route). The standalone HTML - // block below (no base) retains its cwd-based containment check. - const base = url.searchParams.get("base"); - const resolvedBase = base ? resolveUserPath(base) : null; - if ( - resolvedBase && - !isAbsoluteUserPath(requestedPath) && - /\.(mdx?|html?)$/i.test(requestedPath) - ) { - const fromBase = resolveUserPath(requestedPath, resolvedBase); - try { - if (existsSync(fromBase)) { - const raw = readFileSync(fromBase, "utf-8"); - const isHtml = /\.html?$/i.test(requestedPath); - const markdown = isHtml ? htmlToMarkdown(raw) : raw; - json(res, { markdown, filepath: fromBase, isConverted: isHtml }); - return; - } - } catch { - /* fall through to standard resolution */ - } - } - - // HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx) - const projectRoot = process.cwd(); - if (/\.html?$/i.test(requestedPath)) { - const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot); - if (!isWithinProjectRoot(resolvedHtml, projectRoot)) { - json(res, { error: "Access denied: path is outside project root" }, 403); - return; - } - try { - if (existsSync(resolvedHtml)) { - const html = readFileSync(resolvedHtml, "utf-8"); - json(res, { markdown: htmlToMarkdown(html), filepath: resolvedHtml, isConverted: true }); - return; - } - } catch { /* fall through to 404 */ } - json(res, { error: `File not found: ${requestedPath}` }, 404); - return; - } - - // Code files: try literal resolve first; on miss, fall back to smart resolver. - if (isCodeFilePath(requestedPath)) { - const parsed = parseCodePath(requestedPath); - const cleanPath = parsed.filePath; - const literalPath = resolveUserPath(cleanPath, resolvedBase || projectRoot); - const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot); - - let resolvedCode: string | null = null; - if (literalAllowed && existsSync(literalPath)) { - resolvedCode = literalPath; - } - - if (!resolvedCode) { - const result = await resolveCodeFile(cleanPath, projectRoot); - if (result.kind === "found") { - resolvedCode = result.path; - } else if (result.kind === "ambiguous") { - const prefix = `${projectRoot}/`; - const relative = result.matches.map((m: string) => - m.startsWith(prefix) ? m.slice(prefix.length) : m, - ); - json(res, { error: `Ambiguous path '${requestedPath}'`, matches: relative }, 400); - return; - } else { - json(res, { error: `File not found: ${requestedPath}` }, 404); - return; - } - if (!isWithinProjectRoot(resolvedCode, projectRoot)) { - json(res, { error: "Access denied: path is outside project root" }, 403); - return; - } - } - - try { - const stat = statSync(resolvedCode); - if (stat.size > 2 * 1024 * 1024) { - json(res, { error: "File too large (max 2MB)" }, 413); - return; - } - const contents = readFileSync(resolvedCode, "utf-8"); - const displayName = resolvedCode.split("/").pop() || resolvedCode; - let prerenderedHTML: string | undefined; - try { - const result = await preloadFile({ - file: { name: displayName, contents }, - options: { disableFileHeader: true }, - }); - prerenderedHTML = result.prerenderedHTML; - } catch { - // Fall back to client-side rendering - } - json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML, line: parsed.line, lineEnd: parsed.lineEnd }); - return; - } catch { - json(res, { error: `File not found: ${requestedPath}` }, 404); - return; - } - } - - const result = resolveMarkdownFile(requestedPath, projectRoot); - - if (result.kind === "ambiguous") { - json( - res, - { - error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`, - matches: result.matches, - }, - 400, - ); - return; - } - - if (result.kind === "not_found" || result.kind === "unavailable") { - json(res, { error: `File not found: ${result.input}` }, 404); - return; - } - - try { - const markdown = readFileSync(result.path, "utf-8"); - json(res, { markdown, filepath: result.path }); - } catch { - json(res, { error: "Failed to read file" }, 500); - } -} - -/** - * Batch existence check for code-file paths the renderer wants to linkify. - * POST /api/doc/exists with { paths: string[] }. - * - * TODO(security): see packages/server/reference-handlers.ts handleDocExists — - * both absolute paths in `paths[]` AND the `base` field are honored verbatim - * with no project-root containment check, leaking file existence back to the - * caller. Fix in lockstep with the Bun handler. - */ -export async function handleDocExistsRequest(res: Res, req: IncomingMessage): Promise { - const body = await parseBody(req); - const paths = (body as { paths?: unknown }).paths; - if (!Array.isArray(paths) || !paths.every((p) => typeof p === "string")) { - json(res, { error: "Expected { paths: string[] }" }, 400); - return; - } - if (paths.length > 500) { - json(res, { error: "Too many paths (max 500)" }, 400); - return; - } - const baseRaw = (body as { base?: unknown }).base; - const baseDir = typeof baseRaw === "string" && baseRaw.length > 0 - ? resolveUserPath(baseRaw) - : undefined; - - const projectRoot = process.cwd(); - const results: Record< - string, - | { status: "found"; resolved: string } - | { status: "ambiguous"; matches: string[] } - | { status: "missing" } - | { status: "unavailable" } - > = {}; - - await Promise.all( - (paths as string[]).map(async (p) => { - const cleanP = parseCodePath(p).filePath; - const r = await resolveCodeFile(cleanP, projectRoot, baseDir); - if (r.kind === "found") { - results[p] = { status: "found", resolved: r.path }; - } else if (r.kind === "ambiguous") { - const prefix = `${projectRoot}/`; - results[p] = { - status: "ambiguous", - matches: r.matches.map((m: string) => (m.startsWith(prefix) ? m.slice(prefix.length) : m)), - }; - } else if (r.kind === "unavailable") { - results[p] = { status: "unavailable" }; - } else { - results[p] = { status: "missing" }; - } - }), - ); - - json(res, { results }); -} - -export function handleObsidianVaultsRequest(res: Res): void { - json(res, { vaults: detectObsidianVaults() }); -} - -export function handleObsidianFilesRequest(res: Res, url: URL): void { - const vaultPath = url.searchParams.get("vaultPath"); - if (!vaultPath) { - json(res, { error: "Missing vaultPath parameter" }, 400); - return; - } - const resolvedVault = resolveUserPath(vaultPath); - if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { - json(res, { error: "Invalid vault path" }, 400); - return; - } - try { - const files: string[] = []; - walkMarkdownFiles(resolvedVault, resolvedVault, files, /\.mdx?$/i); - files.sort(); - json(res, { tree: buildFileTree(files) }); - } catch { - json(res, { error: "Failed to list vault files" }, 500); - } -} - -export function handleObsidianDocRequest(res: Res, url: URL): void { - const vaultPath = url.searchParams.get("vaultPath"); - const filePath = url.searchParams.get("path"); - if (!vaultPath || !filePath) { - json(res, { error: "Missing vaultPath or path parameter" }, 400); - return; - } - if (!/\.mdx?$/i.test(filePath)) { - json(res, { error: "Only markdown files are supported" }, 400); - return; - } - const resolvedVault = resolveUserPath(vaultPath); - let resolvedFile = resolvePath(resolvedVault, filePath); - - // Bare filename search within vault - if (!existsSync(resolvedFile) && !filePath.includes("/")) { - const files: string[] = []; - walkMarkdownFiles(resolvedVault, resolvedVault, files, /\.mdx?$/i); - const matches = files.filter( - (f) => f.split("/").pop()!.toLowerCase() === filePath.toLowerCase(), - ); - if (matches.length === 1) { - resolvedFile = resolvePath(resolvedVault, matches[0]); - } else if (matches.length > 1) { - json( - res, - { - error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, - matches, - }, - 400, - ); - return; - } - } - - // Security: must be within vault - if ( - !resolvedFile.startsWith(resolvedVault + "/") && - resolvedFile !== resolvedVault - ) { - json(res, { error: "Access denied: path is outside vault" }, 403); - return; - } - - if (!existsSync(resolvedFile)) { - json(res, { error: `File not found: ${filePath}` }, 404); - return; - } - try { - const markdown = readFileSync(resolvedFile, "utf-8"); - json(res, { markdown, filepath: resolvedFile }); - } catch { - json(res, { error: "Failed to read file" }, 500); - } -} - -export function handleFileBrowserRequest(res: Res, url: URL): void { - const dirPath = url.searchParams.get("dirPath"); - if (!dirPath) { - json(res, { error: "Missing dirPath parameter" }, 400); - return; - } - const resolvedDir = resolveUserPath(dirPath); - if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { - json(res, { error: "Invalid directory path" }, 400); - return; - } - try { - const files: string[] = []; - walkMarkdownFiles(resolvedDir, resolvedDir, files); - files.sort(); - json(res, { tree: buildFileTree(files) }); - } catch { - json(res, { error: "Failed to list directory files" }, 500); - } -} diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts deleted file mode 100644 index d9ab9747f..000000000 --- a/apps/pi-extension/server/serverAnnotate.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { createServer } from "node:http"; -import { dirname, resolve as resolvePath } from "node:path"; - -import { contentHash, deleteDraft } from "../generated/draft.js"; -import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; - -import { - handleDraftRequest, - handleFavicon, - handleImageRequest, - handleUploadRequest, -} from "./handlers.js"; -import { html, json, parseBody, requestUrl } from "./helpers.js"; - -import { listenOnPort } from "./network.js"; - -import { getRepoInfo } from "./project.js"; -import { - handleDocRequest, - handleDocExistsRequest, - handleFileBrowserRequest, - handleObsidianVaultsRequest, - handleObsidianFilesRequest, - handleObsidianDocRequest, -} from "./reference.js"; -import { warmFileListCache } from "../generated/resolve-file.js"; -import { createExternalAnnotationHandler } from "./external-annotations.js"; - -export interface AnnotateServerResult { - port: number; - portSource: "env" | "remote-default" | "random"; - url: string; - waitForDecision: () => Promise<{ feedback: string; annotations: unknown[]; exit?: boolean; approved?: boolean }>; - stop: () => void; -} - -export async function startAnnotateServer(options: { - markdown: string; - filePath: string; - htmlContent: string; - origin?: string; - mode?: string; - folderPath?: string; - sharingEnabled?: boolean; - shareBaseUrl?: string; - pasteApiUrl?: string; - sourceInfo?: string; - sourceConverted?: boolean; - gate?: boolean; - rawHtml?: string; - renderHtml?: boolean; -}): Promise { - // Side-channel pre-warm so /api/doc/exists POSTs land on warm cache. - void warmFileListCache(process.cwd(), "code"); - const gitUser = detectGitUser(); - 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: { - feedback: string; - annotations: unknown[]; - exit?: boolean; - approved?: boolean; - }) => void; - const decisionPromise = new Promise<{ - feedback: string; - annotations: unknown[]; - exit?: boolean; - approved?: boolean; - }>((r) => { - resolveDecision = r; - }); - - // Folder annotation has no stable markdown body, so key drafts by folder path instead. - const draftSource = - options.mode === "annotate-folder" && options.folderPath - ? `folder:${resolvePath(options.folderPath)}` - : options.renderHtml && options.rawHtml ? options.rawHtml : options.markdown; - const draftKey = contentHash(draftSource); - - // Detect repo info (cached for this session) - const repoInfo = getRepoInfo(); - - const externalAnnotations = createExternalAnnotationHandler("plan"); - - const server = createServer(async (req, res) => { - const url = requestUrl(req); - - if (await externalAnnotations.handle(req, res, url)) return; - - if (url.pathname === "/api/plan" && req.method === "GET") { - json(res, { - plan: options.markdown, - origin: options.origin ?? "pi", - mode: options.mode || "annotate", - filePath: options.filePath, - sourceInfo: options.sourceInfo, - sourceConverted: options.sourceConverted ?? false, - gate: options.gate ?? false, - renderAs: options.renderHtml && options.rawHtml ? 'html' : 'markdown', - ...(options.renderHtml && options.rawHtml ? { rawHtml: options.rawHtml } : {}), - sharingEnabled, - shareBaseUrl, - pasteApiUrl, - repoInfo, - projectRoot: options.folderPath || process.cwd(), - serverConfig: getServerConfig(gitUser), - }); - } else if (url.pathname === "/api/config" && req.method === "POST") { - try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; - const toSave: Record = {}; - if (body.displayName !== undefined) toSave.displayName = body.displayName; - if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; - if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; - if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); - json(res, { ok: true }); - } catch { - json(res, { error: "Invalid request" }, 400); - } - } else if (url.pathname === "/api/image") { - handleImageRequest(res, url); - } else if (url.pathname === "/api/upload" && req.method === "POST") { - await handleUploadRequest(req, res); - } else if (url.pathname === "/api/draft") { - await handleDraftRequest(req, res, draftKey); - } else if (url.pathname === "/api/doc" && req.method === "GET") { - // Inject source file's directory as base for relative path resolution. - // Skip for URL annotations — there's no local directory to resolve against. - if (!url.searchParams.has("base") && options.filePath && !/^https?:\/\//i.test(options.filePath)) { - url.searchParams.set("base", dirname(resolvePath(options.filePath))); - } - await handleDocRequest(res, url); - } else if (url.pathname === "/api/doc/exists" && req.method === "POST") { - await handleDocExistsRequest(res, req); - } else if (url.pathname === "/api/obsidian/vaults") { - handleObsidianVaultsRequest(res); - } else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") { - handleObsidianFilesRequest(res, url); - } else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") { - handleObsidianDocRequest(res, url); - } else if (url.pathname === "/api/reference/files" && req.method === "GET") { - handleFileBrowserRequest(res, url); - } else if (url.pathname === "/favicon.svg") { - handleFavicon(res); - } else if (url.pathname === "/api/exit" && req.method === "POST") { - deleteDraft(draftKey); - resolveDecision({ feedback: "", annotations: [], exit: true }); - json(res, { ok: true }); - } else if (url.pathname === "/api/approve" && req.method === "POST") { - deleteDraft(draftKey); - resolveDecision({ feedback: "", annotations: [], approved: true }); - json(res, { ok: true }); - } else if (url.pathname === "/api/feedback" && req.method === "POST") { - try { - const body = await parseBody(req); - deleteDraft(draftKey); - resolveDecision({ - feedback: (body.feedback as string) || "", - annotations: (body.annotations as unknown[]) || [], - }); - 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); - - return { - port, - portSource, - url: `http://localhost:${port}`, - waitForDecision: () => decisionPromise, - stop: () => server.close(), - }; -} diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts deleted file mode 100644 index 06ba52754..000000000 --- a/apps/pi-extension/server/serverPlan.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { createServer } from "node:http"; - -import { contentHash, deleteDraft } from "../generated/draft.js"; -import { - type ArchivedPlan, - generateSlug, - getPlanVersion, - getPlanVersionPath, - getVersionCount, - listArchivedPlans, - listVersions, - readArchivedPlan, - saveAnnotations, - saveFinalSnapshot, - saveToHistory, -} from "../generated/storage.js"; -import { createEditorAnnotationHandler } from "./annotations.js"; -import { createExternalAnnotationHandler } from "./external-annotations.js"; -import { - handleDraftRequest, - handleFavicon, - handleImageRequest, - handleUploadRequest, -} from "./handlers.js"; -import { html, json, parseBody, requestUrl } from "./helpers.js"; -import { openEditorDiff } from "./ide.js"; -import { - type BearConfig, - type IntegrationResult, - type ObsidianConfig, - type OctarineConfig, - saveToBear, - saveToObsidian, - saveToOctarine, -} from "./integrations.js"; -import { listenOnPort } from "./network.js"; - -import { loadConfig, saveConfig, detectGitUser, getServerConfig } from "../generated/config.js"; -import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.js"; -import { composeImproveContext } from "../generated/pfm-reminder.js"; -import { detectProjectName, getRepoInfo } from "./project.js"; -import { - handleDocRequest, - handleDocExistsRequest, - handleFileBrowserRequest, - handleObsidianDocRequest, - handleObsidianFilesRequest, - handleObsidianVaultsRequest, -} from "./reference.js"; -import { warmFileListCache } from "../generated/resolve-file.js"; - -export interface PlanReviewDecision { - approved: boolean; - feedback?: string; - savedPath?: string; - agentSwitch?: string; - permissionMode?: string; -} - -export interface PlanServerResult { - reviewId: string; - port: number; - portSource: "env" | "remote-default" | "random"; - url: string; - waitForDecision: () => Promise; - onDecision: (listener: (result: PlanReviewDecision) => void | Promise) => () => void; - waitForDone?: () => Promise; - stop: () => void; -} - -export async function startPlanReviewServer(options: { - plan: string; - htmlContent: string; - origin?: string; - permissionMode?: string; - sharingEnabled?: boolean; - shareBaseUrl?: string; - pasteApiUrl?: string; - mode?: "archive"; - customPlanPath?: string | null; -}): Promise { - // Side-channel pre-warm so /api/doc/exists POSTs land on warm cache. - void warmFileListCache(process.cwd(), "code"); - const gitUser = detectGitUser(); - 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; - - // --- Archive mode setup --- - let archivePlans: ArchivedPlan[] = []; - let initialArchivePlan = ""; - let resolveDone: (() => void) | undefined; - let donePromise: Promise | undefined; - - if (options.mode === "archive") { - archivePlans = listArchivedPlans(options.customPlanPath ?? undefined); - initialArchivePlan = - archivePlans.length > 0 - ? (readArchivedPlan( - archivePlans[0].filename, - options.customPlanPath ?? undefined, - ) ?? "") - : ""; - donePromise = new Promise((resolve) => { - resolveDone = resolve; - }); - } - - // --- Plan review mode setup (skip in archive mode) --- - const repoInfo = options.mode !== "archive" ? getRepoInfo() : null; - const slug = options.mode !== "archive" ? generateSlug(options.plan) : ""; - const project = options.mode !== "archive" ? detectProjectName() : ""; - const historyResult = - options.mode !== "archive" - ? saveToHistory(project, slug, options.plan) - : { version: 0, path: "", isNew: false }; - const previousPlan = - options.mode !== "archive" && historyResult.version > 1 - ? getPlanVersion(project, slug, historyResult.version - 1) - : null; - const versionInfo = - options.mode !== "archive" - ? { - version: historyResult.version, - totalVersions: getVersionCount(project, slug), - project, - } - : null; - - const reviewId = randomUUID(); - let resolveDecision!: (result: PlanReviewDecision) => void; - const decisionListeners = new Set<(result: PlanReviewDecision) => void | Promise>(); - let decisionSettled = false; - const decisionPromise = new Promise((r) => { - resolveDecision = r; - }); - const publishDecision = (result: PlanReviewDecision): boolean => { - if (decisionSettled) return false; - decisionSettled = true; - resolveDecision(result); - for (const listener of decisionListeners) { - Promise.resolve(listener(result)).catch((error) => { - console.error("[Plan Review] Decision listener failed:", error); - }); - } - return true; - }; - - // Draft key for annotation persistence - const draftKey = options.mode !== "archive" ? contentHash(options.plan) : ""; - - // Editor annotations (in-memory, VS Code integration — skip in archive mode) - const editorAnnotations = options.mode !== "archive" ? createEditorAnnotationHandler() : null; - const externalAnnotations = options.mode !== "archive" ? createExternalAnnotationHandler("plan") : null; - - // Lazy cache for in-session archive tab - let cachedArchivePlans: ArchivedPlan[] | null = null; - - const server = createServer(async (req, res) => { - const url = requestUrl(req); - - if (url.pathname === "/api/done" && req.method === "POST") { - resolveDone?.(); - json(res, { ok: true }); - } else if (url.pathname === "/api/archive/plans" && req.method === "GET") { - const customPath = url.searchParams.get("customPath") || undefined; - if (!cachedArchivePlans) - cachedArchivePlans = listArchivedPlans(customPath); - json(res, { plans: cachedArchivePlans }); - } else if (url.pathname === "/api/archive/plan" && req.method === "GET") { - const filename = url.searchParams.get("filename"); - const customPath = url.searchParams.get("customPath") || undefined; - if (!filename) { - json(res, { error: "Missing filename" }, 400); - return; - } - const markdown = readArchivedPlan(filename, customPath); - if (!markdown) { - json(res, { error: "Not found" }, 404); - return; - } - json(res, { markdown, filepath: filename }); - } else if (url.pathname === "/api/plan/version") { - const vParam = url.searchParams.get("v"); - if (!vParam) { - json(res, { error: "Missing v parameter" }, 400); - return; - } - const v = parseInt(vParam, 10); - if (Number.isNaN(v) || v < 1) { - json(res, { error: "Invalid version number" }, 400); - return; - } - const content = getPlanVersion(project, slug, v); - if (content === null) { - json(res, { error: "Version not found" }, 404); - return; - } - json(res, { plan: content, version: v }); - } else if (url.pathname === "/api/plan/versions") { - json(res, { project, slug, versions: listVersions(project, slug) }); - } else if (url.pathname === "/api/plan") { - if (options.mode === "archive") { - json(res, { - plan: initialArchivePlan, - origin: options.origin ?? "pi", - mode: "archive", - archivePlans, - sharingEnabled, - shareBaseUrl, - serverConfig: getServerConfig(gitUser), - }); - } else { - json(res, { - plan: options.plan, - origin: options.origin ?? "pi", - permissionMode: options.permissionMode, - previousPlan, - versionInfo, - sharingEnabled, - shareBaseUrl, - pasteApiUrl, - repoInfo, - projectRoot: process.cwd(), - serverConfig: getServerConfig(gitUser), - }); - } - } else if (url.pathname === "/api/hooks/status" && req.method === "GET") { - const config = loadConfig(); - const hook = readImprovementHook("enterplanmode-improve"); - const pfmEnabled = config.pfmReminder === true; - const composed = composeImproveContext({ pfmEnabled, improvementHookContent: hook?.content ?? null }); - json(res, { - pfmReminder: { enabled: pfmEnabled }, - improvementHook: { - present: !!hook, - filePath: hook?.filePath ?? getImprovementHookExpectedPath("enterplanmode-improve"), - fileSize: hook?.content?.length ?? null, - content: hook?.content ?? null, - }, - composedLength: composed?.length ?? null, - }); - } else if (url.pathname === "/api/config" && req.method === "POST") { - try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean; pfmReminder?: boolean }; - const toSave: Record = {}; - if (body.displayName !== undefined) toSave.displayName = body.displayName; - if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; - if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; - if (body.pfmReminder !== undefined) toSave.pfmReminder = body.pfmReminder; - if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); - json(res, { ok: true }); - } catch { - json(res, { error: "Invalid request" }, 400); - } - } else if (url.pathname === "/api/image") { - handleImageRequest(res, url); - } else if (url.pathname === "/api/upload" && req.method === "POST") { - await handleUploadRequest(req, res); - } else if (url.pathname === "/api/draft") { - await handleDraftRequest(req, res, draftKey); - } else if (editorAnnotations && (await editorAnnotations.handle(req, res, url))) { - return; - } else if (externalAnnotations && (await externalAnnotations.handle(req, res, url))) { - return; - } else if (url.pathname === "/api/doc" && req.method === "GET") { - await handleDocRequest(res, url); - } else if (url.pathname === "/api/doc/exists" && req.method === "POST") { - await handleDocExistsRequest(res, req); - } else if (url.pathname === "/api/obsidian/vaults") { - handleObsidianVaultsRequest(res); - } else if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") { - handleObsidianFilesRequest(res, url); - } else if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") { - handleObsidianDocRequest(res, url); - } else if (url.pathname === "/api/reference/files" && req.method === "GET") { - handleFileBrowserRequest(res, url); - } else if ( - url.pathname === "/api/plan/vscode-diff" && - req.method === "POST" - ) { - try { - const body = await parseBody(req); - const baseVersion = body.baseVersion as number; - if (!baseVersion) { - json(res, { error: "Missing baseVersion" }, 400); - return; - } - const basePath = getPlanVersionPath(project, slug, baseVersion); - if (!basePath) { - json(res, { error: `Version ${baseVersion} not found` }, 404); - return; - } - const result = await openEditorDiff(basePath, historyResult.path); - if ("error" in result) { - json(res, { error: result.error }, 500); - return; - } - json(res, { ok: true }); - } catch (err) { - json( - res, - { - error: - err instanceof Error - ? err.message - : "Failed to open VS Code diff", - }, - 500, - ); - } - } else if (url.pathname === "/api/agents" && req.method === "GET") { - json(res, { agents: [] }); - } else if (url.pathname === "/favicon.svg") { - handleFavicon(res); - } else if (url.pathname === "/api/save-notes" && req.method === "POST") { - const results: { - obsidian?: IntegrationResult; - bear?: IntegrationResult; - octarine?: IntegrationResult; - } = {}; - try { - const body = await parseBody(req); - const promises: Promise[] = []; - const obsConfig = body.obsidian as ObsidianConfig | undefined; - const bearConfig = body.bear as BearConfig | undefined; - const octConfig = body.octarine as OctarineConfig | undefined; - if (obsConfig?.vaultPath && obsConfig?.plan) { - promises.push( - saveToObsidian(obsConfig).then((r) => { - results.obsidian = r; - }), - ); - } - if (bearConfig?.plan) { - promises.push( - saveToBear(bearConfig).then((r) => { - results.bear = r; - }), - ); - } - if (octConfig?.plan && octConfig?.workspace) { - promises.push( - saveToOctarine(octConfig).then((r) => { - results.octarine = r; - }), - ); - } - await Promise.allSettled(promises); - for (const [name, result] of Object.entries(results)) { - if (!result?.success && result) - console.error(`[${name}] Save failed: ${result.error}`); - } - } catch (err) { - console.error(`[Save Notes] Error:`, err); - json(res, { error: "Save failed" }, 500); - return; - } - json(res, { ok: true, results }); - } else if (url.pathname === "/api/approve" && req.method === "POST") { - if (decisionSettled) { - json(res, { ok: true, duplicate: true }); - return; - } - let feedback: string | undefined; - let agentSwitch: string | undefined; - let requestedPermissionMode: string | undefined; - let planSaveEnabled = true; - let planSaveCustomPath: string | undefined; - try { - const body = await parseBody(req); - if (body.feedback) feedback = body.feedback as string; - if (body.agentSwitch) agentSwitch = body.agentSwitch as string; - if (body.permissionMode) - requestedPermissionMode = body.permissionMode as string; - if (body.planSave !== undefined) { - const ps = body.planSave as { enabled: boolean; customPath?: string }; - planSaveEnabled = ps.enabled; - planSaveCustomPath = ps.customPath; - } - // Run note integrations in parallel - const integrationResults: Record = {}; - const integrationPromises: Promise[] = []; - const obsConfig = body.obsidian as ObsidianConfig | undefined; - const bearConfig = body.bear as BearConfig | undefined; - const octConfig = body.octarine as OctarineConfig | undefined; - if (obsConfig?.vaultPath && obsConfig?.plan) { - integrationPromises.push( - saveToObsidian(obsConfig).then((r) => { - integrationResults.obsidian = r; - }), - ); - } - if (bearConfig?.plan) { - integrationPromises.push( - saveToBear(bearConfig).then((r) => { - integrationResults.bear = r; - }), - ); - } - if (octConfig?.plan && octConfig?.workspace) { - integrationPromises.push( - saveToOctarine(octConfig).then((r) => { - integrationResults.octarine = r; - }), - ); - } - await Promise.allSettled(integrationPromises); - for (const [name, result] of Object.entries(integrationResults)) { - if (!result?.success && result) - console.error(`[${name}] Save failed: ${result.error}`); - } - } catch (err) { - console.error(`[Integration] Error:`, err); - } - // Save annotations and final snapshot - let savedPath: string | undefined; - if (planSaveEnabled) { - const annotations = feedback || ""; - if (annotations) saveAnnotations(slug, annotations, planSaveCustomPath); - savedPath = saveFinalSnapshot( - slug, - "approved", - options.plan, - annotations, - planSaveCustomPath, - ); - } - deleteDraft(draftKey); - const effectivePermissionMode = requestedPermissionMode || options.permissionMode; - publishDecision({ - approved: true, - feedback, - savedPath, - agentSwitch, - permissionMode: effectivePermissionMode, - }); - json(res, { ok: true, savedPath }); - } else if (url.pathname === "/api/deny" && req.method === "POST") { - if (decisionSettled) { - json(res, { ok: true, duplicate: true }); - return; - } - let feedback = "Plan rejected by user"; - let planSaveEnabled = true; - let planSaveCustomPath: string | undefined; - try { - const body = await parseBody(req); - feedback = (body.feedback as string) || feedback; - if (body.planSave !== undefined) { - const ps = body.planSave as { enabled: boolean; customPath?: string }; - planSaveEnabled = ps.enabled; - planSaveCustomPath = ps.customPath; - } - } catch { - /* use default feedback */ - } - let savedPath: string | undefined; - if (planSaveEnabled) { - saveAnnotations(slug, feedback, planSaveCustomPath); - savedPath = saveFinalSnapshot( - slug, - "denied", - options.plan, - feedback, - planSaveCustomPath, - ); - } - deleteDraft(draftKey); - publishDecision({ approved: false, feedback, savedPath }); - json(res, { ok: true, savedPath }); - } else { - html(res, options.htmlContent); - } - }); - - const { port, portSource } = await listenOnPort(server); - - return { - reviewId, - port, - portSource, - url: `http://localhost:${port}`, - waitForDecision: () => decisionPromise, - onDecision: (listener) => { - decisionListeners.add(listener); - return () => { - decisionListeners.delete(listener); - }; - }, - ...(donePromise && { waitForDone: () => donePromise }), - stop: () => server.close(), - }; -} diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts deleted file mode 100644 index 98fa2deaa..000000000 --- a/apps/pi-extension/server/serverReview.ts +++ /dev/null @@ -1,1182 +0,0 @@ -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-types.js"; -import { - type DiffType, - type GitContext, - getFileContentsForDiff as getFileContentsForDiffCore, - parseWorktreeDiffType, - resolveBaseBranch, - 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, - buildCodexCommand, - generateOutputPath, - parseCodexOutput, - transformReviewFindings, -} from "../generated/codex-review.js"; -import { buildAgentReviewUserMessage } from "../generated/agent-review-message.js"; -import { - CLAUDE_REVIEW_PROMPT, - buildClaudeCommand, - parseClaudeStreamOutput, - transformClaudeFindings, -} from "../generated/claude-review.js"; -import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "../generated/tour-review.js"; -import { - type CodeNavRequest, - type CodeNavRuntime, - resolveCodeNav, - validateCodeNavRequest, - extractChangedFiles, -} from "../generated/code-nav.js"; -import { - canStageFiles, - detectRemoteDefaultCompareTarget, - getVcsContext, - getVcsFileContentsForDiff, - resolveVcsCwd, - reviewRuntime, - runVcsDiff, - stageFile, - unstageFile, -} from "./vcs.js"; - -const piCodeNavRuntime: CodeNavRuntime = { - runCommand(command, args, options) { - return new Promise((resolve) => { - const proc = spawn(command, args, { - cwd: options?.cwd, - stdio: ["ignore", "pipe", "pipe"], - }); - let timer: ReturnType | undefined; - if (options?.timeoutMs) { - timer = setTimeout(() => proc.kill(), options.timeoutMs); - } - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - proc.stdout!.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); - proc.stderr!.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); - proc.on("close", (code: number | null) => { - 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: "command not found", exitCode: 1 }); - }); - }); - }, -}; - -/** 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 async function startReviewServer(options: { - rawPatch: string; - gitRef: string; - htmlContent: string; - origin?: string; - diffType?: DiffType; - gitContext?: GitContext; - /** - * Initial base branch the caller used to compute `rawPatch`. When a caller - * overrides the detected default (e.g. `openCodeReview({ defaultBranch })`), - * this must be forwarded so the server's internal `currentBase` state, the - * `/api/diff` response, and downstream agent prompts stay consistent with - * the patch that's already on screen. - */ - initialBase?: string; - error?: string; - sharingEnabled?: boolean; - shareBaseUrl?: string; - pasteApiUrl?: string; - prMetadata?: PRMetadata; - /** Working directory for agent processes (e.g., --local worktree). Independent of diff pipeline. */ - agentCwd?: string; - /** Per-PR worktree pool. When set, pr-switch creates worktrees instead of checking out. */ - worktreePool?: WorktreePool; - /** Cleanup callback invoked when server stops (e.g., remove temp worktree) */ - onCleanup?: () => void | Promise; - /** Called when server starts with the URL, remote status, and port */ - onReady?: (url: string, isRemote: boolean, port: number) => void; -}): Promise { - const gitUser = detectGitUser(); - let draftKey = contentHash(options.rawPatch); - let prMeta = options.prMetadata; - const isPRMode = !!prMeta; - const hasLocalAccess = !!options.gitContext; - const sessionVcsType = options.gitContext?.vcsType; - 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-types.js").PRListItem[] | null = null; - let prListCacheTime = 0; - const prSwitchCache = new Map(); - if (isPRMode && prMeta) prSwitchCache.set(prMeta.url, { metadata: prMeta, rawPatch: options.rawPatch }); - const prStackTreeCache = new Map(); - - // Fetch full stack tree (best-effort — always try in PR mode so root PRs - // that target the default branch can still discover descendant PRs) - let prStackTree: import("../generated/pr-types.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. - const detectedCompareTarget = (): string => - options.gitContext?.defaultBranch || options.gitContext?.compareTarget?.fallback || "main"; - let currentBase = options.initialBase || detectedCompareTarget(); - let baseEverSwitched = false; - - // Fire-and-forget: query the remote for its actual default branch. - if (options.gitContext && !options.initialBase && !isPRMode) { - detectRemoteDefaultCompareTarget(options.gitContext.cwd, sessionVcsType).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; - return resolveVcsCwd(currentDiffType, 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 = buildAgentReviewUserMessage(currentPatch, currentDiffType, userMessageOptions, prMeta); - - if (provider === "codex") { - const model = typeof config?.model === "string" && config.model ? config.model : undefined; - const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined; - const fastMode = config?.fastMode === true; - const outputPath = generateOutputPath(); - const prompt = CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage; - const command = await buildCodexCommand({ cwd, outputPath, prompt, model, reasoningEffort, fastMode }); - return { command, outputPath, prompt, cwd, label: "Code Review", model, reasoningEffort, fastMode: fastMode || undefined, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext }; - } - - if (provider === "claude") { - const model = typeof config?.model === "string" && config.model ? config.model : undefined; - const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined; - const prompt = CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage; - const { command, stdinPrompt } = buildClaudeCommand(prompt, model, effort); - return { command, stdinPrompt, prompt, cwd, label: "Code Review", captureStdout: true, model, effort, prUrl: launchPrUrl, diffScope: launchDiffScope, diffContext }; - } - - return null; - }, - - async onJobComplete(job, meta) { - const cwd = meta.cwd ?? resolveAgentCwd(); - const jobPrUrl = job.prUrl; - const jobDiffScope = job.diffScope; - const jobPrMeta = jobPrUrl ? prSwitchCache.get(jobPrUrl)?.metadata : undefined; - const jobPrContext = jobPrMeta ? { - prUrl: jobPrUrl, - prNumber: jobPrMeta.platform === "github" ? jobPrMeta.number : jobPrMeta.iid, - prTitle: jobPrMeta.title, - prRepo: getDisplayRepo(jobPrMeta), - } : jobPrUrl ? { prUrl: jobPrUrl } : {}; - - if (job.provider === "codex" && meta.outputPath) { - const output = await parseCodexOutput(meta.outputPath); - if (!output) return; - - const hasBlockingFindings = output.findings.some(f => f.priority !== null && f.priority <= 1); - job.summary = { - correctness: hasBlockingFindings ? "Issues Found" : output.overall_correctness, - explanation: output.overall_explanation, - confidence: output.overall_confidence_score, - }; - - if (output.findings.length > 0) { - const annotations = transformReviewFindings(output.findings, job.source, cwd, "Codex") - .map(a => ({ ...a, ...jobPrContext, ...(jobDiffScope && { diffScope: jobDiffScope }) })); - const result = externalAnnotations.addAnnotations({ annotations }); - if ("error" in result) console.error(`[codex-review] addAnnotations error:`, result.error); - } - return; - } - - if (job.provider === "claude" && meta.stdout) { - const output = parseClaudeStreamOutput(meta.stdout); - if (!output) { - console.error(`[claude-review] Failed to parse output (${meta.stdout.length} bytes, last 200: ${meta.stdout.slice(-200)})`); - return; - } - - const total = output.summary.important + output.summary.nit + output.summary.pre_existing; - job.summary = { - correctness: output.summary.important === 0 ? "Correct" : "Issues Found", - explanation: `${output.summary.important} important, ${output.summary.nit} nit, ${output.summary.pre_existing} pre-existing`, - confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (output.summary.important * 0.2)), - }; - - if (output.findings.length > 0) { - const annotations = transformClaudeFindings(output.findings, job.source, cwd) - .map(a => ({ ...a, ...jobPrContext, ...(jobDiffScope && { diffScope: jobDiffScope }) })); - const result = externalAnnotations.addAnnotations({ annotations }); - if ("error" in result) console.error(`[claude-review] addAnnotations error:`, result.error); - } - return; - } - - if (job.provider === "tour") { - const { summary } = await tour.onJobComplete({ job, meta }); - if (summary) { - job.summary = summary; - } else { - // The process exited 0 but the model returned empty or malformed output - // and nothing was stored. Flip status so the client doesn't auto-open - // a successful-looking card that 404s on /api/tour/:id. - job.status = "failed"; - job.error = TOUR_EMPTY_OUTPUT_ERROR; - } - return; - } - }, - }); - const sharingEnabled = - options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; - const shareBaseUrl = - (options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined; - const pasteApiUrl = - (options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined; - let resolveDecision!: (result: { - approved: boolean; - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - exit?: boolean; - }) => void; - const decisionPromise = new Promise<{ - approved: boolean; - feedback: string; - annotations: unknown[]; - agentSwitch?: string; - exit?: boolean; - }>((r) => { - resolveDecision = r; - }); - - // AI provider setup (graceful — AI features degrade if SDK unavailable) - // Types are `any` because @plannotator/ai is a dynamic import - let aiEndpoints: Record Promise> | null = - null; - let aiSessionManager: { disposeAll: () => void } | null = null; - let aiRegistry: { disposeAll: () => void } | null = null; - try { - const ai = await import("../generated/ai/index.js"); - const registry = new ai.ProviderRegistry(); - const sessionManager = new ai.SessionManager(); - - // which() helper for Node.js - const whichCmd = (cmd: string): string | null => { - try { - return ( - execSync(`which ${cmd}`, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim() || null - ); - } catch { - return null; - } - }; - - // Claude Agent SDK - try { - // @ts-ignore — dynamic import; Bun-only types resolved at runtime - await import("../generated/ai/providers/claude-agent-sdk.js"); - const claudePath = whichCmd("claude"); - const provider = await ai.createProvider({ - type: "claude-agent-sdk", - cwd: process.cwd(), - ...(claudePath && { claudeExecutablePath: claudePath }), - }); - registry.register(provider); - } catch { - /* Claude SDK not available */ - } - - // Codex SDK - try { - // @ts-ignore — dynamic import; Bun-only types resolved at runtime - await import("../generated/ai/providers/codex-sdk.js"); - await import("@openai/codex-sdk"); - const codexPath = whichCmd("codex"); - const provider = await ai.createProvider({ - type: "codex-sdk", - cwd: process.cwd(), - ...(codexPath && { codexExecutablePath: codexPath }), - }); - registry.register(provider); - } catch { - /* Codex SDK not available */ - } - - // Pi SDK (Node.js variant) - try { - await import("../generated/ai/providers/pi-sdk-node.js"); - const piPath = whichCmd("pi"); - if (piPath) { - const provider = await ai.createProvider({ - type: "pi-sdk", - cwd: process.cwd(), - piExecutablePath: piPath, - } as any); - if (provider && "fetchModels" in provider) { - await ( - provider as { fetchModels: () => Promise } - ).fetchModels(); - } - registry.register(provider); - } - } catch { - /* Pi not available */ - } - - // OpenCode SDK - try { - // @ts-ignore — dynamic import; Bun-only types resolved at runtime - await import("../generated/ai/providers/opencode-sdk.js"); - const opencodePath = whichCmd("opencode"); - if (opencodePath) { - const provider = await ai.createProvider({ - type: "opencode-sdk", - cwd: process.cwd(), - }); - if (provider && "fetchModels" in provider) { - await ( - provider as { fetchModels: () => Promise } - ).fetchModels(); - } - registry.register(provider); - } - } catch { - /* OpenCode not available */ - } - - if (registry.size > 0) { - aiEndpoints = ai.createAIEndpoints({ - registry, - sessionManager, - getCwd: resolveAgentCwd, - }); - aiSessionManager = sessionManager; - aiRegistry = registry; - } - } catch { - /* AI backbone not available */ - } - - const server = createServer(async (req, res) => { - const url = requestUrl(req); - - // API: Get tour result - if (url.pathname.match(/^\/api\/tour\/[^/]+$/) && req.method === "GET") { - const jobId = url.pathname.slice("/api/tour/".length); - const result = tour.getTour(jobId); - if (!result) { - json(res, { error: "Tour not found" }, 404); - return; - } - json(res, result); - return; - } - - // API: Save tour checklist state - const checklistMatch = url.pathname.match(/^\/api\/tour\/([^/]+)\/checklist$/); - if (checklistMatch && req.method === "PUT") { - const jobId = checklistMatch[1]; - try { - const body = await parseBody(req) as { checked: boolean[] }; - if (Array.isArray(body.checked)) tour.saveChecklist(jobId, body.checked); - json(res, { ok: true }); - } catch { - json(res, { error: "Invalid JSON" }, 400); - } - return; - } - - if (url.pathname === "/api/diff" && req.method === "GET") { - json(res, { - rawPatch: currentPatch, - gitRef: currentGitRef, - origin: options.origin ?? "pi", - diffType: hasLocalAccess ? currentDiffType : undefined, - // Echo the active base so page refresh/reconnect rehydrates the - // picker to what the server is actually using, not the detected default. - base: hasLocalAccess ? currentBase : undefined, - hideWhitespace: currentHideWhitespace, - gitContext: hasLocalAccess ? options.gitContext : undefined, - sharingEnabled, - shareBaseUrl, - pasteApiUrl, - repoInfo, - isWSL: wslFlag, - ...(options.agentCwd && { agentCwd: options.agentCwd }), - ...(isPRMode && { - prMetadata: prMeta, - platformUser, - prStackInfo, - prStackTree, - prDiffScope: currentPRDiffScope, - prDiffScopeOptions, - }), - ...(isPRMode && initialViewedFiles.length > 0 && { viewedFiles: initialViewedFiles }), - ...(currentError && { error: currentError }), - serverConfig: getServerConfig(gitUser), - }); - } else if (url.pathname === "/api/diff/switch" && req.method === "POST") { - if (!hasLocalAccess) { - json(res, { error: "Not available without local file access" }, 400); - return; - } - try { - const body = await parseBody(req); - const newType = body.diffType as DiffType; - if (!newType) { - json(res, { error: "Missing diffType" }, 400); - return; - } - if (typeof body.hideWhitespace === "boolean") { - currentHideWhitespace = body.hideWhitespace; - } - const detectedBase = detectedCompareTarget(); - const base = resolveBaseBranch( - typeof body.base === "string" ? body.base : undefined, - detectedBase, - ); - const defaultCwd = options.gitContext?.cwd; - const result = await runVcsDiff(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 effectiveCwd = resolveVcsCwd(newType, options.gitContext.cwd); - updatedContext = await getVcsContext(effectiveCwd, sessionVcsType); - } 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 = detectedCompareTarget(); - const base = resolveBaseBranch( - url.searchParams.get("base") ?? undefined, - detectedBase, - ); - const defaultCwd = options.gitContext?.cwd; - const result = await getVcsFileContentsForDiff( - 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/code-nav/resolve" && req.method === "POST") { - const hasCodeNavAccess = !!options.gitContext || !!options.agentCwd || !!options.worktreePool; - if (!hasCodeNavAccess) { - json(res, { error: "Code navigation requires local access" }, 400); - return; - } - try { - const body = (await parseBody(req)) as unknown as CodeNavRequest; - const error = validateCodeNavRequest(body); - if (error) { - json(res, { error }, 400); - return; - } - const navCwd = resolveAgentCwd(); - const changedFiles = extractChangedFiles(currentPatch); - const result = await resolveCodeNav(piCodeNavRuntime, body, navCwd, changedFiles); - json(res, result); - } catch (err) { - json(res, { error: err instanceof Error ? err.message : "Code navigation failed" }, 500); - } - } else if (url.pathname === "/api/code-nav/file" && req.method === "GET") { - const hasCodeNavAccess = !!options.gitContext || !!options.agentCwd || !!options.worktreePool; - if (!hasCodeNavAccess) { - json(res, { error: "Code navigation requires local access" }, 400); - return; - } - 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; - } - try { - const navCwd = resolveAgentCwd(); - const content = readFileSync(`${navCwd}/${filePath}`, "utf-8"); - json(res, { content }); - } catch { - json(res, { error: "File not found" }, 404); - } - } else if (url.pathname === "/api/config" && req.method === "POST") { - try { - const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record; conventionalComments?: boolean }; - const toSave: Record = {}; - if (body.displayName !== undefined) toSave.displayName = body.displayName; - if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions; - if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments; - if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); - json(res, { ok: true }); - } catch { - json(res, { error: "Invalid request" }, 400); - } - } else if (url.pathname === "/api/image") { - handleImageRequest(res, url); - } else if (url.pathname === "/api/upload" && req.method === "POST") { - await handleUploadRequest(req, res); - } else if (url.pathname === "/api/agents" && req.method === "GET") { - json(res, { agents: [] }); - } else if (url.pathname === "/api/git-add" && req.method === "POST") { - const stageCwd = resolveVcsCwd(currentDiffType, options.gitContext?.cwd); - if (isPRMode || !(await canStageFiles(currentDiffType, stageCwd))) { - 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; - } - if (body.undo) { - await unstageFile(currentDiffType, filePath, stageCwd); - } else { - await stageFile(currentDiffType, filePath, stageCwd); - } - json(res, { ok: true }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to stage file"; - json(res, { error: message }, 500); - } - } else if (url.pathname === "/api/draft") { - await handleDraftRequest(req, res, draftKey); - } else if (url.pathname === "/favicon.svg") { - handleFavicon(res); - } else if (await editorAnnotations.handle(req, res, url)) { - return; - } else if (await externalAnnotations.handle(req, res, url)) { - return; - } else if (await agentJobs.handle(req, res, url)) { - return; - } else if (aiEndpoints && url.pathname.startsWith("/api/ai/")) { - const handler = aiEndpoints[url.pathname]; - if (handler) { - try { - const webReq = toWebRequest(req); - const webRes = await handler(webReq); - // Pipe Web Response → node:http response - const headers: Record = {}; - webRes.headers.forEach((v, k) => { - headers[k] = v; - }); - res.writeHead(webRes.status, headers); - if (webRes.body) { - const nodeStream = Readable.fromWeb(webRes.body as any); - nodeStream.pipe(res); - } else { - res.end(); - } - } catch (err) { - json( - res, - { error: err instanceof Error ? err.message : "AI endpoint error" }, - 500, - ); - } - return; - } - json(res, { error: "Not found" }, 404); - } else if (url.pathname === "/api/exit" && req.method === "POST") { - deleteDraft(draftKey); - resolveDecision({ approved: false, feedback: '', annotations: [], exit: true }); - json(res, { ok: true }); - } else if (url.pathname === "/api/feedback" && req.method === "POST") { - try { - const body = await parseBody(req); - deleteDraft(draftKey); - resolveDecision({ - approved: (body.approved as boolean) ?? false, - feedback: (body.feedback as string) || "", - annotations: (body.annotations as unknown[]) || [], - agentSwitch: body.agentSwitch as string | undefined, - }); - json(res, { ok: true }); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to process feedback"; - json(res, { error: message }, 500); - } - } else { - html(res, options.htmlContent); - } - }); - - const { port, portSource } = await listenOnPort(server); - serverUrl = `http://localhost:${port}`; - const exitHandler = () => agentJobs.killAll(); - process.once("exit", exitHandler); - - if (options.onReady) { - options.onReady(serverUrl, isRemote, port); - } - - return { - port, - portSource, - url: serverUrl, - isRemote, - waitForDecision: () => decisionPromise, - stop: () => { - process.removeListener("exit", exitHandler); - agentJobs.killAll(); - aiSessionManager?.disposeAll(); - aiRegistry?.disposeAll(); - server.close(); - // Invoke cleanup callback (e.g., remove temp worktree) - if (options.onCleanup) { - try { - const result = options.onCleanup(); - if (result instanceof Promise) result.catch(() => {}); - } catch { /* best effort */ } - } - }, - }; -} diff --git a/apps/pi-extension/server/vcs.ts b/apps/pi-extension/server/vcs.ts deleted file mode 100644 index 628b72e38..000000000 --- a/apps/pi-extension/server/vcs.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { - type DiffResult, - type DiffType, - type GitCommandResult, - type GitContext, - type GitDiffOptions, - type ReviewGitRuntime, - getGitContext as getGitContextCore, - runGitDiff as runGitDiffCore, -} from "../generated/review-core.js"; -import { - type ReviewJjRuntime, -} from "../generated/jj-core.js"; -import { - type VcsSelection, - createGitProvider, - createJjProvider, - createVcsApi, - resolveInitialDiffType, -} from "../generated/vcs-core.js"; - -function runCommand( - command: string, - args: string[], - notFoundMessage: string, - options?: { cwd?: string; timeoutMs?: number }, -): Promise { - return new Promise((resolve) => { - const proc = spawn(command, args, { - cwd: options?.cwd, - stdio: ["ignore", "pipe", "pipe"], - }); - - let timer: ReturnType | undefined; - if (options?.timeoutMs) { - timer = setTimeout(() => proc.kill(), options.timeoutMs); - } - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - proc.stdout!.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); - proc.stderr!.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); - - proc.on("close", (code) => { - if (timer) clearTimeout(timer); - resolve({ - stdout: Buffer.concat(stdoutChunks).toString("utf-8"), - stderr: Buffer.concat(stderrChunks).toString("utf-8"), - exitCode: code ?? 1, - }); - }); - - proc.on("error", () => { - if (timer) clearTimeout(timer); - resolve({ stdout: "", stderr: notFoundMessage, exitCode: 1 }); - }); - }); -} - -export const reviewRuntime: ReviewGitRuntime = { - runGit( - args: string[], - options?: { cwd?: string; timeoutMs?: number }, - ): Promise { - return runCommand("git", ["-c", "core.quotePath=false", ...args], "git not found", options); - }, - - async readTextFile(path: string): Promise { - try { - return readFileSync(path, "utf-8"); - } catch { - return null; - } - }, -}; - -export const jjRuntime: ReviewJjRuntime = { - runJj( - args: string[], - options?: { cwd?: string; timeoutMs?: number }, - ): Promise { - return runCommand("jj", args, "jj not found", options); - }, -}; - -const api = createVcsApi([ - createJjProvider(jjRuntime), - createGitProvider(reviewRuntime), -]); - -export const { - detectVcs, - getVcsContext, - detectRemoteDefaultCompareTarget, - prepareLocalReviewDiff, - runVcsDiff, - getVcsFileContentsForDiff, - canStageFiles, - stageFile, - unstageFile, - resolveVcsCwd, -} = api; - -export { resolveInitialDiffType }; -export type { VcsSelection }; - -export function getGitContext(cwd?: string): Promise { - return getGitContextCore(reviewRuntime, cwd); -} - -export function runGitDiff( - diffType: DiffType, - defaultBranch = "main", - cwd?: string, - options?: GitDiffOptions, -): Promise { - return runGitDiffCore(reviewRuntime, diffType, defaultBranch, cwd, options); -} diff --git a/apps/pi-extension/tsconfig.json b/apps/pi-extension/tsconfig.json index 9dfd596da..df95457bc 100644 --- a/apps/pi-extension/tsconfig.json +++ b/apps/pi-extension/tsconfig.json @@ -11,6 +11,6 @@ "moduleDetection": "force", "types": ["node"] }, - "include": ["*.ts", "server/**/*.ts"], + "include": ["*.ts", "generated/**/*.ts"], "exclude": ["**/*.test.ts"] } diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 1c95e2b70..45ada08a7 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -5,41 +5,9 @@ set -euo pipefail cd "$(dirname "$0")" rm -rf generated -mkdir -p generated generated/ai/providers +mkdir -p generated -for f in feedback-templates prompts review-core jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown url-to-markdown tour annotate-args at-reference pfm-reminder improvement-hooks code-nav; do +for f in prompts review-core vcs-core jj-core review-args checklist reference-common code-file resolve-file config html-to-markdown url-to-markdown annotate-args at-reference pfm-reminder improvement-hooks plugin-binary plugin-protocol plugin-client agents; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done - -# Vendor review agent modules from packages/server/ — rewrite imports for generated/ layout -for f in agent-review-message codex-review claude-review path-utils; do - src="../../packages/server/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/server/%s.ts\n' "$f" | cat - "$src" \ - | sed 's|from "./vcs"|from "./review-core.js"|' \ - | sed 's|from "./pr"|from "./pr-provider.js"|' \ - | sed 's|from "./path-utils"|from "./path-utils.js"|' \ - > "generated/$f.ts" -done - -# tour-review lives in packages/server/tour/ — parent-relative imports and the -# shared tour types package each map to the flat generated/ layout. -for f in tour-review; do - src="../../packages/server/tour/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/server/tour/%s.ts\n' "$f" | cat - "$src" \ - | sed 's|from "\.\./vcs"|from "./review-core.js"|' \ - | sed 's|from "\.\./pr"|from "./pr-provider.js"|' \ - | sed 's|from "\.\./agent-review-message"|from "./agent-review-message.js"|' \ - | sed 's|from "@plannotator/shared/tour"|from "./tour.js"|' \ - > "generated/$f.ts" -done - -for f in index types provider session-manager endpoints context base-session; do - src="../../packages/ai/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/%s.ts\n' "$f" | cat - "$src" > "generated/ai/$f.ts" -done - -for f in claude-agent-sdk codex-sdk opencode-sdk pi-sdk pi-sdk-node pi-events; do - src="../../packages/ai/providers/$f.ts" - printf '// @generated — DO NOT EDIT. Source: packages/ai/providers/%s.ts\n' "$f" | cat - "$src" > "generated/ai/providers/$f.ts" -done diff --git a/apps/skills/plannotator-last/SKILL.md b/apps/skills/plannotator-last/SKILL.md index 9df2b32e5..b37ed76ef 100644 --- a/apps/skills/plannotator-last/SKILL.md +++ b/apps/skills/plannotator-last/SKILL.md @@ -7,10 +7,6 @@ description: Open Plannotator on the latest rendered assistant message and use t Use this skill when the user wants to annotate the latest assistant response in Plannotator. -Do not send a commentary/status message before running the command. The command -targets the latest rendered assistant response, so a preamble can mistakenly become the -thing being annotated. - Run: ```bash diff --git a/bin/plannotator.cmd b/bin/plannotator.cmd new file mode 100644 index 000000000..1811fbc76 --- /dev/null +++ b/bin/plannotator.cmd @@ -0,0 +1,2 @@ +@echo off +node "%~dp0plannotator.js" %* diff --git a/bin/plannotator.js b/bin/plannotator.js index 29e42ccc9..6487cb1f6 100755 --- a/bin/plannotator.js +++ b/bin/plannotator.js @@ -11,14 +11,31 @@ if (!fs.existsSync(sourceEntry)) { process.exit(1); } -const result = childProcess.spawnSync("bun", [sourceEntry, ...process.argv.slice(2)], { +const child = childProcess.spawn("bun", [sourceEntry, ...process.argv.slice(2)], { cwd: process.cwd(), stdio: "inherit", }); -if (result.error) { - console.error(result.error.message); +let forwardedSignal = null; +const forwardSignal = (signal) => { + forwardedSignal = signal; + if (!child.killed) child.kill(signal); +}; + +process.once("SIGINT", () => forwardSignal("SIGINT")); +process.once("SIGTERM", () => forwardSignal("SIGTERM")); + +child.on("error", (err) => { + console.error(err.message); process.exit(1); -} +}); -process.exit(typeof result.status === "number" ? result.status : 0); +child.on("exit", (code, signal) => { + if (code !== null) { + process.exit(code); + } + if (signal || forwardedSignal) { + process.exit(signal === "SIGINT" || forwardedSignal === "SIGINT" ? 130 : 143); + } + process.exit(1); +}); diff --git a/bun.lock b/bun.lock index bc637b46a..327880162 100644 --- a/bun.lock +++ b/bun.lock @@ -16,9 +16,11 @@ "sonner": "^2.0.7", }, "devDependencies": { + "@types/dompurify": "^3.2.0", "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", "bun-types": "^1.3.11", + "typescript": "~5.8.2", }, }, "apps/hook": { @@ -69,7 +71,6 @@ "@opencode-ai/plugin": "^1.1.10", }, "devDependencies": { - "@plannotator/server": "workspace:*", "@plannotator/shared": "workspace:*", }, "peerDependencies": { @@ -192,7 +193,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.19.17", + "version": "0.19.18", "dependencies": { "@pierre/diffs": "^1.1.12", "@plannotator/ai": "workspace:*", @@ -1097,6 +1098,8 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], diff --git a/docs/single-binary-runtime.md b/docs/single-binary-runtime.md new file mode 100644 index 000000000..99f416491 --- /dev/null +++ b/docs/single-binary-runtime.md @@ -0,0 +1,73 @@ +# Single Binary Runtime + +Plannotator has one UI server runtime: the Bun server compiled into the released `plannotator` binary. Claude Code invokes that binary directly. OpenCode and Pi are binary clients. + +## Phase One Boundary + +OpenCode and Pi discover the binary with this order: + +1. `PLANNOTATOR_BIN` +2. `plannotator` on `PATH` +3. Standard install locations such as `~/.local/bin/plannotator` + +Clients call `plannotator plugin capabilities` first and require the versioned `plannotator-plugin` protocol. If the binary is missing or incompatible, clients can run the official installer unless `PLANNOTATOR_DISABLE_AUTO_INSTALL` is set. + +The binary-owned plugin surface is: + +- `plannotator plugin capabilities` +- `plannotator plugin plan --origin opencode|pi` +- `plannotator plugin review --origin opencode|pi` +- `plannotator plugin annotate --origin opencode|pi` +- `plannotator plugin archive --origin opencode|pi` + +Requests and responses are JSON over stdin/stdout today. The protocol is intentionally transport-neutral so the same request and result shapes can be implemented by an IPC or HTTP daemon later. + +## What Plugins Own + +OpenCode owns OpenCode behavior: workflow/prompt transforms, `submit_plan`, backing-file edits, line-number denial feedback, slash-command interception, feedback injection, and agent switching. + +Pi owns Pi behavior: phase state, tool gating, non-UI auto-approval, checklist progress, slash commands, current-session fallback, and `plannotator:request` / `plannotator:review-result` compatibility. + +Neither plugin owns browser HTML assets, starts Plannotator HTTP servers, or ships the mirrored Pi `node:http` server. + +## Daemon Next + +Phase one is daemon-ready, not the final daemon. The current binary still starts request-scoped browser sessions behind the plugin protocol. The follow-on daemon should be one long-running binary-owned service with: + +- session creation for plan, review, annotate, and archive requests +- stable session IDs returned before human review completes +- session-scoped browser URLs and API routing +- decision delivery back to the requesting client +- cancellation and TTL cleanup for abandoned sessions +- concurrent requests from Claude Code, OpenCode, Pi, Codex, Gemini, and Copilot without state collisions + +The current `packages/server/sessions.ts` registry is a session discovery aid, not the final multi-session daemon. + +## Future Phases + +### 1. Single Binary Runtime + +Status: completed in the single-server migration. + +The released Bun binary is the only Plannotator server/UI runtime. OpenCode and Pi discover and call the installed binary instead of importing server code, copying browser HTML, or shipping a mirrored server. + +### 2. Dumb Plugin Clients + +Move more integration behavior behind the binary protocol so OpenCode and Pi do less local Plannotator work. The binary should own prompt formatting, command argument interpretation, content preparation, and config-driven Plannotator wording wherever practical. + +The target shape is: + +- plugin receives command/hook/event input +- plugin calls the binary with raw or lightly structured input +- binary returns exact actions/messages to inject +- plugin applies the result to its host agent + +This phase should shrink or remove Pi's `vendor.sh` by eliminating most generated shared-helper imports from the Pi package. + +### 3. True Multi-Session Daemon + +Turn `plannotator` into one long-running service that can host concurrent plan, review, annotate, and archive sessions. This requires stable session IDs, session-scoped browser URLs and API routing, result delivery back to the requesting client, cancellation, cleanup, and collision-free state management across multiple agent runtimes. + +### 4. Transport Swap + +Keep the protocol shape from phase one, but replace subprocess-backed `plannotator plugin ...` calls with IPC or HTTP calls to the daemon. OpenCode and Pi should not need another behavior rewrite if the protocol remains stable. diff --git a/openpackage.yml b/openpackage.yml index f79bb5d5d..0fb0ea141 100644 --- a/openpackage.yml +++ b/openpackage.yml @@ -1,5 +1,5 @@ name: plannotator -version: 0.19.18 +version: 0.19.17 description: Annotate Claude Code and other agent plans and review code visually. Share with your team, and send feedback to your agent with one click author: backnotprop license: MIT/Apache2.0 diff --git a/package.json b/package.json index 37d327312..3fb1540fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plannotator", - "version": "0.19.18", + "version": "0.19.17", "private": true, "description": "Interactive Plan Review for Claude Code - annotate plans visually, share with team, automatically send feedback", "author": "backnotprop", @@ -32,7 +32,7 @@ "dev:vscode": "bun run --cwd apps/vscode-extension watch", "build:vscode": "bun run --cwd apps/vscode-extension build", "package:vscode": "bun run --cwd apps/vscode-extension package", - "test": "bun test", + "test": "bash apps/pi-extension/vendor.sh && bun test", "typecheck": "bash apps/pi-extension/vendor.sh && tsc --noEmit -p packages/shared/tsconfig.json && tsc --noEmit -p packages/ai/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ui/tsconfig.json && tsc --noEmit -p apps/pi-extension/tsconfig.json" }, "dependencies": { @@ -47,8 +47,10 @@ "sonner": "^2.0.7" }, "devDependencies": { + "@types/dompurify": "^3.2.0", "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", - "bun-types": "^1.3.11" + "bun-types": "^1.3.11", + "typescript": "~5.8.2" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index 21c1f17b8..b42c59d01 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -40,7 +40,10 @@ "./annotate-args": "./annotate-args.ts", "./at-reference": "./at-reference.ts", "./code-nav": "./code-nav.ts", - "./goal-setup": "./goal-setup.ts" + "./goal-setup": "./goal-setup.ts", + "./plugin-protocol": "./plugin-protocol.ts", + "./plugin-binary": "./plugin-binary.ts", + "./plugin-client": "./plugin-client.ts" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", diff --git a/packages/shared/plugin-binary.test.ts b/packages/shared/plugin-binary.test.ts new file mode 100644 index 000000000..241c64617 --- /dev/null +++ b/packages/shared/plugin-binary.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, test } from "bun:test"; +import { + discoverPlannotatorBinary, + discoverPlannotatorBinaryCandidates, + discoverInstalledPlannotatorBinary, + getOfficialInstallerCommand, + isCompatiblePluginBinary, + parsePluginCapabilities, + shouldAutoInstallPlannotator, + findPlannotatorSourceRoot, +} from "./plugin-binary"; +import { getPluginCapabilities } from "./plugin-protocol"; + +function existsOnly(paths: string[]) { + const set = new Set(paths); + return (candidate: string) => set.has(candidate); +} + +describe("discoverPlannotatorBinary", () => { + test("prefers PLANNOTATOR_BIN when it exists", () => { + const result = discoverPlannotatorBinary({ + env: { PLANNOTATOR_BIN: "/custom/plannotator", PATH: "/bin" }, + homeDir: "/home/test", + exists: existsOnly(["/custom/plannotator", "/bin/plannotator"]), + platform: "darwin", + }); + + expect(result).toMatchObject({ + found: true, + path: "/custom/plannotator", + source: "env", + }); + }); + + test("falls back to PATH when explicit override is missing", () => { + const result = discoverPlannotatorBinary({ + env: { PATH: "/one:/two" }, + homeDir: "/home/test", + exists: existsOnly(["/two/plannotator"]), + platform: "linux", + pathDelimiter: ":", + }); + + expect(result).toMatchObject({ + found: true, + path: "/two/plannotator", + source: "path", + }); + }); + + test("uses a source checkout shim before PATH when provided", () => { + const result = discoverPlannotatorBinary({ + env: { PATH: "/old" }, + homeDir: "/home/test", + sourceRoot: "/repo/plannotator", + exists: existsOnly([ + "/repo/plannotator/bin/plannotator.js", + "/old/plannotator", + ]), + platform: "linux", + pathDelimiter: ":", + }); + + expect(result).toMatchObject({ + found: true, + path: "/repo/plannotator/bin/plannotator.js", + source: "source", + }); + }); + + test("uses a Windows source checkout shim before PATH when provided", () => { + const result = discoverPlannotatorBinary({ + env: { PATH: "C:\\Old" }, + homeDir: "C:\\Users\\test", + sourceRoot: "C:\\repo\\plannotator", + exists: existsOnly([ + "C:\\repo\\plannotator/bin/plannotator.cmd", + "C:\\Old/plannotator.exe", + ]), + platform: "win32", + pathDelimiter: ";", + }); + + expect(result).toMatchObject({ + found: true, + path: "C:\\repo\\plannotator/bin/plannotator.cmd", + source: "source", + }); + }); + + test("finds a source root by walking up to the repo shim", () => { + const existing = new Set([ + "/repo/plannotator/bin/plannotator.js", + "/repo/plannotator/apps/hook/server/index.ts", + ]); + + expect(findPlannotatorSourceRoot( + "/repo/plannotator/apps/pi-extension/generated", + existsOnly([...existing]), + )).toBe("/repo/plannotator"); + }); + + test("falls back to standard install location", () => { + const result = discoverPlannotatorBinary({ + env: { PATH: "/one" }, + homeDir: "/home/test", + exists: existsOnly(["/home/test/.local/bin/plannotator"]), + platform: "linux", + pathDelimiter: ":", + }); + + expect(result).toMatchObject({ + found: true, + path: "/home/test/.local/bin/plannotator", + source: "standard", + }); + }); + + test("checks Windows executable names", () => { + const result = discoverPlannotatorBinary({ + env: { PATH: "C:\\Tools" }, + homeDir: "C:\\Users\\test", + exists: existsOnly(["C:\\Tools/plannotator.exe"]), + platform: "win32", + pathDelimiter: ";", + }); + + expect(result).toMatchObject({ + found: true, + path: "C:\\Tools/plannotator.exe", + source: "path", + }); + }); + + test("checks the PowerShell installer location on Windows", () => { + const result = discoverPlannotatorBinary({ + env: { PATH: "", LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" }, + homeDir: "C:\\Users\\test", + exists: existsOnly(["C:\\Users\\test\\AppData\\Local/plannotator/plannotator.exe"]), + platform: "win32", + pathDelimiter: ";", + }); + + expect(result).toMatchObject({ + found: true, + path: "C:\\Users\\test\\AppData\\Local/plannotator/plannotator.exe", + source: "standard", + }); + }); + + test("can rediscover only standard install locations after installation", () => { + const result = discoverInstalledPlannotatorBinary({ + env: { PLANNOTATOR_BIN: "/old/plannotator", PATH: "/old", LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" }, + homeDir: "C:\\Users\\test", + exists: existsOnly([ + "/old/plannotator", + "C:\\Users\\test\\AppData\\Local/plannotator/plannotator.exe", + ]), + platform: "win32", + pathDelimiter: ";", + }); + + expect(result).toMatchObject({ + found: true, + path: "C:\\Users\\test\\AppData\\Local/plannotator/plannotator.exe", + source: "standard", + }); + }); + + test("returns all checked candidates when missing", () => { + const result = discoverPlannotatorBinary({ + env: { PLANNOTATOR_BIN: "/missing", PATH: "/bin" }, + homeDir: "/home/test", + exists: existsOnly([]), + platform: "linux", + pathDelimiter: ":", + }); + + expect(result.found).toBe(false); + expect(result.checked).toEqual([ + "/missing", + "/bin/plannotator", + "/home/test/.local/bin/plannotator", + ]); + }); + + test("can return later compatible candidates for capability probing", () => { + const result = discoverPlannotatorBinaryCandidates({ + env: { PATH: "/old:/current" }, + homeDir: "/home/test", + exists: existsOnly([ + "/old/plannotator", + "/current/plannotator", + "/home/test/.local/bin/plannotator", + ]), + platform: "linux", + pathDelimiter: ":", + }); + + expect(result.candidates).toEqual([ + { path: "/old/plannotator", source: "path" }, + { path: "/current/plannotator", source: "path" }, + { path: "/home/test/.local/bin/plannotator", source: "standard" }, + ]); + }); +}); + +describe("plugin binary install and capabilities", () => { + test("auto-install is enabled unless explicitly disabled", () => { + expect(shouldAutoInstallPlannotator({})).toBe(true); + expect(shouldAutoInstallPlannotator({ PLANNOTATOR_DISABLE_AUTO_INSTALL: "1" })).toBe(false); + expect(shouldAutoInstallPlannotator({ PLANNOTATOR_DISABLE_AUTO_INSTALL: "true" })).toBe(false); + expect(shouldAutoInstallPlannotator({ PLANNOTATOR_DISABLE_AUTO_INSTALL: "yes" })).toBe(false); + }); + + test("selects official installer commands by platform", () => { + expect(getOfficialInstallerCommand("linux")).toEqual({ + command: "bash", + args: ["-c", "curl -fsSL https://plannotator.ai/install.sh | bash"], + }); + expect(getOfficialInstallerCommand("linux", "0.19.17")).toEqual({ + command: "bash", + args: ["-c", "curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version 'v0.19.17'"], + }); + expect(getOfficialInstallerCommand("win32").command).toBe("powershell.exe"); + expect(getOfficialInstallerCommand("win32").args.join(" ")).toContain("install.ps1"); + expect(getOfficialInstallerCommand("win32", "v0.19.17").args.join(" ")).toContain("-Version 'v0.19.17'"); + }); + + test("parses and validates plugin capabilities", () => { + const capabilities = getPluginCapabilities(); + + expect(parsePluginCapabilities(JSON.stringify(capabilities))).toEqual(capabilities); + expect(isCompatiblePluginBinary(capabilities)).toBe(true); + expect(parsePluginCapabilities(JSON.stringify({ + ...capabilities, + multiSessionDaemon: undefined, + }))).toMatchObject({ + protocol: capabilities.protocol, + features: capabilities.features, + }); + expect(parsePluginCapabilities("{}")).toBeNull(); + expect(parsePluginCapabilities("not-json")).toBeNull(); + }); + + test("rejects incompatible protocol versions", () => { + const capabilities = { + ...getPluginCapabilities(), + minClientVersion: 999, + }; + + expect(isCompatiblePluginBinary(capabilities)).toBe(false); + }); + + test("checks required plugin features during compatibility", () => { + const capabilities = { + ...getPluginCapabilities(), + features: ["capabilities", "plan-review"], + }; + + expect(isCompatiblePluginBinary(capabilities, { requiredFeatures: ["plan-review"] })).toBe(true); + expect(isCompatiblePluginBinary(capabilities, { requiredFeatures: ["archive"] })).toBe(false); + }); +}); diff --git a/packages/shared/plugin-binary.ts b/packages/shared/plugin-binary.ts new file mode 100644 index 000000000..12e1c9873 --- /dev/null +++ b/packages/shared/plugin-binary.ts @@ -0,0 +1,246 @@ +import { existsSync } from "fs"; +import { homedir } from "os"; +import path from "path"; +import { + PLANNOTATOR_PLUGIN_PROTOCOL, + PLANNOTATOR_PLUGIN_PROTOCOL_VERSION, + type PluginCapabilities, + type PluginFeature, +} from "./plugin-protocol"; + +export type PluginBinarySource = "env" | "source" | "path" | "standard"; + +export interface PluginBinaryDiscoveryOptions { + env?: Record; + platform?: NodeJS.Platform; + homeDir?: string; + sourceRoot?: string; + pathDelimiter?: string; + exists?: (candidate: string) => boolean; +} + +export interface PluginBinaryDiscoveryResult { + found: boolean; + path?: string; + source?: PluginBinarySource; + checked: string[]; +} + +export interface PluginBinaryCandidate { + path: string; + source: PluginBinarySource; +} + +export interface PluginBinaryCandidatesResult { + candidates: PluginBinaryCandidate[]; + checked: string[]; +} + +export interface InstallerCommand { + command: string; + args: string[]; +} + +export interface PluginBinaryCompatibilityOptions { + requiredFeatures?: readonly PluginFeature[]; +} + +function executableNames(platform: NodeJS.Platform): string[] { + return platform === "win32" + ? ["plannotator.exe", "plannotator.cmd", "plannotator.bat", "plannotator"] + : ["plannotator"]; +} + +function defaultHomeDir(env: Record, platform: NodeJS.Platform): string { + if (platform === "win32") return env.USERPROFILE || homedir(); + return env.HOME || homedir(); +} + +function standardInstallCandidates( + homeDir: string, + platform: NodeJS.Platform, + env: Record, +): string[] { + const binDir = path.join(homeDir, ".local", "bin"); + const names = executableNames(platform); + if (platform !== "win32") return names.map((name) => path.join(binDir, name)); + + const candidates: string[] = []; + const localAppData = env.LOCALAPPDATA?.trim(); + if (localAppData) { + candidates.push(...names.map((name) => path.join(localAppData, "plannotator", name))); + } + candidates.push(...names.map((name) => path.join(binDir, name))); + return candidates; +} + +export function findPlannotatorSourceRoot( + startDir: string, + exists: (candidate: string) => boolean = existsSync, +): string | undefined { + let current = path.resolve(startDir); + for (let depth = 0; depth < 8; depth += 1) { + const sourceEntry = path.join(current, "apps", "hook", "server", "index.ts"); + const sourceShim = path.join(current, "bin", "plannotator.js"); + if (exists(sourceEntry) && exists(sourceShim)) return current; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return undefined; +} + +export function discoverPlannotatorBinary( + options: PluginBinaryDiscoveryOptions = {}, +): PluginBinaryDiscoveryResult { + const result = discoverPlannotatorBinaryCandidates(options); + const first = result.candidates[0]; + if (!first) return { found: false, checked: result.checked }; + return { + found: true, + path: first.path, + source: first.source, + checked: result.checked, + }; +} + +export function discoverPlannotatorBinaryCandidates( + options: PluginBinaryDiscoveryOptions = {}, +): PluginBinaryCandidatesResult { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const exists = options.exists ?? existsSync; + const delimiter = options.pathDelimiter ?? path.delimiter; + const checked: string[] = []; + const candidates: PluginBinaryCandidate[] = []; + const seen = new Set(); + + const addIfExists = (candidate: string, source: PluginBinarySource) => { + checked.push(candidate); + if (!seen.has(candidate) && exists(candidate)) { + seen.add(candidate); + candidates.push({ path: candidate, source }); + } + }; + + const explicit = env.PLANNOTATOR_BIN?.trim(); + if (explicit) { + addIfExists(explicit, "env"); + } + + if (options.sourceRoot) { + addIfExists( + path.join(options.sourceRoot, "bin", platform === "win32" ? "plannotator.cmd" : "plannotator.js"), + "source", + ); + } + + const pathDirs = (env.PATH || "") + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); + for (const dir of pathDirs) { + for (const name of executableNames(platform)) { + addIfExists(path.join(dir, name), "path"); + } + } + + const home = options.homeDir ?? defaultHomeDir(env, platform); + for (const candidate of standardInstallCandidates(home, platform, env)) { + addIfExists(candidate, "standard"); + } + + return { candidates, checked }; +} + +export function discoverInstalledPlannotatorBinary( + options: PluginBinaryDiscoveryOptions = {}, +): PluginBinaryDiscoveryResult { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const exists = options.exists ?? existsSync; + const checked: string[] = []; + const home = options.homeDir ?? defaultHomeDir(env, platform); + + for (const candidate of standardInstallCandidates(home, platform, env)) { + checked.push(candidate); + if (exists(candidate)) { + return { found: true, path: candidate, source: "standard", checked }; + } + } + + return { found: false, checked }; +} + +export function shouldAutoInstallPlannotator(env: Record = process.env): boolean { + const raw = env.PLANNOTATOR_DISABLE_AUTO_INSTALL?.trim().toLowerCase(); + return raw !== "1" && raw !== "true" && raw !== "yes"; +} + +function normalizeInstallerVersion(version: string | null | undefined): string | undefined { + const trimmed = version?.trim(); + if (!trimmed) return undefined; + if (!/^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(trimmed)) return undefined; + return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; +} + +export function getOfficialInstallerCommand( + platform: NodeJS.Platform = process.platform, + version?: string | null, +): InstallerCommand { + const installVersion = normalizeInstallerVersion(version); + if (platform === "win32") { + const command = installVersion + ? `& ([scriptblock]::Create((irm https://plannotator.ai/install.ps1))) -Version '${installVersion}'` + : "irm https://plannotator.ai/install.ps1 | iex"; + return { + command: "powershell.exe", + args: [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + command, + ], + }; + } + + const command = installVersion + ? `curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version '${installVersion}'` + : "curl -fsSL https://plannotator.ai/install.sh | bash"; + return { + command: "bash", + args: ["-c", command], + }; +} + +export function parsePluginCapabilities(raw: string): PluginCapabilities | null { + try { + const parsed = JSON.parse(raw) as Partial; + if (parsed.protocol !== PLANNOTATOR_PLUGIN_PROTOCOL) return null; + if (typeof parsed.protocolVersion !== "number") return null; + if (typeof parsed.minClientVersion !== "number") return null; + if (!Array.isArray(parsed.features)) return null; + if (parsed.daemonReady !== true) return null; + if ( + "multiSessionDaemon" in parsed && + typeof parsed.multiSessionDaemon !== "boolean" + ) return null; + return parsed as PluginCapabilities; + } catch { + return null; + } +} + +export function isCompatiblePluginBinary( + capabilities: PluginCapabilities, + options: PluginBinaryCompatibilityOptions = {}, +): boolean { + const requiredFeatures = options.requiredFeatures ?? []; + return ( + capabilities.protocol === PLANNOTATOR_PLUGIN_PROTOCOL && + capabilities.minClientVersion <= PLANNOTATOR_PLUGIN_PROTOCOL_VERSION && + capabilities.protocolVersion >= PLANNOTATOR_PLUGIN_PROTOCOL_VERSION && + requiredFeatures.every((feature) => capabilities.features.includes(feature)) + ); +} diff --git a/packages/shared/plugin-client.test.ts b/packages/shared/plugin-client.test.ts new file mode 100644 index 000000000..1ea82f86a --- /dev/null +++ b/packages/shared/plugin-client.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { unsafeWindowsShellInvocationError } from "./plugin-client"; + +describe("unsafeWindowsShellInvocationError", () => { + test("accepts safe Windows command wrappers", () => { + expect( + unsafeWindowsShellInvocationError( + "C:\\Tools\\plannotator.cmd", + ["plugin", "plan", "--origin", "opencode"], + "win32", + ), + ).toBeUndefined(); + }); + + test("rejects metacharacters in Windows command wrapper paths", () => { + expect( + unsafeWindowsShellInvocationError( + "C:\\Tools&Bad\\plannotator.cmd", + ["plugin", "plan"], + "win32", + ), + ).toContain("C:\\Tools&Bad\\plannotator.cmd"); + }); + + test("rejects metacharacters in Windows command wrapper arguments", () => { + expect( + unsafeWindowsShellInvocationError( + "C:\\Tools\\plannotator.cmd", + ["plugin", "plan", "--origin", "opencode&calc"], + "win32", + ), + ).toContain("opencode&calc"); + }); + + test("rejects delayed-expansion markers in Windows command wrapper arguments", () => { + expect( + unsafeWindowsShellInvocationError( + "C:\\Tools\\plannotator.cmd", + ["plugin", "plan", "--origin", "opencode!calc"], + "win32", + ), + ).toContain("opencode!calc"); + }); + + test("does not apply shell-wrapper checks on non-Windows platforms", () => { + expect( + unsafeWindowsShellInvocationError( + "/tmp/a&b/plannotator.cmd", + ["opencode&calc"], + "linux", + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/shared/plugin-client.ts b/packages/shared/plugin-client.ts new file mode 100644 index 000000000..790d39ca2 --- /dev/null +++ b/packages/shared/plugin-client.ts @@ -0,0 +1,436 @@ +import { + discoverPlannotatorBinaryCandidates, + discoverInstalledPlannotatorBinary, + findPlannotatorSourceRoot, + getOfficialInstallerCommand, + isCompatiblePluginBinary, + parsePluginCapabilities, + shouldAutoInstallPlannotator, + type PluginBinaryDiscoveryOptions, + type PluginBinarySource, +} from "./plugin-binary"; +import { + createPluginErrorResponse, + parsePluginResponse, + type PluginCapabilities, + type PluginAnnotateRequest, + type PluginAnnotateResult, + type PluginArchiveRequest, + type PluginArchiveResult, + type PluginPlanRequest, + type PluginPlanResult, + type PluginResponse, + type PluginReviewRequest, + type PluginReviewResult, + type PluginSessionInfo, + type PluginFeature, +} from "./plugin-protocol"; +import { spawn, spawnSync } from "node:child_process"; + +export { findPlannotatorSourceRoot }; + +export interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; + error?: string; +} + +export interface CommandRunOptions { + timeoutMs?: number | null; + cwd?: string; + env?: NodeJS.ProcessEnv; + onSession?: (session: PluginSessionInfo) => void; + signal?: AbortSignal; +} + +export type CommandRunner = ( + command: string, + args: string[], + input?: string, + options?: CommandRunOptions, +) => CommandResult; + +export type PluginCommandRunner = ( + command: string, + args: string[], + input?: string, + options?: CommandRunOptions, +) => CommandResult | Promise; + +export interface EnsurePlannotatorBinaryOptions extends PluginBinaryDiscoveryOptions { + run?: CommandRunner; + requiredFeatures?: readonly PluginFeature[]; + capabilityTimeoutMs?: number | null; + installVersion?: string | null; +} + +export type EnsurePlannotatorBinaryResult = + | { + ok: true; + path: string; + source: PluginBinarySource; + installed: boolean; + capabilities: PluginCapabilities; + } + | { + ok: false; + code: string; + message: string; + checked: string[]; + }; + +const SESSION_READY_PREFIX = "PLANNOTATOR_SESSION_READY "; +const DEFAULT_CAPABILITY_TIMEOUT_MS = 5_000; + +function hasTimeout(timeoutMs: number | null | undefined): timeoutMs is number { + return timeoutMs !== null && timeoutMs !== undefined; +} + +function hasWindowsShellMetachar(value: string): boolean { + return /[&|<>^%!]/.test(value); +} + +function shouldUseShell(command: string, platform: NodeJS.Platform = process.platform): boolean { + return platform === "win32" && /\.(?:cmd|bat)$/i.test(command); +} + +export function unsafeWindowsShellInvocationError( + command: string, + args: readonly string[] = [], + platform: NodeJS.Platform = process.platform, +): string | undefined { + if (!shouldUseShell(command, platform)) return undefined; + const unsafeValue = [command, ...args].find(hasWindowsShellMetachar); + if (!unsafeValue) return undefined; + return `Refusing to execute Windows command wrapper with shell metacharacters in the path or arguments: ${unsafeValue}`; +} + +function handleSessionReadyLine(line: string, options: CommandRunOptions): void { + try { + const session = JSON.parse(line.slice(SESSION_READY_PREFIX.length)) as PluginSessionInfo; + if (options.onSession) { + options.onSession(session); + } else { + process.stderr.write(`[Plannotator] ${session.url}\n`); + } + } catch { + // Ignore malformed progress lines; final stdout still decides command success. + } +} + +function defaultRunner( + command: string, + args: string[], + input?: string, + options: CommandRunOptions = {}, +): CommandResult { + const shellError = unsafeWindowsShellInvocationError(command, args); + if (shellError) { + return { + exitCode: 1, + stdout: "", + stderr: "", + error: shellError, + }; + } + + const result = spawnSync(command, args, { + encoding: "utf-8", + input, + cwd: options.cwd, + env: options.env, + shell: shouldUseShell(command), + ...(hasTimeout(options.timeoutMs) ? { timeout: options.timeoutMs } : {}), + }); + return { + exitCode: typeof result.status === "number" ? result.status : 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + error: result.error?.message, + }; +} + +function defaultPluginRunner( + command: string, + args: string[], + input?: string, + options: CommandRunOptions = {}, +): Promise { + return new Promise((resolve) => { + const shellError = unsafeWindowsShellInvocationError(command, args); + if (shellError) { + resolve({ + exitCode: 1, + stdout: "", + stderr: "", + error: shellError, + }); + return; + } + + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + shell: shouldUseShell(command), + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let settled = false; + let timedOut = false; + let aborted = false; + let killTimer: ReturnType | undefined; + let pendingStderr = ""; + const terminateChild = (reason: "timeout" | "abort") => { + if (settled || timedOut || aborted) return; + if (reason === "timeout") timedOut = true; + if (reason === "abort") aborted = true; + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 1_000); + killTimer.unref?.(); + }; + const timeoutTimer = hasTimeout(options.timeoutMs) + ? setTimeout(() => terminateChild("timeout"), options.timeoutMs) + : undefined; + timeoutTimer?.unref?.(); + const abortHandler = () => terminateChild("abort"); + if (options.signal?.aborted) { + abortHandler(); + } else { + options.signal?.addEventListener("abort", abortHandler, { once: true }); + } + + const finish = (result: CommandResult) => { + if (settled) return; + settled = true; + if (timeoutTimer) clearTimeout(timeoutTimer); + if (killTimer) clearTimeout(killTimer); + options.signal?.removeEventListener("abort", abortHandler); + resolve(result); + }; + const flushPendingStderr = () => { + if (!pendingStderr) return; + if (pendingStderr.startsWith(SESSION_READY_PREFIX)) { + handleSessionReadyLine(pendingStderr, options); + } else { + process.stderr.write(pendingStderr); + stderrChunks.push(Buffer.from(pendingStderr)); + } + pendingStderr = ""; + }; + + child.stdout?.on("data", (chunk: Buffer) => { + stdoutChunks.push(Buffer.from(chunk)); + }); + child.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf-8"); + pendingStderr += text; + const lines = pendingStderr.split(/\r?\n/); + pendingStderr = lines.pop() ?? ""; + for (const line of lines) { + if (line.startsWith(SESSION_READY_PREFIX)) { + handleSessionReadyLine(line, options); + } else { + process.stderr.write(`${line}\n`); + stderrChunks.push(Buffer.from(`${line}\n`)); + } + } + }); + child.on("error", (err) => { + flushPendingStderr(); + finish({ + exitCode: 1, + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + error: err.message, + }); + }); + child.on("close", (code, signal) => { + flushPendingStderr(); + finish({ + exitCode: typeof code === "number" ? code : 1, + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + error: aborted + ? "Command aborted." + : timedOut + ? `Command timed out after ${options.timeoutMs}ms.` + : signal + ? `Command exited after signal ${signal}.` + : undefined, + }); + }); + + child.stdin?.on("error", () => {}); + if (aborted) { + child.stdin?.end(); + } else { + child.stdin?.end(input ?? ""); + } + }); +} + +function readCapabilities( + binaryPath: string, + run: CommandRunner, + timeoutMs: number | null, +): PluginCapabilities | null { + const result = run( + binaryPath, + ["plugin", "capabilities"], + undefined, + { timeoutMs }, + ); + if (result.exitCode !== 0) return null; + return parsePluginCapabilities(result.stdout); +} + +function incompatibleMessage(binaryPath: string): string { + return `The Plannotator binary at ${binaryPath} does not support the required plugin integration protocol.`; +} + +export function ensurePlannotatorBinary( + options: EnsurePlannotatorBinaryOptions = {}, +): EnsurePlannotatorBinaryResult { + const run = options.run ?? defaultRunner; + const capabilityTimeoutMs = options.capabilityTimeoutMs === undefined + ? DEFAULT_CAPABILITY_TIMEOUT_MS + : options.capabilityTimeoutMs; + const compatibility = options.requiredFeatures + ? { requiredFeatures: options.requiredFeatures } + : {}; + const discovery = discoverPlannotatorBinaryCandidates(options); + + for (const candidate of discovery.candidates) { + const capabilities = readCapabilities(candidate.path, run, capabilityTimeoutMs); + if (capabilities && isCompatiblePluginBinary(capabilities, compatibility)) { + return { + ok: true, + path: candidate.path, + source: candidate.source, + installed: false, + capabilities, + }; + } + } + + const firstCandidate = discovery.candidates[0]; + if (firstCandidate) { + if (!shouldAutoInstallPlannotator(options.env)) { + return { + ok: false, + code: "incompatible-binary", + message: incompatibleMessage(firstCandidate.path), + checked: discovery.checked, + }; + } + } else if (!shouldAutoInstallPlannotator(options.env)) { + return { + ok: false, + code: "missing-binary", + message: "The Plannotator binary was not found and automatic installation is disabled.", + checked: discovery.checked, + }; + } + + const installer = getOfficialInstallerCommand(options.platform, options.installVersion); + const installResult = run(installer.command, installer.args); + if (installResult.exitCode !== 0) { + return { + ok: false, + code: "install-failed", + message: installResult.stderr || installResult.error || "The official Plannotator installer failed.", + checked: discovery.checked, + }; + } + + const afterInstall = discoverInstalledPlannotatorBinary(options); + if (!afterInstall.found || !afterInstall.path || !afterInstall.source) { + return { + ok: false, + code: "install-missing-binary", + message: "The Plannotator installer completed, but the binary could not be found.", + checked: afterInstall.checked, + }; + } + + const capabilities = readCapabilities(afterInstall.path, run, capabilityTimeoutMs); + if (!capabilities || !isCompatiblePluginBinary(capabilities, compatibility)) { + return { + ok: false, + code: "incompatible-binary", + message: incompatibleMessage(afterInstall.path), + checked: afterInstall.checked, + }; + } + + return { + ok: true, + path: afterInstall.path, + source: afterInstall.source, + installed: true, + capabilities, + }; +} + +export function runPluginPlan( + binaryPath: string, + request: PluginPlanRequest, + run: PluginCommandRunner = defaultPluginRunner, + options: CommandRunOptions = {}, +): Promise> { + return runPluginCommand(binaryPath, "plan", request, run, options); +} + +export function runPluginReview( + binaryPath: string, + request: PluginReviewRequest, + run: PluginCommandRunner = defaultPluginRunner, + options: CommandRunOptions = {}, +): Promise> { + return runPluginCommand(binaryPath, "review", request, run, options); +} + +export function runPluginAnnotate( + binaryPath: string, + request: PluginAnnotateRequest, + run: PluginCommandRunner = defaultPluginRunner, + options: CommandRunOptions = {}, +): Promise> { + return runPluginCommand(binaryPath, "annotate", request, run, options); +} + +export function runPluginArchive( + binaryPath: string, + request: PluginArchiveRequest, + run: PluginCommandRunner = defaultPluginRunner, + options: CommandRunOptions = {}, +): Promise> { + return runPluginCommand(binaryPath, "archive", request, run, options); +} + +async function runPluginCommand( + binaryPath: string, + command: "plan" | "review" | "annotate" | "archive", + request: TRequest, + run: PluginCommandRunner, + options: CommandRunOptions, +): Promise> { + const result = await run( + binaryPath, + ["plugin", command, "--origin", request.origin], + JSON.stringify(request), + options, + ); + const parsed = parsePluginResponse(result.stdout); + if (parsed) return parsed; + + return createPluginErrorResponse( + result.exitCode === 0 ? "invalid-plugin-response" : "plugin-command-failed", + result.exitCode === 0 + ? result.stderr || result.error || `The Plannotator plugin ${command} command did not return valid JSON.` + : result.error || result.stderr || `The Plannotator plugin ${command} command did not return valid JSON.`, + ) as PluginResponse; +} diff --git a/packages/shared/plugin-protocol.test.ts b/packages/shared/plugin-protocol.test.ts new file mode 100644 index 000000000..2948f2b59 --- /dev/null +++ b/packages/shared/plugin-protocol.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { + PLANNOTATOR_PLUGIN_FEATURES, + PLANNOTATOR_PLUGIN_MIN_CLIENT_VERSION, + PLANNOTATOR_PLUGIN_PROTOCOL, + PLANNOTATOR_PLUGIN_PROTOCOL_VERSION, + createPluginErrorResponse, + createPluginSuccessResponse, + getPluginCapabilities, + parsePluginResponse, +} from "./plugin-protocol"; + +describe("plugin protocol", () => { + test("exposes versioned capabilities for plugin clients", () => { + expect(getPluginCapabilities()).toEqual({ + protocol: PLANNOTATOR_PLUGIN_PROTOCOL, + protocolVersion: PLANNOTATOR_PLUGIN_PROTOCOL_VERSION, + minClientVersion: PLANNOTATOR_PLUGIN_MIN_CLIENT_VERSION, + features: [...PLANNOTATOR_PLUGIN_FEATURES], + daemonReady: true, + multiSessionDaemon: false, + }); + }); + + test("wraps successful plugin results with protocol metadata", () => { + const response = createPluginSuccessResponse( + { approved: true }, + { mode: "plan", url: "http://localhost:19432", port: 19432, isRemote: false }, + ); + + expect(response).toEqual({ + ok: true, + protocol: PLANNOTATOR_PLUGIN_PROTOCOL, + protocolVersion: PLANNOTATOR_PLUGIN_PROTOCOL_VERSION, + session: { + mode: "plan", + url: "http://localhost:19432", + port: 19432, + isRemote: false, + }, + result: { approved: true }, + }); + }); + + test("wraps plugin errors with stable code and message fields", () => { + expect(createPluginErrorResponse("invalid-request", "Missing plan")).toEqual({ + ok: false, + protocol: PLANNOTATOR_PLUGIN_PROTOCOL, + protocolVersion: PLANNOTATOR_PLUGIN_PROTOCOL_VERSION, + error: { + code: "invalid-request", + message: "Missing plan", + }, + }); + }); + + test("parses protocol responses", () => { + const success = createPluginSuccessResponse({ approved: true }); + const error = createPluginErrorResponse("invalid-request", "Missing plan"); + const newerCompatible = { + ...success, + protocolVersion: PLANNOTATOR_PLUGIN_PROTOCOL_VERSION + 1, + }; + + expect(parsePluginResponse(JSON.stringify(success))).toEqual(success); + expect(parsePluginResponse(JSON.stringify(error))).toEqual(error); + expect(parsePluginResponse(JSON.stringify(newerCompatible))).toEqual(newerCompatible); + expect(parsePluginResponse("{}")).toBeNull(); + expect(parsePluginResponse("not-json")).toBeNull(); + }); +}); diff --git a/packages/shared/plugin-protocol.ts b/packages/shared/plugin-protocol.ts new file mode 100644 index 000000000..1feb0c8d5 --- /dev/null +++ b/packages/shared/plugin-protocol.ts @@ -0,0 +1,208 @@ +import type { Origin } from "./agents"; + +export const PLANNOTATOR_PLUGIN_PROTOCOL = "plannotator-plugin"; +export const PLANNOTATOR_PLUGIN_PROTOCOL_VERSION = 1; +export const PLANNOTATOR_PLUGIN_MIN_CLIENT_VERSION = 1; + +export const PLANNOTATOR_PLUGIN_FEATURES = [ + "capabilities", + "plan-review", + "code-review", + "annotate", + "annotate-last", + "archive", +] as const; + +export type PluginFeature = (typeof PLANNOTATOR_PLUGIN_FEATURES)[number]; +export type PluginClientOrigin = Extract; +export type PluginSessionMode = "plan" | "review" | "annotate" | "archive"; + +export interface PluginCapabilities { + protocol: typeof PLANNOTATOR_PLUGIN_PROTOCOL; + protocolVersion: typeof PLANNOTATOR_PLUGIN_PROTOCOL_VERSION; + minClientVersion: typeof PLANNOTATOR_PLUGIN_MIN_CLIENT_VERSION; + features: PluginFeature[]; + daemonReady: true; + multiSessionDaemon?: boolean; +} + +export interface PluginBaseRequest { + origin: PluginClientOrigin; + cwd?: string; + sharingEnabled?: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; +} + +export interface PluginAgentInfo { + name: string; + description?: string; + mode: string; + hidden?: boolean; +} + +export interface PluginPlanRequest extends PluginBaseRequest { + plan?: string; + planFilePath?: string; + permissionMode?: string; + availableAgents?: PluginAgentInfo[]; +} + +export interface PluginReviewRequest extends PluginBaseRequest { + args?: string; + prUrl?: string; + vcsType?: "auto" | "git" | "jj" | "p4"; + useLocal?: boolean; + diffType?: string; + defaultBranch?: string; + availableAgents?: PluginAgentInfo[]; +} + +export interface PluginAnnotateRequest extends PluginBaseRequest { + args?: string; + markdown?: string; + filePath?: string; + mode?: "annotate" | "annotate-folder" | "annotate-last"; + folderPath?: string; + sourceInfo?: string; + sourceConverted?: boolean; + gate?: boolean; + rawHtml?: string; + renderHtml?: boolean; +} + +export interface PluginArchiveRequest extends PluginBaseRequest { + customPlanPath?: string | null; +} + +export type PluginRequest = + | ({ action: "plan" } & PluginPlanRequest) + | ({ action: "review" } & PluginReviewRequest) + | ({ action: "annotate" } & PluginAnnotateRequest) + | ({ action: "annotate-last" } & PluginAnnotateRequest) + | ({ action: "archive" } & PluginArchiveRequest); + +export interface PluginSessionInfo { + mode: PluginSessionMode; + url: string; + port: number; + isRemote: boolean; +} + +export interface PluginPlanResult { + approved: boolean; + feedback?: string; + savedPath?: string; + agentSwitch?: string; + permissionMode?: string; +} + +export interface PluginReviewResult { + approved: boolean; + feedback?: string; + annotations?: unknown[]; + agentSwitch?: string; + exit?: boolean; +} + +export interface PluginAnnotateResult { + feedback: string; + annotations?: unknown[]; + exit?: boolean; + approved?: boolean; + filePath?: string; + mode?: "annotate" | "annotate-folder" | "annotate-last"; +} + +export interface PluginArchiveResult { + opened: boolean; +} + +export type PluginActionResult = + | PluginPlanResult + | PluginReviewResult + | PluginAnnotateResult + | PluginArchiveResult; + +export type PluginSuccessResponse = { + ok: true; + protocol: typeof PLANNOTATOR_PLUGIN_PROTOCOL; + protocolVersion: typeof PLANNOTATOR_PLUGIN_PROTOCOL_VERSION; + session?: PluginSessionInfo; + result: T; +}; + +export type PluginErrorResponse = { + ok: false; + protocol: typeof PLANNOTATOR_PLUGIN_PROTOCOL; + protocolVersion: typeof PLANNOTATOR_PLUGIN_PROTOCOL_VERSION; + error: { + code: string; + message: string; + }; +}; + +export type PluginResponse = + | PluginSuccessResponse + | PluginErrorResponse; + +export function getPluginCapabilities(): PluginCapabilities { + return { + protocol: PLANNOTATOR_PLUGIN_PROTOCOL, + protocolVersion: PLANNOTATOR_PLUGIN_PROTOCOL_VERSION, + minClientVersion: PLANNOTATOR_PLUGIN_MIN_CLIENT_VERSION, + features: [...PLANNOTATOR_PLUGIN_FEATURES], + daemonReady: true, + multiSessionDaemon: false, + }; +} + +export function createPluginSuccessResponse( + result: T, + session?: PluginSessionInfo, +): PluginSuccessResponse { + return { + ok: true, + protocol: PLANNOTATOR_PLUGIN_PROTOCOL, + protocolVersion: PLANNOTATOR_PLUGIN_PROTOCOL_VERSION, + ...(session && { session }), + result, + }; +} + +export function createPluginErrorResponse(code: string, message: string): PluginErrorResponse { + return { + ok: false, + protocol: PLANNOTATOR_PLUGIN_PROTOCOL, + protocolVersion: PLANNOTATOR_PLUGIN_PROTOCOL_VERSION, + error: { code, message }, + }; +} + +export function parsePluginResponse( + raw: string, +): PluginResponse | null { + try { + const parsed = JSON.parse(raw) as Partial>; + if (parsed.protocol !== PLANNOTATOR_PLUGIN_PROTOCOL) return null; + if (typeof parsed.protocolVersion !== "number") return null; + if (parsed.protocolVersion < PLANNOTATOR_PLUGIN_PROTOCOL_VERSION) return null; + + if (parsed.ok === true) { + if (!("result" in parsed)) return null; + return parsed as PluginSuccessResponse; + } + + if (parsed.ok === false) { + const error = (parsed as PluginErrorResponse).error; + if (!error || typeof error.code !== "string" || typeof error.message !== "string") { + return null; + } + return parsed as PluginErrorResponse; + } + + return null; + } catch { + return null; + } +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f3be71bfc..843b13780 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -470,7 +470,7 @@ Address the annotation feedback above. The user has reviewed your last message a Write-Host "Installed /plannotator-last command to $claudeCommandsDir\plannotator-last.md" # Install OpenCode slash command -$opencodeCommandsDir = "$env:USERPROFILE\.config\opencode\commands" +$opencodeCommandsDir = "$env:USERPROFILE\.config\opencode\command" New-Item -ItemType Directory -Force -Path $opencodeCommandsDir | Out-Null @" diff --git a/scripts/install.sh b/scripts/install.sh index ddfc11048..bb752556a 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -694,7 +694,7 @@ COMMAND_EOF echo "Installed /plannotator-last command to ${CLAUDE_COMMANDS_DIR}/plannotator-last.md" # Install OpenCode slash command -OPENCODE_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands" +OPENCODE_COMMANDS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/command" mkdir -p "$OPENCODE_COMMANDS_DIR" cat > "$OPENCODE_COMMANDS_DIR/plannotator-review.md" << 'COMMAND_EOF' diff --git a/tests/parity/route-parity.test.ts b/tests/parity/route-parity.test.ts index c64ad0f30..2da127d2b 100644 --- a/tests/parity/route-parity.test.ts +++ b/tests/parity/route-parity.test.ts @@ -1,12 +1,12 @@ /** - * Route Parity Test + * Runtime Route Ownership Test * - * Extracts all API routes from Bun and Pi server files and asserts - * they are identical per server (plan, review, annotate) plus shared - * delegated handlers (editor annotations, AI endpoints). + * The Bun server is now the only Plannotator UI server runtime. This test + * keeps coverage that the canonical server still exposes routes while proving + * Pi no longer ships a mirrored node:http route implementation. */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "bun:test"; @@ -56,35 +56,29 @@ const pi = { review: join(ROOT, "apps/pi-extension/server/serverReview.ts"), annotate: join(ROOT, "apps/pi-extension/server/serverAnnotate.ts"), editorAnnotations: join(ROOT, "apps/pi-extension/server/annotations.ts"), + serverDir: join(ROOT, "apps/pi-extension/server"), + serverBarrel: join(ROOT, "apps/pi-extension/server.ts"), }; const aiEndpointsFile = join(ROOT, "packages/ai/endpoints.ts"); // --- Tests --- -describe("route parity: Bun ↔ Pi", () => { - test("plan server routes match", () => { - const bunRoutes = unique(extractInlineRoutes(bun.plan)); - const piRoutes = unique(extractInlineRoutes(pi.plan)); - expect(piRoutes).toEqual(bunRoutes); +describe("route ownership: Bun server only", () => { + test("canonical Bun route files still expose API routes", () => { + expect(unique(extractInlineRoutes(bun.plan)).length).toBeGreaterThan(0); + expect(unique(extractInlineRoutes(bun.review)).length).toBeGreaterThan(0); + expect(unique(extractInlineRoutes(bun.annotate)).length).toBeGreaterThan(0); + expect(unique(extractInlineRoutes(bun.editorAnnotations)).length).toBeGreaterThan(0); }); - test("review server routes match", () => { - const bunRoutes = unique(extractInlineRoutes(bun.review)); - const piRoutes = unique(extractInlineRoutes(pi.review)); - expect(piRoutes).toEqual(bunRoutes); - }); - - test("annotate server routes match", () => { - const bunRoutes = unique(extractInlineRoutes(bun.annotate)); - const piRoutes = unique(extractInlineRoutes(pi.annotate)); - expect(piRoutes).toEqual(bunRoutes); - }); - - test("editor annotation routes match", () => { - const bunRoutes = unique(extractInlineRoutes(bun.editorAnnotations)); - const piRoutes = unique(extractInlineRoutes(pi.editorAnnotations)); - expect(piRoutes).toEqual(bunRoutes); + test("Pi mirrored route files are absent", () => { + expect(existsSync(pi.serverDir)).toBe(false); + expect(existsSync(pi.serverBarrel)).toBe(false); + expect(existsSync(pi.plan)).toBe(false); + expect(existsSync(pi.review)).toBe(false); + expect(existsSync(pi.annotate)).toBe(false); + expect(existsSync(pi.editorAnnotations)).toBe(false); }); test("AI endpoint keys are present (shared file)", () => { @@ -98,7 +92,7 @@ describe("route parity: Bun ↔ Pi", () => { expect(routes).toContain("/api/ai/sessions"); }); - test("all routes across all servers match", () => { + test("canonical Bun routes cover all server surfaces", () => { const bunAll = unique([ ...extractInlineRoutes(bun.plan), ...extractInlineRoutes(bun.review), @@ -107,14 +101,9 @@ describe("route parity: Bun ↔ Pi", () => { ...extractAIEndpointKeys(aiEndpointsFile), ]); - const piAll = unique([ - ...extractInlineRoutes(pi.plan), - ...extractInlineRoutes(pi.review), - ...extractInlineRoutes(pi.annotate), - ...extractInlineRoutes(pi.editorAnnotations), - ...extractAIEndpointKeys(aiEndpointsFile), - ]); - - expect(piAll).toEqual(bunAll); + expect(bunAll).toContain("/api/plan"); + expect(bunAll).toContain("/api/diff"); + expect(bunAll).toContain("/api/feedback"); + expect(bunAll).toContain("/api/ai/query"); }); });