From 36ff9f5055506f78a7572731643adf51bebad53c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 18 May 2026 08:45:21 -0700 Subject: [PATCH 01/31] Move plugins to single binary runtime OpenCode and Pi now discover and call the plannotator binary instead of bundling their own server implementations. Adds plugin protocol, binary discovery, capability checks, and auto-install bridge. --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- AGENTS.md | 14 +- apps/copilot/plugin.json | 2 +- apps/hook/.claude-plugin/plugin.json | 2 +- apps/hook/README.md | 2 + apps/hook/server/cli.ts | 2 + apps/hook/server/codex-session.test.ts | 37 - apps/hook/server/codex-session.ts | 32 +- apps/hook/server/html-assets.ts | 10 + apps/hook/server/index.ts | 1002 +++++++++++--- .../src/content/blog/plannotator-meets-pi.md | 2 +- apps/opencode-plugin/README.md | 16 +- apps/opencode-plugin/binary-client.test.ts | 361 +++++ apps/opencode-plugin/binary-client.ts | 1 + apps/opencode-plugin/commands.test.ts | 241 +++- apps/opencode-plugin/commands.ts | 439 +++--- apps/opencode-plugin/index.ts | 135 +- apps/opencode-plugin/package.json | 9 +- apps/opencode-plugin/packaging.test.ts | 36 + apps/pi-extension/README.md | 34 +- apps/pi-extension/binary-client.test.ts | 335 +++++ apps/pi-extension/binary-client.ts | 1 + apps/pi-extension/index.ts | 55 +- apps/pi-extension/package.json | 9 +- apps/pi-extension/packaging.test.ts | 28 + apps/pi-extension/plannotator-browser.test.ts | 81 +- apps/pi-extension/plannotator-browser.ts | 633 ++++----- apps/pi-extension/plannotator-events.ts | 16 +- apps/pi-extension/server.test.ts | 651 --------- apps/pi-extension/server.ts | 42 - apps/pi-extension/server/agent-jobs.ts | 515 ------- apps/pi-extension/server/annotations.ts | 85 -- .../server/external-annotations.ts | 189 --- apps/pi-extension/server/handlers.ts | 210 --- apps/pi-extension/server/helpers.ts | 78 -- apps/pi-extension/server/ide.ts | 46 - apps/pi-extension/server/integrations.ts | 195 --- apps/pi-extension/server/network.test.ts | 109 -- apps/pi-extension/server/network.ts | 173 --- apps/pi-extension/server/pr.ts | 126 -- apps/pi-extension/server/project.ts | 64 - apps/pi-extension/server/reference.ts | 362 ----- apps/pi-extension/server/serverAnnotate.ts | 185 --- apps/pi-extension/server/serverPlan.ts | 498 ------- apps/pi-extension/server/serverReview.ts | 1182 ----------------- apps/pi-extension/server/vcs.ts | 120 -- apps/pi-extension/tsconfig.json | 2 +- apps/pi-extension/vendor.sh | 36 +- apps/skills/plannotator-last/SKILL.md | 4 - bin/plannotator.cmd | 2 + bin/plannotator.js | 27 +- bun.lock | 7 +- docs/single-binary-runtime.md | 73 + openpackage.yml | 2 +- package.json | 8 +- packages/shared/plugin-binary.test.ts | 264 ++++ packages/shared/plugin-binary.ts | 246 ++++ packages/shared/plugin-client.test.ts | 54 + packages/shared/plugin-client.ts | 436 ++++++ packages/shared/plugin-protocol.test.ts | 71 + packages/shared/plugin-protocol.ts | 208 +++ scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- tests/parity/route-parity.test.ts | 61 +- 65 files changed, 3838 insertions(+), 6036 deletions(-) create mode 100644 apps/hook/server/html-assets.ts create mode 100644 apps/opencode-plugin/binary-client.test.ts create mode 100644 apps/opencode-plugin/binary-client.ts create mode 100644 apps/opencode-plugin/packaging.test.ts create mode 100644 apps/pi-extension/binary-client.test.ts create mode 100644 apps/pi-extension/binary-client.ts create mode 100644 apps/pi-extension/packaging.test.ts delete mode 100644 apps/pi-extension/server.test.ts delete mode 100644 apps/pi-extension/server.ts delete mode 100644 apps/pi-extension/server/agent-jobs.ts delete mode 100644 apps/pi-extension/server/annotations.ts delete mode 100644 apps/pi-extension/server/external-annotations.ts delete mode 100644 apps/pi-extension/server/handlers.ts delete mode 100644 apps/pi-extension/server/helpers.ts delete mode 100644 apps/pi-extension/server/ide.ts delete mode 100644 apps/pi-extension/server/integrations.ts delete mode 100644 apps/pi-extension/server/network.test.ts delete mode 100644 apps/pi-extension/server/network.ts delete mode 100644 apps/pi-extension/server/pr.ts delete mode 100644 apps/pi-extension/server/project.ts delete mode 100644 apps/pi-extension/server/reference.ts delete mode 100644 apps/pi-extension/server/serverAnnotate.ts delete mode 100644 apps/pi-extension/server/serverPlan.ts delete mode 100644 apps/pi-extension/server/serverReview.ts delete mode 100644 apps/pi-extension/server/vcs.ts create mode 100644 bin/plannotator.cmd create mode 100644 docs/single-binary-runtime.md create mode 100644 packages/shared/plugin-binary.test.ts create mode 100644 packages/shared/plugin-binary.ts create mode 100644 packages/shared/plugin-client.test.ts create mode 100644 packages/shared/plugin-client.ts create mode 100644 packages/shared/plugin-protocol.test.ts create mode 100644 packages/shared/plugin-protocol.ts 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/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"); }); }); From e7260ee4370d40bd6f01499e7df0c185f16b95bd Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 18 May 2026 11:09:41 -0700 Subject: [PATCH 02/31] Add plugin subpath exports to shared package bun build --compile requires explicit package exports for workspace imports. plugin-protocol, plugin-binary, and plugin-client were added as files by 733 but never registered in package.json exports. --- packages/shared/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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", From c5b203d3b4b2e0209135e6707171cfc282b9823b Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 26 May 2026 17:41:22 -0700 Subject: [PATCH 03/31] Add long-running Plannotator daemon runtime (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add long-running Plannotator daemon runtime Single daemon process per machine manages session lifecycle, serves browser UIs at /s/, and exposes control APIs. CLI commands auto-start the daemon and create sessions via HTTP. Includes session store, auth tokens, lock files, event broadcasting, and goal-setup daemon integration. * Add daemon debug shell and simulator (#738) * Add long-running Plannotator daemon runtime Single daemon process per machine manages session lifecycle, serves browser UIs at /s/, and exposes control APIs. CLI commands auto-start the daemon and create sessions via HTTP. Includes session store, auth tokens, lock files, event broadcasting, and goal-setup daemon integration. * Add daemon debug shell and simulator Debug frontend (apps/debug-frontend) served by the daemon as the session browser shell. Agent simulator TUI (apps/debug-tui) exercises all agent protocols with concurrent session support. Includes reactive session dashboard, event log, prominent session action buttons, and goal-setup scenarios. * Add daemon WebSocket event hub (#744) * Add long-running Plannotator daemon runtime Single daemon process per machine manages session lifecycle, serves browser UIs at /s/, and exposes control APIs. CLI commands auto-start the daemon and create sessions via HTTP. Includes session store, auth tokens, lock files, event broadcasting, and goal-setup daemon integration. * Add daemon debug shell and simulator Debug frontend (apps/debug-frontend) served by the daemon as the session browser shell. Agent simulator TUI (apps/debug-tui) exercises all agent protocols with concurrent session support. Includes reactive session dashboard, event log, prominent session action buttons, and goal-setup scenarios. * Add daemon WebSocket event hub Replace persistent SSE streams with a single WebSocket connection per frontend instance. Daemon multiplexes session-scoped events (annotations, agent jobs, lifecycle) through /daemon/ws with subscribe/unsubscribe messaging. Includes session actions over WebSocket, reconnect/resync, auth enforcement, and polling fallback. * Restore goal-setup integration to WebSocket layer The goal-setup daemon integration from layers 734/738 was lost during re-squash cascades. This commit restores all missing pieces: - PluginGoalSetupRequest type and goal-setup action in plugin protocol - goal-setup case in daemon session factory - setup-goal CLI subcommand routed through runDaemonSessionRequest - goal-setup-submit/goal-setup-exit debug frontend actions - TUI scenarios for interview and facts with fixture bundles - TUI completion for goal-setup sessions - Fix getRepoInfo to use session cwd in createGoalSetupSession - Standardize naming: "goal-setup" everywhere (was "setup-goal" in daemon protocol and debug frontend) * Add daemon subpath exports to server package The source shim resolves workspace imports through package exports. Without these entries, `bun apps/hook/server/index.ts` fails to find daemon modules even though the files exist on disk. * Complete goal-setup protocol typing and daemon test coverage - Add PluginGoalSetupResult to PluginActionResult union - Add goal-setup to PLANNOTATOR_PLUGIN_FEATURES - Use typed result in CLI instead of cast - Add session factory tests: interview submit and facts exit * Fix goal-setup bundle path resolution and plugin feature list - Resolve relative bundle paths against getInvocationCwd() so hook/wrapper invocations find the file in the project directory - Remove goal-setup from PLANNOTATOR_PLUGIN_FEATURES since it is a direct CLI command invoked by agent skills, not a plugin action dispatched through the plugin protocol * Fix hub-client: reconnect after protocol-level error frames The onerror and onclose socket handlers both called scheduleReconnect(), but the handleMessage error branch tore down the socket without scheduling a reconnect — leaving the client permanently dead. * Add production frontend with initial view (#753) * Add production frontend app with daemon project registry Daemon: - Project registry persisted to ~/.plannotator/projects.json - Auto-registers projects from session cwd on creation - GET/POST/DELETE /daemon/projects endpoints - cwd exposed on DaemonSessionSummary - project-registry feature flag + 9 unit tests Frontend (apps/frontend/): - TanStack Router, Zustand+Immer, Tailwind v4, oxlint/oxfmt - Daemon API client with project + session creation methods - WebSocket hub client with event stream + polling fallback - Landing page: project selector table + Code Review/Archive actions - Offcanvas sidebar: sessions grouped by mode (matching prototype) - Add project dialog - DiffKit theme + prototype shell pattern (bg-muted → card → content) - 13 shadcn primitives vendored in components/ui/ - Single-file HTML build (viteSingleFile) - Dev script: daemon + Vite with auto-proxy (scripts/dev-frontend.ts) * Remove unused shadcn components and hooks badge, card, dialog, dropdown-menu, label — not imported by any app code. use-project-actions, use-sessions-by-project — orphaned after sidebar and landing page rewrites. * Rename diffkit theme to neutral * Add active sessions list to landing page * Embed code review surface in frontend app (#755) * Copy review-editor and editor as new embeddable packages packages/plannotator-code-review — copy of packages/review-editor packages/plannotator-plan-review — copy of packages/editor Unmodified copies to start. These will be refactored to strip standalone providers (ThemeProvider, TooltipProvider, Toaster) and accept session-scoped API context from the frontend shell. The original packages remain untouched for the legacy single-file HTML flow. * Add useSessionFetch hook and SessionProvider React context that scopes fetch calls to a daemon session. When inside a SessionProvider, fetch("/api/diff") rewrites to fetch("/s/:sessionId/api/diff"). Without a provider, returns the global fetch unchanged. * Migrate shared hooks to useSessionFetch Add const fetch = useSessionFetch() to 10 hooks in packages/ui/hooks/. The shadowed fetch variable routes /api/ calls through the session context when a SessionProvider is present, and falls back to global fetch when not. configStore.ts uses apiFetch (import-based) since it's a class method. * Migrate code review package to useSessionFetch Add const fetch = useSessionFetch() to all 11 files in packages/plannotator-code-review/ that call fetch("/api/..."). The shadowed fetch variable routes calls through the session context. No fetch call sites were modified — only the function that provides the fetch was changed. * Export ReviewAppEmbedded without standalone providers ReviewApp accepts __embedded prop to skip ThemeProvider, TooltipProvider, and Toaster (shell provides these). Uses h-full instead of h-screen when embedded. ReviewAppEmbedded is a named export that passes the prop. Default export unchanged. Shell Layout gains TooltipProvider for code review tooltips. * Mount code review surface in frontend session route When session.mode === "review", the /s/:sessionId route wraps ReviewAppEmbedded in a SessionProvider and renders the full code review UI. Other modes keep the placeholder. - Added @plannotator/code-review as frontend dependency - Created App.d.ts type declaration for the code review package - Added plannotator-ui.d.ts for SessionProvider and ThemeProvider types - Added PNG module declaration for asset imports - Fixed settings.ts satisfies type for strict-mode compatibility - Vite alias resolves to package source for bundling - TypeScript uses .d.ts for type checking (avoids strict-checking loose package source) * Fix session surface integration issues - Add @custom-variant dark to code-review CSS for .light class toggle - Remove forced theme cookies from main.tsx (use defaultColorTheme prop) - Clean up document.title on review unmount (restore previous) - Clean up CSS custom properties on review unmount - Define __APP_VERSION__ from root package.json in vite config - Narrow Vite proxy to /s/:id/api/ only (page loads stay with Vite SPA) - Skip auto-registering temp directory projects in session factory - Add max-height scroll to project table for overflow - Add headerLeft prop to ReviewAppEmbedded for global sidebar trigger - Fix header padding when sidebar trigger is present * Resolve ~ to home directory in addProject * Fix duplicate Tailwind build causing style conflicts The code review's index.css had its own @import "tailwindcss" with separate @source and @theme directives, producing a second Tailwind build that competed with the frontend's styles.css. This caused buttons, dialogs, and other components to render with wrong styles. Fix: remove Tailwind, theme import, and @source from the code review's index.css (keep only dockview + custom CSS). Add @source directives to the frontend's styles.css to scan the code review package and shared UI components. One Tailwind build, one theme, no conflicts. * Clean up CSS: remove duplications, fix keyframe collision - Rename code review's @keyframes fade-in to cr-fade-in to avoid collision with the frontend's fade-in (different animation) - Remove redundant panel scrollbar rules from styles.css (global scrollbar rules already cover all elements) - Add comment noting intentional scrollbar override of theme.css - Single Tailwind build, single theme import, zero duplications * Make sidebar logo link to homepage * Match sidebar trigger hover style to code review buttons * Fix sidebar session labels and badge overlap - Strip machine-generated prefixes from session labels (plugin-review-, claude-code-, etc.) to show just the project/PR name - Add pr-7 padding to menu buttons so truncation ellipsis doesn't overlap with the status badge dot - Full label still visible on hover via tooltip * Fix formatting * Fix test suite: restore globalThis.fetch after useSessionFetch tests The useSessionFetch test replaced globalThis.fetch with a mock in beforeEach but never restored it. Other test files running in the same process (daemon runtime tests) got the mock instead of real fetch, causing JSON parse failures on "ok" responses. Added afterEach to restore the original fetch. All 1,463 tests pass. * Add React Activity keep-alive for session surfaces Sessions now stay alive when the user navigates away. Instead of unmounting and remounting on each navigation, visited sessions are hidden via React's and restored instantly when the user returns. - AppStore tracks visitedSessions (keyed by session ID) and activeSessionId - Layout renders all visited sessions in wrappers — only the active one is visible, the rest are hidden but preserved - Session route registers its bootstrap data with the store and renders nothing — Layout owns the rendering - Landing page deactivates the current session when navigated to - SessionSurface component extracted to handle mode-based rendering Effects clean up when hidden (WebSocket subs, timers stop) and restart when visible. DOM, React state, scroll position, annotations, dock layout all survive navigation. * Fix: show error state when session load fails during active session * Fix: deactivate session from Layout instead of inside hidden Activity * Dispatch resize event when session becomes visible to fix Pierre diffs * Replace Activity with visibility:hidden for session keep-alive Activity uses display:none which breaks Pierre diffs' virtualizer — it measures the container at zero height and renders no content. When made visible again, the virtualizer doesn't recalculate. Switch to visibility:hidden + position:absolute which preserves element dimensions. Pierre diffs keeps its measurements, Dockview keeps its layout. The tradeoff is effects don't pause for hidden sessions, but that's preferable to broken diffs. * Fix: derive landing page visibility from route match synchronously * Set router pendingMs to 0 to eliminate navigation delay * Auto-restore code review drafts silently, remove restore dialog Drafts are keyed by diff content hash on the server. Same diff = same draft. When a draft exists on mount, it's now restored automatically with a subtle toast notification instead of a blocking dialog. - useCodeAnnotationDraft takes an onRestore callback instead of returning draftBanner/restoreDraft/dismissDraft - ConfirmDialog for draft restore removed from App.tsx - Toast shows "Restored N annotations" on auto-restore * Fix flash of unstyled sidebar on page load * Style Toaster with theme tokens * Add rounded top-left corner to embedded code review * Move rounded corner to Layout session container and clip overflow * Fix curved border: move border from sidebar to session container * Only show curved border when sidebar is open * Fix: use useSidebar hook for conditional curved border * Fix project registry: key by cwd, defer registration until session succeeds - registerProject now finds existing entries by cwd (not name), so two repos with the same name get separate entries - removeProject takes cwd instead of name for unambiguous deletion - Server DELETE /daemon/projects now accepts JSON body with cwd - Session factory defers registerProject until after session creation succeeds, avoiding phantom entries from failed requests - Hub-client: add missing scheduleReconnect after protocol error frames * Embed plan review surface and fix cross-surface issues (#758) * Migrate missed shared UI components to useSessionFetch 8 component/hook files in packages/ui/ still had bare fetch('/api/...') calls that weren't caught during the code review migration: - Settings.tsx, InlineMarkdown.tsx, AttachmentsButton.tsx, ExportModal.tsx - settings/HooksTab.tsx, goal-setup/GoalSetupSurface.tsx - plan-diff/PlanDiffViewer.tsx, hooks/useLinkedDoc.ts Also set window.__PLANNOTATOR_API_BASE__ in SessionProvider so non-hook consumers (apiPath for in ImageThumbnail) get session-scoped paths. * Migrate plan review App.tsx to useSessionFetch + title cleanup * Auto-restore plan review drafts silently, remove restore dialog Rewrite useAnnotationDraft with onRestore callback pattern (same as useCodeAnnotationDraft). Legacy tuple format preserved. Toast on restore. ConfirmDialog removed from plan review App.tsx. * Export PlanAppEmbedded without standalone providers Strip ThemeProvider, TooltipProvider, Toaster when __embedded. h-full instead of h-screen. headerLeft prop passed through to AppHeader for sidebar trigger. App.d.ts type declaration added. * Strip Tailwind from plan review CSS, add @source to frontend * Remove dead toast animation classes that conflicted with tailwindcss-animate * Add visibility guards to keyboard handlers on both surfaces When keep-alive hides a surface with visibility:hidden, its keyboard listeners on window/document stay active. Without guards, Mod+Enter on the visible code review would also fire the hidden plan review's submit handler. Both App.tsx files now check getComputedStyle(rootRef).visibility at the start of every keyboard handler. If hidden, return early. * Wire plan review surface into frontend app All session modes now render production surfaces: - review → ReviewAppEmbedded - plan, annotate, archive, goal-setup → PlanAppEmbedded Added @plannotator/plan-review dependency, Vite aliases, and styles. SessionSurface simplified — review gets code review, everything else gets plan review (which determines its mode from /api/plan response). * Fix: pass session fetch to submitGoalSetup helper * Replace full-screen completion overlay with inline banner in embedded mode When running inside the frontend app (__embedded), the CompletionOverlay blocked the entire viewport including the sidebar. Now: - Embedded surfaces show a CompletionBanner (colored bar below the header) - Action buttons hide after submission (plan review hides via AppHeader submitted prop, code review hides via !submitted guard) - Standalone mode keeps the original full-screen overlay with auto-close - No window.close() fires in embedded mode since useAutoClose lives inside CompletionOverlay which is skipped * Serve production frontend from daemon, debug shell via env var only The daemon now serves the production frontend HTML (apps/frontend/) at session URLs. The debug frontend is only loaded when PLANNOTATOR_DEBUG_SHELL=1, read from disk at runtime — never bundled in the compiled binary. * Session lifecycle, worktree projects, and directory picker (#759) * Add frontend visibility and focus reporting to daemon WebSocket The daemon now tracks per-connection client state: tab visibility and active session ID. The frontend reports these via a new `client-state` WebSocket message type on connect, visibility change, and route navigation. The event hub exposes `getFrontendState()` which returns whether any frontend is connected, any tab is visible, and which sessions are actively being viewed. This is the foundation for smart session opening — the daemon will use this state to decide between opening a browser and sending an in-app notification. * Move browser opening from CLI to daemon with smart presentation The daemon now decides how to present new sessions based on frontend connection state. If a frontend tab is connected and visible, it sends a notification event (no new tab). If no frontend is connected or the tab is backgrounded, it opens a browser. - Add presentSession() to daemon runtime with decision matrix - Add legacyTabMode config: always opens browser when enabled - Remove handleServerReady/handleReviewServerReady/handleAnnotateServerReady calls from CLI hook — the daemon handles it - Add browserAction field to POST /daemon/sessions response - CLI sessions --open command kept as-is (explicit user action) * Add session notification toasts and keep completed sessions in sidebar Phase 3: When the daemon notifies instead of opening a browser, it publishes a session-notify event. The frontend shows an auto-dismissing toast (8s) with mode, project, and an Open button. Toasts are gated on document.visibilityState — queued when tab is backgrounded, flushed on return. Phase 4: Completed sessions no longer disappear from the sidebar. The terminal-status splice in event-store was removed — sessions now update in-place with their new status. Only explicit session-removed events cause removal. * Collapse sidebar on direct session links, open on landing page SidebarProvider defaultOpen is now based on the initial route: collapsed when loading /s/:id directly, open when loading /. Users can still toggle the sidebar manually after the initial render. * Add disk-backed session snapshots for completed session persistence When a session completes, the daemon writes a content snapshot to ~/.plannotator/sessions/.json before disposing the handler. Snapshots capture the plan markdown, diff data, or annotation content — everything the frontend needs to render the session read-only. The daemon server serves snapshot content when a request hits a disposed or missing session. This means completed sessions survive page refresh and daemon restart. Snapshots are capped at 5MB to avoid oversized review diffs. Each session type provides a snapshot callback in the factory that closes over its content at creation time. * Wire legacy tab mode through server config to surface overlays When legacyTabMode is set in config.json, the daemon always opens a browser (already wired in Phase 2), and both surfaces render the full-screen CompletionOverlay with auto-close instead of the inline CompletionBanner — even in embedded mode. This preserves the old tab-per-session + auto-close experience for users who prefer it. The legacyTabMode flag flows through getServerConfig() → /api/plan and /api/diff responses → surface state. * Document legacyTabMode config setting in AGENTS.md * Load session snapshots from disk on daemon startup Completed sessions from previous daemon runs now appear in the sidebar immediately. On startup, the daemon reads all snapshots from ~/.plannotator/sessions/ and creates completed records in the store. These records have no handlers but serve content via the snapshot fallback in the server. * Add worktree-aware project hierarchy to landing page Projects that are git worktrees auto-detect their parent repo and nest underneath it. The landing page shows projects as collapsible tree nodes — expanding a project fetches its worktrees via git worktree list and shows them with branch names. - DaemonProjectEntry gains optional parentCwd and branch fields - addProject detects worktrees via git rev-parse --git-common-dir and auto-registers the parent repo - New GET /daemon/projects/worktrees?cwd= endpoint lists worktrees - Frontend ProjectTable refactored to collapsible tree with worktree children, selection passes cwd to session creation - Session labels include branch name when created from a worktree cwd * Fix parent project registration dedup and add branch to all session labels - Parent auto-registration now adds directly to the flat array instead of calling registerProject, avoiding name-based dedup that could overwrite unrelated projects with the same derived name - All session modes (annotate, archive, goal-setup) now include the branch name in their labels, matching plan and review * Fix blank page when adding a worktree project When adding a directory that is a worktree, the daemon auto-creates the parent project. But the store only added the returned entry (the worktree child), leaving the parent missing from the frontend state. Since the worktree has parentCwd set, the topLevel filter found zero entries and nothing rendered. Fix: when the added entry has parentCwd, re-fetch the full project list so the auto-created parent is included. * Filter temp directory worktrees from project listing * Sort worktrees by last activity (index mtime > commit time > dir mtime) Each worktree gets a lastActive timestamp derived from: 1. Git index file mtime (updates on add, checkout, stash — reflects active work even without commits) 2. Last commit timestamp (fallback if index unavailable) 3. Directory mtime (fallback for brand new worktrees) All three signals are cross-platform (fs.statSync + git log). Worktrees are sorted most-recently-active first. * Fix toast: skip for frontend-initiated sessions, clean label, fix colors - Don't call presentSession for origin "plannotator-frontend" — the frontend already navigates to the session it just created - Strip internal prefixes from session label in toast description, suppress description when it matches the project name - Style toast action button with theme primary colors - Widen project selector to max-w-2xl - Remove opacity-50 from worktree icons * Replace manual project input with searchable directory picker The Add Project dialog is now a searchable directory browser inspired by OpenCode's project picker: - Type a path (~/work/, /Users/...) and see child directories listed - Arrow keys to navigate, Enter to select, Tab to navigate into a dir - Recent projects shown at top for quick re-selection - ~ expansion handled server-side - Hidden directories (.git, .cache, etc.) filtered out - 150ms debounced directory listing for responsive typeahead New daemon endpoint: GET /daemon/fs/list?path= returns child directories for any path with ~ expansion. * Only show worktree chevron when worktrees exist, add Worktrees label - Fetch worktrees eagerly on mount instead of on expand, so the chevron only appears when there are actual worktrees to show - Projects without worktrees get a plain spacer instead of the chevron - Add a "Worktrees" section label above the expanded list * Fix: add missing useEffect import in LandingPage * Fix project row layout: chevron to right, remove branch icons, align folders - The whole project row is now one selectable button with folder icon consistently at the left - Worktree expand chevron moved to the right end, only visible on hover area — doesn't block the selectable feel - Removed all GitBranch icons from worktree entries — just indentation and the branch/worktree name - Projects without worktrees have no chevron at all, no spacer needed * Add ASCII art Plannotator banner to landing page * Increase ASCII banner opacity to 70% * Remove redundant Plannotator label from landing page nav * Make Add Project buttons more visible * Design audit: fix color contrast, remove opacity abuse, fix a11y Applied Emil's design engineering principles: - Interactive rows use text-foreground by default, not text-muted-foreground. Muted text is only for metadata (paths, timestamps, section labels). Items should look clickable at rest, not disabled. - Replaced all opacity-60 on secondary text with text-muted-foreground (semantic token instead of raw opacity) - Borders use border-border (full opacity) not border-border/40 — borders should be visible enough to serve their structural purpose - Removed transition-colors (was a transition: all risk) — hover states are instant by design for frequently-used UI - Changed expand to proper + + +
+ {recentProjects.length > 0 && ( +
+ + Recent + + {recentProjects.map((project, i) => ( + handleSelect(project.cwd)} + onHover={() => setActiveIndex(i)} + /> + ))} +
+ )} + +
+ {recentProjects.length > 0 && dirs.length > 0 && ( + + Directories + + )} + {dirs.map((dir, i) => { + const idx = recentProjects.length + i; + return ( + handleSelect(dir.path)} + onNavigate={() => handleNavigate(dir.path)} + onHover={() => setActiveIndex(idx)} + /> + ); + })} + {!loading && dirs.length === 0 && recentProjects.length === 0 && ( +
+ No directories found +
+ )} +
+
+ +
+ + select + + + Tab navigate into + + + Esc close + +
+ + + ); +} + +function ProjectRow({ + project, + active, + index, + onSelect, + onHover, +}: { + project: ProjectEntry; + active: boolean; + index: number; + onSelect: () => void; + onHover: () => void; +}) { + return ( + + ); +} + +function DirectoryRow({ + dir, + active, + index, + onSelect, + onNavigate, + onHover, +}: { + dir: DirectoryEntry; + active: boolean; + index: number; + onSelect: () => void; + onNavigate: () => void; + onHover: () => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx new file mode 100644 index 000000000..1ca7ba6f0 --- /dev/null +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -0,0 +1,818 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { + Code2, + Archive, + Folder, + FolderPlus, + ChevronRight, + ChevronDown, + Trash2, +} from "lucide-react"; +import * as ContextMenu from "@radix-ui/react-context-menu"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import { ASCII_BANNER } from "./ascii-banner"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { useProjectStore, projectStore } from "../../stores/project-store"; +import { GitDashboard } from "./git-dashboard/GitDashboard"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import { daemonApiClient } from "../../daemon/api/client"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; +import type { + ProjectEntry, + PRListItem, + SessionSummary, + WorktreeEntry, +} from "../../daemon/contracts"; + +interface LandingPageProps { + onAddProject: () => void; +} + +interface Selection { + key: string; + cwd: string; + label: string; + prUrl?: string; +} + +function selectionKey(sel: Omit): string { + return sel.prUrl ?? sel.cwd; +} + +export function LandingPage({ onAddProject }: LandingPageProps) { + const projects = useProjectStore((s) => s.projects); + const sessions = useDaemonEventStore((s) => s.sessions); + const [selections, setSelections] = useState>(new Map()); + useEffect(() => { + const cwds = new Set(projects.map((p) => p.cwd)); + setSelections((prev) => { + const next = new Map(); + for (const [k, sel] of prev) { + if (cwds.has(sel.cwd)) next.set(k, sel); + } + return next.size === prev.size ? prev : next; + }); + }, [projects]); + const [loading, setLoading] = useState(null); + const [viewIndex, setViewIndex] = useState(() => + typeof window !== "undefined" && window.location.hash === "#dashboard" ? 1 : 0, + ); + const navigate = useNavigate(); + + const toggleSelection = useCallback((sel: Omit) => { + setSelections((prev) => { + const key = selectionKey(sel); + const next = new Map(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.set(key, { ...sel, key }); + } + return next; + }); + }, []); + + const selectionCount = selections.size; + + const handleAction = useCallback( + async (action: "review" | "archive") => { + if (selectionCount === 0) return; + setLoading(action); + let items = [...selections.values()]; + if (action === "archive") { + const seen = new Set(); + items = items.filter((sel) => { + if (seen.has(sel.cwd)) return false; + seen.add(sel.cwd); + return true; + }); + } + + const results = await Promise.allSettled( + items.map(async (sel) => { + const result = + action === "review" + ? await daemonApiClient.createReviewSession(sel.cwd, sel.prUrl) + : await daemonApiClient.createArchiveSession(sel.cwd); + return { sel, result }; + }), + ); + setLoading(null); + + let firstSessionId: string | null = null; + let successCount = 0; + const failures: { label: string; message: string }[] = []; + + for (const outcome of results) { + if (outcome.status === "fulfilled" && outcome.value.result.ok) { + successCount++; + if (!firstSessionId) firstSessionId = outcome.value.result.data.session.id; + } else { + const label = outcome.status === "fulfilled" ? outcome.value.sel.label : "Unknown"; + const message = + outcome.status === "fulfilled" && !outcome.value.result.ok + ? outcome.value.result.error.message + : outcome.status === "rejected" + ? String(outcome.reason) + : "Unknown error"; + failures.push({ label, message }); + } + } + + if (firstSessionId) { + setSelections(new Map()); + void navigate({ to: "/s/$sessionId", params: { sessionId: firstSessionId } }); + if (successCount > 1) { + toast.success(`Launched ${successCount} sessions`); + } + } + + for (const fail of failures) { + toast.error(fail.label, { description: fail.message }); + } + }, + [selections, selectionCount, navigate], + ); + + return ( +
+
+
+
+ +
+
+
+
+
+ + + {projects.length === 0 && sessions.length === 0 ? ( + + ) : ( +
+ {projects.length > 0 && ( +
+
+ + Select project + + +
+ + +
+ + Launch + +
+ + + +
+
+
+ )} + + {sessions.length > 0 && ( +
+
+ Active sessions +
+ +
+ )} + + {projects.length === 0 && ( + + )} +
+ )} +
+
+
+
+ setViewIndex(0)} /> +
+
+
+
+
+ ); +} + +function ProjectTable({ + projects, + selections, + onToggle, +}: { + projects: ProjectEntry[]; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const topLevel = projects.filter((p) => !p.parentCwd); + const worktreeChildren = (parentCwd: string) => projects.filter((p) => p.parentCwd === parentCwd); + + return ( +
+ {topLevel.map((project, i) => { + const children = worktreeChildren(project.cwd); + return ( + + ); + })} +
+ ); +} + +function ProjectNode({ + project, + children, + isFirst, + selections, + onToggle, +}: { + project: ProjectEntry; + children: ProjectEntry[]; + isFirst: boolean; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [worktrees, setWorktrees] = useState([]); + const [worktreesFetched, setWorktreesFetched] = useState(false); + const [prs, setPrs] = useState([]); + const [prPlatform, setPrPlatform] = useState(null); + const [prDefaultBranch, setPrDefaultBranch] = useState("main"); + const [prError, setPrError] = useState(null); + const [prsLoading, setPrsLoading] = useState(false); + const [prsFetchedAt, setPrsFetchedAt] = useState(0); + const hasChildren = children.length > 0; + + useEffect(() => { + if (!expanded || worktreesFetched) return; + setWorktreesFetched(true); + daemonApiClient.listWorktrees(project.cwd).then((result) => { + if (result.ok) { + setWorktrees(result.data.worktrees.filter((wt) => wt.path !== project.cwd)); + } + }); + }, [expanded, project.cwd, worktreesFetched]); + + useEffect(() => { + if (!expanded) return; + const stale = !prsFetchedAt || Date.now() - prsFetchedAt > 30_000; + if (!stale || prsLoading) return; + setPrsLoading(true); + daemonApiClient.listPRs(project.cwd).then((result) => { + if (result.ok) { + setPrs(result.data.prs); + setPrPlatform(result.data.platform); + if (result.data.defaultBranch) setPrDefaultBranch(result.data.defaultBranch); + setPrError(result.data.error ?? null); + } + setPrsLoading(false); + setPrsFetchedAt(Date.now()); + }); + }, [expanded, project.cwd, prsFetchedAt, prsLoading]); + + const hasWorktrees = hasChildren || worktrees.length > 0; + const isSelected = selections.has(project.cwd); + + const handleRemove = useCallback(async () => { + const ok = window.confirm( + `Remove "${project.name}"?\n\nThis will cancel active sessions and delete plan history for this project.`, + ); + if (!ok) return; + const removed = await projectStore.getState().removeProject(project.cwd, true); + if (removed) { + toast.success(`Removed ${project.name}`); + } else { + toast.error(`Failed to remove ${project.name}`); + } + }, [project.name, project.cwd]); + + return ( + + +
+
+ + +
+ + {expanded && ( +
+ + + + PRs + + + Worktrees + + + + + + + + + +
+ )} +
+
+ + + + + Remove project + + + +
+ ); +} + +interface PRStack { + prs: PRListItem[]; + label: string; +} + +function buildStacks( + prs: PRListItem[], + defaultBranch: string, +): { stacks: PRStack[]; loose: PRListItem[] } { + const byHead = new Map(); + for (const pr of prs) byHead.set(pr.headBranch, pr); + + const stacked = new Set(); + const chains: PRListItem[][] = []; + + for (const pr of prs) { + if (stacked.has(pr.id)) continue; + if (pr.baseBranch === defaultBranch) continue; + + const chain: PRListItem[] = []; + let current: PRListItem | undefined = pr; + while (current && !stacked.has(current.id)) { + chain.unshift(current); + stacked.add(current.id); + current = byHead.get(current.baseBranch); + } + if (chain.length > 1) { + chains.push(chain); + } else { + stacked.delete(pr.id); + } + } + + const stacks = chains.map((chain) => ({ + prs: chain, + label: `#${chain[0].number} → #${chain[chain.length - 1].number}`, + })); + const loose = prs.filter((pr) => !stacked.has(pr.id)); + return { stacks, loose }; +} + +function PRRow({ + pr, + projectCwd, + projectName, + selections, + onToggle, +}: { + pr: PRListItem; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + return ( + + ); +} + +function StackIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function StackGroup({ + stack, + projectCwd, + projectName, + selections, + onToggle, +}: { + stack: PRStack; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {stack.prs.map((pr) => ( + + ))} +
+ )} +
+ ); +} + +function PRList({ + prs, + loading, + error, + platform, + defaultBranch, + projectCwd, + projectName, + selections, + onToggle, +}: { + prs: PRListItem[]; + loading: boolean; + error: string | null; + platform: string | null; + defaultBranch: string; + projectCwd: string; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + const [showAll, setShowAll] = useState(false); + const visible = useMemo( + () => (showAll ? prs : prs.filter((pr) => pr.state === "open")), + [prs, showAll], + ); + const hiddenCount = prs.length - visible.length; + const { stacks, loose } = useMemo( + () => buildStacks(visible, defaultBranch), + [visible, defaultBranch], + ); + + if (loading) { + return
Loading PRs…
; + } + if (error === "no-remote") { + return
No git remote detected
; + } + if (error === "no-cli") { + return ( +
+ {platform === "gitlab" ? "GitLab CLI (glab)" : "GitHub CLI (gh)"} not installed +
+ ); + } + if (error === "auth-failed") { + return ( +
+ {platform === "gitlab" ? "glab" : "gh"} not authenticated — run{" "} + + {platform === "gitlab" ? "glab" : "gh"} auth login + +
+ ); + } + if (platform === "gitlab" && prs.length === 0) { + return ( +
GitLab MR listing coming soon
+ ); + } + if (visible.length === 0 && !showAll) { + return ( +
+ No open pull requests + {hiddenCount > 0 && ( + <> + {" · "} + + + )} +
+ ); + } + + return ( +
+ {stacks.map((stack) => ( + + ))} + {loose.map((pr) => ( + + ))} + {!showAll && hiddenCount > 0 && ( + + )} +
+ ); +} + +function WorktreeList({ + children, + worktrees, + hasWorktrees, + projectName, + selections, + onToggle, +}: { + children: ProjectEntry[]; + worktrees: WorktreeEntry[]; + hasWorktrees: boolean; + projectName: string; + selections: Map; + onToggle: (sel: Omit) => void; +}) { + if (!hasWorktrees) { + return
No worktrees
; + } + + const allWorktrees: { path: string; branch: string }[] = []; + for (const child of children) { + allWorktrees.push({ path: child.cwd, branch: child.branch ?? child.name }); + } + for (const wt of worktrees) { + if (!children.some((c) => c.cwd === wt.path)) { + allWorktrees.push({ path: wt.path, branch: wt.branch ?? "detached" }); + } + } + + return ( +
+ {allWorktrees.map((wt) => ( + + ))} +
+ ); +} + +function SessionList({ sessions }: { sessions: SessionSummary[] }) { + return ( +
+ {sessions.map((session, i) => { + const meta = getSessionModeMeta(session.mode); + const Icon = meta.icon; + return ( + 0 && "border-t border-border", + "text-foreground hover:bg-surface-1", + )} + > + + + {formatSessionLabel(session.label, session.mode)} + + {meta.label} + + ); + })} +
+ ); +} + +function EmptyState({ onAddProject }: { onAddProject: () => void }) { + return ( +
+

No projects yet

+

+ Projects appear automatically when an agent creates a session, or you can add one manually. +

+ +
+ ); +} diff --git a/apps/frontend/src/components/landing/ascii-banner.ts b/apps/frontend/src/components/landing/ascii-banner.ts new file mode 100644 index 000000000..4b096f41f --- /dev/null +++ b/apps/frontend/src/components/landing/ascii-banner.ts @@ -0,0 +1,2 @@ +export const ASCII_BANNER = + " _ __ ,---. .-._ .-._ _,.---._ ,--.--------. ,---. ,--.--------. _,.---._ \n .-`.' ,`. _.-. .--.' \\ /==/ \\ .-._/==/ \\ .-._ ,-.' , - `. /==/, - , -\\.--.' \\ /==/, - , -\\,-.' , - `. .-.,.---. \n /==/, - \\.-,.'| \\==\\-/\\ \\ |==|, \\/ /, /==|, \\/ /, /==/_, , - \\\\==\\.-. - ,-./\\==\\-/\\ \\\\==\\.-. - ,-./==/_, , - \\ /==/ ` \\ \n|==| _ .=. |==|, | /==/-|_\\ | |==|- \\| ||==|- \\| |==| .=. |`--`\\==\\- \\ /==/-|_\\ |`--`\\==\\- \\ |==| .=. |==|-, .=., | \n|==| , '=',|==|- | \\==\\, - \\ |==| , | -||==| , | -|==|_ : ;=: - | \\==\\_ \\ \\==\\, - \\ \\==\\_ \\|==|_ : ;=: - |==| '=' / \n|==|- '..'|==|, | /==/ - ,| |==| - _ ||==| - _ |==| , '=' | |==|- | /==/ - ,| |==|- ||==| , '=' |==|- , .' \n|==|, | |==|- `-._/==/- /\\ - \\|==| /\\ , ||==| /\\ , |\\==\\ - ,_ / |==|, | /==/- /\\ - \\ |==|, | \\==\\ - ,_ /|==|_ . ,'. \n/==/ - | /==/ - , ,|==\\ _.\\=\\.-'/==/, | |- |/==/, | |- | '.='. - .' /==/ -/ \\==\\ _.\\=\\.-' /==/ -/ '.='. - .' /==/ /\\ , ) \n`--`---' `--`-----' `--` `--`./ `--``--`./ `--` `--`--'' `--`--` `--` `--`--` `--`--'' `--`-`--`--' "; diff --git a/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx new file mode 100644 index 000000000..2720756cc --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/GitDashboard.tsx @@ -0,0 +1,122 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { GitPullRequest, GitMerge, FileEdit } from "lucide-react"; +import { toast } from "sonner"; +import { daemonApiClient } from "../../../daemon/api/client"; +import type { GitDashboardPR } from "../../../stores/git-dashboard-store"; +import { useGitDashboard } from "./use-git-dashboard"; +import { MetricCards } from "./MetricCards"; +import { PRGroup } from "./PRGroup"; +import { PRRow } from "./PRRow"; + +interface GitDashboardProps { + active: boolean; + onBack: () => void; +} + +export function GitDashboard({ active, onBack }: GitDashboardProps) { + const { groups, metrics, loading, error, isEmpty } = useGitDashboard(active); + const [launchingId, setLaunchingId] = useState(null); + const navigate = useNavigate(); + + const handleSelect = useCallback( + async (pr: GitDashboardPR) => { + setLaunchingId(pr.url); + const result = await daemonApiClient.createReviewSession(pr.projectCwd, pr.url); + setLaunchingId(null); + if (result.ok) { + void navigate({ to: "/s/$sessionId", params: { sessionId: result.data.session.id } }); + } else { + toast.error("Failed to start review", { description: result.error.message }); + } + }, + [navigate], + ); + + return ( +
+
+
+ +
+ + {loading && isEmpty && ( +
Loading PRs…
+ )} + + {!loading && isEmpty && ( +
+ {error ?? "No pull requests found across your projects"} +
+ )} + + {!isEmpty && ( +
+
+
+ {groups.open.length > 0 && ( + + {groups.open.map((pr) => ( + + ))} + + )} + {groups.draft.length > 0 && ( + + {groups.draft.map((pr) => ( + + ))} + + )} + {groups.merged.length > 0 && ( + + {groups.merged.map((pr) => ( + + ))} + + )} +
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx b/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx new file mode 100644 index 000000000..5e605e529 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/MetricCards.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; +import type { DashboardMetrics } from "./use-git-dashboard"; + +interface MetricCardProps { + label: string; + count: number; + active?: boolean; + onClick?: () => void; +} + +function MetricCard({ label, count, active, onClick }: MetricCardProps) { + return ( + + ); +} + +function scrollToGroup(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +export function MetricCards({ metrics }: { metrics: DashboardMetrics }) { + return ( +
+

Pull Requests

+ scrollToGroup("pr-group-open")} + /> + scrollToGroup("pr-group-draft")} + /> + scrollToGroup("pr-group-merged")} + /> +
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx b/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx new file mode 100644 index 000000000..42fb6928a --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRGroup.tsx @@ -0,0 +1,24 @@ +import type { LucideIcon } from "lucide-react"; + +interface PRGroupProps { + id?: string; + title: string; + icon: LucideIcon; + count: number; + children: React.ReactNode; +} + +export function PRGroup({ id, title, icon: Icon, count, children }: PRGroupProps) { + return ( +
+
+ + {title} + + {count} + +
+
{children}
+
+ ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx new file mode 100644 index 000000000..2b04046ec --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/PRRow.tsx @@ -0,0 +1,93 @@ +import { cn } from "@/lib/utils"; +import { PullRequestIcon } from "@plannotator/ui/components/PullRequestIcon"; +import type { GitDashboardPR } from "../../../stores/git-dashboard-store"; +import { formatRelativeTime } from "./use-git-dashboard"; + +const STATUS_COLORS: Record = { + open: "text-green-500", + merged: "text-purple-500", + closed: "text-red-500", + draft: "text-muted-foreground/50", +}; + +const REVIEW_BADGES: Record = { + APPROVED: { label: "Approved", className: "bg-green-500/10 text-green-600 dark:text-green-400" }, + CHANGES_REQUESTED: { + label: "Changes requested", + className: "bg-red-500/10 text-red-600 dark:text-red-400", + }, + REVIEW_REQUIRED: { + label: "Review required", + className: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", + }, +}; + +interface PRRowProps { + pr: GitDashboardPR; + loading?: boolean; + onSelect: (pr: GitDashboardPR) => void; +} + +export function PRRow({ pr, loading, onSelect }: PRRowProps) { + const statusKey = pr.isDraft && pr.state === "open" ? "draft" : pr.state; + const reviewBadge = pr.reviewDecision ? (REVIEW_BADGES[pr.reviewDecision] ?? null) : null; + const repoName = pr.repoSlug.split("/")[1] ?? pr.repoSlug; + + return ( + + ); +} diff --git a/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts new file mode 100644 index 000000000..aec89ee78 --- /dev/null +++ b/apps/frontend/src/components/landing/git-dashboard/use-git-dashboard.ts @@ -0,0 +1,92 @@ +import { useEffect, useMemo } from "react"; +import { useProjectStore } from "../../../stores/project-store"; +import { useGitDashboardStore, type GitDashboardPR } from "../../../stores/git-dashboard-store"; + +export interface PRGroups { + open: GitDashboardPR[]; + draft: GitDashboardPR[]; + merged: GitDashboardPR[]; +} + +export interface DashboardMetrics { + open: number; + draft: number; + merged: number; + total: number; +} + +function groupPRs(prs: GitDashboardPR[]): PRGroups { + const open: GitDashboardPR[] = []; + const draft: GitDashboardPR[] = []; + const merged: GitDashboardPR[] = []; + for (const pr of prs) { + if (pr.state === "open" && pr.isDraft) draft.push(pr); + else if (pr.state === "open") open.push(pr); + else if (pr.state === "merged") merged.push(pr); + } + return { open, draft, merged }; +} + +function computeMetrics(prs: GitDashboardPR[]): DashboardMetrics { + let open = 0; + let draft = 0; + let merged = 0; + for (const pr of prs) { + if (pr.state === "open" && pr.isDraft) draft++; + else if (pr.state === "open") open++; + else if (pr.state === "merged") merged++; + } + return { open, draft, merged, total: prs.length }; +} + +export function formatRelativeTime(iso: string): string { + if (!iso) return ""; + const diff = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + return `${months}mo`; +} + +const STALE_MS = 30_000; + +export function useGitDashboard(active = true) { + const projects = useProjectStore((s) => s.projects); + const prs = useGitDashboardStore((s) => s.prs); + const loading = useGitDashboardStore((s) => s.loading); + const error = useGitDashboardStore((s) => s.error); + const lastFetchedAt = useGitDashboardStore((s) => s.lastFetchedAt); + const lastProjectKey = useGitDashboardStore((s) => s.lastProjectKey); + const fetchAllPRs = useGitDashboardStore((s) => s.fetchAllPRs); + + const clear = useGitDashboardStore((s) => s.clear); + + useEffect(() => { + if (!active) return; + const topLevel = projects.filter((p) => !p.parentCwd); + if (topLevel.length === 0) { + if (prs.length > 0) clear(); + return; + } + const projectKey = topLevel + .map((p) => p.cwd) + .sort() + .join("|"); + const stale = + !lastFetchedAt || Date.now() - lastFetchedAt > STALE_MS || projectKey !== lastProjectKey; + if (stale && !loading) fetchAllPRs(projects); + }, [active, projects, prs.length, lastFetchedAt, lastProjectKey, loading, fetchAllPRs, clear]); + + const groups = useMemo(() => groupPRs(prs), [prs]); + const metrics = useMemo(() => computeMetrics(prs), [prs]); + + const isEmpty = + groups.open.length === 0 && groups.draft.length === 0 && groups.merged.length === 0; + + return { groups, metrics, loading, error, isEmpty }; +} diff --git a/apps/frontend/src/components/sessions/SessionSurface.tsx b/apps/frontend/src/components/sessions/SessionSurface.tsx new file mode 100644 index 000000000..1aac30548 --- /dev/null +++ b/apps/frontend/src/components/sessions/SessionSurface.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { SessionProvider } from "@plannotator/ui/hooks/useSessionFetch"; +import { ReviewAppEmbedded } from "@plannotator/code-review"; +import { PlanAppEmbedded } from "@plannotator/plan-review"; +import "@plannotator/code-review/styles"; +import "@plannotator/plan-review/styles"; +import type { SessionBootstrap } from "../../daemon/contracts"; +import { appStore } from "../../stores/app-store"; + +const sidebarTrigger = ( + +); + +const openSettings = () => appStore.getState().setSettingsOpen(true); + +interface SessionSurfaceProps { + bootstrap: SessionBootstrap; +} + +export const SessionSurface = React.memo(function SessionSurface({ + bootstrap, +}: SessionSurfaceProps) { + const { session } = bootstrap; + + if (session.mode === "review") { + return ( + + + + ); + } + + return ( + + + + ); +}); diff --git a/apps/frontend/src/components/settings/AppSettingsDialog.tsx b/apps/frontend/src/components/settings/AppSettingsDialog.tsx new file mode 100644 index 000000000..857ee73c7 --- /dev/null +++ b/apps/frontend/src/components/settings/AppSettingsDialog.tsx @@ -0,0 +1,353 @@ +import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { useAppStore } from "../../stores/app-store"; +import { GeneralTab } from "@plannotator/ui/components/settings/GeneralTab"; +import { PlanGeneralTab } from "@plannotator/ui/components/settings/PlanGeneralTab"; +import { PlanDisplayTab } from "@plannotator/ui/components/settings/PlanDisplayTab"; +import { SavingTab } from "@plannotator/ui/components/settings/SavingTab"; +import { LabelsTab } from "@plannotator/ui/components/settings/LabelsTab"; +import { FilesTab } from "@plannotator/ui/components/settings/FilesTab"; +import { ObsidianTab } from "@plannotator/ui/components/settings/ObsidianTab"; +import { BearTab } from "@plannotator/ui/components/settings/BearTab"; +import { OctarineTab } from "@plannotator/ui/components/settings/OctarineTab"; +import { GitTab, ReviewDisplayTab, CommentsTab } from "@plannotator/ui/components/Settings"; +import { ThemeTab } from "@plannotator/ui/components/ThemeTab"; +import { KeyboardShortcuts } from "@plannotator/ui/components/KeyboardShortcuts"; +import { AISettingsTab } from "@plannotator/ui/components/AISettingsTab"; +import { HooksTab } from "@plannotator/ui/components/settings/HooksTab"; +import { getAIProviderSettings, saveAIProviderSettings } from "@plannotator/ui/utils/aiProvider"; +import { configStore } from "@plannotator/ui/config"; + +interface TabDef { + id: string; + label: string; +} + +const GENERAL_TABS: TabDef[] = [ + { id: "general", label: "General" }, + { id: "theme", label: "Theme" }, + { id: "shortcuts", label: "Shortcuts" }, +]; + +const PLAN_TABS: TabDef[] = [ + { id: "plan-general", label: "General" }, + { id: "plan-display", label: "Display" }, + { id: "plan-saving", label: "Saving" }, + { id: "plan-labels", label: "Labels" }, + { id: "plan-hooks", label: "Hooks" }, +]; + +const REVIEW_TABS: TabDef[] = [ + { id: "review-git", label: "Git" }, + { id: "review-display", label: "Display" }, + { id: "review-comments", label: "Comments" }, + { id: "review-ai", label: "AI" }, +]; + +const INTEGRATION_TABS: TabDef[] = [ + { id: "int-files", label: "Files" }, + { id: "int-obsidian", label: "Obsidian" }, + { id: "int-bear", label: "Bear" }, + { id: "int-octarine", label: "Octarine" }, +]; + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function AppSettingsDialog() { + const open = useAppStore((s) => s.settingsOpen); + const setOpen = useAppStore((s) => s.setSettingsOpen); + const [activeTab, setActiveTab] = useState("general"); + const [themePreview, setThemePreview] = useState(false); + + useEffect(() => { + if (!themePreview) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setThemePreview(false); + setOpen(true); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [themePreview, setOpen]); + + // Force re-mount of tab content when dialog opens to ensure fresh state + const [mountKey, setMountKey] = useState(0); + useEffect(() => { + if (open) setMountKey((k) => k + 1); + }, [open]); + + // Detect origin from the active session (if any) + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + const activeOrigin = activeSessionId + ? ((visitedSessions[activeSessionId]?.bootstrap.session.origin as string | undefined) ?? null) + : null; + + // Fetch git user and config from daemon on open + const [gitUser, setGitUser] = useState(); + const [legacyTabMode, setLegacyTabMode] = useState(false); + + useEffect(() => { + if (!open) return; + fetch("/daemon/git/user") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.gitUser) setGitUser(data.gitUser); + }) + .catch(() => {}); + fetch("/daemon/config") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.config) { + configStore.init(data.config); + setLegacyTabMode(!!data.config.legacyTabMode); + } + }) + .catch(() => {}); + }, [open]); + + // Daemon-routed fetch for tabs that need server calls without session context + const daemonFetch = useCallback((input: string, init?: RequestInit) => { + const path = + typeof input === "string" && input.startsWith("/api/") ? `/daemon${input.slice(4)}` : input; + return fetch(path, init); + }, []); + + // AI provider state — fetched once when dialog opens + const [aiProviders, setAiProviders] = useState< + Array<{ id: string; name: string; capabilities: Record }> + >([]); + const [aiProviderId, setAiProviderId] = useState( + () => getAIProviderSettings().providerId, + ); + + // Re-read AI provider on each open (could have changed via per-surface settings) + useEffect(() => { + if (open) setAiProviderId(getAIProviderSettings().providerId); + }, [open]); + + useEffect(() => { + if (!open) return; + const apiBase = activeSessionId ? `/s/${activeSessionId}/api` : null; + if (!apiBase) return; + fetch(`${apiBase}/ai/capabilities`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.providers) setAiProviders(data.providers); + }) + .catch(() => {}); + }, [open, activeSessionId]); + + const handleAiProviderChange = useCallback((providerId: string | null) => { + setAiProviderId(providerId); + const current = getAIProviderSettings(); + saveAIProviderSettings({ ...current, providerId }); + }, []); + + return ( + <> + + + Settings + +
+ +
+
+ +
+
+ {/* General */} + + { + setLegacyTabMode(enabled); + fetch("/daemon/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ legacyTabMode: enabled }), + }).catch(() => {}); + }} + /> + + + { + setOpen(false); + setThemePreview(true); + }} + /> + + +
+
+
+ Plan Review +
+ +
+
+
+ Code Review +
+ +
+
+
+ + {/* Plan Review */} + + + + + + + + + + + + + + + + + {/* Code Review */} + + + + + + + + + + + + + + {/* Integrations */} + + + + + + + + + + + + +
+
+ + + + + {themePreview && + createPortal( +
+
+
+
+ + Theme Preview + + +
+
+ +
+
+
, + document.body, + )} + + ); +} diff --git a/apps/frontend/src/components/sidebar/AppSidebar.tsx b/apps/frontend/src/components/sidebar/AppSidebar.tsx new file mode 100644 index 000000000..ec923f135 --- /dev/null +++ b/apps/frontend/src/components/sidebar/AppSidebar.tsx @@ -0,0 +1,152 @@ +import { useCallback, useMemo } from "react"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { Moon, Settings, Sun } from "lucide-react"; +import { TaterSpriteSidebar } from "./TaterSpriteSidebar"; +import { appStore } from "../../stores/app-store"; +import { cn } from "@/lib/utils"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { useTheme } from "@plannotator/ui/components/ThemeProvider"; +import { useDaemonEventStore } from "../../daemon/events/event-store"; +import type { SessionSummary } from "../../daemon/contracts"; +import { getSessionModeMeta, formatSessionLabel } from "../../shared/session-meta"; + +const MODE_ORDER = ["plan", "review", "annotate", "goal-setup", "archive"]; + +export function AppSidebarContent() { + const sessions = useDaemonEventStore((s) => s.sessions); + const { resolvedMode, setMode } = useTheme(); + const matchRoute = useMatchRoute(); + + const grouped = useMemo(() => { + const map = new Map(); + for (const s of sessions) { + const list = map.get(s.mode) ?? []; + list.push(s); + map.set(s.mode, list); + } + return map; + }, [sessions]); + + const toggleTheme = useCallback(() => { + setMode(resolvedMode === "dark" ? "light" : "dark"); + }, [resolvedMode, setMode]); + + return ( + <> + + + +
+ + Plannotator + + + v{__APP_VERSION__} ·{" "} + e.stopPropagation()} + > + Send feedback + + +
+ +
+ + + {MODE_ORDER.map((mode) => { + const modeSessions = grouped.get(mode); + if (!modeSessions?.length) return null; + const meta = getSessionModeMeta(mode); + + const Icon = meta.icon; + return ( + + + + {meta.label}s + + + + {modeSessions.map((session) => { + const isActive = !!matchRoute({ + to: "/s/$sessionId", + params: { sessionId: session.id }, + }); + const isTerminal = + session.status === "completed" || session.status === "cancelled"; + + return ( + + + + + + {formatSessionLabel(session.label, session.mode)} + + + + + ); + })} + + + + ); + })} + + + + + + appStore.getState().setSettingsOpen(true)} + tooltip="Settings" + > + + Settings + + + + + {resolvedMode === "dark" ? : } + Toggle theme + + + + + + ); +} + +export function AppSidebar() { + return ( + + + + ); +} diff --git a/apps/frontend/src/components/sidebar/SidebarPeek.tsx b/apps/frontend/src/components/sidebar/SidebarPeek.tsx new file mode 100644 index 000000000..705d25dc5 --- /dev/null +++ b/apps/frontend/src/components/sidebar/SidebarPeek.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSidebar } from "@/components/ui/sidebar"; +import { AppSidebarContent } from "./AppSidebar"; + +export function SidebarPeek() { + const { open } = useSidebar(); + const [visible, setVisible] = useState(false); + const [backdropMounted, setBackdropMounted] = useState(false); + const [backdropVisible, setBackdropVisible] = useState(false); + const hideTimeout = useRef | null>(null); + + const show = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = null; + } + setVisible(true); + }, []); + + const hide = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } + hideTimeout.current = setTimeout(() => setVisible(false), 150); + }, []); + + useEffect(() => { + if (visible) { + setBackdropMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setBackdropVisible(true)); + }); + } else { + setBackdropVisible(false); + const timer = setTimeout(() => setBackdropMounted(false), 200); + return () => clearTimeout(timer); + } + }, [visible]); + + if (open) return null; + + return ( + <> + {/* Hover strip — invisible hit area on left edge */} +
+ {/* Backdrop overlay */} + {backdropMounted && ( +
+ )} + {/* Floating sidebar panel */} +
+
+ +
+
+ + ); +} diff --git a/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx new file mode 100644 index 000000000..2da10b85c --- /dev/null +++ b/apps/frontend/src/components/sidebar/TaterSpriteSidebar.tsx @@ -0,0 +1,32 @@ +import spriteSheet from "../../assets/sprite_package_sidebar/sprite.png"; + +const NATIVE_W = 117; +const NATIVE_H = 96; +const FRAMES = 24; +const DISPLAY_H = 40; +const SCALE = DISPLAY_H / NATIVE_H; +const DISPLAY_W = NATIVE_W * SCALE; +const TOTAL_WIDTH = NATIVE_W * FRAMES * SCALE; + +export function TaterSpriteSidebar() { + return ( +
+ +
+ ); +} diff --git a/apps/frontend/src/components/ui/button.tsx b/apps/frontend/src/components/ui/button.tsx new file mode 100644 index 000000000..acf8c3d9e --- /dev/null +++ b/apps/frontend/src/components/ui/button.tsx @@ -0,0 +1,77 @@ +import { Slot, Slottable } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-medium transition-[color,background-color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + xxs: "h-6 rounded-md gap-1.5 px-2.5", + xs: "h-7 rounded-md gap-1.5 px-2.5", + sm: "h-8 rounded-md gap-1.5 px-3", + lg: "h-10 rounded-md px-6", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +type ButtonIcon = React.ReactNode; + +function Button({ + children, + className, + variant, + size, + asChild = false, + iconLeft, + iconRight, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + iconLeft?: ButtonIcon; + iconRight?: ButtonIcon; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + {iconLeft ? ( + + ) : null} + {children} + {iconRight ? ( + + ) : null} + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx new file mode 100644 index 000000000..1a98f2f22 --- /dev/null +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; +const DialogTrigger = DialogPrimitive.Trigger; +const DialogPortal = DialogPrimitive.Portal; +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + hideClose?: boolean; + } +>(({ className, children, hideClose, ...props }, ref) => ( + + + + {children} + {!hideClose && ( + + + Close + + )} + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +}; diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 000000000..13fc29e98 --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/frontend/src/components/ui/separator.tsx b/apps/frontend/src/components/ui/separator.tsx new file mode 100644 index 000000000..856296e91 --- /dev/null +++ b/apps/frontend/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/frontend/src/components/ui/sheet.tsx b/apps/frontend/src/components/ui/sheet.tsx new file mode 100644 index 000000000..4873123f8 --- /dev/null +++ b/apps/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { X } from "lucide-react"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Sheet({ ...props }: React.ComponentProps) { + return ; +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function SheetClose({ ...props }: React.ComponentProps) { + return ; +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return ; +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + + {children} + + + Close + + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/frontend/src/components/ui/sidebar.tsx b/apps/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 000000000..21ea19364 --- /dev/null +++ b/apps/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { PanelLeft } from "lucide-react"; +import { Slot } from "@radix-ui/react-slot"; +import type { VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +const SIDEBAR_STORAGE_KEY = "sidebar_state"; +const SIDEBAR_WIDTH = "244px"; // 16rem +const SIDEBAR_WIDTH_MOBILE = "260px"; // 18rem +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +const MOBILE_BREAKPOINT = 1024; + +function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} + +type SidebarContext = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(() => { + if (typeof window === "undefined") { + return defaultOpen; + } + + const storedOpenState = window.localStorage.getItem(SIDEBAR_STORAGE_KEY); + return storedOpenState === null ? defaultOpen : storedOpenState === "true"; + }); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(openState)); + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, toggleSidebar], + ); + + return ( + + +
+ {children} +
+
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ); + } + + return ( +
+
+ +
+ ); +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { toggleSidebar } = useSidebar(); + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( + +
+ +
+ +
+

+ Session Persistence +

+

+ Denied sessions stay alive. The agent revises. The same session updates in place. No more starting over. +

+
+ + +
+

User Experience

+ +
+
+
+ +

Before

+
+
    +
  1. 1 You review a plan, leave annotations, deny
  2. +
  3. 2 Session dies — completion screen, done
  4. +
  5. 3 Agent revises and resubmits
  6. +
  7. 4 Brand new session — new tab, no context, no diff
  8. +
  9. 5 Start over from scratch
  10. +
+
+
+
+ +

After

+
+
    +
  1. 1 You review a plan, leave annotations, deny
  2. +
  3. 2 Banner: "Waiting for agent to revise..."
  4. +
  5. 3 Agent revises and resubmits
  6. +
  7. 4 Same session updates — diff shows what changed
  8. +
  9. 5 Review again — approve or deny with more feedback
  10. +
+
+
+
+ + +
+

Works across all modes

+
+
+
📋
+

Plan Review

+

Agent revises the plan, session shows plan diff

+
+
+
🔍
+

Code Review

+

Agent makes changes, session refreshes with new diff

+
+
+
✏️
+

Annotate

+

Agent edits the file, session refreshes with updated content

+
+
+
+ + + + + +
+

Session lifecycle

+
+ + + + active + + + deny + + + awaiting-resubmission + + + agent resubmits + + + 10 min TTL + + expired + + + approve + + completed + + + + + + +
+
+ + +
+

How sessions are matched

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ModeMatch Key
Planplan:project:slug
Reviewreview:project:branch
Annotateannotate:filepath
+
+

No match (heading changed, different branch, different file) → new session as before.

+
+ + +
+

Files changed — 14 files, +423 / -143

+
+ + Show file list → + +
+ + + + + + + + + + + + + + + + +
daemon-protocol.tsNew status, event family, protocol v2
session-handler.tscreateDecisionCycle, resolveAndCycle helpers
session-store.tssuspend(), reactivate(), matchKey field
session-factory.tsMatching, persistent loop, all three types wired
server.ts (daemon)Skip deletion timer for awaiting sessions
index.ts (plan)Cycle model, updateContent, slug
annotate.tsCycle model, updateContent for files
review.tsCycle model, updateContent for diffs
hook/index.ts (CLI)Accept awaiting-resubmission status
plan-review App.tsxAwaiting state + revision subscription
code-review App.tsxAwaiting state + revision subscription
CompletionBanner.tsxAwaiting variant with spinner
AGENTS.mdDocumentation
+
+
+
+ + +
+

What does NOT persist

+
+
+ + URL-based annotations +
+
+ + "Annotate last message" sessions +
+
+ + Archive sessions (read-only) +
+
+ + Standalone / demo sessions +
+
+
+ + +
+

Recap

+
+
    +
  1. Denied sessions stay alive instead of dying
  2. +
  3. Agent resubmits → same session updates in place
  4. +
  5. Works for plan, code review, and file-based annotate
  6. +
  7. Matching by project+slug, project+branch, or filepath
  8. +
  9. 10-minute timeout if the agent doesn't come back
  10. +
  11. Agent doesn't need to know — matching is server-side
  12. +
  13. Shared createDecisionCycle eliminates duplication
  14. +
  15. Frontend shows amber "waiting" banner with cancel
  16. +
  17. No changes to approve, exit, or standalone flows
  18. +
+
+
+ + +
+

Knowledge check

+
+
+
+ +
+ + + + + diff --git a/goals/session-persistence/overview.md b/goals/session-persistence/overview.md new file mode 100644 index 000000000..a3ea81e2f --- /dev/null +++ b/goals/session-persistence/overview.md @@ -0,0 +1,163 @@ +# Session Persistence — What You Need To Know + +## The User Experience + +### Before + +You review a plan. You leave annotations. You click Deny. Your feedback gets sent to the agent. The session dies. Completion screen. Done. + +The agent reads your feedback, revises the plan, and submits again. A completely new session appears — new tab or new sidebar entry. No connection to the one you just closed. No awareness that this is a revision of the same plan. You start from scratch every time. + +Same story for code review and annotate. Send feedback, session dies, agent makes changes, new session. Every deny-resubmit cycle is a fresh start. + +### After + +You deny a plan. Instead of the completion screen, you see: **"Feedback sent — waiting for agent to revise..."** The session stays alive. Your browser tab stays open. The sidebar shows a pulsing amber indicator. + +The agent revises the plan and submits again. The **same session** updates in place. You see the new plan with a diff showing what changed. Your annotations are cleared (the agent already has them). You review the fresh version. Approve or deny again. Repeat until you're satisfied. + +This works for all three session types: +- **Plan review** — agent revises the plan, session updates with plan diff +- **Code review** — agent makes code changes, session refreshes with new diff +- **Annotate** — agent edits the file, session refreshes with updated content + +### What stays the same + +- Approve works exactly as before +- Exit works exactly as before +- Auto-close works exactly as before +- Sessions opened without an agent (standalone, demo) behave exactly as before — deny is still final +- Sessions do not expire — they persist until daemon restart + +--- + +## Why This Was Needed + +Users and community members repeatedly asked for this. The linear "deny → wait → new session" flow was friction-heavy. Every cycle required the user to re-orient: find the new session, remember what they asked for, compare mentally against the previous version. The plan diff system already existed but couldn't show diffs across sessions — only within a session's version history. + +The deny-resubmit cycle is the core feedback loop of plan-driven development. Making it seamless makes the entire product more useful. + +--- + +## Technical Overview + +### New Session Status: `awaiting-resubmission` + +A non-terminal status in the daemon session lifecycle. The session stays alive — its HTTP handler keeps serving requests, the WebSocket connection stays open, and the frontend connection persists. Sessions do not expire; they persist until daemon restart. + +``` +active → awaiting-resubmission → active → awaiting-resubmission → ... +``` + +### Decision Cycle Model + +Each server (plan, annotate, review) previously used a one-shot promise for the user's decision. Now they use a **cycle model**: every action (deny, approve, exit, send feedback) resolves the current cycle and starts a new one for agent-originated sessions. The decision loop stays alive after all actions. + +Shared helper in `packages/server/session-handler.ts`: +- `createDecisionCycle()` — creates a resolvable cycle with `promise()`, `resolve()`, `startNew()` +- `resolveAndCycle(cycle, result, origin)` — resolves current cycle, starts new one if agent-originated, returns `{ awaitingResubmission: true }` flag + +### Session Matching + +When the agent resubmits, the daemon matches the new request to the existing suspended session using a **match key**: + +| Session Type | Match Key | Example | +|-------------|-----------|---------| +| Plan | `plan:${project}:${slug}` | `plan:plannotator:implementation-plan-2026-05-22` | +| Code Review | `review:${project}:${branch}` or `review:${prUrl}` | `review:plannotator:feat/session-persistence` | +| Annotate (file) | `annotate:${project}:${filePath}` | `annotate:plannotator:/path/to/README.md` | +| Annotate (folder) | `annotate:${project}:folder:${folderPath}` | `annotate:plannotator:folder:/path/to/docs` | + +If a match is found: the session's `updateContent` method pushes new content, the store reactivates the session, and a `session-revision` WebSocket event notifies the frontend. + +If no match (different slug, different branch, different file): a new session is created as before. + +### Content Update + +Each server exposes a `handleUpdateContent` function that: +- Replaces the content in the server's closure (plan text, diff patch, markdown) +- Resets draft state +- Publishes a `session-revision` event to the frontend + +### Frontend + +All three surfaces (plan review, code review, annotate) handle the `awaitingResubmission` response from their feedback endpoints. When received: +- Show the "Feedback sent — waiting for agent to revise..." banner +- Subscribe to `session-revision` WebSocket events +- On revision: refresh content, clear annotations, reset awaiting state + +### CLI + +The CLI binary accepts `awaiting-resubmission` as a valid non-error status. It outputs the denial feedback and exits with code 0 — the agent reads the feedback and replans, same as always. The matching happens server-side; the agent doesn't know about session persistence. + +--- + +## Files Changed + +| File | What changed | +|------|-------------| +| `packages/shared/daemon-protocol.ts` | New `awaiting-resubmission` status, `session-revision` event family, protocol v2 | +| `packages/server/session-handler.ts` | `createDecisionCycle()` and `resolveAndCycle()` shared helpers | +| `packages/server/daemon/session-store.ts` | `suspend()`, `reactivate()` methods, `matchKey` field | +| `packages/server/daemon/session-factory.ts` | `createDecisionScope`, `registerPersistentDecision`, `findAwaitingSession`, matching + reactivation for all three types | +| `packages/server/daemon/server.ts` | Skip deletion timer for awaiting-resubmission sessions | +| `packages/server/index.ts` | Cycle model, `handleUpdateContent`, slug/getSnapshot on session | +| `packages/server/annotate.ts` | Cycle model, `handleUpdateContent` for file-based modes | +| `packages/server/review.ts` | Cycle model, `handleUpdateContent(rawPatch, gitRef)` | +| `apps/hook/server/index.ts` | Accept `awaiting-resubmission` status (exit 0, not error) | +| `packages/plannotator-plan-review/App.tsx` | `awaitingResubmission` state, deny handler check, `session-revision` subscription | +| `packages/plannotator-code-review/App.tsx` | Same as plan review, adapted for diff refresh | +| `packages/ui/components/CompletionBanner.tsx` | `awaiting` variant with spinner and cancel button | +| `AGENTS.md` | Documentation for new status, event family, resubmission flow | + +--- + +## What Does NOT Persist + +- **URL-based annotations** — session stays alive but can't be matched for reuse (source URL might change) +- **"Annotate last message" sessions** — session stays alive but can't be matched for reuse (no stable identity) +- **Archive sessions** — read-only, no feedback cycle +- **Goal setup sessions** — one-shot Q&A, not a review cycle +- **Standalone/demo sessions** — no agent to resubmit + +--- + +## Recap + +1. Denied sessions stay alive instead of dying +2. The agent resubmits → same session updates in place +3. Works for plan, code review, and file-based annotate +4. Matching is by project+slug (plan), project+branch (review), or filepath (annotate) +5. Sessions persist until daemon restart — no timeout +6. Agent doesn't need to know — matching is server-side +7. Shared `createDecisionCycle` helper eliminates duplication across three servers +8. Frontend shows amber "waiting" banner with cancel option +9. No changes to approve, exit, or standalone flows + +--- + +## Quiz + +**1.** What happens to a denied session's HTTP handler? +> It stays alive. `suspend()` does NOT call `disposeResources()` or clear `handleRequest`. + +**2.** How does the daemon know a new plan submission is a revision of an existing session? +> It computes a match key (`plan:${project}:${slug}`) and searches for an `awaiting-resubmission` session with the same key. + +**3.** What happens if the agent changes the plan's heading when resubmitting? +> Different heading → different slug → no match → new session. The old session persists until daemon restart. + +**4.** Does the agent need to track session IDs or know about persistence? +> No. The CLI binary runs fresh each time. Matching is entirely server-side. + +**5.** What's the difference between `suspend()` and `complete()`? +> `complete()` sets terminal status, disposes resources, clears the HTTP handler. `suspend()` sets `awaiting-resubmission`, resolves waiters (so the CLI gets feedback), but keeps everything alive. + +**6.** How does the frontend know the content changed? +> A `session-revision` WebSocket event carrying the new content. The frontend always subscribes in API mode. State resets only fire for live events or when content actually changed (snapshots with unchanged content are ignored to prevent wiping restored state on tab refresh). + +**7.** What happens to a URL annotation session when denied? +> It completes normally (no persistence). URL sources can't be refreshed, so no match key is set. + +**8.** How long does the session wait for the agent to resubmit? +> Indefinitely. Sessions persist until daemon restart — no timeout. diff --git a/goals/worktree-projects/facts.md b/goals/worktree-projects/facts.md new file mode 100644 index 000000000..b60c54633 --- /dev/null +++ b/goals/worktree-projects/facts.md @@ -0,0 +1,47 @@ +# Worktree-Aware Project Hierarchy — Facts + +## Auto-Detection + +- When a user adds a directory that is a git worktree, the daemon auto-detects the parent repo using `git rev-parse --git-common-dir`. +- The parent repo becomes a top-level project entry if it doesn't already exist. +- The added worktree directory nests under the parent project automatically. +- Adding a regular repo (not a worktree) works the same as today — it becomes a top-level project. + +## Data Model + +- `DaemonProjectEntry` gains an optional `parentCwd` field. Worktree entries have `parentCwd` set to the parent repo's cwd. Regular projects leave it unset. +- The on-disk format (`~/.plannotator/projects.json`) stays a flat array. The tree structure is resolved at query time, not stored. +- A new optional `branch` field on `DaemonProjectEntry` stores the worktree's checked-out branch name for display. + +## Worktree Listing + +- Expanding a project node in the UI triggers a `git worktree list` call via a daemon API endpoint. +- Worktree data is fetched on demand, not cached or polled. Each expand gets fresh data. +- The daemon returns worktrees as an array of `{ path, branch, head }` using the existing `WorktreeInfo` type from `packages/shared/review-core.ts`. + +## Landing Page UI + +- The project table on the landing page shows projects as collapsible tree nodes. +- Projects with worktrees display a chevron/expand control. +- Clicking the chevron expands the node and shows worktrees indented underneath, each with its branch name and path. +- Both parent projects and worktree entries are selectable for launching sessions (Code Review, Browse Archive). +- Selecting a worktree entry passes its `cwd` (the worktree path) to the session creation API. +- Projects without worktrees display the same as today — a flat row with no expand control. +- Collapsed by default. + +## Sidebar + +- The sidebar continues to show only sessions, not projects. No change to sidebar project display. +- Sessions created from a worktree cwd show the branch name in their sidebar label for context. + +## Actions + +- All session actions (Code Review, Browse Archive, Plan, Annotate) work identically on both parent projects and worktree entries. +- The only difference is the `cwd` passed to the daemon — the parent repo path or the worktree path. + +## Out of Scope + +- Worktree creation or deletion from the UI. Users manage worktrees via git CLI. +- Sidebar project hierarchy. Projects stay landing-page-only. +- Automatic worktree scanning in the background or on a timer. +- Worktree-specific session grouping in the sidebar (sessions group by mode, not by worktree). diff --git a/goals/worktree-projects/goal.md b/goals/worktree-projects/goal.md new file mode 100644 index 000000000..6d0153c08 --- /dev/null +++ b/goals/worktree-projects/goal.md @@ -0,0 +1,19 @@ +# Worktree-Aware Project Hierarchy + +Make the project list understand git worktree relationships. Directories that are worktrees auto-detect their parent repo and nest underneath it. Projects with worktrees show them as expandable branches. Users can launch sessions scoped to any worktree. + +## Shared Understanding + +See `facts.md` for the approved fact sheet. + +## Execution Plan + +See `plan.md`. + +## Done Condition + +- Adding a worktree directory auto-detects parent and creates hierarchy +- Expanding a project shows its worktrees with branch names +- Sessions can be launched from any worktree entry +- Session sidebar labels include branch name for worktree sessions +- Typecheck and tests pass diff --git a/goals/worktree-projects/plan.md b/goals/worktree-projects/plan.md new file mode 100644 index 000000000..e3380bc0a --- /dev/null +++ b/goals/worktree-projects/plan.md @@ -0,0 +1,22 @@ +# Worktree-Aware Project Hierarchy — Plan + +## Approach + +Extend the project registry data model with `parentCwd` and `branch` fields. When a directory is added, detect if it's a worktree and auto-discover the parent repo. Add a daemon endpoint to list worktrees for a project. Update the landing page to render projects as collapsible tree nodes with worktrees nested underneath. Add branch names to session labels for worktree-scoped sessions. + +## Steps + +1. Extend `DaemonProjectEntry` type with optional `parentCwd` and `branch` +2. Add worktree detection to `registerProject` / `addProject` +3. Add `GET /daemon/projects/worktrees?cwd=` endpoint +4. Add `listWorktrees` to frontend API client +5. Refactor `ProjectTable` to collapsible tree with worktree children +6. Add branch name to session labels for worktree cwds + +## Verification + +- Add a worktree directory → parent auto-detected, nests correctly +- Expand project → worktrees listed with branch names +- Select worktree → launch code review scoped to that path +- Session label shows branch name +- Typecheck + tests pass diff --git a/package.json b/package.json index 3fb1540fe..2eee5ec7c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "dev:portal": "bun run --cwd apps/portal dev", "dev:marketing": "bun run --cwd apps/marketing dev", "dev:review": "bun run --cwd apps/review dev", + "dev:frontend": "bun run scripts/dev-frontend.ts", + "dev:debug-stack": "bun run scripts/dev-debug-stack.ts", "build:hook": "bun run --cwd apps/hook build", "build:portal": "bun run --cwd apps/portal build", "build:marketing": "bun run --cwd apps/marketing build", diff --git a/packages/plannotator-code-review/App.d.ts b/packages/plannotator-code-review/App.d.ts new file mode 100644 index 000000000..a3d9a93a1 --- /dev/null +++ b/packages/plannotator-code-review/App.d.ts @@ -0,0 +1,4 @@ +import type { FC, ReactNode } from "react"; +export declare const ReviewAppEmbedded: FC<{ headerLeft?: ReactNode }>; +declare const ReviewApp: FC; +export default ReviewApp; diff --git a/packages/plannotator-code-review/App.tsx b/packages/plannotator-code-review/App.tsx new file mode 100644 index 000000000..413b9ac8f --- /dev/null +++ b/packages/plannotator-code-review/App.tsx @@ -0,0 +1,2606 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { type Origin, getAgentName } from '@plannotator/shared/agents'; +import { ThemeProvider, useTheme } from '@plannotator/ui/components/ThemeProvider'; +import { TooltipProvider } from '@plannotator/ui/components/Tooltip'; +import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; +import { Settings } from '@plannotator/ui/components/Settings'; +import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; +import { AgentReviewActions } from './components/AgentReviewActions'; +import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner'; +import { storage } from '@plannotator/ui/utils/storage'; +import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; +import { CompletionBanner } from '@plannotator/ui/components/CompletionBanner'; +import { GitHubIcon } from '@plannotator/ui/components/GitHubIcon'; +import { GitLabIcon } from '@plannotator/ui/components/GitLabIcon'; +import { RepoIcon } from '@plannotator/ui/components/RepoIcon'; +import { PullRequestIcon } from '@plannotator/ui/components/PullRequestIcon'; +import { getPlatformLabel, getMRLabel, getMRNumberLabel, getDisplayRepo } from '@plannotator/shared/pr-types'; +import { configStore, useConfigValue } from '@plannotator/ui/config'; +import { loadDiffFont } from '@plannotator/ui/utils/diffFonts'; +import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch'; +import { getAIProviderSettings, saveAIProviderSettings, getPreferredModel } from '@plannotator/ui/utils/aiProvider'; +import { AISetupDialog } from '@plannotator/ui/components/AISetupDialog'; +import { needsAISetup } from '@plannotator/ui/utils/aiSetup'; +import { DiffTypeSetupDialog } from '@plannotator/ui/components/DiffTypeSetupDialog'; +import { needsDiffTypeSetup } from '@plannotator/ui/utils/diffTypeSetup'; +import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; +import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel'; +import { useCodeAnnotationDraft } from '@plannotator/ui/hooks/useCodeAnnotationDraft'; +import { useGitAdd } from './hooks/useGitAdd'; +import { generateId } from './utils/generateId'; +import { useAIChat } from './hooks/useAIChat'; +import { toast, Toaster } from 'sonner'; +import { useCodeNav, type CodeNavRequest } from './hooks/useCodeNav'; +import { extractLinesFromPatch } from './utils/patchParser'; +import { isTypingTarget, useReviewSearch, type ReviewSearchMatch } from './hooks/useReviewSearch'; +import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; +import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; +import { useAgentJobs } from '@plannotator/ui/hooks/useAgentJobs'; +import { subscribeToDaemonSessionFamily } from '@plannotator/ui/utils/daemonHub'; +import { exportEditorAnnotations } from '@plannotator/ui/utils/parser'; +import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; +import { DockviewReact, type DockviewReadyEvent, type DockviewApi } from 'dockview-react'; +import { ReviewHeaderMenu } from './components/ReviewHeaderMenu'; +import { ReviewSidebar } from './components/ReviewSidebar'; +import type { ReviewSidebarTab } from './components/ReviewSidebar'; +import { SparklesIcon } from './components/SparklesIcon'; +import { ReviewAgentsIcon } from '@plannotator/ui/components/ReviewAgentsIcon'; +import { useSidebar } from '@plannotator/ui/hooks/useSidebar'; +import { FileTree } from './components/FileTree'; +import { StackedPRLabel } from './components/StackedPRLabel'; +import { PRSelector } from './components/PRSelector'; +import { PRSwitchOverlay } from './components/PRSwitchOverlay'; +import { usePRStack } from './hooks/usePRStack'; +import { usePRSession, type PRSessionUpdate } from './hooks/usePRSession'; +import { useAnnotationFactory } from './hooks/useAnnotationFactory'; +import { DEMO_DIFF } from './demoData'; +import { exportReviewFeedback } from './utils/exportFeedback'; +import { ReviewSubmissionDialog, buildReviewSubmission, type ReviewSubmission, type SubmissionTarget } from './components/ReviewSubmissionDialog'; +import { ReviewStateProvider, type ReviewState } from './dock/ReviewStateContext'; +import { JobLogsProvider } from './dock/JobLogsContext'; +import { reviewPanelComponents } from './dock/reviewPanelComponents'; +import { ReviewDockTabRenderer } from './dock/ReviewDockTabRenderer'; +import { usePRContext } from './hooks/usePRContext'; +import { + REVIEW_PANEL_TYPES, + REVIEW_DIFF_PANEL_ID, + makeReviewAgentJobPanelId, + getReviewDiffPanelFilePath, + isReviewDiffPanelId, + REVIEW_PR_SUMMARY_PANEL_ID, + REVIEW_PR_COMMENTS_PANEL_ID, + REVIEW_PR_CHECKS_PANEL_ID, + REVIEW_ALL_FILES_PANEL_ID, + REVIEW_CODE_NAV_PANEL_ID, +} from './dock/reviewPanelTypes'; +import type { DiffFile } from './types'; +import { retainUnchangedViewedFiles } from './utils/diffFiles'; +import type { DiffOption, WorktreeInfo, GitContext } from '@plannotator/shared/types'; +import type { PRMetadata } from '@plannotator/shared/pr-types'; +import type { PRDiffScope, PRDiffScopeOption, PRStackInfo, PRStackTree } from '@plannotator/shared/pr-stack'; +import { altKey } from '@plannotator/ui/utils/platform'; +import { TourDialog } from './components/tour/TourDialog'; +import { DEMO_TOUR_ID } from './demoTour'; +import { useSessionFetch } from '@plannotator/ui/hooks/useSessionFetch'; +import { ReviewStoreProvider, useReviewStore, useReviewStoreApi } from './store'; +import { selectAllAnnotations } from './store/selectors'; + +declare const __APP_VERSION__: string; + +interface DiffData { + files: DiffFile[]; + rawPatch: string; + gitRef: string; + origin?: Origin; + diffType?: string; + gitContext?: GitContext; + sharingEnabled?: boolean; + prStackInfo?: PRStackInfo | null; + prDiffScope?: PRDiffScope; + prDiffScopeOptions?: PRDiffScopeOption[]; +} + +// Simple diff parser to extract files from unified diff +function parseDiffToFiles(rawPatch: string): DiffFile[] { + const files: DiffFile[] = []; + const fileChunks = rawPatch.split(/^diff --git /m).filter(Boolean); + + for (const chunk of fileChunks) { + const lines = chunk.split('\n'); + const headerMatch = lines[0]?.match(/a\/(.+) b\/(.+)/); + if (!headerMatch) continue; + + const oldPath = headerMatch[1]; + const newPath = headerMatch[2]; + + let additions = 0; + let deletions = 0; + + for (const line of lines) { + if (line.startsWith('+') && !line.startsWith('+++')) additions++; + if (line.startsWith('-') && !line.startsWith('---')) deletions++; + } + + files.push({ + path: newPath, + oldPath: oldPath !== newPath ? oldPath : undefined, + patch: 'diff --git ' + chunk, + additions, + deletions, + }); + } + + return files; +} + +function getFileTabTitle(filePath: string): string { + return filePath.split('/').pop() ?? filePath; +} + +function useSessionVisible(rootRef: React.RefObject): boolean { + const [visible, setVisible] = useState(true); + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const container = el.parentElement; + if (!container) return; + const check = () => setVisible(getComputedStyle(el).visibility !== 'hidden'); + check(); + const observer = new MutationObserver(check); + observer.observe(container, { attributes: true, attributeFilter: ['style'] }); + return () => observer.disconnect(); + }, []); + return visible; +} + +const ReviewApp: React.FC<{ __embedded?: boolean; headerLeft?: React.ReactNode; onOpenSettings?: () => void }> = ({ __embedded, headerLeft, onOpenSettings: externalOpenSettings }) => { + const fetch = useSessionFetch(); + const { resolvedMode } = useTheme(); + const rootRef = useRef(null); + const sessionVisible = useSessionVisible(rootRef); + const isVisible = useCallback(() => { + if (!rootRef.current) return true; + return getComputedStyle(rootRef.current).visibility !== 'hidden'; + }, []); + const storeApi = useReviewStoreApi(); + const localAnnotations = useReviewStore(s => s.localAnnotations); + const selectedAnnotationId = useReviewStore(s => s.selectedAnnotationId); + const pendingSelection = useReviewStore(s => s.pendingSelection); + const files = useReviewStore(s => s.files); + const activeFileIndex = useReviewStore(s => s.focusedFileIndex); + const [diffData, setDiffData] = useState(null); + const isAllFilesActive = useReviewStore(s => s.isAllFilesActive); + const [isDiffPanelActive, setIsDiffPanelActive] = useState(false); + const [allFilesVisibleFile, setAllFilesVisibleFile] = useState(null); + const [showExportModal, setShowExportModal] = useState(false); + const [showWorktreeDialog, setShowWorktreeDialog] = useState(false); + const [openSettingsMenu, setOpenSettingsMenu] = useState(false); + const [showNoAnnotationsDialog, setShowNoAnnotationsDialog] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const diffStyle = useConfigValue('diffStyle'); + const diffOverflow = useConfigValue('diffOverflow'); + const diffIndicators = useConfigValue('diffIndicators'); + const diffLineDiffType = useConfigValue('diffLineDiffType'); + const diffShowLineNumbers = useConfigValue('diffShowLineNumbers'); + const diffShowBackground = useConfigValue('diffShowBackground'); + const diffHideWhitespace = useConfigValue('diffHideWhitespace'); + const diffFontFamily = useConfigValue('diffFontFamily'); + const diffFontSize = useConfigValue('diffFontSize'); + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + if (diffFontFamily) { + loadDiffFont(diffFontFamily); + el.style.setProperty('--diff-font-override', `'${diffFontFamily}', monospace`); + } else { + el.style.removeProperty('--diff-font-override'); + } + if (diffFontSize) { + el.style.setProperty('--diff-font-size-override', diffFontSize); + el.classList.add('has-font-size-override'); + } else { + el.style.removeProperty('--diff-font-size-override'); + el.classList.remove('has-font-size-override'); + } + return () => { + el.style.removeProperty('--diff-font-override'); + el.style.removeProperty('--diff-font-size-override'); + el.classList.remove('has-font-size-override'); + }; + }, [diffFontFamily, diffFontSize]); + + const reviewSidebar = useSidebar(true, 'annotations'); + const [isFileTreeOpen, setIsFileTreeOpen] = useState(true); + const [copyFeedback, setCopyFeedback] = useState(null); + const [copyRawDiffStatus, setCopyRawDiffStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [viewedFiles, setViewedFiles] = useState>(new Set()); + const [hideViewedFiles, setHideViewedFiles] = useState(false); + const [origin, setOrigin] = useState(null); + const [gitUser, setGitUser] = useState(); + const [isWSL, setIsWSL] = useState(false); + const [legacyTabMode, setLegacyTabMode] = useState(false); + const [diffType, setDiffType] = useState('uncommitted'); + const [gitContext, setGitContext] = useState(null); + // Two bases: + // selectedBase — what the picker is currently showing (UI intent). + // Updates immediately when the user picks, so the chip + // feels responsive. + // committedBase — the base the server last computed the patch against. + // Drives file-content fetches. Only updates after + // /api/diff/switch returns, so we never pair an old + // patch with a new base's file contents (race that + // produced "trailing context mismatch" warnings). + const [selectedBase, setSelectedBase] = useState(null); + const [committedBase, setCommittedBase] = useState(null); + const [agentCwd, setAgentCwd] = useState(null); + const [isLoadingDiff, setIsLoadingDiff] = useState(false); + const [diffError, setDiffError] = useState(null); + const [isSendingFeedback, setIsSendingFeedback] = useState(false); + const [isApproving, setIsApproving] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const [submitted, setSubmitted] = useState<'approved' | 'feedback' | 'exited' | false>(false); + const [feedbackSent, setFeedbackSent] = useState(false); + const [showApproveWarning, setShowApproveWarning] = useState(false); + const [showExitWarning, setShowExitWarning] = useState(false); + const [sharingEnabled, setSharingEnabled] = useState(true); + const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null); + + useEffect(() => { + if (!sessionVisible) return; + document.title = repoInfo ? `${repoInfo.display} · Code Review` : "Code Review"; + }, [repoInfo, sessionVisible]); + + const { prMetadata, prStackInfo, prStackTree, prDiffScope, prDiffScopeOptions, updatePRSession } = usePRSession(); + const { withPRContext } = useAnnotationFactory(prMetadata, prStackInfo ? prDiffScope : undefined); + + const prStackCallbacksRef = useRef(null); + const { + isSwitchingPRScope, + handleScopeSelect: handlePRDiffScopeSelect, + handlePRSwitch, + } = usePRStack(prStackCallbacksRef); + const [reviewDestination, setReviewDestination] = useState<'agent' | 'platform'>(() => { + const stored = storage.getItem('plannotator-review-dest'); + return stored === 'agent' ? 'agent' : 'platform'; // 'github' (legacy) → 'platform' + }); + const [showDestinationMenu, setShowDestinationMenu] = useState(false); + const [isPlatformActioning, setIsPlatformActioning] = useState(false); + const [platformActionError, setPlatformActionError] = useState(null); + const [platformUser, setPlatformUser] = useState(null); + const [platformCommentDialog, setPlatformCommentDialog] = useState<{ action: 'approve' | 'comment'; plan: ReviewSubmission } | null>(null); + const [platformGeneralComment, setPlatformGeneralComment] = useState(''); + const [platformOpenPR, setPlatformOpenPR] = useState(() => { + const platformSetting = storage.getItem('plannotator-platform-open-pr'); + if (platformSetting !== null) return platformSetting !== 'false'; + + const legacyGitHubSetting = storage.getItem('plannotator-github-open-pr'); + if (legacyGitHubSetting !== null) { + storage.setItem('plannotator-platform-open-pr', legacyGitHubSetting); + return legacyGitHubSetting !== 'false'; + } + + return true; + }); + + // Derived: Platform mode is active when destination is platform AND we have PR/MR metadata + const platformMode = reviewDestination === 'platform' && !!prMetadata; + + // Platform-aware labels + const platformLabel = prMetadata ? getPlatformLabel(prMetadata) : 'GitHub'; + const mrLabel = prMetadata ? getMRLabel(prMetadata) : 'PR'; + const mrNumberLabel = prMetadata ? getMRNumberLabel(prMetadata) : ''; + const displayRepo = prMetadata ? getDisplayRepo(prMetadata) : ''; + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'; + + const identity = useConfigValue('displayName'); + + const clearPendingSelection = useCallback(() => { + storeApi.getState().setPendingSelection(null); + }, [storeApi]); + + // VS Code editor annotations (only polls when inside VS Code webview) + const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); + + // External annotations (HTTP mutations + daemon WebSocket events) + // TODO: Replace !!origin with a dedicated isApiMode boolean (set on /api/diff success/failure). + // origin is an identity field, not a connectivity signal — the standalone dev server + // (apps/review/) doesn't set it, so external annotations are silently disabled there. + // The same !!origin proxy is used elsewhere in this file (draft hook, feedback guard, conditional UI) + // so this should be addressed as a broader refactor. + const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: !!origin }); + const agentJobs = useAgentJobs({ enabled: !!origin }); + + // Listen for session-revision events (agent pushed a new diff) + useEffect(() => { + if (!origin) return; + const unsubscribe = subscribeToDaemonSessionFamily("session-revision", (msg) => { + if (!msg.payload) return; + const revision = msg.payload as { rawPatch?: string; gitRef?: string }; + if (revision.rawPatch !== undefined) { + const oldFiles = storeApi.getState().files; + const newFiles = parseDiffToFiles(revision.rawPatch); + const contentChanged = newFiles.length !== oldFiles.length || + newFiles.some((f, i) => f.patch !== oldFiles[i]?.patch); + if (contentChanged) { + setDiffData(prev => prev ? { ...prev, rawPatch: revision.rawPatch!, gitRef: revision.gitRef ?? prev.gitRef } : prev); + storeApi.getState().setFiles(newFiles); + storeApi.getState().setFocusedFile(0); + storeApi.getState().setLocalAnnotations([]); + storeApi.getState().selectAnnotation(null); + storeApi.getState().setPendingSelection(null); + setViewedFiles(prev => retainUnchangedViewedFiles(oldFiles, newFiles, prev)); + } + if (contentChanged || msg.type === "event") { + setFeedbackSent(false); + setSubmitted(false); + setIsSendingFeedback(false); + } + } + }); + return unsubscribe; + }, [origin, storeApi]); + + // Tour dialog state — opens as an overlay instead of a dock panel + const [tourDialogJobId, setTourDialogJobId] = useState(null); + + // Dockview center panel API for the review workspace. + const [dockApi, setDockApi] = useState(null); + const filesRef = useRef(files); + filesRef.current = files; + const needsInitialDiffPanel = useRef(true); + + useEffect(() => { storeApi.getState().setExternalAnnotations(externalAnnotations); }, [storeApi, externalAnnotations]); + useEffect(() => { + storeApi.getState().setDiffOptions({ + diffStyle, diffOverflow, diffIndicators, lineDiffType: diffLineDiffType, + disableLineNumbers: !diffShowLineNumbers, disableBackground: !diffShowBackground, + fontFamily: diffFontFamily || undefined, fontSize: diffFontSize || undefined, + }); + }, [storeApi, diffStyle, diffOverflow, diffIndicators, diffLineDiffType, diffShowLineNumbers, diffShowBackground, diffFontFamily, diffFontSize]); + + // PR context (lifted from sidebar so center dock PR panels can access it) + const { prContext, isLoading: isPRContextLoading, error: prContextError, fetchContext: fetchPRContext } = usePRContext(prMetadata ?? null); + + // Sync activeFileIndex from dockview's active panel (wired in handleDockReady) + + const openDiffFile = useCallback((filePath: string) => { + const file = files.find(candidate => candidate.path === filePath); + if (!file) return; + + if (!dockApi) { + const fileIndex = files.findIndex(candidate => candidate.path === filePath); + if (fileIndex !== -1) { + storeApi.getState().setFocusedFile(fileIndex); + } + return; + } + + const existing = dockApi.getPanel(REVIEW_DIFF_PANEL_ID); + if (existing) { + const existingFilePath = getReviewDiffPanelFilePath(existing.params); + if (existingFilePath === filePath) { + if (dockApi.activePanel?.id !== REVIEW_DIFF_PANEL_ID) { + existing.api.setActive(); + } + const fileIndex = files.findIndex(candidate => candidate.path === filePath); + if (fileIndex !== -1) { + storeApi.getState().setFocusedFile(fileIndex); + } + needsInitialDiffPanel.current = false; + return; + } + + storeApi.getState().setPendingSelection(null); + existing.api.updateParameters({ filePath }); + existing.api.setTitle(getFileTabTitle(file.path)); + existing.api.setActive(); + } else { + storeApi.getState().setPendingSelection(null); + dockApi.addPanel({ + id: REVIEW_DIFF_PANEL_ID, + component: REVIEW_PANEL_TYPES.DIFF, + title: getFileTabTitle(file.path), + params: { filePath }, + }); + } + + storeApi.getState().setFocusedFile(files.findIndex(candidate => candidate.path === filePath)); + needsInitialDiffPanel.current = false; + }, [dockApi, files]); + + const handleRevealSearchMatch = useCallback((match: ReviewSearchMatch) => { + openDiffFile(match.filePath); + }, [openDiffFile]); + + const { + searchQuery, + debouncedSearchQuery, + isSearchPending, + isSearchOpen, + activeSearchMatchId, + activeSearchMatch, + activeFileSearchMatches, + searchMatches, + searchGroups, + searchInputRef, + openSearch, + closeSearch, + clearSearch, + stepSearchMatch, + handleSearchInputChange, + handleSelectSearchMatch, + } = useReviewSearch({ + files, + activeFilePath: files[activeFileIndex]?.path ?? null, + onRevealMatch: handleRevealSearchMatch, + }); + + const hasSearchableFiles = files.length > 0; + const shouldShowFileTree = + hasSearchableFiles || + !!gitContext?.diffOptions?.length || + !!gitContext?.worktrees?.length; + + // Merge local + live annotations, deduping draft-restored externals against + // live WebSocket versions. Prefer the live version when both exist (same source, + // type, and originalText). This avoids the timing issues of an effect-based + // cleanup — draft-restored externals persist until live events re-deliver them. + const allAnnotations = useMemo( + () => selectAllAnnotations({ localAnnotations, externalAnnotations }), + [localAnnotations, externalAnnotations], + ); + // Auto-save and auto-restore code annotation drafts + useCodeAnnotationDraft({ + annotations: allAnnotations, + viewedFiles, + isApiMode: !!origin, + submitted: !!submitted, + onRestore: useCallback((restoredAnnotations: CodeAnnotation[], restoredViewed: string[]) => { + if (restoredAnnotations.length > 0) storeApi.getState().setLocalAnnotations(restoredAnnotations); + if (restoredViewed.length > 0) setViewedFiles(new Set(restoredViewed)); + toast(`Restored ${restoredAnnotations.length} annotation${restoredAnnotations.length !== 1 ? 's' : ''}${restoredViewed.length > 0 ? ` and ${restoredViewed.length} viewed file${restoredViewed.length !== 1 ? 's' : ''}` : ''}`); + }, [storeApi]), + }); + + // AI Chat + const [aiAvailable, setAiAvailable] = useState(false); + const [aiProviders, setAiProviders] = useState; models?: Array<{ id: string; label: string; default?: boolean }> }>>([]); + const [aiConfig, setAiConfig] = useState(() => { + const saved = getAIProviderSettings(); + const pid = saved.providerId; + return { + providerId: pid, + model: pid ? (saved.preferredModels[pid] ?? null) : null, + reasoningEffort: null as string | null, + }; + }); + const [showAISetup, setShowAISetup] = useState(false); + const [aiCheckComplete, setAiCheckComplete] = useState(false); + const [showDiffTypeSetup, setShowDiffTypeSetup] = useState(false); + const [diffTypeSetupPending, setDiffTypeSetupPending] = useState(false); + const aiChat = useAIChat({ + patch: diffData?.rawPatch ?? '', + providerId: aiConfig.providerId, + model: aiConfig.model, + reasoningEffort: aiConfig.reasoningEffort, + }); + + const codeNav = useCodeNav(); + + const handleCodeNavRequest = useCallback((request: CodeNavRequest) => { + if (!gitContext && !agentCwd) { + toast('Code navigation requires a local checkout', { + description: 'Re-run with --local for PR reviews', + duration: 4000, + }); + return; + } + codeNav.resolve(request); + if (!dockApi) return; + const existing = dockApi.getPanel(REVIEW_CODE_NAV_PANEL_ID); + if (existing) { + existing.api.setTitle(`References: ${request.symbol}`); + existing.api.setActive(); + } else { + const refPanel = isAllFilesActive + ? REVIEW_ALL_FILES_PANEL_ID + : REVIEW_DIFF_PANEL_ID; + dockApi.addPanel({ + id: REVIEW_CODE_NAV_PANEL_ID, + component: REVIEW_PANEL_TYPES.CODE_NAV, + title: `References: ${request.symbol}`, + position: { direction: 'below', referencePanel: refPanel }, + initialHeight: 250, + }); + } + }, [codeNav.resolve, dockApi, isAllFilesActive, gitContext, agentCwd]); + + // Check AI capabilities on mount + useEffect(() => { + fetch('/api/ai/capabilities') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.available) { + setAiAvailable(true); + const providers = data.providers ?? []; + setAiProviders(providers); + } + setAiCheckComplete(true); + }) + .catch(() => { setAiCheckComplete(true); }); + }, []); + + const handleAIConfigChange = useCallback((config: { providerId?: string | null; model?: string | null }) => { + setAiConfig(prev => { + const next = { ...prev, ...config }; + // If provider changed, load that provider's preferred model + if (config.providerId !== undefined && config.providerId !== prev.providerId) { + next.model = config.providerId ? getPreferredModel(config.providerId) : null; + } + // Persist provider selection + const saved = getAIProviderSettings(); + saveAIProviderSettings({ ...saved, providerId: next.providerId }); + return next; + }); + aiChat.resetSession(); + }, [aiChat]); + + const handleAskAI = useCallback((question: string) => { + const { pendingSelection: sel, files: f, focusedFileIndex } = storeApi.getState(); + if (!sel || !f[focusedFileIndex]) return; + const lineStart = Math.min(sel.start, sel.end); + const lineEnd = Math.max(sel.start, sel.end); + const side = sel.side === 'additions' ? 'new' : 'old'; + const selectedCode = extractLinesFromPatch(f[focusedFileIndex].patch, lineStart, lineEnd, side); + + aiChat.ask({ + prompt: question, + filePath: f[focusedFileIndex].path, + lineStart, + lineEnd, + side, + selectedCode: selectedCode || undefined, + }); + }, [storeApi, aiChat]); + + const handleViewAIResponse = useCallback((questionId?: string) => { + reviewSidebar.open('ai'); + if (questionId) { + setScrollToQuestionId(questionId); + setTimeout(() => setScrollToQuestionId(null), 500); + } + }, []); + + const handleScrollToAILines = useCallback((filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => { + openDiffFile(filePath); + // Set a selection to highlight the lines + storeApi.getState().setPendingSelection({ + start: lineStart, + end: lineEnd, + side: side === 'new' ? 'additions' : 'deletions', + }); + }, [storeApi, openDiffFile]); + + + // AI messages overlapping the current selection (for toolbar history) + const aiHistoryForSelection = useMemo(() => { + if (!pendingSelection || !files[activeFileIndex]) return []; + const filePath = files[activeFileIndex].path; + const selStart = Math.min(pendingSelection.start, pendingSelection.end); + const selEnd = Math.max(pendingSelection.start, pendingSelection.end); + const side = pendingSelection.side === 'additions' ? 'new' : 'old'; + return aiChat.messages.filter(m => { + const q = m.question; + return q.filePath === filePath && q.side === side && + q.lineStart != null && q.lineEnd != null && + q.lineStart <= selEnd && q.lineEnd >= selStart; + }); + }, [pendingSelection, files, activeFileIndex, aiChat.messages]); + + // Click AI marker in diff → scroll sidebar to that Q&A + const [scrollToQuestionId, setScrollToQuestionId] = useState(null); + const handleClickAIMarker = useCallback((questionId: string) => { + setScrollToQuestionId(questionId); + reviewSidebar.open('ai'); + // Clear after a tick so it can re-trigger for the same question + setTimeout(() => setScrollToQuestionId(null), 500); + }, []); + + // General AI question from sidebar input + const handleAskGeneral = useCallback((question: string) => { + aiChat.ask({ prompt: question }); + }, [aiChat.ask]); + + // Resizable panels + const panelResize = useResizablePanel({ storageKey: 'plannotator-review-panel-width' }); + const fileTreeResize = useResizablePanel({ + storageKey: 'plannotator-filetree-width', + defaultWidth: 256, minWidth: 160, maxWidth: 400, side: 'left', + }); + const isResizing = panelResize.isDragging || fileTreeResize.isDragging; + + // Dockview ready handler — stores API and wires active panel tracking. + // Initial panel creation happens in the effect below once dockApi is set. + const handleDockReady = useCallback((event: DockviewReadyEvent) => { + setDockApi(event.api); + + // Sync activeFileIndex when user switches between dock tabs + event.api.onDidActivePanelChange((panel) => { + if (!panel) { storeApi.getState().setIsAllFilesActive(false); setIsDiffPanelActive(false); return; } + storeApi.getState().setIsAllFilesActive(panel.id === REVIEW_ALL_FILES_PANEL_ID); + setIsDiffPanelActive(isReviewDiffPanelId(panel.id)); + if (!isReviewDiffPanelId(panel.id)) return; + const filePath = getReviewDiffPanelFilePath(panel.params); + if (!filePath) return; + const fileIndex = filesRef.current.findIndex(file => file.path === filePath); + if (fileIndex !== -1) { + storeApi.getState().setFocusedFile(fileIndex); + } + }); + + // Hide Dockview chrome only for the dedicated single diff tab. + // Any lone non-diff panel still needs a visible header so it can be + // dragged, closed, and used as a way back out of the dock. + const updateHeaders = () => { + const lonePanel = + event.api.totalPanels === 1 && event.api.groups.length === 1 + ? event.api.groups[0]?.panels[0] + : undefined; + const hideHeaders = lonePanel?.id === REVIEW_DIFF_PANEL_ID || lonePanel?.id === REVIEW_ALL_FILES_PANEL_ID; + for (const group of event.api.groups) { + group.header.hidden = hideHeaders; + } + }; + event.api.onDidAddPanel(updateHeaders); + event.api.onDidRemovePanel(updateHeaders); + event.api.onDidAddGroup(updateHeaders); + event.api.onDidRemoveGroup(updateHeaders); + event.api.onDidMovePanel(updateHeaders); + event.api.onDidLayoutChange(updateHeaders); + updateHeaders(); + }, []); + + // Open agent job detail as center dock panel + const handleOpenJobDetail = useCallback((jobId: string) => { + const api = dockApi; + if (!api) return; + const panelId = makeReviewAgentJobPanelId(jobId); + const existing = api.getPanel(panelId); + if (existing) { + existing.api.setActive(); + return; + } + const job = agentJobs.jobs.find(j => j.id === jobId); + api.addPanel({ + id: panelId, + component: REVIEW_PANEL_TYPES.AGENT_JOB_DETAIL, + title: job?.label ?? `Job ${jobId.slice(0, 8)}`, + params: { jobId }, + }); + }, [dockApi, agentJobs.jobs]); + + // Open tour as a dialog overlay + const handleOpenTour = useCallback((jobId: string) => { + setTourDialogJobId(jobId); + }, []); + + // Dev-only: Cmd/Ctrl+Shift+T toggles the demo tour for fast UI iteration. + useEffect(() => { + if (!import.meta.env.DEV) return; + const handler = (e: KeyboardEvent) => { + if (!isVisible()) return; + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'T' || e.key === 't')) { + e.preventDefault(); + setTourDialogJobId(prev => (prev === DEMO_TOUR_ID ? null : DEMO_TOUR_ID)); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + // Auto-open tour dialog when a tour job completes + const tourAutoOpenRef = useRef(new Set()); + useEffect(() => { + for (const job of agentJobs.jobs) { + if ( + job.provider === 'tour' && + job.status === 'done' && + !tourAutoOpenRef.current.has(job.id) + ) { + tourAutoOpenRef.current.add(job.id); + setTourDialogJobId(job.id); + } + } + }, [agentJobs.jobs]); + + // Open PR panel as center dock panel + const handleOpenPRPanel = useCallback((type: 'summary' | 'comments' | 'checks') => { + const api = dockApi; + if (!api) return; + const config = { + summary: { id: REVIEW_PR_SUMMARY_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_SUMMARY, title: 'PR Summary' }, + comments: { id: REVIEW_PR_COMMENTS_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_COMMENTS, title: 'PR Comments' }, + checks: { id: REVIEW_PR_CHECKS_PANEL_ID, component: REVIEW_PANEL_TYPES.PR_CHECKS, title: 'PR Checks' }, + }[type]; + const existing = api.getPanel(config.id); + if (existing) { + existing.api.setActive(); + return; + } + api.addPanel({ + id: config.id, + component: config.component, + title: config.title, + }); + }, [dockApi]); + + const openAllFilesPanel = useCallback(() => { + if (!dockApi) return; + const existing = dockApi.getPanel(REVIEW_ALL_FILES_PANEL_ID); + if (existing) { existing.api.setActive(); return; } + dockApi.addPanel({ + id: REVIEW_ALL_FILES_PANEL_ID, + component: REVIEW_PANEL_TYPES.ALL_FILES, + title: 'All files', + }); + }, [dockApi]); + + // Open the all-files panel on first load. + useEffect(() => { + if (!dockApi || !needsInitialDiffPanel.current || files.length === 0) return; + needsInitialDiffPanel.current = false; + openAllFilesPanel(); + }, [dockApi, files, openAllFilesPanel]); + + // Global keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; + // Cmd/Ctrl+F to focus file search when diff files are available. + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f' && !isTypingTarget(e.target)) { + if (hasSearchableFiles) { + e.preventDefault(); + setIsFileTreeOpen(true); + openSearch(); + } + return; + } + + // Enter/F3 to step through search matches + if ((e.key === 'Enter' || e.key === 'F3') && searchMatches.length > 0 && !isSearchPending && !isTypingTarget(e.target)) { + e.preventDefault(); + stepSearchMatch(e.shiftKey ? -1 : 1); + return; + } + + // Escape closes modals or clears search + if (e.key === 'Escape') { + if (showDestinationMenu) { + setShowDestinationMenu(false); + } else if (showExportModal) { + setShowExportModal(false); + } else if (isSearchOpen) { + if (searchQuery) { + clearSearch(); + } else { + closeSearch(); + } + } else if (searchQuery) { + clearSearch(); + } + } + // Cmd/Ctrl+B to toggle file tree + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !isTypingTarget(e.target)) { + e.preventDefault(); + setIsFileTreeOpen(prev => !prev); + } + // Cmd/Ctrl+. to toggle sidebar + if ((e.metaKey || e.ctrlKey) && e.key === '.' && !isTypingTarget(e.target)) { + e.preventDefault(); + if (reviewSidebar.isOpen) reviewSidebar.close(); + else reviewSidebar.open(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showExportModal, showDestinationMenu, isSearchOpen, searchQuery, searchMatches, isSearchPending, openSearch, stepSearchMatch, clearSearch, closeSearch, hasSearchableFiles, reviewSidebar.isOpen, reviewSidebar.open, reviewSidebar.close, isFileTreeOpen]); + + + // Load diff content - try API first, fall back to demo + useEffect(() => { + fetch('/api/diff') + .then(res => { + if (!res.ok) throw new Error('Not in API mode'); + return res.json(); + }) + .then((data: { + rawPatch: string; + gitRef: string; + origin?: Origin; + diffType?: string; + base?: string; + gitContext?: GitContext; + agentCwd?: string; + sharingEnabled?: boolean; + repoInfo?: { display: string; branch?: string }; + prMetadata?: PRMetadata; + prStackInfo?: PRStackInfo | null; + prStackTree?: PRStackTree | null; + prDiffScope?: PRDiffScope; + prDiffScopeOptions?: PRDiffScopeOption[]; + platformUser?: string; + viewedFiles?: string[]; + error?: string; + isWSL?: boolean; + serverConfig?: { displayName?: string; gitUser?: string }; + lastDecision?: 'approved' | 'feedback' | 'exited' | null; + }) => { + configStore.init(data.serverConfig); + setGitUser(data.serverConfig?.gitUser); + if ((data.serverConfig as { legacyTabMode?: boolean } | undefined)?.legacyTabMode) setLegacyTabMode(true); + const apiFiles = parseDiffToFiles(data.rawPatch); + setDiffData({ + files: apiFiles, + rawPatch: data.rawPatch, + gitRef: data.gitRef, + origin: data.origin, + diffType: data.diffType, + gitContext: data.gitContext, + sharingEnabled: data.sharingEnabled, + }); + storeApi.getState().setFiles(apiFiles); + if (data.origin) setOrigin(data.origin); + if (data.diffType) setDiffType(data.diffType); + if (data.gitContext) { + setGitContext(data.gitContext); + // Prefer the server's active base (survives page refresh / reconnect) + // over the detected default, so the picker rehydrates to what the + // server is actually using. + const initial = data.base || data.gitContext.defaultBranch || data.gitContext.compareTarget?.fallback || null; + setSelectedBase(initial); + setCommittedBase(initial); + } + if (data.agentCwd) setAgentCwd(data.agentCwd); + if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled); + if (data.repoInfo) setRepoInfo(data.repoInfo); + updatePRSession({ + ...(data.prMetadata && { prMetadata: data.prMetadata }), + ...(data.prStackInfo !== undefined && { prStackInfo: data.prStackInfo }), + ...(data.prStackTree !== undefined && { prStackTree: data.prStackTree }), + ...(data.prDiffScope && { prDiffScope: data.prDiffScope }), + ...(data.prDiffScopeOptions && { prDiffScopeOptions: data.prDiffScopeOptions }), + }); + if (data.platformUser) setPlatformUser(data.platformUser); + // Initialize viewed files from GitHub's state (set before draft restore so draft takes precedence) + if (data.viewedFiles && data.viewedFiles.length > 0) { + setViewedFiles(new Set(data.viewedFiles)); + } + if (data.error) setDiffError(data.error); + if (data.isWSL) setIsWSL(true); + // Mark diff type setup as pending on first run (local mode only) + if (data.diffType && !data.prMetadata && data.gitContext?.vcsType !== 'p4' && data.gitContext?.vcsType !== 'jj' && needsDiffTypeSetup()) { + setDiffTypeSetupPending(true); + } + if (data.lastDecision) { + if (data.lastDecision === 'approved') setSubmitted('approved'); + else if (data.lastDecision === 'feedback') setFeedbackSent(true); + else if (data.lastDecision === 'exited') setSubmitted('exited'); + } + }) + .catch(() => { + // Not in API mode - use demo content + const demoFiles = parseDiffToFiles(DEMO_DIFF); + setDiffData({ + files: demoFiles, + rawPatch: DEMO_DIFF, + gitRef: 'demo', + }); + storeApi.getState().setFiles(demoFiles); + }) + .finally(() => setIsLoading(false)); + }, []); + + // Show diff type setup dialog only after AI setup dialog is dismissed (avoid stacking) + useEffect(() => { + if (diffTypeSetupPending && aiCheckComplete && !showAISetup) { + setDiffTypeSetupPending(false); + setShowDiffTypeSetup(true); + } + }, [diffTypeSetupPending, aiCheckComplete, showAISetup]); + + const handleDiffStyleChange = useCallback((style: 'split' | 'unified') => { + configStore.set('diffStyle', style); + }, []); + + // Handle line selection from diff viewer + const handleLineSelection = useCallback((range: SelectedLineRange | null) => { + storeApi.getState().setPendingSelection(range); + }, [storeApi]); + + const handleAddAnnotationForFile = useCallback(( + filePath: string, + type: CodeAnnotationType, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], + tokenMeta?: TokenAnnotationMeta + ) => { + const sel = storeApi.getState().pendingSelection; + if (!sel) return; + const lineStart = Math.min(sel.start, sel.end); + const lineEnd = Math.max(sel.start, sel.end); + const newAnnotation: CodeAnnotation = { + id: generateId(), + type, + scope: 'line', + filePath, + lineStart, + lineEnd, + side: sel.side === 'additions' ? 'new' : 'old', + text, + suggestedCode, + originalCode, + ...(tokenMeta && { + charStart: tokenMeta.charStart, + charEnd: tokenMeta.charEnd, + tokenText: tokenMeta.tokenText, + }), + createdAt: Date.now(), + author: identity, + conventionalLabel, + decorations, + }; + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); + + const handleAddAnnotation = useCallback(( + type: CodeAnnotationType, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], + tokenMeta?: TokenAnnotationMeta + ) => { + const { files: f, focusedFileIndex } = storeApi.getState(); + if (!f[focusedFileIndex]) return; + handleAddAnnotationForFile(f[focusedFileIndex].path, type, text, suggestedCode, originalCode, conventionalLabel, decorations, tokenMeta); + }, [storeApi, handleAddAnnotationForFile]); + + const handleAddFileComment = useCallback((text: string) => { + const { files: f, focusedFileIndex } = storeApi.getState(); + const activeFile = f[focusedFileIndex]; + const trimmed = text.trim(); + if (!activeFile || !trimmed) return; + + const newAnnotation: CodeAnnotation = { + id: generateId(), + type: 'comment', + scope: 'file', + filePath: activeFile.path, + lineStart: 1, + lineEnd: 1, + side: 'new', + text: trimmed, + createdAt: Date.now(), + author: identity, + }; + + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); + + const handleAddFileCommentForFile = useCallback((filePath: string, text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + + const newAnnotation: CodeAnnotation = { + id: generateId(), + type: 'comment', + scope: 'file', + filePath, + lineStart: 1, + lineEnd: 1, + side: 'new', + text: trimmed, + createdAt: Date.now(), + author: identity, + }; + + storeApi.getState().addAnnotation(withPRContext(newAnnotation)); + }, [storeApi, identity, withPRContext]); + + // Edit annotation + const handleEditAnnotation = useCallback(( + id: string, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel | null, + decorations?: ConventionalDecoration[], + ) => { + const updates: Partial = { + ...(text !== undefined && { text }), + ...(suggestedCode !== undefined && { suggestedCode }), + ...(originalCode !== undefined && { originalCode }), + ...(conventionalLabel !== undefined && { conventionalLabel: conventionalLabel ?? undefined }), + ...(decorations !== undefined && { decorations }), + }; + const state = storeApi.getState(); + const ann = selectAllAnnotations(state).find(a => a.id === id); + if (ann?.source && state.externalAnnotations.some(e => e.id === id)) { + updateExternalAnnotation(id, updates); + return; + } + state.editAnnotation(id, updates); + }, [storeApi, updateExternalAnnotation]); + + const handleDeleteAnnotation = useCallback((id: string) => { + const state = storeApi.getState(); + const ann = selectAllAnnotations(state).find(a => a.id === id); + if (ann?.source && state.externalAnnotations.some(e => e.id === id)) { + deleteExternalAnnotation(id); + if (state.selectedAnnotationId === id) state.selectAnnotation(null); + return; + } + state.deleteAnnotation(id); + }, [storeApi, deleteExternalAnnotation]); + + // Handle identity change - update author on existing annotations + const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => { + storeApi.getState().setLocalAnnotations( + storeApi.getState().localAnnotations.map(ann => + ann.author === oldIdentity ? { ...ann, author: newIdentity } : ann + ), + ); + }, [storeApi]); + + // Switch file in the dedicated center diff panel. + const handleFilePreview = useCallback((index: number) => { + const file = files[index]; + if (!file) return; + openDiffFile(file.path); + }, [files, openDiffFile]); + + // Double-click currently behaves the same as single-click. + const handleFilePinned = useCallback((index: number) => { + const file = files[index]; + if (!file) return; + openDiffFile(file.path); + }, [files, openDiffFile]); + + // Legacy file switch (used by handleSelectAnnotation, diff switch, etc.) + const handleFileSwitch = useCallback((index: number) => { + const file = files[index]; + if (file) { + openDiffFile(file.path); + } + }, [files, openDiffFile]); + + const handleToggleViewed = useCallback((filePath: string) => { + setViewedFiles(prev => { + const next = new Set(prev); + const willBeViewed = !prev.has(filePath); + if (willBeViewed) { + next.add(filePath); + } else { + next.delete(filePath); + } + // Sync viewed state to GitHub (fire and forget — best effort) + // Capture willBeViewed inside the callback to ensure correctness with React batching + if (prMetadata && prMetadata.platform === 'github') { + fetch('/api/pr-viewed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filePaths: [filePath], viewed: willBeViewed }), + }).catch(() => { + // Silently ignore — viewed sync is best-effort + }); + } + return next; + }); + }, [prMetadata]); + + // Derive worktree path and base diff type from the composite diffType string + const { activeWorktreePath, activeDiffBase } = useMemo(() => { + if (diffType.startsWith('worktree:')) { + const rest = diffType.slice('worktree:'.length); + const lastColon = rest.lastIndexOf(':'); + if (lastColon !== -1) { + const sub = rest.slice(lastColon + 1); + if (['uncommitted', 'staged', 'unstaged', 'last-commit', 'branch', 'merge-base', 'all'].includes(sub)) { + return { activeWorktreePath: rest.slice(0, lastColon), activeDiffBase: sub }; + } + } + return { activeWorktreePath: rest, activeDiffBase: 'uncommitted' }; + } + return { activeWorktreePath: null, activeDiffBase: diffType }; + }, [diffType]); + + // Git add/staging logic + const handleFileViewedFromStage = useCallback( + (path: string) => setViewedFiles(prev => new Set(prev).add(path)), + [], + ); + const { stagedFiles, stagingFile, canStageFiles: canStageRaw, stageFile, resetStagedFiles, stageError } = useGitAdd({ + activeDiffBase, + onFileViewed: handleFileViewedFromStage, + }); + // Staging is never available in PR review mode — the server rejects it and the UI shouldn't offer it. + const canStageFiles = canStageRaw && !prMetadata; + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!isVisible()) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || isTypingTarget(e.target)) return; + if (!isDiffPanelActive) return; + const { files: f, focusedFileIndex } = storeApi.getState(); + const filePath = f[focusedFileIndex]?.path; + if (!filePath) return; + + if (e.key === 'v') { + e.preventDefault(); + handleToggleViewed(filePath); + } else if (e.key === 'a' && canStageFiles) { + e.preventDefault(); + stageFile(filePath); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [storeApi, isDiffPanelActive, handleToggleViewed, canStageFiles, stageFile]); + + // Shared function: apply a PR response (used by both initial load and PR switch) + function applyPRResponse(data: PRSessionUpdate & { + rawPatch: string; gitRef: string; + repoInfo?: { display: string; branch?: string }; + viewedFiles?: string[]; agentCwd?: string | null; error?: string; + }) { + const isPRSwitch = !!data.prMetadata; + const nextFiles = parseDiffToFiles(data.rawPatch); + dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close(); + needsInitialDiffPanel.current = true; + setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef } : prev); + const currentPath = files[activeFileIndex]?.path; + storeApi.getState().setFiles(nextFiles); + if (isPRSwitch) { + storeApi.getState().setFocusedFile(0); + } else { + const preserved = currentPath ? nextFiles.findIndex(f => f.path === currentPath) : -1; + storeApi.getState().setFocusedFile(preserved >= 0 ? preserved : 0); + } + storeApi.getState().setPendingSelection(null); + updatePRSession({ + ...(data.prMetadata && { prMetadata: data.prMetadata }), + ...(data.prStackInfo !== undefined && { prStackInfo: data.prStackInfo }), + ...(data.prStackTree !== undefined && { prStackTree: data.prStackTree }), + ...(data.prDiffScope && { prDiffScope: data.prDiffScope }), + ...(data.prDiffScopeOptions && { prDiffScopeOptions: data.prDiffScopeOptions }), + }); + if (data.repoInfo) setRepoInfo(data.repoInfo); + if (data.agentCwd !== undefined) setAgentCwd(data.agentCwd); + if (data.prMetadata) { + setViewedFiles(data.viewedFiles ? new Set(data.viewedFiles) : new Set()); + } + setDiffError(data.error || null); + resetStagedFiles(); + } + + prStackCallbacksRef.current = { + applyPRResponse, + onError: (message) => setDiffError(message), + }; + + // Shared helper: fetch a diff switch and update state. + // Returns true on success, false on failure — callers that optimistically + // updated UI state (e.g. the base picker) can use this to revert. + const fetchDiffSwitch = useCallback(async (fullDiffType: string, baseOverride?: string, options?: { preserveFile?: boolean }): Promise => { + setIsLoadingDiff(true); + try { + const res = await fetch('/api/diff/switch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + diffType: fullDiffType, + // Server ignores base for modes that don't use it (uncommitted/staged/etc), + // so forwarding unconditionally is safe and keeps the request shape uniform. + ...((baseOverride ?? selectedBase) && { base: baseOverride ?? selectedBase }), + hideWhitespace: diffHideWhitespace, + }), + }); + + if (!res.ok) throw new Error('Failed to switch diff'); + + const data = await res.json() as { + rawPatch: string; + gitRef: string; + diffType: string; + base?: string; + gitContext?: GitContext; + error?: string; + }; + + const nextFiles = parseDiffToFiles(data.rawPatch); + + if (options?.preserveFile) { + // Whitespace toggle: update patch in-place, keep the active file. + // If the current file was removed (whitespace-only), retarget the + // dock panel to the first remaining file. + const currentPath = storeApi.getState().files[storeApi.getState().focusedFileIndex]?.path; + setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef } : prev); + storeApi.getState().setFiles(nextFiles); + const nextIdx = currentPath ? nextFiles.findIndex(f => f.path === currentPath) : -1; + if (nextIdx !== -1) { + storeApi.getState().setFocusedFile(nextIdx); + } else if (nextFiles.length > 0) { + storeApi.getState().setFocusedFile(0); + openDiffFile(nextFiles[0].path); + } + } else { + dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close(); + needsInitialDiffPanel.current = true; + setDiffData(prev => prev ? { ...prev, rawPatch: data.rawPatch, gitRef: data.gitRef, diffType: data.diffType } : prev); + storeApi.getState().setFiles(nextFiles); + setDiffType(data.diffType); + if (data.base) { + setSelectedBase(data.base); + setCommittedBase(data.base); + } + // Merge only the per-cwd fields so the sidebar reflects the worktree + // we're now in. Keep the original `worktrees` list (already filtered to + // exclude the server's startup cwd — replacing it with the new context's + // list would duplicate the "Main repo" entry) and `availableBranches` + // (shared across worktrees of the same repo). + // + // IMPORTANT: we deliberately do NOT overwrite `currentBranch`. The + // WorktreePicker's top "launch" row uses it as a label, and that row + // represents the cwd plannotator was launched in — not whichever + // worktree is currently active. Freezing `currentBranch` at its + // initial-load value keeps that label truthful. `defaultBranch` and + // `diffOptions` update because they describe the active diff, which + // other UI (empty-state text, diff-type picker) should see fresh. + if (data.gitContext) { + setGitContext((prev) => { + if (!prev) return data.gitContext!; + return { + ...prev, + defaultBranch: data.gitContext!.defaultBranch, + diffOptions: data.gitContext!.diffOptions, + compareTarget: data.gitContext!.compareTarget, + jjEvologs: data.gitContext!.jjEvologs, + // HEAD differs per worktree, so refresh the commit-baseline picker. + recentCommits: data.gitContext!.recentCommits, + }; + }); + } + storeApi.getState().setFocusedFile(0); + storeApi.getState().setPendingSelection(null); + resetStagedFiles(); + } + setDiffError(data.error || null); + return true; + } catch (err) { + console.error('Failed to switch diff:', err); + setDiffError(err instanceof Error ? err.message : 'Failed to switch diff'); + return false; + } finally { + setIsLoadingDiff(false); + } + }, [storeApi, dockApi, resetStagedFiles, selectedBase, diffHideWhitespace, openDiffFile]); + + // Switch the base branch the current diff compares against. + // Only triggers a refetch when the active mode actually uses a base. + // Optimistically updates the picker; reverts if the server-side switch + // fails so the chip doesn't lie about what the viewer is actually showing. + const handleBaseSelect = useCallback( + async (branch: string) => { + if (branch === selectedBase) return; + const previous = selectedBase; + setSelectedBase(branch); + if (activeDiffBase === 'branch' || activeDiffBase === 'merge-base' || activeDiffBase === 'jj-line' || activeDiffBase === 'jj-evolog') { + const ok = await fetchDiffSwitch(diffType, branch); + if (!ok) setSelectedBase(previous); + } + }, + [selectedBase, activeDiffBase, diffType, fetchDiffSwitch], + ); + + // Switch diff type (uncommitted, last-commit, branch) — composes worktree prefix if active + const handleDiffSwitch = useCallback(async (baseDiffType: string) => { + const fullDiffType = activeWorktreePath + ? `worktree:${activeWorktreePath}:${baseDiffType}` + : baseDiffType; + if (fullDiffType === diffType) return; + // For evolog, default to the second entry (previous state of @) so the + // server doesn't fall back to the jj bookmark/trunk revset. + // When leaving evolog, restore the base to the detected compare target + // so other base-dependent modes (jj-line) don't inherit a commit ID. + const enteringEvolog = + baseDiffType === 'jj-evolog' && gitContext?.jjEvologs && gitContext.jjEvologs.length >= 2; + const leavingEvolog = + !enteringEvolog && activeDiffBase === 'jj-evolog' && gitContext?.defaultBranch; + const baseOverride = enteringEvolog + ? gitContext!.jjEvologs![1].commitId + : leavingEvolog + ? gitContext!.defaultBranch + : undefined; + if (baseOverride) setSelectedBase(baseOverride); + await fetchDiffSwitch(fullDiffType, baseOverride); + }, [diffType, activeWorktreePath, fetchDiffSwitch, gitContext]); + + // Switch worktree context (or back to main repo). Preserves the current + // diff mode across the switch — if the reviewer was looking at "PR Diff" + // in the main repo, they should keep looking at "PR Diff" in the target + // worktree rather than being silently snapped back to "Uncommitted". + const handleWorktreeSwitch = useCallback(async (worktreePath: string | null) => { + if (worktreePath === activeWorktreePath) return; + const fullDiffType = worktreePath + ? `worktree:${worktreePath}:${activeDiffBase}` + : activeDiffBase; + await fetchDiffSwitch(fullDiffType); + }, [activeWorktreePath, activeDiffBase, fetchDiffSwitch]); + + // Re-fetch diff when hideWhitespace toggles so the server applies git diff -w. + // Preserves the active file since only whitespace hunks change. + const hideWhitespaceInitialized = useRef(false); + useEffect(() => { + if (!origin || !gitContext) return; + if (!hideWhitespaceInitialized.current) { + hideWhitespaceInitialized.current = true; + return; + } + fetchDiffSwitch(diffType, selectedBase, { preserveFile: true }); + }, [diffHideWhitespace, origin]); // eslint-disable-line react-hooks/exhaustive-deps + + // Select annotation - switches file if needed and scrolls to it + const handleSelectAnnotation = useCallback((id: string | null) => { + if (!id) { + storeApi.getState().selectAnnotation(null); + return; + } + + const state = storeApi.getState(); + const annotation = selectAllAnnotations(state).find(a => a.id === id); + if (!annotation) { + state.selectAnnotation(id); + return; + } + + if (!state.isAllFilesActive) { + const fileIndex = state.files.findIndex(f => f.path === annotation.filePath); + if (fileIndex !== -1) { + handleFileSwitch(fileIndex); + } + } + + state.selectAnnotation(id); + }, [storeApi, handleFileSwitch]); + + // Diff context bundled into local-mode feedback headers so the receiving + // agent knows which diff the annotations are anchored to. Uses committedBase + // (what the server actually computed) and activeDiffBase/activeWorktreePath + // (derived from the committed diffType). Skipped in PR mode — the PR header + // already carries the relevant context. + // Declared before reviewStateValue because both reviewStateValue and the + // feedbackMarkdown memo below read it; moving it below either would put it + // in the TDZ when those memos run on first render. + const feedbackDiffContext = useMemo( + () => + prMetadata || !activeDiffBase + ? undefined + : { + mode: activeDiffBase, + base: committedBase ?? undefined, + worktreePath: activeWorktreePath, + }, + [prMetadata, activeDiffBase, committedBase, activeWorktreePath], + ); + + const prReviewScopeLabel = useMemo(() => { + if (!prMetadata || !prStackInfo) return undefined; + if (prDiffScope === 'full-stack') { + return `Diff vs \`${prMetadata.defaultBranch ?? 'default branch'}\``; + } + return `Diff vs \`${prMetadata.baseBranch}\``; + }, [prMetadata, prStackInfo, prDiffScope]); + + // Build ReviewState value for dock panel context + const reviewStateValue = useMemo(() => ({ + files, + focusedFileIndex: activeFileIndex, + focusedFilePath: files[activeFileIndex]?.path ?? null, + diffStyle, + diffOverflow, + diffIndicators, + lineDiffType: diffLineDiffType, + disableLineNumbers: !diffShowLineNumbers, + disableBackground: !diffShowBackground, + fontFamily: diffFontFamily || undefined, + fontSize: diffFontSize || undefined, + // Only propagate base for modes where it affects old/new content. Avoids + // needless file-content re-fetches when switching to uncommitted/staged/etc. + // Uses committedBase (not selectedBase) so file-content queries wait for + // the new patch to arrive before refetching — otherwise the viewer can + // briefly pair an old patch with the new base's content. + reviewBase: + (activeDiffBase === 'branch' || activeDiffBase === 'merge-base' || activeDiffBase === 'jj-line' || activeDiffBase === 'jj-evolog') + ? committedBase ?? undefined + : undefined, + activeDiffBase, + feedbackDiffContext, + prReviewScope: prReviewScopeLabel, + prDiffScope, + allAnnotations, + externalAnnotations, + selectedAnnotationId, + pendingSelection, + onLineSelection: handleLineSelection, + onAddAnnotation: handleAddAnnotation, + onAddAnnotationForFile: handleAddAnnotationForFile, + onAddFileComment: handleAddFileComment, + onAddFileCommentForFile: handleAddFileCommentForFile, + onEditAnnotation: handleEditAnnotation, + onSelectAnnotation: handleSelectAnnotation, + onDeleteAnnotation: handleDeleteAnnotation, + viewedFiles, + onToggleViewed: handleToggleViewed, + stagedFiles, + stagingFile, + onStage: stageFile, + canStageFiles, + stageError, + searchQuery: isSearchPending ? '' : debouncedSearchQuery, + isSearchPending, + debouncedSearchQuery, + activeFileSearchMatches, + activeSearchMatchId, + activeSearchMatch: activeSearchMatch?.filePath === files[activeFileIndex]?.path ? activeSearchMatch : null, + aiAvailable, + aiMessages: aiChat.messages, + onAskAI: handleAskAI, + isAILoading: aiChat.isCreatingSession || aiChat.isStreaming, + onViewAIResponse: handleViewAIResponse, + onClickAIMarker: handleClickAIMarker, + aiHistoryForSelection, + agentJobs: agentJobs.jobs, + prMetadata, + prContext, + isPRContextLoading, + prContextError, + fetchPRContext, + platformUser, + openDiffFile, + onAllFilesVisibleFileChange: setAllFilesVisibleFile, + isAllFilesActive, + openTourPanel: handleOpenTour, + onCodeNavRequest: handleCodeNavRequest, + codeNavResult: codeNav.result, + codeNavIsLoading: codeNav.isLoading, + codeNavActiveSymbol: codeNav.activeSymbol, + }), [ + files, activeFileIndex, diffStyle, diffOverflow, diffIndicators, + diffLineDiffType, diffShowLineNumbers, diffShowBackground, + diffFontFamily, diffFontSize, activeDiffBase, committedBase, feedbackDiffContext, prReviewScopeLabel, prDiffScope, + allAnnotations, externalAnnotations, + selectedAnnotationId, pendingSelection, handleLineSelection, + handleAddAnnotation, handleAddFileComment, handleAddFileCommentForFile, handleEditAnnotation, + handleSelectAnnotation, handleDeleteAnnotation, viewedFiles, + handleToggleViewed, stagedFiles, stagingFile, stageFile, + canStageFiles, stageError, isSearchPending, debouncedSearchQuery, + activeFileSearchMatches, activeSearchMatchId, activeSearchMatch, + aiAvailable, aiChat.messages, aiChat.isCreatingSession, aiChat.isStreaming, + handleAskAI, handleViewAIResponse, handleClickAIMarker, + aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext, + isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile, + handleOpenTour, isAllFilesActive, handleAddAnnotationForFile, + handleCodeNavRequest, codeNav.result, codeNav.isLoading, codeNav.activeSymbol, + ]); + + // Separate context for high-frequency job logs — prevents re-rendering all panels on every live event + const jobLogsValue = useMemo(() => ({ jobLogs: agentJobs.jobLogs }), [agentJobs.jobLogs]); + + // Copy raw diff to clipboard + const handleCopyDiff = useCallback(async () => { + if (!diffData) return; + try { + await navigator.clipboard.writeText(diffData.rawPatch); + setCopyRawDiffStatus('success'); + setTimeout(() => setCopyRawDiffStatus('idle'), 2000); + } catch (err) { + console.error('Failed to copy:', err); + setCopyRawDiffStatus('error'); + setTimeout(() => setCopyRawDiffStatus('idle'), 2000); + } + }, [diffData]); + + // Copy feedback markdown to clipboard + const handleCopyFeedback = useCallback(async () => { + if (allAnnotations.length === 0) { + setShowNoAnnotationsDialog(true); + return; + } + try { + const feedback = exportReviewFeedback(allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel); + await navigator.clipboard.writeText(feedback); + setCopyFeedback('Feedback copied!'); + setTimeout(() => setCopyFeedback(null), 2000); + } catch (err) { + console.error('Failed to copy:', err); + setCopyFeedback('Failed to copy'); + setTimeout(() => setCopyFeedback(null), 2000); + } + }, [allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel]); + + const feedbackMarkdown = useMemo(() => { + let output = exportReviewFeedback(allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel); + if (editorAnnotations.length > 0) { + output += exportEditorAnnotations(editorAnnotations); + } + return output; + }, [allAnnotations, prMetadata, feedbackDiffContext, prReviewScopeLabel, editorAnnotations]); + + const totalAnnotationCount = allAnnotations.length + editorAnnotations.length; + + // Send feedback to OpenCode via API + const handleSendFeedback = useCallback(async () => { + if (totalAnnotationCount === 0) { + setShowNoAnnotationsDialog(true); + return; + } + setIsSendingFeedback(true); + try { + const agentSwitchSettings = getAgentSwitchSettings(); + const effectiveAgent = getEffectiveAgentName(agentSwitchSettings); + + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + approved: false, + feedback: feedbackMarkdown, + annotations: allAnnotations, + ...(effectiveAgent && { agentSwitch: effectiveAgent }), + }), + }); + if (res.ok) { + const data = await res.json().catch(() => ({})); + if (data.feedbackDelivered) { + setFeedbackSent(true); + setIsSendingFeedback(false); + storeApi.getState().setLocalAnnotations([]); + storeApi.getState().selectAnnotation(null); + storeApi.getState().setPendingSelection(null); + } else { + setSubmitted('feedback'); + } + } else { + throw new Error('Failed to send'); + } + } catch (err) { + console.error('Failed to send feedback:', err); + setCopyFeedback('Failed to send'); + setTimeout(() => setCopyFeedback(null), 2000); + setIsSendingFeedback(false); + } + }, [totalAnnotationCount, feedbackMarkdown, allAnnotations, storeApi]); + + // Exit review session without sending any feedback + const handleExit = useCallback(async () => { + setIsExiting(true); + try { + const res = await fetch('/api/exit', { method: 'POST' }); + if (res.ok) { + setSubmitted('exited'); + } else { + throw new Error('Failed to exit'); + } + } catch (error) { + console.error('Failed to exit review:', error); + setIsExiting(false); + } + }, []); + + // Approve without feedback (LGTM) + const handleApprove = useCallback(async () => { + setIsApproving(true); + try { + const res = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + approved: true, + feedback: 'LGTM - no changes requested.', // unused — integrations branch on `approved` flag + annotations: [], + }), + }); + if (res.ok) { + setSubmitted('approved'); + } else { + throw new Error('Failed to send'); + } + } catch (err) { + console.error('Failed to approve:', err); + setCopyFeedback('Failed to send'); + setTimeout(() => setCopyFeedback(null), 2000); + setIsApproving(false); + } + }, []); + + // Submit reviews to one or more PRs via /api/pr-action + const handlePlatformAction = useCallback(async (action: 'approve' | 'comment', plan: ReviewSubmission, generalComment?: string) => { + setIsPlatformActioning(true); + setPlatformActionError(null); + + try { + const bodyForTarget = (target: SubmissionTarget) => { + const parts: string[] = []; + if (generalComment) parts.push(generalComment); + parts.push('Review from Plannotator'); + if (target.fileScopedBody) parts.push(target.fileScopedBody); + return parts.join('\n\n'); + }; + + // For approve, only post to the currently viewed PR. + // For comment with no targets but a general comment, create a minimal target. + let targets = plan.targets; + if (action === 'approve' || (targets.length === 0 && generalComment?.trim())) { + const currentTarget = plan.targets.find(t => t.prUrl === prMetadata?.url); + targets = currentTarget ? [currentTarget] : [{ + prUrl: prMetadata?.url ?? '', + prNumber: prMetadata ? (prMetadata.platform === 'github' ? prMetadata.number : prMetadata.iid) : 0, + prTitle: prMetadata?.title ?? '', + prRepo: prMetadata ? getDisplayRepo(prMetadata) : '', + fileComments: [], fileScopedBody: '', + fileCount: 0, annotationCount: 0, status: 'pending' as const, + }]; + } + + const openUrls: string[] = []; + const results = await Promise.allSettled( + targets.map(async (target): Promise => { + if (target.status === 'success') return target; + try { + const prRes = await fetch('/api/pr-action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action, + body: bodyForTarget(target), + fileComments: target.fileComments, + targetPrUrl: target.prUrl || undefined, + }), + }); + const prData = await prRes.json() as { ok?: boolean; prUrl?: string; error?: string }; + if (!prRes.ok || prData.error) { + return { ...target, status: 'failed', error: prData.error ?? 'Failed to submit' }; + } + if (prData.prUrl) openUrls.push(prData.prUrl); + return { ...target, status: 'success' }; + } catch (err) { + return { ...target, status: 'failed', error: err instanceof Error ? err.message : 'Network error' }; + } + }), + ); + const updatedTargets = results.map((r, i) => r.status === 'fulfilled' ? r.value : { ...targets[i], status: 'failed' as const, error: 'Unexpected error' }); + const allOk = updatedTargets.every(t => t.status === 'success'); + + if (!allOk) { + setPlatformCommentDialog(prev => prev ? { + ...prev, + plan: { ...plan, targets: updatedTargets }, + } : null); + return; + } + + setPlatformCommentDialog(null); + setSubmitted(action === 'approve' ? 'approved' : 'feedback'); + + if (platformOpenPR) { + for (const url of openUrls) window.open(url, '_blank'); + } + + const agentSwitchSettings = getAgentSwitchSettings(); + const effectiveAgent = getEffectiveAgentName(agentSwitchSettings); + const prLinks = openUrls.join(', '); + const statusMessage = action === 'approve' + ? `${mrLabel === 'MR' ? 'Merge request' : 'Pull request'} approved on ${platformLabel}${prLinks ? ': ' + prLinks : ''}` + : `${mrLabel === 'MR' ? 'Merge request' : 'Pull request'} reviewed on ${platformLabel}${prLinks ? ': ' + prLinks : ''}`; + fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + keepalive: true, + body: JSON.stringify({ + approved: false, + feedback: statusMessage, + annotations: [], + ...(effectiveAgent && { agentSwitch: effectiveAgent }), + }), + }).catch(() => {}); + } catch (err) { + setPlatformActionError(err instanceof Error ? err.message : 'Failed to submit review'); + } finally { + setIsPlatformActioning(false); + } + }, [platformOpenPR, platformLabel, mrLabel, prMetadata]); + + const openPlatformDialog = useCallback((action: 'approve' | 'comment') => { + const diffPaths = new Set(files.map(f => f.path)); + const prMeta = prMetadata ? { + number: prMetadata.platform === 'github' ? prMetadata.number : prMetadata.iid, + title: prMetadata.title, + repo: getDisplayRepo(prMetadata), + } : undefined; + const plan = buildReviewSubmission(allAnnotations, editorAnnotations, prMetadata?.url, diffPaths, prMeta); + setPlatformGeneralComment(''); + setPlatformCommentDialog({ action, plan }); + }, [allAnnotations, editorAnnotations, files, prMetadata]); + + // Double-tap Option/Alt to toggle review destination (PR mode only) + useEffect(() => { + if (!prMetadata) return; + let lastAltUp = 0; + const DOUBLE_TAP_WINDOW = 300; + + const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; + if (e.key !== 'Alt' || e.repeat) return; + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key !== 'Alt') return; + const now = Date.now(); + if (now - lastAltUp < DOUBLE_TAP_WINDOW) { + setReviewDestination(prev => { + const next = prev === 'platform' ? 'agent' : 'platform'; + storage.setItem('plannotator-review-dest', next); + setPlatformActionError(null); + return next; + }); + lastAltUp = 0; + } else { + lastAltUp = now; + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [prMetadata]); + + // Cmd/Ctrl+Enter keyboard shortcut to approve or send feedback + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isVisible()) return; + if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; + + // If the platform post dialog is open, Cmd+Enter submits it + if (platformCommentDialog) { + if (submitted || feedbackSent || isPlatformActioning) return; + const isApproveAction = platformCommentDialog.action === 'approve'; + const hasTargets = platformCommentDialog.plan.targets.length > 0; + const canSubmit = isApproveAction || hasTargets || platformGeneralComment.trim(); + if (!canSubmit) return; + e.preventDefault(); + handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment); + return; + } + + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (showExportModal || showNoAnnotationsDialog || showApproveWarning || showExitWarning) return; + if (submitted || feedbackSent || isSendingFeedback || isApproving || isExiting || isPlatformActioning) return; + if (!origin) return; // Demo mode + + e.preventDefault(); + + if (platformMode) { + // GitHub mode: No annotations → Approve on GitHub, otherwise → Post Review + const isOwnPR = !!platformUser && prMetadata?.author === platformUser; + if (totalAnnotationCount === 0 && !isOwnPR) { + openPlatformDialog('approve'); + } else { + openPlatformDialog('comment'); + } + } else { + // Agent mode: No annotations → Approve, otherwise → Send Feedback + if (totalAnnotationCount === 0) { + handleApprove(); + } else { + handleSendFeedback(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + showExportModal, showNoAnnotationsDialog, showApproveWarning, showExitWarning, + platformCommentDialog, platformGeneralComment, + submitted, feedbackSent, isSendingFeedback, isApproving, isExiting, isPlatformActioning, + origin, platformMode, platformLabel, platformUser, prMetadata, totalAnnotationCount, openPlatformDialog, + handleApprove, handleSendFeedback, handlePlatformAction + ]); + + if (isLoading) { + const skeleton = ( +
+
Loading diff...
+
+ ); + if (__embedded) return skeleton; + return {skeleton}; + } + + const completionTitle = !submitted ? '' : + submitted === 'approved' ? 'Changes Approved' + : submitted === 'exited' ? 'Session Closed' + : 'Feedback Sent'; + const completionSubtitle = !submitted ? '' : + submitted === 'exited' + ? 'Review session closed without feedback.' + : platformMode + ? submitted === 'approved' + ? `Your approval was submitted to ${platformLabel}.` + : `Your feedback was submitted to ${platformLabel}.` + : submitted === 'approved' + ? `${getAgentName(origin)} will proceed with the changes.` + : `${getAgentName(origin)} will address your review feedback.`; + + const innerContent = ( + + + {isSwitchingPRScope && } +
+ {/* Header */} +
+
+ {headerLeft} + {headerLeft && shouldShowFileTree && ( +
+ )} + {shouldShowFileTree && ( + <> + +
+ + )} + {prMetadata ? ( +
+ + + {displayRepo} + + + +
+ + + +
+
+ ) : repoInfo ? ( +
+ {repoInfo.branch && ( + + {repoInfo.branch} + + )} + + + {repoInfo.display} + +
+ ) : ( + Review + )} +
+ +
+ {/* Diff style toggle */} +
+ + +
+ + {origin && !submitted && !feedbackSent ? ( + <> + {/* Destination dropdown (PR mode only) */} + {prMetadata && ( +
+ + {showDestinationMenu && ( + <> +
setShowDestinationMenu(false)} /> +
+ + +
+ + {altKey} + {altKey} + to toggle + +
+
+ + )} +
+ )} + + {/* GitHub error message */} + {platformActionError && ( +
+ {platformActionError} +
+ )} + + {/* Agent mode: Close/SendFeedback flip + Approve */} + {!platformMode ? ( + totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} + onExit={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + /> + ) : ( + <> + {/* Platform mode: Close + Post Comments + Approve */} + totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + disabled={isSendingFeedback || isApproving || isExiting || isPlatformActioning} + isLoading={isExiting} + /> + openPlatformDialog('comment')} + disabled={isSendingFeedback || isApproving || isPlatformActioning} + isLoading={isSendingFeedback || isPlatformActioning} + label="Post Comments" + shortLabel="Post" + loadingLabel="Posting..." + shortLoadingLabel="Posting..." + title="Post review to platform" + /> +
+ { + if (platformUser && prMetadata?.author === platformUser) return; + openPlatformDialog('approve'); + }} + disabled={ + isSendingFeedback || isApproving || isPlatformActioning || + (!!platformUser && prMetadata?.author === platformUser) + } + isLoading={isApproving} + muted={!!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning} + title={ + platformUser && prMetadata?.author === platformUser + ? `You can't approve your own ${mrLabel}` + : "Approve - no changes needed" + } + /> + {platformUser && prMetadata?.author === platformUser && ( +
+
+
+ You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}. +
+ )} +
+ + )} + + ) : ( + + )} + +
+ + { if (externalOpenSettings) { externalOpenSettings(); return; } setOpenSettingsMenu(true); }} + onOpenExport={() => setShowExportModal(true)} + onToggleFileTree={() => setIsFileTreeOpen(prev => !prev)} + onToggleSidebar={() => reviewSidebar.isOpen ? reviewSidebar.close() : reviewSidebar.open()} + isFileTreeOpen={isFileTreeOpen} + isSidebarOpen={reviewSidebar.isOpen} + appVersion={appVersion} + /> + +
+ + {/* Sidebar tab toggles */} + + {aiAvailable && ( + + )} + {agentJobs.capabilities?.available && ( + + )} +
+
+ + {/* Embedded completion banner — inline, non-blocking */} + {__embedded && !legacyTabMode && ( + + )} + + {/* Main content */} +
+ {shouldShowFileTree && isFileTreeOpen && ( + <> + f.path === allFilesVisibleFile) : undefined} + onSelectFile={handleFilePreview} + onDoubleClickFile={handleFilePinned} + annotations={allAnnotations} + viewedFiles={viewedFiles} + onToggleViewed={handleToggleViewed} + hideViewedFiles={hideViewedFiles} + onToggleHideViewed={() => setHideViewedFiles(prev => !prev)} + enableKeyboardNav={!showExportModal && hasSearchableFiles} + diffOptions={gitContext?.diffOptions} + activeDiffType={activeDiffBase} + onSelectDiff={handleDiffSwitch} + isLoadingDiff={isLoadingDiff} + width={fileTreeResize.width} + worktrees={gitContext?.worktrees} + activeWorktreePath={activeWorktreePath} + onSelectWorktree={handleWorktreeSwitch} + currentBranch={gitContext?.currentBranch} + availableBranches={prMetadata ? undefined : gitContext?.availableBranches} + selectedBase={prMetadata ? undefined : selectedBase ?? undefined} + detectedBase={prMetadata ? undefined : gitContext?.defaultBranch || gitContext?.compareTarget?.fallback} + onSelectBase={prMetadata ? undefined : handleBaseSelect} + compareTarget={gitContext?.compareTarget} + recentCommits={prMetadata ? undefined : gitContext?.recentCommits} + jjEvologs={prMetadata ? undefined : gitContext?.jjEvologs} + detectedEvoBase={prMetadata ? undefined : gitContext?.jjEvologs?.[1]?.commitId} + stagedFiles={stagedFiles} + onCopyRawDiff={handleCopyDiff} + canCopyRawDiff={!!diffData?.rawPatch} + copyRawDiffStatus={copyRawDiffStatus} + searchQuery={hasSearchableFiles ? searchQuery : ''} + isSearchOpen={hasSearchableFiles ? isSearchOpen : false} + isSearchPending={isSearchPending} + searchInputRef={hasSearchableFiles ? searchInputRef : undefined} + onOpenSearch={hasSearchableFiles ? openSearch : undefined} + onSearchChange={hasSearchableFiles ? handleSearchInputChange : undefined} + onSearchClear={hasSearchableFiles ? clearSearch : undefined} + onSearchClose={hasSearchableFiles ? closeSearch : undefined} + searchGroups={hasSearchableFiles ? searchGroups : []} + searchMatches={hasSearchableFiles ? searchMatches : []} + activeSearchMatchId={hasSearchableFiles ? activeSearchMatchId : null} + onSelectSearchMatch={hasSearchableFiles ? handleSelectSearchMatch : undefined} + onStepSearchMatch={hasSearchableFiles ? stepSearchMatch : undefined} + repoRoot={prMetadata ? null : (activeWorktreePath ?? agentCwd ?? gitContext?.cwd ?? null)} + /> + + + )} + + {/* Center dock area */} +
+ {files.length > 0 ? ( + + ) : ( +
+
+
+ {diffError ? ( + + + + ) : ( + + + + )} +
+
+ {diffError ? ( + <> +

Failed to load diff

+

{diffError}

+ + ) : ( + <> +

No changes

+

+ {activeDiffBase === 'uncommitted' && `No uncommitted changes${activeWorktreePath ? ' in this worktree' : ' to review'}.`} + {activeDiffBase === 'staged' && "No staged changes. Stage some files with git add."} + {activeDiffBase === 'unstaged' && "No unstaged changes. All changes are staged."} + {activeDiffBase === 'last-commit' && `No changes in the last commit${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'jj-current' && "No changes in the current jj change."} + {activeDiffBase === 'jj-last' && "No changes in the last jj change."} + {activeDiffBase === 'jj-line' && `No changes in your line of work vs ${selectedBase || gitContext?.defaultBranch || '@-'}.`} + {activeDiffBase === 'jj-evolog' && `No changes since evolution ${selectedBase ? selectedBase.slice(0, 8) : 'previous'} — the change looks the same as before.`} + {activeDiffBase === 'jj-all' && "No files at the current jj change."} + {activeDiffBase === 'branch' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'merge-base' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'all' && `No tracked files${activeWorktreePath ? ' in this worktree' : ' in this repository'}.`} +

+ + )} +
+ {gitContext?.diffOptions && gitContext.diffOptions.length > 1 && ( +

+ Try selecting a different view from the dropdown. +

+ )} +
+
+ )} +
+ + {/* Resize Handle + Sidebar */} + {reviewSidebar.isOpen && ( + <> + + + + )} +
+ + {/* Export Modal */} + {showExportModal && ( +
+
+
+

Export Review Feedback

+ +
+
+
+ {allAnnotations.length} annotation{allAnnotations.length !== 1 ? 's' : ''} +
+
+                  {feedbackMarkdown}
+                
+
+
+ +
+
+
+ )} + + {!externalOpenSettings && ( + + )} + + {/* Worktree info dialog */} + {(gitContext?.cwd || agentCwd) && prMetadata && ( + setShowWorktreeDialog(false)} + title="Local Worktree" + wide + message={ +
+

This PR is checked out locally so review agents have full file access.

+
+ Path + +
+

Automatically removed when this review session ends.

+
+ } + variant="info" + /> + )} + + {/* No annotations dialog */} + setShowNoAnnotationsDialog(false)} + title="No Annotations" + message="You haven't made any annotations yet. There's nothing to copy." + variant="info" + /> + + {/* Approve with annotations warning */} + setShowApproveWarning(false)} + onConfirm={() => { + setShowApproveWarning(false); + handleApprove(); + }} + title="Annotations Won't Be Sent" + message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you approve.} + subMessage="To send your feedback, use Send Feedback instead." + confirmText="Approve Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + + setShowExitWarning(false)} + onConfirm={() => { + setShowExitWarning(false); + handleExit(); + }} + title="Annotations Won't Be Sent" + message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you close.} + subMessage="To send your feedback, use Send Feedback instead." + confirmText="Close Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + + {/* AI setup dialog — first-run only */} + { + setShowAISetup(false); + handleAIConfigChange({ providerId }); + }} + /> + + {/* Diff type setup dialog — first-run only */} + {showDiffTypeSetup && ( + { + setShowDiffTypeSetup(false); + if (selected !== diffType) handleDiffSwitch(selected); + }} + /> + )} + + {/* Full-screen overlay: standalone mode, or legacy tab mode even when embedded */} + {(!__embedded || legacyTabMode) && ( + + )} + + {/* Update notification */} + + + {/* GitHub general comment dialog */} + { + setPlatformOpenPR(checked); + storage.setItem('plannotator-platform-open-pr', String(checked)); + }} + onConfirm={() => { + if (!platformCommentDialog) return; + handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment); + }} + onCancel={() => setPlatformCommentDialog(null)} + isSubmitting={isPlatformActioning} + mrLabel={mrLabel} + platformLabel={platformLabel} + /> +
+ + {/* Tour dialog overlay */} + setTourDialogJobId(null)} /> + + {/* Dev-only: open a fully-formed demo tour without running the agent. + Stripped from production builds via import.meta.env.DEV. */} + {import.meta.env.DEV && ( + + )} + + {!__embedded && ( + + )} +
+
+ ); + + if (__embedded) return innerContent; + + return ( + + + {innerContent} + + + ); +}; + +export default function ReviewAppStandalone(props: { __embedded?: boolean; headerLeft?: React.ReactNode; onOpenSettings?: () => void }) { + return ; +} + +export function ReviewAppEmbedded({ headerLeft, onOpenSettings }: { headerLeft?: React.ReactNode; onOpenSettings?: () => void }) { + return ( + + + + ); +} + diff --git a/packages/plannotator-code-review/components/AIConfigBar.tsx b/packages/plannotator-code-review/components/AIConfigBar.tsx new file mode 100644 index 000000000..d3b2986d7 --- /dev/null +++ b/packages/plannotator-code-review/components/AIConfigBar.tsx @@ -0,0 +1,275 @@ +import type React from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { getProviderMeta } from '@plannotator/ui/components/ProviderIcons'; + +interface AIProviderModel { + id: string; + label: string; + default?: boolean; +} + +interface AIProviderInfo { + id: string; + name: string; + models?: AIProviderModel[]; +} + +const REASONING_EFFORTS = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Max' }, +] as const; + +interface AIConfigBarProps { + providers: AIProviderInfo[]; + selectedProviderId: string | null; + selectedModel: string | null; + selectedReasoningEffort: string | null; + onProviderChange: (providerId: string) => void; + onModelChange: (model: string) => void; + onReasoningEffortChange: (effort: string | null) => void; + hasSession: boolean; +} + +export const AIConfigBar: React.FC = ({ + providers, + selectedProviderId, + selectedModel, + selectedReasoningEffort, + onProviderChange, + onModelChange, + onReasoningEffortChange, + hasSession, +}) => { + const [showSessionNote, setShowSessionNote] = useState(false); + const [openMenu, setOpenMenu] = useState<'provider' | 'model' | 'effort' | null>(null); + const [modelSearch, setModelSearch] = useState(''); + const barRef = useRef(null); + const searchInputRef = useRef(null); + + // Flash "New chat session" briefly when config changes while a session exists + useEffect(() => { + if (showSessionNote) { + const t = setTimeout(() => setShowSessionNote(false), 2000); + return () => clearTimeout(t); + } + }, [showSessionNote]); + + // Close menu on click outside + useEffect(() => { + if (!openMenu) return; + const handler = (e: MouseEvent) => { + if (barRef.current && !barRef.current.contains(e.target as Node)) { + setOpenMenu(null); + setModelSearch(''); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [openMenu]); + + if (providers.length === 0) { + return ( +
+ No AI providers available +
+ ); + } + + const effectiveProviderId = selectedProviderId ?? providers[0]?.id; + const currentProvider = providers.find(p => p.id === effectiveProviderId) ?? providers[0]; + if (!currentProvider) return null; + + const meta = getProviderMeta(currentProvider.name); + const Icon = meta.icon; + const models = currentProvider.models ?? []; + const defaultModel = models.find(m => m.default) ?? models[0]; + const effectiveModel = selectedModel ?? defaultModel?.id; + const currentModelLabel = models.find(m => m.id === effectiveModel)?.label ?? defaultModel?.label; + + const handleProviderSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onProviderChange(id); + setOpenMenu(null); + }; + + const handleModelSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onModelChange(id); + setOpenMenu(null); + setModelSearch(''); + }; + + const handleEffortSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onReasoningEffortChange(id); + setOpenMenu(null); + }; + + const chevron = ( + + + + ); + + return ( +
+ {/* Provider selector */} + {providers.length > 1 ? ( +
+ + + {openMenu === 'provider' && ( +
+ {providers.map(p => { + const m = getProviderMeta(p.name); + const ProvIcon = m.icon; + const isActive = p.id === effectiveProviderId; + return ( + + ); + })} +
+ )} +
+ ) : ( + + + {meta.label} + + )} + + {/* Model selector */} + {models.length > 1 ? ( + <> + · +
+ + + {openMenu === 'model' && ( +
+ {models.length > 8 && ( +
+ setModelSearch(e.target.value)} + autoFocus + /> +
+ )} +
8 ? 'ai-config-menu-scroll' : ''}> + {models + .filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase())) + .map(m => { + const isActive = m.id === effectiveModel; + return ( + + ); + })} +
+
+ )} +
+ + ) : currentModelLabel ? ( + <> + · + {currentModelLabel} + + ) : null} + + {/* Reasoning effort — Codex only */} + {currentProvider.name === 'codex-sdk' && ( + <> + · +
+ + + {openMenu === 'effort' && ( +
+ {REASONING_EFFORTS.map(e => { + const isActive = e.id === (selectedReasoningEffort ?? 'high'); + return ( + + ); + })} +
+ )} +
+ + )} + + {/* Spacer */} +
+ + {/* Session reset note */} + {showSessionNote && ( + New chat session + )} +
+ ); +}; diff --git a/packages/plannotator-code-review/components/AITab.tsx b/packages/plannotator-code-review/components/AITab.tsx new file mode 100644 index 000000000..4d395e9cd --- /dev/null +++ b/packages/plannotator-code-review/components/AITab.tsx @@ -0,0 +1,398 @@ +import React, { useRef, useEffect, useState, useMemo, useCallback, memo } from 'react'; +import type { AIChatEntry, PendingPermission } from '../hooks/useAIChat'; +import { renderChatMarkdown } from '../utils/renderChatMarkdown'; +import { formatLineRange } from '../utils/formatLineRange'; +import { formatRelativeTime } from '../utils/formatRelativeTime'; +import { SparklesIcon } from './SparklesIcon'; +import { CountBadge } from './CountBadge'; +import { CopyButton } from './CopyButton'; +import { PermissionCard } from './PermissionCard'; +import { AIConfigBar } from './AIConfigBar'; +import { submitHint } from '@plannotator/ui/utils/platform'; +import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; + +interface AIProviderInfo { + id: string; + name: string; + models?: Array<{ id: string; label: string; default?: boolean }>; +} + +interface AITabProps { + messages: AIChatEntry[]; + isCreatingSession: boolean; + isStreaming: boolean; + activeFilePath?: string; + scrollToQuestionId?: string | null; + onScrollToLines: (filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => void; + onAskGeneral?: (question: string) => void; + permissionRequests?: PendingPermission[]; + onRespondToPermission?: (requestId: string, allow: boolean) => void; + aiProviders?: AIProviderInfo[]; + aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; + onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; + hasAISession?: boolean; +} + +interface FileGroup { + filePath: string; + messages: AIChatEntry[]; +} + +function getQuestionScope(q: AIChatEntry['question']): 'general' | 'file' | 'line' { + if (!q.filePath) return 'general'; + if (q.lineStart == null) return 'file'; + return 'line'; +} + +export const AITab: React.FC = ({ + messages, + isCreatingSession, + isStreaming, + activeFilePath, + scrollToQuestionId, + onScrollToLines, + onAskGeneral, + permissionRequests = [], + onRespondToPermission, + aiProviders = [], + aiConfig, + onAIConfigChange, + hasAISession = false, +}) => { + const scrollRef = useRef(null); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const [generalInput, setGeneralInput] = useState(''); + const [highlightFilePath, setHighlightFilePath] = useState(null); + + // Group messages by file + const { fileGroups, generalMessages } = useMemo(() => { + const grouped = new Map(); + const general: AIChatEntry[] = []; + + for (const msg of messages) { + if (!msg.question.filePath) { + general.push(msg); + } else { + const existing = grouped.get(msg.question.filePath) || []; + existing.push(msg); + grouped.set(msg.question.filePath, existing); + } + } + + const fileGroups: FileGroup[] = []; + for (const [filePath, msgs] of grouped) { + msgs.sort((a, b) => { + const aScope = getQuestionScope(a.question); + const bScope = getQuestionScope(b.question); + if (aScope !== bScope) return aScope === 'file' ? -1 : 1; + return (a.question.lineStart ?? 0) - (b.question.lineStart ?? 0); + }); + fileGroups.push({ filePath, messages: msgs }); + } + + return { fileGroups, generalMessages: general }; + }, [messages]); + + // Auto-expand active file's group + useEffect(() => { + if (activeFilePath) { + setExpandedFiles(prev => { + if (prev.has(activeFilePath)) return prev; + const next = new Set(prev); + next.add(activeFilePath); + return next; + }); + } + }, [activeFilePath]); + + // Scroll to specific question and flash-highlight its file group header + useEffect(() => { + if (!scrollToQuestionId || !scrollRef.current) return; + + const msg = messages.find(m => m.question.id === scrollToQuestionId); + const filePath = msg?.question.filePath; + + if (filePath) { + const header = scrollRef.current.querySelector(`[data-file-group="${CSS.escape(filePath)}"]`); + if (header) { + header.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + setHighlightFilePath(filePath); + setTimeout(() => setHighlightFilePath(null), 1200); + } + + if (filePath && expandedFiles.has(filePath)) { + setTimeout(() => { + const el = scrollRef.current?.querySelector(`[data-question-id="${scrollToQuestionId}"]`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 300); + } + }, [scrollToQuestionId]); + + // Auto-scroll when new messages arrive (not on every streaming token) + const prevMsgCount = useRef(messages.length); + useEffect(() => { + if (!scrollRef.current) return; + const isNewMessage = messages.length > prevMsgCount.current; + prevMsgCount.current = messages.length; + + if (isNewMessage) { + const allQAs = scrollRef.current.querySelectorAll('[data-question-id]'); + const lastQA = allQAs[allQAs.length - 1]; + if (lastQA) { + lastQA.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + } + }, [messages.length]); + + const toggleFile = (filePath: string) => { + setExpandedFiles(prev => { + const next = new Set(prev); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }; + + const handleGeneralSubmit = () => { + if (!generalInput.trim() || !onAskGeneral) return; + onAskGeneral(generalInput.trim()); + setGeneralInput(''); + }; + + // Empty state + if (messages.length === 0 && !isCreatingSession) { + return ( +
+
+
+ +
+

+ Select lines and click Ask AI, or ask a general question below. +

+
+ onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} + onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} + hasSession={hasAISession} + /> + {onAskGeneral && } +
+ ); + } + + return ( +
+ +
+ {isCreatingSession && messages.length === 0 && ( +
+ Starting AI session... +
+ )} + + {/* File-grouped questions */} + {fileGroups.map(({ filePath, messages: fileMessages }) => { + const isExpanded = expandedFiles.has(filePath); + const basename = filePath.split('/').pop() || filePath; + + return ( +
+ + + {isExpanded && ( +
+ {fileMessages.map(({ question, response }) => ( + + ))} +
+ )} +
+ ); + })} + + {/* Pending permission requests */} + {permissionRequests.filter(p => !p.decided).map(perm => ( +
+ {})} + /> +
+ ))} + + {/* General questions */} + {generalMessages.length > 0 && ( +
+ {fileGroups.length > 0 && ( +
+
+ General +
+
+ )} +
+ {generalMessages.map(({ question, response }) => ( + + ))} +
+
+ )} +
+ + + {/* Config bar */} + onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} + onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} + hasSession={hasAISession} + /> + + {/* General question input */} + {onAskGeneral && } +
+ ); +}; + +/** General question input pinned at bottom — textarea grows upward on multi-line */ +const GeneralInput: React.FC<{ + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + disabled?: boolean; +}> = ({ value, onChange, onSubmit, disabled }) => { + const textareaRef = useRef(null); + + const autoResize = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + // Cap at ~6 lines (6 * 16px line-height + padding) + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }, []); + + useEffect(() => { autoResize(); }, [value, autoResize]); + + return ( +
+
+
+ + +
+ + General + {GENERAL_TABS.map((tab) => ( + + {tab.label} + + ))} + + Plan Review + {PLAN_TABS.map((tab) => ( + + {tab.label} + + ))} + + Code Review + {REVIEW_TABS.map((tab) => ( + + {tab.label} + + ))} + + Integrations + {INTEGRATION_TABS.map((tab) => ( + + {tab.label} + + ))} + +
+