From 37bcde50ce9ef3b70a51bdf5f6f6ace7ce74da6d Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 18 May 2026 10:31:52 -0700 Subject: [PATCH 1/2] 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. --- .github/workflows/release.yml | 33 +- .gitignore | 2 - AGENTS.md | 21 + apps/hook/README.md | 15 +- apps/hook/server/cli.test.ts | 6 +- apps/hook/server/cli.ts | 5 +- apps/hook/server/index.ts | 1688 +++++------------ apps/opencode-plugin/README.md | 12 + apps/opencode-plugin/binary-client.test.ts | 21 + apps/pi-extension/README.md | 12 + apps/pi-extension/index.ts | 5 +- .../src/editor-annotations.ts | 14 +- apps/vscode-extension/src/extension.ts | 3 +- bun.lock | 6 +- docs/single-binary-runtime.md | 48 +- packages/review-editor/App.tsx | 3 +- packages/review-editor/hooks/usePRStack.ts | 1 + packages/server/annotate.ts | 111 +- packages/server/daemon/client.test.ts | 441 +++++ packages/server/daemon/client.ts | 297 +++ packages/server/daemon/runtime.test.ts | 141 ++ packages/server/daemon/runtime.ts | 114 ++ packages/server/daemon/server.test.ts | 306 +++ packages/server/daemon/server.ts | 249 +++ .../server/daemon/session-factory.test.ts | 540 ++++++ packages/server/daemon/session-factory.ts | 672 +++++++ packages/server/daemon/session-store.test.ts | 166 ++ packages/server/daemon/session-store.ts | 271 +++ packages/server/daemon/start-command.test.ts | 41 + packages/server/daemon/start-command.ts | 14 + packages/server/daemon/state.test.ts | 171 ++ packages/server/daemon/state.ts | 243 +++ packages/server/external-annotations.test.ts | 15 + packages/server/external-annotations.ts | 40 +- packages/server/goal-setup.test.ts | 132 +- packages/server/goal-setup.ts | 257 +-- packages/server/image.test.ts | 71 +- packages/server/index.ts | 135 +- packages/server/integrations.test.ts | 47 +- packages/server/integrations.ts | 19 +- packages/server/project.ts | 5 +- packages/server/reference-handlers.test.ts | 37 + packages/server/reference-handlers.ts | 23 +- packages/server/repo.ts | 16 +- packages/server/review-agent-cwd.test.ts | 63 + packages/server/review.ts | 217 ++- packages/server/session-handler.ts | 9 + packages/server/share-url.ts | 34 +- packages/server/shared-handlers.ts | 46 +- packages/shared/config.test.ts | 35 + packages/shared/config.ts | 9 +- packages/shared/daemon-protocol.test.ts | 35 + packages/shared/daemon-protocol.ts | 161 ++ packages/shared/package.json | 4 + packages/shared/plugin-binary.test.ts | 5 + packages/shared/plugin-client.ts | 2 +- packages/shared/plugin-protocol.test.ts | 2 +- packages/shared/plugin-protocol.ts | 20 +- packages/shared/url-to-markdown.test.ts | 66 + packages/shared/url-to-markdown.ts | 7 +- packages/ui/components/ImageThumbnail.test.ts | 36 + packages/ui/components/ImageThumbnail.tsx | 3 +- packages/ui/utils/api.test.ts | 49 + packages/ui/utils/api.ts | 45 + .../ui/utils/planAgentInstructions.test.ts | 12 + packages/ui/utils/planAgentInstructions.ts | 34 +- 66 files changed, 5746 insertions(+), 1617 deletions(-) create mode 100644 packages/server/daemon/client.test.ts create mode 100644 packages/server/daemon/client.ts create mode 100644 packages/server/daemon/runtime.test.ts create mode 100644 packages/server/daemon/runtime.ts create mode 100644 packages/server/daemon/server.test.ts create mode 100644 packages/server/daemon/server.ts create mode 100644 packages/server/daemon/session-factory.test.ts create mode 100644 packages/server/daemon/session-factory.ts create mode 100644 packages/server/daemon/session-store.test.ts create mode 100644 packages/server/daemon/session-store.ts create mode 100644 packages/server/daemon/start-command.test.ts create mode 100644 packages/server/daemon/start-command.ts create mode 100644 packages/server/daemon/state.test.ts create mode 100644 packages/server/daemon/state.ts create mode 100644 packages/server/reference-handlers.test.ts create mode 100644 packages/server/review-agent-cwd.test.ts create mode 100644 packages/server/session-handler.ts create mode 100644 packages/shared/config.test.ts create mode 100644 packages/shared/daemon-protocol.test.ts create mode 100644 packages/shared/daemon-protocol.ts create mode 100644 packages/ui/components/ImageThumbnail.test.ts create mode 100644 packages/ui/utils/api.test.ts create mode 100644 packages/ui/utils/api.ts create mode 100644 packages/ui/utils/planAgentInstructions.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d5571467..1db79114f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,22 +170,29 @@ jobs: local ok=0 for _ in $(seq 1 60); do - if curl -sf "http://127.0.0.1:${port}${endpoint}" -o /dev/null 2>/dev/null; then - ok=1 - break + local sessions + sessions="$(curl -sf "http://127.0.0.1:${port}/daemon/sessions" 2>/dev/null || true)" + if [ -n "$sessions" ]; then + local session_url + session_url="$(python3 -c 'import json,sys; data=json.load(sys.stdin); sessions=data.get("sessions") or []; print(sessions[0].get("url","") if sessions else "")' <<< "$sessions")" + if [ -n "$session_url" ] && curl -sf "${session_url}${endpoint}" -o /dev/null 2>/dev/null; then + ok=1 + break + fi fi sleep 0.5 done kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true + PLANNOTATOR_PORT="$port" "$BINARY" daemon stop >/dev/null 2>&1 || true if [ "$ok" = "0" ]; then - echo "FAIL: ${label} did not respond on :${port}${endpoint}" + echo "FAIL: ${label} did not expose a daemon-scoped ${endpoint}" exit 1 fi - echo "OK: ${label} responded on :${port}${endpoint}" + echo "OK: ${label} exposed daemon-scoped ${endpoint}" } # 2. review: exercises server startup, bundled HTML, git diff, and HTTP. @@ -232,9 +239,14 @@ jobs: try { for ($i = 0; $i -lt 60; $i++) { try { - Invoke-WebRequest -Uri "http://127.0.0.1:$Port$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null - $ok = $true - break + $sessionsResponse = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/daemon/sessions" -UseBasicParsing -TimeoutSec 1 + $sessionsBody = $sessionsResponse.Content | ConvertFrom-Json + if ($sessionsBody.sessions.Count -gt 0) { + $sessionUrl = $sessionsBody.sessions[0].url + Invoke-WebRequest -Uri "$sessionUrl$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null + $ok = $true + break + } } catch { if ($process.HasExited) { break @@ -247,6 +259,7 @@ jobs: Stop-Process -Id $process.Id -Force Wait-Process -Id $process.Id -ErrorAction SilentlyContinue } + & $binary daemon stop *> $null Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue } @@ -255,10 +268,10 @@ jobs: Get-Content $stdout -ErrorAction SilentlyContinue Write-Host "stderr:" Get-Content $stderr -ErrorAction SilentlyContinue - throw "FAIL: $Label did not respond on :$Port$Endpoint" + throw "FAIL: $Label did not expose a daemon-scoped $Endpoint" } - Write-Host "OK: $Label responded on :$Port$Endpoint" + Write-Host "OK: $Label exposed daemon-scoped $Endpoint" } # 2. review: exercises server startup, bundled HTML, git diff, and HTTP. diff --git a/.gitignore b/.gitignore index ccdb004de..9d3f75a12 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,4 @@ opencode.json plannotator-local # Local research/reference docs (not for repo) /reference/ -# Local goal setup packages generated by the setup-goal skill. -/goals/ *.bun-build diff --git a/AGENTS.md b/AGENTS.md index de0f32123..96af73406 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ plannotator/ │ │ ├── index.ts # startPlannotatorServer(), handleServerReady() │ │ ├── review.ts # startReviewServer(), handleReviewServerReady() │ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady() +│ │ ├── daemon/ # Long-running daemon runtime, state, client, and session store │ │ ├── storage.ts # Re-exports from @plannotator/shared/storage │ │ ├── share-url.ts # Server-side share URL generation for remote sessions │ │ ├── remote.ts # isRemoteSession(), getServerPort() @@ -99,6 +100,8 @@ Plannotator has one server implementation: 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/`. +Daemon-backed commands run through one long-running `plannotator` process per user/machine environment. `plannotator daemon start|status|stop` manage that lifecycle, while normal plan/review/annotate/archive commands auto-start a compatible daemon and create session-scoped browser URLs at `/s/`. Browser API calls must use `/s//api/...`; root `/api/...` routes are not a daemon session boundary. + ## Installation **Via plugin marketplace** (when repo is public): @@ -216,6 +219,24 @@ During normal plan review, an Archive sidebar tab provides the same browsing via ## Server API +### Daemon Runtime (`packages/server/daemon/`) + +The daemon is the single long-running Bun server used by normal plan/review/annotate/archive commands. It owns a session store and exposes browser sessions at `/s/`. Session browser APIs are scoped under `/s//api/...`; root `/api/...` is not a valid daemon session API boundary. + +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/daemon/capabilities` | GET | Return daemon protocol/capability metadata | +| `/daemon/status` | GET | Return daemon process, endpoint, and session counts | +| `/daemon/sessions` | GET | List active sessions (`?clean=1` also reaps expired sessions before listing) | +| `/daemon/sessions` | POST | Create a plan/review/annotate/archive session from a plugin-protocol request | +| `/daemon/sessions/:id` | GET | Fetch a session summary | +| `/daemon/sessions/:id/result` | GET | Wait for a session decision/result | +| `/daemon/sessions/:id/cancel` | POST | Cancel a session and dispose its resources | +| `/daemon/sessions/:id` | DELETE | Delete a session record | +| `/daemon/shutdown` | POST | Ask the daemon to stop | +| `/s/:id` | GET | Serve the browser HTML for a session | +| `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler | + ### Plan Server (`packages/server/index.ts`) | Endpoint | Method | Purpose | diff --git a/apps/hook/README.md b/apps/hook/README.md index 7336fdec0..51866cfcc 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -23,7 +23,7 @@ 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. +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 runtime design. --- @@ -84,6 +84,19 @@ When Claude Code calls `ExitPlanMode`, this hook intercepts and: | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | | `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. | +## Daemon Runtime + +Plan, review, annotate, and archive sessions are created through one long-running `plannotator` daemon. Normal commands auto-start a compatible daemon when needed. + +```bash +plannotator daemon status +plannotator daemon stop +plannotator daemon start +plannotator sessions +``` + +`daemon status` reports the daemon PID, endpoint, protocol version, and active session count. If the running daemon was started with different remote/port settings, stop it and retry with the desired `PLANNOTATOR_REMOTE` / `PLANNOTATOR_PORT` values. + ## Remote / Devcontainer Usage When running Claude Code in a remote environment (SSH, devcontainer, WSL), set `PLANNOTATOR_REMOTE=1` (or `true`) and these environment variables: diff --git a/apps/hook/server/cli.test.ts b/apps/hook/server/cli.test.ts index 4f54d7ccc..5d815ee51 100644 --- a/apps/hook/server/cli.test.ts +++ b/apps/hook/server/cli.test.ts @@ -23,7 +23,8 @@ describe("CLI top-level help", () => { expect(output).toContain("plannotator [--browser ]"); expect(output).toContain("plannotator review [--git] [PR_URL]"); expect(output).toContain("plannotator annotate "); - expect(output).toContain("plannotator setup-goal "); + expect(output).toContain("plannotator daemon start|status|stop"); + expect(output).toContain("plannotator plugin capabilities"); expect(output).toContain("running 'plannotator' without arguments is for hook integration"); }); }); @@ -56,8 +57,9 @@ describe("interactive no-arg invocation", () => { expect(output).toContain("usually launched automatically by Claude Code hooks"); expect(output).toContain("It expects hook JSON on stdin."); expect(output).toContain("plannotator review"); - expect(output).toContain("plannotator setup-goal interview bundle.json --json"); expect(output).toContain("plannotator sessions"); + expect(output).toContain("plannotator daemon status"); + expect(output).toContain("plannotator plugin capabilities"); expect(output).toContain("Run 'plannotator --help' for top-level usage."); }); }); diff --git a/apps/hook/server/cli.ts b/apps/hook/server/cli.ts index 802cdaaef..eeccf300f 100644 --- a/apps/hook/server/cli.ts +++ b/apps/hook/server/cli.ts @@ -27,10 +27,11 @@ export function formatTopLevelHelp(): string { " plannotator [--browser ]", " plannotator review [--git] [PR_URL]", " plannotator annotate [--no-jina] [--gate] [--json] [--hook]", - " plannotator setup-goal [--json]", " plannotator last", " plannotator archive", + " plannotator setup-goal [--json]", " plannotator sessions", + " plannotator daemon start|status|stop", " plannotator improve-context", " plannotator plugin capabilities", "", @@ -47,10 +48,10 @@ export function formatInteractiveNoArgClarification(): string { "For interactive use, try:", " plannotator review", " plannotator annotate ", - " plannotator setup-goal interview bundle.json --json", " plannotator last", " plannotator archive", " plannotator sessions", + " plannotator daemon status", " plannotator plugin capabilities", "", "Run 'plannotator --help' for top-level usage.", diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index c825570e1..1bf380be2 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -53,40 +53,22 @@ */ import { - startPlannotatorServer, handleServerReady, } from "@plannotator/server"; import { - startReviewServer, handleReviewServerReady, } from "@plannotator/server/review"; import { - startAnnotateServer, handleAnnotateServerReady, } from "@plannotator/server/annotate"; -import { - startGoalSetupServer, - handleGoalSetupServerReady, -} from "@plannotator/server/goal-setup"; -import { type DiffType, prepareLocalReviewDiff, gitRuntime } from "@plannotator/server/vcs"; -import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; +import { loadConfig, resolveUseJina } from "@plannotator/shared/config"; import { parseReviewArgs } from "@plannotator/shared/review-args"; -import { parseAnnotateArgs } from "@plannotator/shared/annotate-args"; import { normalizeGoalSetupBundle, type GoalSetupStage, } from "@plannotator/shared/goal-setup"; -import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference"; -import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; -import { urlToMarkdown, isConvertedSource } from "@plannotator/shared/url-to-markdown"; -import { fetchRef, createWorktree, removeWorktree, ensureObjectAvailable } from "@plannotator/shared/worktree"; -import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; -import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr"; -import { writeRemoteShareLink } from "@plannotator/server/share-url"; -import { resolveMarkdownFile, resolveUserPath, hasMarkdownFiles } from "@plannotator/shared/resolve-file"; -import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; -import { statSync, rmSync, realpathSync, existsSync } from "fs"; -import { parseRemoteUrl } from "@plannotator/shared/repo"; +import { statSync, existsSync, rmSync } from "fs"; +import { tmpdir } from "os"; import { getReviewApprovedPrompt, getReviewDeniedSuffix, @@ -94,22 +76,28 @@ import { getPlanToolName, buildPlanFileRule, } from "@plannotator/shared/prompts"; -import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; -import { detectProjectName } from "@plannotator/server/project"; +import { cleanupDaemonState, discoverDaemon, waitForDaemonShutdown } from "@plannotator/server/daemon/client"; +import { startDaemonRuntime } from "@plannotator/server/daemon/runtime"; +import { createDaemonSessionFactory } from "@plannotator/server/daemon/session-factory"; +import { getDaemonStartCommand } from "@plannotator/server/daemon/start-command"; +import { formatRemoteShareNotice } from "@plannotator/server/share-url"; 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 type { DaemonSessionSummary } from "@plannotator/shared/daemon-protocol"; import { createPluginErrorResponse, createPluginSuccessResponse, getPluginCapabilities, + type PluginActionResult, type PluginAnnotateRequest, type PluginArchiveRequest, type PluginBaseRequest, type PluginClientOrigin, type PluginPlanRequest, + type PluginRequest, type PluginReviewRequest, type PluginSessionInfo, } from "@plannotator/shared/plugin-protocol"; @@ -132,7 +120,6 @@ import { isVersionInvocation, } from "./cli"; import path from "path"; -import { tmpdir } from "os"; let planHtmlContentPromise: Promise | undefined; let reviewHtmlContentPromise: Promise | undefined; @@ -153,10 +140,7 @@ function getReviewHtmlContent(): Promise { return reviewHtmlContentPromise; } -async function loadGoalSetupBundle( - stage: GoalSetupStage, - bundlePath: string, -) { +async function loadGoalSetupBundle(stage: GoalSetupStage, bundlePath: string) { const raw = bundlePath === "-" ? await Bun.stdin.text() @@ -166,6 +150,7 @@ async function loadGoalSetupBundle( // Check for subcommand const args = process.argv.slice(2); +const launcherCwd = process.cwd(); // Global flag: --browser const browserIdx = args.indexOf("--browser"); @@ -262,9 +247,6 @@ if (isInteractiveNoArgInvocation(args, process.stdin.isTTY)) { 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"; @@ -294,51 +276,137 @@ const detectedOrigin: Origin = 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); - }; +async function runDaemonCommand(): Promise { + const command = args[1] ?? "status"; + const foreground = args.includes("--foreground"); - process.once("exit", run); - process.once("SIGINT", onSigint); - process.once("SIGTERM", onSigterm); + if (command === "status") { + const daemon = await discoverDaemon({ validateEnvironment: false }); + if (!daemon.ok) { + console.log(JSON.stringify({ ok: false, code: daemon.code, message: daemon.message })); + process.exit(1); + } + console.log(JSON.stringify({ ok: true, status: daemon.status })); + process.exit(0); + } - return () => { - process.removeListener("exit", run); - process.removeListener("SIGINT", onSigint); - process.removeListener("SIGTERM", onSigterm); - run(); - }; -} + if (command === "stop") { + const daemon = await discoverDaemon({ validateEnvironment: false }); + if (!daemon.ok) { + if (daemon.state && (daemon.code === "incompatible" || daemon.code === "unhealthy")) { + await cleanupDaemonStateForDaemonCommand(daemon.state); + console.log(JSON.stringify({ ok: true, stopped: true, recovered: daemon.code })); + process.exit(0); + } + if (daemon.code === "missing" || daemon.code === "stale" || daemon.code === "malformed") { + console.log(JSON.stringify({ ok: true, stopped: false, code: daemon.code, message: daemon.message })); + process.exit(0); + } + console.log(JSON.stringify({ ok: false, code: daemon.code, message: daemon.message })); + process.exit(1); + } + const result = await daemon.client.shutdown(); + if ("ok" in result && result.ok) { + const stopped = await waitForDaemonShutdown(daemon.state); + if (!stopped) { + console.log(JSON.stringify({ ok: false, code: "daemon-stop-timeout", message: "Timed out waiting for the Plannotator daemon to stop." })); + process.exit(1); + } + } + console.log(JSON.stringify(result)); + process.exit("ok" in result && result.ok ? 0 : 1); + } -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 }); + if (command === "start") { + const existing = await discoverDaemon(); + if (existing.ok) { + console.log(JSON.stringify({ ok: true, alreadyRunning: true, status: existing.status })); + process.exit(0); + } + if (existing.state && (existing.code === "incompatible" || existing.code === "unhealthy")) { + await cleanupDaemonStateForDaemonCommand(existing.state); + } else if (existing.code === "mismatch") { + console.log(JSON.stringify({ ok: false, code: existing.code, message: existing.message })); + process.exit(1); + } + + if (!foreground) { + const child = Bun.spawn(getDaemonStartCommand(process.argv, process.execPath, launcherCwd), { + cwd: getInvocationCwd(), + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + child.unref(); + + for (let attempt = 0; attempt < 30; attempt++) { + await Bun.sleep(100); + const daemon = await discoverDaemon(); + if (daemon.ok) { + console.log(JSON.stringify({ ok: true, started: true, status: daemon.status })); + process.exit(0); + } } - } else { - Bun.spawnSync(["git", "worktree", "remove", "--force", fallbackWorktreePath], { cwd: repoDir }); + + console.log(JSON.stringify({ + ok: false, + code: "daemon-start-failed", + message: "Timed out waiting for the Plannotator daemon to start.", + })); + process.exit(1); } - } catch {} - try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + + let runtime: Awaited>; + try { + runtime = await startDaemonRuntime({ + createSession: createDaemonSessionFactory({ + planHtmlContent: await getPlanHtmlContent(), + reviewHtmlContent: await getReviewHtmlContent(), + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + }), + onShutdown: () => { + setTimeout(() => process.exit(0), 10); + }, + }); + } catch (err) { + console.log(JSON.stringify({ + ok: false, + code: "daemon-start-failed", + message: err instanceof Error ? err.message : "Failed to start Plannotator daemon.", + })); + process.exit(1); + } + + console.log(JSON.stringify({ ok: true, started: true, status: { + pid: runtime.state.pid, + endpoint: { + hostname: runtime.state.hostname, + port: runtime.state.port, + baseUrl: runtime.state.baseUrl, + isRemote: runtime.state.isRemote, + }, + protocol: runtime.state.protocol, + protocolVersion: runtime.state.protocolVersion, + startedAt: runtime.state.startedAt, + activeSessionCount: 0, + sessionCount: 0, + } })); + + let stopping = false; + const stop = () => { + if (stopping) return; + stopping = true; + runtime.stop().finally(() => process.exit(0)); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + await new Promise(() => {}); + } + + console.error("Usage: plannotator daemon start|status|stop"); + process.exit(1); } function emitPluginError(code: string, message: string, exitCode = 1): never { @@ -346,6 +414,33 @@ function emitPluginError(code: string, message: string, exitCode = 1): never { process.exit(exitCode); } +function emitCommandError(_code: string, message: string, exitCode = 1): never { + console.error(message); + process.exit(exitCode); +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +async function cleanupDaemonStateForDaemonCommand(state: unknown): Promise { + try { + await cleanupDaemonState(state); + } catch (err) { + console.log(JSON.stringify({ ok: false, code: "daemon-cleanup-failed", message: errorMessage(err) })); + process.exit(1); + } +} + +async function cleanupDaemonStateForSessionCommand(state: unknown, options: { pluginError?: boolean }): Promise { + try { + await cleanupDaemonState(state); + } catch (err) { + const fail = options.pluginError ? emitPluginError : emitCommandError; + fail("daemon-cleanup-failed", errorMessage(err)); + } +} + async function readPluginRequest(): Promise> { try { const raw = await Bun.stdin.text(); @@ -371,562 +466,282 @@ function getPluginOrigin(request: Partial): PluginClientOrigi return origin; } -function applyPluginCwd(request: Partial): void { - if (!request.cwd) return; +function getInvocationCwd(): string { + return process.env.PLANNOTATOR_CWD || process.cwd(); +} + +async function readDaemonStartLog(logPath: string): Promise { try { - process.chdir(request.cwd); + return (await Bun.file(logPath).text()).trim(); + } catch { + return ""; + } finally { + try { rmSync(logPath, { force: true }); } catch {} + } +} + +async function stopDaemonStartChild(child: ReturnType): Promise { + try { child.kill("SIGTERM"); } catch {} + const exited = await Promise.race([ + child.exited.then(() => true).catch(() => true), + Bun.sleep(1_000).then(() => false), + ]); + if (!exited) { + try { child.kill("SIGKILL"); } catch {} + } +} + +function resolvePluginCwd(request: Partial): string { + const cwd = path.resolve(request.cwd || getInvocationCwd()); + try { + if (!statSync(cwd).isDirectory()) { + emitPluginError("invalid-cwd", `Invalid cwd: ${request.cwd || cwd}`); + } + } catch (err) { + emitPluginError( + "invalid-cwd", + err instanceof Error ? err.message : `Invalid cwd: ${request.cwd || cwd}`, + ); + } + try { + process.chdir(cwd); } catch (err) { emitPluginError( "invalid-cwd", - err instanceof Error ? err.message : `Invalid cwd: ${request.cwd}`, + err instanceof Error ? err.message : `Invalid cwd: ${request.cwd || cwd}`, ); } + return 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, +async function ensureDaemonClient(options: { pluginError?: boolean } = {}) { + const fail = options.pluginError ? emitPluginError : emitCommandError; + const existing = await discoverDaemon(); + if (existing.ok) return existing.client; + if (existing.state && (existing.code === "incompatible" || existing.code === "unhealthy")) { + await cleanupDaemonStateForSessionCommand(existing.state, options); + } else if (existing.code === "mismatch") { + fail(`daemon-${existing.code}`, existing.message); + } + + const command = getDaemonStartCommand(process.argv, process.execPath, launcherCwd); + const startLogPath = path.join(tmpdir(), `plannotator-daemon-start-${process.pid}-${Date.now()}.log`); + const child = Bun.spawn(command, { + cwd: getInvocationCwd(), + stdin: "ignore", + stdout: "ignore", + stderr: Bun.file(startLogPath), + detached: true, + }); + child.unref(); + let startExit: { exitCode?: number; error?: unknown } | undefined; + void child.exited + .then((exitCode) => { + startExit = { exitCode }; + }) + .catch((error) => { + startExit = { error }; + }); + + let lastStartProblem: Awaited> | undefined; + for (let attempt = 0; attempt < 30; attempt++) { + await Bun.sleep(100); + const daemon = await discoverDaemon(); + if (daemon.ok) { + try { rmSync(startLogPath, { force: true }); } catch {} + return daemon.client; + } + if (daemon.code === "mismatch") { + await stopDaemonStartChild(child); + fail(`daemon-${daemon.code}`, daemon.message); + } + if (daemon.code !== "missing" && daemon.code !== "stale") { + lastStartProblem = daemon; + } + if (startExit && attempt >= 10) { + const log = await readDaemonStartLog(startLogPath); + const detail = startExit.error instanceof Error + ? startExit.error.message + : `exited with code ${startExit.exitCode ?? "unknown"}`; + fail( + "daemon-start-failed", + `Plannotator daemon start ${detail}.${log ? `\n${log}` : ""}`, + ); + } + } + + if (!startExit) { + await stopDaemonStartChild(child); + } + try { rmSync(startLogPath, { force: true }); } catch {} + if (lastStartProblem && !lastStartProblem.ok) { + fail(`daemon-${lastStartProblem.code}`, lastStartProblem.message); + } + fail("daemon-start-failed", "Timed out waiting for the Plannotator daemon to start."); +} + +function registerDaemonSessionInterruptCleanup(cancelSession: () => Promise): () => void { + let cancelling = false; + const handleSignal = (exitCode: number) => { + if (cancelling) return; + cancelling = true; + void cancelSession().finally(() => process.exit(exitCode)); + }; + const onSigint = () => handleSignal(130); + const onSigterm = () => handleSignal(143); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + return () => { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); }; } -function emitPluginSessionReady(session: PluginSessionInfo): void { - console.error(`PLANNOTATOR_SESSION_READY ${JSON.stringify(session)}`); +async function withProcessCwd(cwd: string | undefined, fn: () => Promise): Promise { + if (!cwd) return fn(); + const original = process.cwd(); + const target = path.resolve(cwd); + if (target === original) return fn(); + process.chdir(target); + try { + return await fn(); + } finally { + process.chdir(original); + } } -async function runPluginPlanCommand(): Promise { - const request = await readPluginRequest(); - const origin = getPluginOrigin(request); - applyPluginCwd(request); +async function runDaemonSessionRequest(request: PluginRequest, options: { pluginError?: boolean } = {}): Promise<{ + result: PluginActionResult; + session: PluginSessionInfo; +}> { + const fail = options.pluginError ? emitPluginError : emitCommandError; + let daemon: Awaited> | undefined; + let createdSessionId: string | undefined; + let unregisterInterruptCleanup: (() => void) | undefined; + + const cancelCreatedSession = async () => { + if (!daemon || !createdSessionId) return; + await daemon.cancelSession(createdSessionId).catch(() => undefined); + }; - 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}`, + try { + daemon = await ensureDaemonClient(options); + const created = await daemon.createSession({ request }); + if (created.ok !== true) { + fail(created.error.code, created.error.message); + } + createdSessionId = created.session.id; + unregisterInterruptCleanup = registerDaemonSessionInterruptCleanup(cancelCreatedSession); + + const sessionUrl = new URL(created.session.url); + const sessionPort = Number(sessionUrl.port); + const session: PluginSessionInfo = { + mode: created.session.mode, + url: created.session.url, + port: sessionPort, + isRemote: daemon.state.isRemote, + }; + if (created.session.remoteShare) { + process.stderr.write(formatRemoteShareNotice(created.session.remoteShare)); + } else if (daemon.state.isRemote) { + process.stderr.write(`\n Open this forwarded Plannotator session URL:\n ${created.session.url}\n\n`); + } + if (options.pluginError) { + emitPluginSessionReady(session); + } + + await withProcessCwd(request.cwd, async () => { + if (request.action === "review") { + await handleReviewServerReady(created.session.url, daemon.state.isRemote, sessionPort); + } else if (request.action === "annotate" || request.action === "annotate-last") { + await handleAnnotateServerReady(created.session.url, daemon.state.isRemote, sessionPort); + } else { + await handleServerReady(created.session.url, daemon.state.isRemote, sessionPort); + } + }); + + const completed = await daemon.waitForResult(created.session.id); + if (completed.ok !== true) { + await cancelCreatedSession(); + fail(completed.error.code, completed.error.message); + } + if (completed.session.status !== "completed") { + fail( + completed.session.status, + completed.session.error ?? `Plannotator session ${completed.session.id} ended with status ${completed.session.status}.`, ); } - } - if (!planContent.trim()) { - emitPluginError( - "missing-plan", - "Plugin plan requests must include a non-empty plan or planFilePath.", - ); + unregisterInterruptCleanup(); + return { + result: completed.result, + session, + }; + } catch (err) { + unregisterInterruptCleanup?.(); + await cancelCreatedSession(); + fail("daemon-session-failed", errorMessage(err)); } +} - const effectiveSharingEnabled = request.sharingEnabled ?? sharingEnabled; - const effectiveShareBaseUrl = request.shareBaseUrl ?? shareBaseUrl; - const effectivePasteApiUrl = request.pasteApiUrl ?? pasteApiUrl; - const planProject = (await detectProjectName()) ?? "_unknown"; +async function runDaemonBackedPluginRequest(request: PluginRequest): Promise { + const outcome = await runDaemonSessionRequest(request, { pluginError: true }); + console.log(JSON.stringify(createPluginSuccessResponse(outcome.result, outcome.session))); +} - 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(() => {}); - } - }, - }); +function emitPluginSessionReady(session: PluginSessionInfo): void { + console.error(`PLANNOTATOR_SESSION_READY ${JSON.stringify(session)}`); +} - registerSession({ - pid: process.pid, - port: server.port, - url: server.url, - mode: "plan", - project: planProject, - startedAt: new Date().toISOString(), - label: `plugin-plan-${origin}-${planProject}`, +async function runPluginPlanCommand(): Promise { + const request = await readPluginRequest(); + const origin = getPluginOrigin(request); + await runDaemonBackedPluginRequest({ + ...request, + action: "plan", + origin, + cwd: resolvePluginCwd(request), }); - - 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: "", + await runDaemonBackedPluginRequest({ + ...request, + action: "archive", 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}`, + cwd: resolvePluginCwd(request), }); - - 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, + const useJina = resolveUseJina(request.noJina === true, loadConfig()); + await runDaemonBackedPluginRequest({ + ...request, + action: defaultMode, 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)}`, + cwd: resolvePluginCwd(request), + useJina, + jinaApiKey: process.env.JINA_API_KEY, }); - - 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; - } - - const effectiveSharingEnabled = request.sharingEnabled ?? sharingEnabled; - const effectiveShareBaseUrl = request.shareBaseUrl ?? shareBaseUrl; - const reviewProject = (await detectProjectName()) ?? "_unknown"; - - const server = await startReviewServer({ - rawPatch, - gitRef, - error: diffError, + await runDaemonBackedPluginRequest({ + ...request, + action: "review", 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 (isRemote && effectiveSharingEnabled && rawPatch) { - await writeRemoteShareLink(rawPatch, effectiveShareBaseUrl, "review changes", "diff only").catch(() => {}); - } - }, + cwd: resolvePluginCwd(request), }); +} - 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))); +if (args[0] === "daemon") { + await runDaemonCommand(); } if (args[0] === "plugin") { @@ -972,14 +787,20 @@ if (args[0] === "sessions") { // SESSION DISCOVERY MODE // ============================================ - if (args.includes("--clean")) { - // Force cleanup: list sessions (which auto-removes stale entries) - const sessions = listSessions(); - console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`); + const daemon = await discoverDaemon({ validateEnvironment: false }); + if (!daemon.ok) { + console.error("No active Plannotator daemon."); process.exit(0); } - const sessions = listSessions(); + const clean = args.includes("--clean"); + const listResponse = await daemon.client.listSessions({ clean }) as { ok?: boolean; sessions?: DaemonSessionSummary[] }; + const sessions = Array.isArray(listResponse.sessions) ? listResponse.sessions : []; + + if (clean) { + console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`); + process.exit(0); + } if (sessions.length === 0) { console.error("No active Plannotator sessions."); @@ -996,7 +817,7 @@ if (args[0] === "sessions") { console.error(`Session #${n} not found. ${sessions.length} active session(s).`); process.exit(1); } - await openBrowser(session.url); + await openBrowser(session.url, { isRemote: daemon.status.endpoint.isRemote }); console.error(`Opened ${session.mode} session in browser: ${session.url}`); process.exit(0); } @@ -1005,9 +826,9 @@ if (args[0] === "sessions") { console.error("Active Plannotator sessions:\n"); for (let i = 0; i < sessions.length; i++) { const s = sessions[i]; - const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000); + const age = Math.round((Date.now() - new Date(s.createdAt).getTime()) / 60000); const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`; - console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`); + console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.status.padEnd(10)} ${s.url.padEnd(28)} ${ageStr} ago`); } console.error(`\nReopen with: plannotator sessions --open [N]`); process.exit(0); @@ -1018,270 +839,23 @@ if (args[0] === "sessions") { // ============================================ const reviewArgs = parseReviewArgs(args.slice(1)); - const urlArg = reviewArgs.prUrl; - const isPRMode = urlArg !== undefined; - const useLocal = isPRMode && 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 agentCwd: string | undefined; - let worktreePool: WorktreePool | undefined; - let worktreeCleanup: (() => void | Promise) | undefined; - - if (isPRMode) { - // --- PR Review Mode --- - const prRef = parsePRUrl(urlArg); - if (!prRef) { - console.error(`Invalid PR/MR URL: ${urlArg}`); - console.error("Supported formats:"); - console.error(" GitHub: https://github.com/owner/repo/pull/123"); - console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42"); - process.exit(1); - } - - 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")) { - console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`); - console.error(`Install it from ${cliUrl}`); - } else { - console.error(msg); - } - process.exit(1); - } - - 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) { - console.error(err instanceof Error ? err.message : "Failed to fetch PR"); - process.exit(1); - } - - // --local: create a local checkout with the PR head for full file access - if (useLocal && prMetadata) { - // Hoisted so catch block can clean up partially-created directories - 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); - // Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/... - // but processes report /private/var/folders/... which breaks path stripping. - 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`; - - // 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 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(); - // Extract host from remote URL to avoid cross-instance false positives (GHE) - 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..."); - // Fetch base branch so origin/ is current for agent diffs. - // Ensure baseSha is available (may fetch, which overwrites FETCH_HEAD). - // Both MUST happen before the PR head fetch since FETCH_HEAD is what - // createWorktree uses — the PR head fetch must be last. - await fetchRef(gitRuntime, prMetadata.baseBranch, { cwd: repoDir }); - await ensureObjectAvailable(gitRuntime, prMetadata.baseSha, { cwd: repoDir }); - // Fetch PR head LAST — sets FETCH_HEAD to the PR tip for createWorktree. - await fetchRef(gitRuntime, fetchRefStr, { cwd: repoDir }); - - await createWorktree(gitRuntime, { - ref: "FETCH_HEAD", - path: localPath, - detach: true, - cwd: repoDir, - }); - - worktreeCleanup = registerProcessCleanup(() => cleanupWorktreeSession( - repoDir, - sessionDir, - worktreePool, - localPath, - )); - } else { - // ── Cross-repo: shallow clone + fetch PR head ── - const prRepo = prMetadata.platform === "github" - ? `${prMetadata.owner}/${prMetadata.repo}` - : prMetadata.projectPath; - // Validate repo identifier to prevent flag injection via crafted URLs - 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 }), - }; - - // Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer) - 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()}`); - } - - // Step 2: Fetch only the PR head ref (targeted, much faster than full fetch) - 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()}`); - - // Step 3: Checkout PR head (critical — if this fails, worktree is empty) - 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()}`); - } - - // Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work - 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 {} - }); - } - - // --local only provides a sandbox path for agent processes. - // Do NOT set gitContext — that would contaminate the diff pipeline. - agentCwd = localPath; - - // Create worktree pool with the initial PR as the first entry - 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 { - // --- Local Review Mode --- - const config = loadConfig(); - const diffResult = await prepareLocalReviewDiff({ - vcsType: reviewArgs.vcsType, - configuredDiffType: resolveDefaultDiffType(config), - hideWhitespace: config.diffOptions?.hideWhitespace ?? false, - }); - gitContext = diffResult.gitContext; - initialDiffType = diffResult.diffType; - rawPatch = diffResult.rawPatch; - gitRef = diffResult.gitRef; - diffError = diffResult.error; - } - - const reviewProject = (await detectProjectName()) ?? "_unknown"; - - // Start review server (even if empty - user can switch diff types in local mode) - const server = await startReviewServer({ - rawPatch, - gitRef, - error: diffError, + const outcome = await runDaemonSessionRequest({ + action: "review", origin: detectedOrigin, - diffType: gitContext ? (initialDiffType ?? "unstaged") : undefined, - gitContext, - prMetadata, - agentCwd, - worktreePool, + cwd: getInvocationCwd(), + args: args.slice(1).join(" "), sharingEnabled, shareBaseUrl, - htmlContent: await getReviewHtmlContent(), - onCleanup: worktreeCleanup, - onReady: async (url, isRemote, port) => { - handleReviewServerReady(url, isRemote, port); - - if (isRemote && sharingEnabled && rawPatch) { - await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {}); - } - }, }); + const result = outcome.result as { approved?: boolean; feedback?: string; exit?: boolean }; - registerSession({ - pid: process.pid, - port: server.port, - url: server.url, - mode: "review", - project: reviewProject, - startedAt: new Date().toISOString(), - label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`, - }); - - // Wait for user feedback - const result = await server.waitForDecision(); - - // Give browser time to receive response and update UI - await Bun.sleep(1500); - - // Cleanup - server.stop(); - - // Output feedback (captured by slash command) if (result.exit) { console.log("Review session closed without feedback."); } else if (result.approved) { console.log(getReviewApprovedPrompt(detectedOrigin)); } else { - console.log(result.feedback); - if (!isPRMode) { + console.log(result.feedback || ""); + if (!reviewArgs.prUrl) { console.log(getReviewDeniedSuffix(detectedOrigin)); } } @@ -1298,167 +872,21 @@ if (args[0] === "sessions") { process.exit(1); } - // Primary resolution strips the `@` reference marker; rawFilePath is - // preserved so each branch can fall back to the literal form below - // (scoped-package-style names). - let filePath = stripAtPrefix(rawFilePath); - - // Use PLANNOTATOR_CWD if set (original working directory before script cd'd) - const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); - - if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Project root: ${projectRoot}`); - console.error(`[DEBUG] File path arg: ${filePath}`); - } - - let markdown: string; - let rawHtml: string | undefined; - let absolutePath: string; - let folderPath: string | undefined; - let annotateMode: "annotate" | "annotate-folder" = "annotate"; - let sourceInfo: string | undefined; - let sourceConverted = false; - - // --- URL annotation --- - 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); - if (process.env.PLANNOTATOR_DEBUG) { - console.error(`[DEBUG] Fetched via ${result.source} (${markdown.length} chars)`); - } - } catch (err) { - console.error(`Failed to fetch URL: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - absolutePath = filePath; // Use URL as the "path" for display - sourceInfo = filePath; // Full URL for source attribution - } else { - // Folder check with literal-@ fallback for scoped-package-style names. - const folderCandidate = resolveAtReference(rawFilePath, (c) => { - try { return statSync(resolveUserPath(c, projectRoot)).isDirectory(); } - catch { return false; } - }); - - if (folderCandidate !== null) { - const resolvedArg = resolveUserPath(folderCandidate, projectRoot); - // Folder annotation mode (markdown + HTML files) - if (!hasMarkdownFiles(resolvedArg, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { - console.error(`No markdown or HTML files found in ${resolvedArg}`); - process.exit(1); - } - folderPath = resolvedArg; - absolutePath = resolvedArg; - markdown = ""; - annotateMode = "annotate-folder"; - console.error(`Folder: ${resolvedArg}`); - } else { - // HTML check with the same literal-@ fallback semantics. - const htmlCandidate = resolveAtReference(rawFilePath, (c) => { - const abs = resolveUserPath(c, 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) { - console.error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedArg}`); - process.exit(1); - } - const html = await htmlFile.text(); - if (renderHtmlFlag) { - rawHtml = html; - markdown = ""; - } else { - markdown = htmlToMarkdown(html); - sourceConverted = true; - } - absolutePath = resolvedArg; - sourceInfo = path.basename(resolvedArg); - console.error(`${renderHtmlFlag ? "Raw HTML" : "Converted"}: ${absolutePath}`); - } else { - // Single markdown file annotation mode - // Strip-first with literal-@ fallback (scoped-package-style names). - let resolved = resolveMarkdownFile(filePath, projectRoot); - if (resolved.kind === "not_found" && rawFilePath !== filePath) { - resolved = resolveMarkdownFile(rawFilePath, projectRoot); - } - - if (resolved.kind === "ambiguous") { - console.error(`Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:`); - for (const match of resolved.matches) { - console.error(` ${match}`); - } - process.exit(1); - } - if (resolved.kind === "not_found") { - console.error(`File not found: ${resolved.input}`); - process.exit(1); - } - - absolutePath = resolved.path; - markdown = await Bun.file(absolutePath).text(); - console.error(`Resolved: ${absolutePath}`); - } - } - } - - const annotateProject = (await detectProjectName()) ?? "_unknown"; - - // Start the annotate server (reuses plan editor HTML) - const server = await startAnnotateServer({ - markdown, - filePath: absolutePath, + const outcome = await runDaemonSessionRequest({ + action: "annotate", origin: detectedOrigin, - mode: annotateMode, - folderPath, - sourceInfo, - sourceConverted, + cwd: getInvocationCwd(), + args: rawFilePath, + noJina: cliNoJina, + useJina: resolveUseJina(cliNoJina, loadConfig()), + jinaApiKey: process.env.JINA_API_KEY, + gate: gateFlag, + renderHtml: renderHtmlFlag, sharingEnabled, shareBaseUrl, pasteApiUrl, - gate: gateFlag, - rawHtml, - renderHtml: renderHtmlFlag, - htmlContent: await getPlanHtmlContent(), - onReady: async (url, isRemote, port) => { - handleAnnotateServerReady(url, isRemote, port); - - if (isRemote && sharingEnabled && markdown) { - await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {}); - } - }, }); - - registerSession({ - pid: process.pid, - port: server.port, - url: server.url, - mode: "annotate", - project: annotateProject, - startedAt: new Date().toISOString(), - label: folderPath - ? `annotate-${path.basename(folderPath)}` - : `annotate-${isUrl ? hostnameOrFallback(absolutePath) : path.basename(absolutePath)}`, - }); - - // Wait for user feedback - const result = await server.waitForDecision(); - - // Give browser time to receive response and update UI - await Bun.sleep(1500); - - // Cleanup - server.stop(); - - // Output feedback (captured by slash command) - emitAnnotateOutcome(result); + emitAnnotateOutcome(outcome.result as { feedback: string; exit?: boolean; approved?: boolean }); process.exit(0); } else if (args[0] === "annotate-last" || args[0] === "last") { @@ -1466,7 +894,7 @@ if (args[0] === "sessions") { // ANNOTATE LAST MESSAGE MODE // ============================================ - const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); + const projectRoot = getInvocationCwd(); const codexThreadId = process.env.CODEX_THREAD_ID; const isCodex = !!codexThreadId; @@ -1546,44 +974,20 @@ if (args[0] === "sessions") { console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`); } - const annotateProject = (await detectProjectName()) ?? "_unknown"; - - const server = await startAnnotateServer({ + const outcome = await runDaemonSessionRequest({ + action: "annotate-last", + origin: detectedOrigin, + cwd: projectRoot, markdown: lastMessage.text, filePath: "last-message", - origin: detectedOrigin, mode: "annotate-last", + gate: gateFlag, sharingEnabled, shareBaseUrl, pasteApiUrl, - gate: gateFlag, - htmlContent: await getPlanHtmlContent(), - onReady: async (url, isRemote, port) => { - handleAnnotateServerReady(url, isRemote, port); - - if (isRemote && sharingEnabled) { - await writeRemoteShareLink(lastMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {}); - } - }, }); - registerSession({ - pid: process.pid, - port: server.port, - url: server.url, - mode: "annotate", - project: annotateProject, - startedAt: new Date().toISOString(), - label: `annotate-last`, - }); - - const result = await server.waitForDecision(); - - await Bun.sleep(1500); - - server.stop(); - - emitAnnotateOutcome(result); + emitAnnotateOutcome(outcome.result as { feedback: string; exit?: boolean; approved?: boolean }); process.exit(0); } else if (args[0] === "archive") { @@ -1591,34 +995,14 @@ if (args[0] === "sessions") { // ARCHIVE BROWSER MODE // ============================================ - const archiveProject = (await detectProjectName()) ?? "_unknown"; - - const server = await startPlannotatorServer({ - plan: "", + await runDaemonSessionRequest({ + action: "archive", origin: detectedOrigin, - mode: "archive", + cwd: getInvocationCwd(), sharingEnabled, shareBaseUrl, - 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: `archive-${archiveProject}`, + pasteApiUrl, }); - - await server.waitForDone!(); - - await Bun.sleep(500); - server.stop(); process.exit(0); } else if (args[0] === "setup-goal") { @@ -1631,7 +1015,7 @@ if (args[0] === "sessions") { if ((stage !== "interview" && stage !== "facts") || !bundlePath) { console.error( - "Usage: plannotator setup-goal [--json]" + "Usage: plannotator setup-goal [--json]", ); process.exit(1); } @@ -1641,45 +1025,32 @@ if (args[0] === "sessions") { bundle = await loadGoalSetupBundle(stage, bundlePath); } catch (err) { console.error( - `Failed to load goal setup bundle: ${err instanceof Error ? err.message : String(err)}` + `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, + const outcome = await runDaemonSessionRequest({ + action: "goal-setup", 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}`, + cwd: getInvocationCwd(), + bundle, + stage, + goalSlug: bundle.goalSlug, }); - 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)); + if (outcome?.result) { + const result = outcome.result as { result?: unknown; exit?: boolean }; + if (result.exit) { + console.log(JSON.stringify({ decision: "dismissed", stage })); + } else if (result.result) { + const output = { + decision: "submitted", + stage, + result: result.result, + }; + console.log(jsonFlag ? JSON.stringify(output) : JSON.stringify(output, null, 2)); + } } process.exit(0); @@ -1715,37 +1086,16 @@ if (args[0] === "sessions") { process.exit(0); } - const planProject = (await detectProjectName()) ?? "_unknown"; - - const server = await startPlannotatorServer({ - plan: planContent, + const outcome = await runDaemonSessionRequest({ + action: "plan", origin: "copilot-cli", + cwd: event.cwd || getInvocationCwd(), + plan: planContent, sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: await getPlanHtmlContent(), - onReady: async (url, isRemote, port) => { - handleServerReady(url, isRemote, port); - - if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "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: `plan-${planProject}`, }); - - const result = await server.waitForDecision(); - await Bun.sleep(1500); - server.stop(); + const result = outcome.result as { approved?: boolean; feedback?: string }; // Output Copilot CLI permission decision format if (result.approved) { @@ -1771,7 +1121,7 @@ if (args[0] === "sessions") { // COPILOT CLI ANNOTATE LAST MESSAGE MODE // ============================================ - const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd(); + const projectRoot = getInvocationCwd(); if (process.env.PLANNOTATOR_DEBUG) { console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`); @@ -1798,41 +1148,20 @@ if (args[0] === "sessions") { console.error(`[DEBUG] Found message (${msg.text.length} chars)`); } - const annotateProject = (await detectProjectName()) ?? "_unknown"; - - const server = await startAnnotateServer({ + const outcome = await runDaemonSessionRequest({ + action: "annotate-last", + origin: "copilot-cli", + cwd: projectRoot, markdown: msg.text, filePath: "last-message", - origin: "copilot-cli", mode: "annotate-last", + gate: gateFlag, sharingEnabled, shareBaseUrl, - gate: gateFlag, - htmlContent: await getPlanHtmlContent(), - onReady: async (url, isRemote, port) => { - handleAnnotateServerReady(url, isRemote, port); - - if (isRemote && sharingEnabled) { - await writeRemoteShareLink(msg.text, shareBaseUrl, "annotate", "message only").catch(() => {}); - } - }, - }); - - registerSession({ - pid: process.pid, - port: server.port, - url: server.url, - mode: "annotate", - project: annotateProject, - startedAt: new Date().toISOString(), - label: `annotate-last`, + pasteApiUrl, }); - const result = await server.waitForDecision(); - await Bun.sleep(1500); - server.stop(); - - emitAnnotateOutcome(result); + emitAnnotateOutcome(outcome.result as { feedback: string; exit?: boolean; approved?: boolean }); process.exit(0); } else if (args[0] === "improve-context") { @@ -1905,36 +1234,16 @@ if (args[0] === "sessions") { process.exit(0); } - const planProject = (await detectProjectName()) ?? "_unknown"; - const server = await startPlannotatorServer({ - plan: latestPlan.text, + const outcome = await runDaemonSessionRequest({ + action: "plan", origin: "codex", + cwd: getInvocationCwd(), + plan: latestPlan.text, sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: await getPlanHtmlContent(), - onReady: async (url, isRemote, port) => { - handleServerReady(url, isRemote, port); - - if (isRemote && sharingEnabled) { - await writeRemoteShareLink(latestPlan.text, shareBaseUrl, "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: `plan-${planProject}`, }); - - const result = await server.waitForDecision(); - await Bun.sleep(1500); - server.stop(); + const result = outcome.result as { approved?: boolean; feedback?: string }; if (result.approved) { console.log("{}"); @@ -1981,44 +1290,21 @@ if (args[0] === "sessions") { process.exit(1); } - const planProject = (await detectProjectName()) ?? "_unknown"; - - // Start the plan review server - const server = await startPlannotatorServer({ - plan: planContent, + const outcome = await runDaemonSessionRequest({ + action: "plan", origin: isGemini ? "gemini-cli" : detectedOrigin, + cwd: getInvocationCwd(), + plan: planContent, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, - htmlContent: await getPlanHtmlContent(), - onReady: async (url, isRemote, port) => { - handleServerReady(url, isRemote, port); - - if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "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: `plan-${planProject}`, - }); - - // Wait for user decision (blocks until approve/deny) - const result = await server.waitForDecision(); - - // Give browser time to receive response and update UI - await Bun.sleep(1500); - - // Cleanup - server.stop(); + const result = outcome.result as { + approved?: boolean; + feedback?: string; + permissionMode?: string; + }; // Output decision in the appropriate format for the harness if (isGemini) { diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md index 38748e258..6883bb81c 100644 --- a/apps/opencode-plugin/README.md +++ b/apps/opencode-plugin/README.md @@ -159,6 +159,18 @@ Register the tool but manage prompts and permissions yourself: | `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. | +## Daemon Runtime + +OpenCode still calls the installed `plannotator` binary through the same plugin command surface, but plan/review/annotate/archive sessions are daemon-backed inside the binary. The first request auto-starts the daemon; compatible later requests reuse it. + +```bash +plannotator daemon status +plannotator daemon stop +plannotator sessions +``` + +Use `daemon status` to see the daemon PID, endpoint, protocol version, and active session count. If remote/port settings change, stop the daemon before retrying with the new `PLANNOTATOR_REMOTE` or `PLANNOTATOR_PORT` values. + ## Devcontainer / Docker Works in containerized environments. Set the env vars and forward the port: diff --git a/apps/opencode-plugin/binary-client.test.ts b/apps/opencode-plugin/binary-client.test.ts index 5f3b710dd..0a6c14ad5 100644 --- a/apps/opencode-plugin/binary-client.test.ts +++ b/apps/opencode-plugin/binary-client.test.ts @@ -328,6 +328,27 @@ describe("OpenCode binary client", () => { ]); }); + test("includes the command timeout in plugin requests", async () => { + const response = createPluginSuccessResponse({ approved: true }); + let inputBody: unknown; + const run: CommandRunner = (_command, _args, input) => { + inputBody = JSON.parse(input ?? "{}"); + return { exitCode: 0, stdout: JSON.stringify(response), stderr: "" }; + }; + + await runPluginPlan( + "/bin/plannotator", + { + origin: "opencode", + plan: "# Plan", + }, + run, + { timeoutMs: 12_000 }, + ); + + expect(inputBody).toMatchObject({ timeoutMs: 12_000 }); + }); + test("turns malformed plugin plan output into a protocol error", async () => { const result = await runPluginPlan( "/bin/plannotator", diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index 627be4fd9..590294c52 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -253,3 +253,15 @@ State persists across session restarts via Pi's `appendEntry` API. | `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. | + +## Daemon Runtime + +Pi continues to call the installed `plannotator` binary through the plugin command protocol. Inside the binary, plan/review/annotate/archive sessions are created through one long-running daemon. The first UI request auto-starts the daemon; compatible later requests reuse it. + +```bash +plannotator daemon status +plannotator daemon stop +plannotator sessions +``` + +`daemon status` reports the daemon PID, endpoint, protocol version, and active session count. If you change `PLANNOTATOR_REMOTE` or `PLANNOTATOR_PORT`, stop the daemon before starting a new session with the new settings. diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index 43fa2e650..bf2b0e340 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -498,7 +498,10 @@ export default function plannotator(pi: ExtensionAPI): void { const useJina = resolveUseJina(false, loadConfig()); ctx.ui.notify(`Fetching: ${filePath}${useJina ? " (via Jina Reader)" : " (via fetch+Turndown)"}...`, "info"); try { - const result = await urlToMarkdown(filePath, { useJina }); + const result = await urlToMarkdown(filePath, { + useJina, + jinaApiKey: process.env.JINA_API_KEY, + }); markdown = result.markdown; sourceConverted = isConvertedSource(result.source); } catch (err) { diff --git a/apps/vscode-extension/src/editor-annotations.ts b/apps/vscode-extension/src/editor-annotations.ts index ac81d2a48..60946e7f0 100644 --- a/apps/vscode-extension/src/editor-annotations.ts +++ b/apps/vscode-extension/src/editor-annotations.ts @@ -13,6 +13,7 @@ import * as http from "http"; // ── State ────────────────────────────────────────────────────────── let activeProxyPort: number | null = null; +let activeProxySessionPath = ""; let commentController: vscode.CommentController | null = null; let annotationDecorationType: vscode.TextEditorDecorationType | null = null; @@ -24,11 +25,13 @@ const decoratedRanges = new Map(); // ── Public API ───────────────────────────────────────────────────── -export function setActiveProxyPort(port: number | null): void { +export function setActiveProxyPort(port: number | null, sessionPath = ""): void { activeProxyPort = port; + activeProxySessionPath = /^\/s\/[^/]+$/.test(sessionPath) ? sessionPath : ""; if (port !== null) { createController(); } else { + activeProxySessionPath = ""; disposeAllThreads(); clearAllDecorations(); if (commentController) { @@ -300,7 +303,7 @@ function requestProxy( } const req = http.request( - { hostname: "127.0.0.1", port, path: urlPath, method, headers }, + { hostname: "127.0.0.1", port, path: scopedProxyPath(urlPath), method, headers }, (res) => { let data = ""; res.on("data", (chunk: string) => (data += chunk)); @@ -318,3 +321,10 @@ function requestProxy( req.end(); }); } + +function scopedProxyPath(urlPath: string): string { + if (activeProxySessionPath && urlPath.startsWith("/api/")) { + return `${activeProxySessionPath}${urlPath}`; + } + return urlPath; +} diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index f2042c5f8..35e8be9f2 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -63,8 +63,9 @@ export async function activate(context: vscode.ExtensionContext): Promise }, }); + const parsedUrl = new URL(url); const panel = await panelManager.open(proxy.rewriteUrl(url)); - setActiveProxyPort(proxy.port); + setActiveProxyPort(proxy.port, parsedUrl.pathname); // Auto-close this specific panel when plannotator signals completion proxy.events.on("close", () => panel.dispose()); diff --git a/bun.lock b/bun.lock index 327880162..7026b04f9 100644 --- a/bun.lock +++ b/bun.lock @@ -193,7 +193,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.19.18", + "version": "0.19.17", "dependencies": { "@pierre/diffs": "^1.1.12", "@plannotator/ai": "workspace:*", @@ -225,7 +225,6 @@ "@viz-js/viz": "^3.25.0", "diff": "^8.0.3", "highlight.js": "^11.11.1", - "lucide-react": "^1.14.0", "marked": "^17.0.6", "mermaid": "^11.12.2", "overlayscrollbars": "^2.11.0", @@ -237,6 +236,7 @@ }, "devDependencies": { "@types/bun": "^1.2.0", + "@types/dompurify": "^3.0.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "typescript": "~5.8.2", @@ -1908,8 +1908,6 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], - "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], diff --git a/docs/single-binary-runtime.md b/docs/single-binary-runtime.md index 99f416491..c14ee0efe 100644 --- a/docs/single-binary-runtime.md +++ b/docs/single-binary-runtime.md @@ -2,6 +2,8 @@ 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. +The daemon runtime work is a stacked follow-on to the single-binary-runtime PR. The daemon PR should target `feat/single-server-runtime` / PR #733, not `main`. + ## Phase One Boundary OpenCode and Pi discover the binary with this order: @@ -20,7 +22,7 @@ The binary-owned plugin surface is: - `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. +Requests and responses are JSON over stdin/stdout at the plugin boundary. Inside the binary, daemon-backed commands create sessions through a localhost HTTP daemon using the same stable request/result shapes. ## What Plugins Own @@ -30,18 +32,42 @@ Pi owns Pi behavior: phase state, tool gating, non-UI auto-approval, checklist p Neither plugin owns browser HTML assets, starts Plannotator HTTP servers, or ships the mirrored Pi `node:http` server. -## Daemon Next +## Daemon Runtime + +The daemon is one long-running binary-owned service per user/machine environment. CLI and plugin commands auto-start it when no compatible daemon is running, then create session-scoped plan, review, annotate, and archive sessions through the shared endpoint. + +Lifecycle commands: -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: +```bash +plannotator daemon start +plannotator daemon status +plannotator daemon stop +plannotator sessions +``` + +The daemon provides: - 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 +- stable session IDs and session-scoped URLs such as `/s/` +- session-scoped API routing such as `/s//api/...` +- decision delivery back to blocking callers such as Claude hooks +- async-compatible plugin behavior for OpenCode and Pi subprocess clients - cancellation and TTL cleanup for abandoned sessions -- concurrent requests from Claude Code, OpenCode, Pi, Codex, Gemini, and Copilot without state collisions +- concurrent requests from Claude Code, OpenCode, Pi, Codex, Gemini, and Copilot without shared-state collisions + +`packages/server/sessions.ts` is no longer the authoritative runtime registry for daemon-backed commands. `plannotator sessions` queries the daemon. -The current `packages/server/sessions.ts` registry is a session discovery aid, not the final multi-session daemon. +## Remote Mode + +Daemon startup uses the same remote rules as the old request-scoped servers: + +- local mode binds `127.0.0.1` and uses a random port unless `PLANNOTATOR_PORT` is set +- remote mode binds `0.0.0.0` and uses `PLANNOTATOR_PORT` or default `19432` +- `PLANNOTATOR_REMOTE=1` / `true` forces remote mode +- `PLANNOTATOR_REMOTE=0` / `false` forces local mode +- when `PLANNOTATOR_REMOTE` is unset, SSH environment variables still auto-detect remote sessions + +Clients compare their requested remote/port settings to the running daemon. A local/remote mismatch or explicit port mismatch returns a stop/retry error instead of starting a parallel daemon. ## Future Phases @@ -66,8 +92,10 @@ This phase should shrink or remove Pi's `vendor.sh` by eliminating most generate ### 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. +Status: implemented in the stacked daemon-runtime branch. + +`plannotator` runs as one long-running service that can host concurrent plan, review, annotate, and archive sessions. It owns 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. +Keep the protocol shape from phase one, but allow OpenCode and Pi to call the daemon directly instead of launching `plannotator plugin ...` subprocesses. The current daemon branch keeps the public plugin command behavior stable while moving session ownership behind the binary. diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index 6c09036e1..d8d95f906 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -1087,7 +1087,7 @@ const ReviewApp: React.FC = () => { function applyPRResponse(data: PRSessionUpdate & { rawPatch: string; gitRef: string; repoInfo?: { display: string; branch?: string }; - viewedFiles?: string[]; error?: string; + viewedFiles?: string[]; agentCwd?: string | null; error?: string; }) { const isPRSwitch = !!data.prMetadata; const nextFiles = parseDiffToFiles(data.rawPatch); @@ -1111,6 +1111,7 @@ const ReviewApp: React.FC = () => { ...(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()); } diff --git a/packages/review-editor/hooks/usePRStack.ts b/packages/review-editor/hooks/usePRStack.ts index c42d29759..bd4e610cf 100644 --- a/packages/review-editor/hooks/usePRStack.ts +++ b/packages/review-editor/hooks/usePRStack.ts @@ -11,6 +11,7 @@ export interface PRSwitchResponse { prDiffScopeOptions?: unknown[]; repoInfo?: unknown; viewedFiles?: string[]; + agentCwd?: string | null; error?: string; } diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 3029a0e9f..363ff880f 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -22,6 +22,7 @@ import { createExternalAnnotationHandler } from "./external-annotations"; import { saveConfig, detectGitUser, getServerConfig } from "./config"; import { dirname, resolve as resolvePath } from "path"; import { isWSL } from "./browser"; +import type { SessionRequestHandler } from "./session-handler"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -31,6 +32,8 @@ export { handleServerReady as handleAnnotateServerReady } from "./shared-handler // --- Types --- export interface AnnotateServerOptions { + /** Working directory for repo/project-relative behavior */ + cwd?: string; /** Markdown content of the file to annotate */ markdown: string; /** Original file path (for display purposes) */ @@ -82,26 +85,23 @@ export interface AnnotateServerResult { stop: () => void; } +export interface AnnotateSession { + htmlContent: string; + handleRequest: SessionRequestHandler; + waitForDecision: AnnotateServerResult["waitForDecision"]; + dispose: () => void; +} + // --- Server Implementation --- const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; -/** - * Start the Annotate server - * - * Handles: - * - Remote detection and port configuration - * - API routes (/api/plan with mode:"annotate", /api/feedback) - * - Port conflict retries - */ -export async function startAnnotateServer( +export async function createAnnotateSession( options: AnnotateServerOptions -): Promise { - // Side-channel pre-warm so /api/doc/exists POSTs land on warm cache. - void warmFileListCache(process.cwd(), "code"); - +): Promise { const { + cwd = process.cwd(), markdown, filePath, htmlContent, @@ -116,13 +116,13 @@ export async function startAnnotateServer( gate = false, rawHtml, renderHtml = false, - onReady, } = options; - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); + // Side-channel pre-warm so /api/doc/exists POSTs land on warm cache. + void warmFileListCache(cwd, "code"); + const wslFlag = await isWSL(); - const gitUser = detectGitUser(); + const gitUser = detectGitUser(cwd); const draftSource = mode === "annotate-folder" && folderPath ? `folder:${resolvePath(folderPath)}` @@ -131,7 +131,7 @@ export async function startAnnotateServer( const externalAnnotations = createExternalAnnotationHandler("plan"); // Detect repo info (cached for this session) - const repoInfo = await getRepoInfo(); + const repoInfo = await getRepoInfo(cwd); // Decision promise let resolveDecision: (result: { @@ -149,17 +149,7 @@ export async function startAnnotateServer( resolveDecision = resolve; }); - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - hostname: getServerHostname(), - port: configuredPort, - - async fetch(req, server) { - const url = new URL(req.url); + const handleRequest: SessionRequestHandler = async (req, url, context) => { // API: Get plan content (reuse /api/plan so the plan editor UI works) if (url.pathname === "/api/plan" && req.method === "GET") { @@ -177,7 +167,7 @@ export async function startAnnotateServer( shareBaseUrl, pasteApiUrl, repoInfo, - projectRoot: folderPath || process.cwd(), + projectRoot: folderPath || cwd, isWSL: wslFlag, serverConfig: getServerConfig(gitUser), }); @@ -201,7 +191,7 @@ export async function startAnnotateServer( // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - return handleImage(req); + return handleImage(req, cwd); } // API: Serve a linked markdown document @@ -211,14 +201,14 @@ export async function startAnnotateServer( if (!url.searchParams.has("base") && !/^https?:\/\//i.test(filePath)) { const docUrl = new URL(req.url); docUrl.searchParams.set("base", dirname(filePath)); - return handleDoc(new Request(docUrl.toString())); + return handleDoc(new Request(docUrl.toString()), { projectRoot: cwd }); } - return handleDoc(req); + return handleDoc(req, { projectRoot: cwd }); } // API: Batch existence check for code-file paths the renderer detected if (url.pathname === "/api/doc/exists" && req.method === "POST") { - return handleDocExists(req); + return handleDocExists(req, { projectRoot: cwd }); } // API: Detect Obsidian vaults @@ -238,7 +228,7 @@ export async function startAnnotateServer( // API: List markdown files in a directory as a tree if (url.pathname === "/api/reference/files" && req.method === "GET") { - return handleFileBrowserFiles(req); + return handleFileBrowserFiles(req, folderPath || cwd); } // API: Upload image -> save to temp -> return path @@ -255,7 +245,7 @@ export async function startAnnotateServer( // API: External annotations (SSE-based, for any external tool) const externalResponse = await externalAnnotations.handle(req, url, { - disableIdleTimeout: () => server.timeout(req, 0), + disableIdleTimeout: () => context?.disableIdleTimeout?.(), }); if (externalResponse) return externalResponse; @@ -304,6 +294,48 @@ export async function startAnnotateServer( return new Response(htmlContent, { headers: { "Content-Type": "text/html" }, }); + }; + + return { + htmlContent, + handleRequest, + waitForDecision: () => decisionPromise, + dispose: () => { + externalAnnotations.dispose(); + }, + }; +} + +/** + * Start the Annotate server + * + * Handles: + * - Remote detection and port configuration + * - API routes (/api/plan with mode:"annotate", /api/feedback) + * - Port conflict retries + */ +export async function startAnnotateServer( + options: AnnotateServerOptions +): Promise { + const { onReady } = options; + const session = await createAnnotateSession(options); + const isRemote = isRemoteSession(); + const configuredPort = getServerPort(); + + // Start server with retry logic + let server: ReturnType | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + server = Bun.serve({ + hostname: getServerHostname(), + port: configuredPort, + + async fetch(req, server) { + const url = new URL(req.url); + return session.handleRequest(req, url, { + disableIdleTimeout: () => server.timeout(req, 0), + }); }, error(err) { @@ -354,7 +386,10 @@ export async function startAnnotateServer( port, url: serverUrl, isRemote, - waitForDecision: () => decisionPromise, - stop: () => server.stop(), + waitForDecision: session.waitForDecision, + stop: () => { + server.stop(); + session.dispose(); + }, }; } diff --git a/packages/server/daemon/client.test.ts b/packages/server/daemon/client.test.ts new file mode 100644 index 000000000..2867f6f7e --- /dev/null +++ b/packages/server/daemon/client.test.ts @@ -0,0 +1,441 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { getDaemonCapabilities } from "@plannotator/shared/daemon-protocol"; +import { createDaemonState, getDaemonPaths, writeDaemonState } from "./state"; +import { cleanupDaemonState, DaemonClient, discoverDaemon } from "./client"; + +let dirs: string[] = []; +const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION"]; +const originalEnv: Record = Object.fromEntries( + envKeys.map((key) => [key, process.env[key]]), +); + +function clearEnv() { + for (const key of envKeys) delete process.env[key]; +} + +function tempBase(): string { + const dir = mkdtempSync(join(tmpdir(), "plannotator-daemon-client-")); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const key of envKeys) { + if (originalEnv[key] !== undefined) { + process.env[key] = originalEnv[key]; + } else { + delete process.env[key]; + } + } + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +function state() { + return createDaemonState({ + pid: 123, + port: 4321, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + startedAt: "2026-01-01T00:00:00.000Z", + }); +} + +describe("DaemonClient", () => { + test("sends JSON body to daemon routes", async () => { + const calls: Request[] = []; + const client = new DaemonClient(state(), { + fetch: async (input, init) => { + const req = new Request(input, init); + calls.push(req); + return Response.json({ ok: true, session: { id: "s1" } }); + }, + }); + + await client.createSession({ request: { action: "plan", origin: "opencode", plan: "x" } }); + + expect(calls[0].url).toBe("http://localhost:4321/daemon/sessions"); + expect(calls[0].headers.get("authorization")).toBeNull(); + expect(calls[0].headers.get("content-type")).toBe("application/json"); + expect(await calls[0].json()).toEqual({ request: { action: "plan", origin: "opencode", plan: "x" } }); + }); + + test("passes explicit cleanup flag to session listing", async () => { + const calls: Request[] = []; + const client = new DaemonClient(state(), { + fetch: async (input, init) => { + const req = new Request(input, init); + calls.push(req); + return Response.json({ ok: true, sessions: [] }); + }, + }); + + await client.listSessions({ clean: true }); + + expect(calls[0].url).toBe("http://localhost:4321/daemon/sessions?clean=1"); + }); + + test("turns non-JSON responses into daemon errors", async () => { + const client = new DaemonClient(state(), { + fetch: async () => new Response("nope", { status: 500 }), + }); + const result = await client.status() as any; + expect(result.ok).toBe(false); + expect(result.error.code).toBe("daemon-unhealthy"); + }); + + test("cleans daemon state when the recorded endpoint is unreachable", async () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeDaemonState(state(), { baseDir }); + writeFileSync(paths.lockPath, "123\n", "utf-8"); + const calls: Request[] = []; + + await cleanupDaemonState(state(), { + baseDir, + isAlive: () => false, + fetch: async (input, init) => { + calls.push(new Request(input, init)); + throw new Error("endpoint is gone"); + }, + }); + + expect(calls.map((call) => call.url)).toEqual(["http://localhost:4321/daemon/shutdown"]); + expect(calls[0].headers.get("content-type")).toBe("application/json"); + expect(await calls[0].text()).toBe("{}"); + expect(existsSync(paths.statePath)).toBe(false); + expect(existsSync(paths.lockPath)).toBe(false); + }); + + test("cleans unreachable daemon state even if the recorded PID has been reused", async () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeDaemonState(state(), { baseDir }); + writeFileSync(paths.lockPath, "123\n", "utf-8"); + + await cleanupDaemonState(state(), { + baseDir, + shutdownTimeoutMs: 1, + isAlive: () => true, + fetch: async () => { + throw new Error("endpoint is temporarily unreachable"); + }, + }); + + expect(existsSync(paths.statePath)).toBe(false); + expect(existsSync(paths.lockPath)).toBe(false); + }); + + test("cleans daemon files when the recorded port is another HTTP app", async () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeDaemonState(state(), { baseDir }); + writeFileSync(paths.lockPath, "123\n", "utf-8"); + + await cleanupDaemonState(state(), { + baseDir, + fetch: async () => new Response("no", { status: 404 }), + }); + + expect(existsSync(paths.statePath)).toBe(false); + expect(existsSync(paths.lockPath)).toBe(false); + }); + + test("keeps daemon files when a daemon rejects shutdown unexpectedly", async () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeDaemonState(state(), { baseDir }); + writeFileSync(paths.lockPath, "123\n", "utf-8"); + + await expect(cleanupDaemonState(state(), { + baseDir, + fetch: async () => new Response("no", { status: 500 }), + })).rejects.toThrow("rejected shutdown"); + + expect(existsSync(paths.statePath)).toBe(true); + expect(existsSync(paths.lockPath)).toBe(true); + }); + + test("waits for accepted shutdown before removing daemon files", async () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + const daemonState = state(); + writeDaemonState(daemonState, { baseDir }); + writeFileSync(paths.lockPath, "123\n", "utf-8"); + let statusCalls = 0; + let stateFileExistedDuringPoll = false; + + await cleanupDaemonState(daemonState, { + baseDir, + isAlive: () => true, + fetch: async (input) => { + const url = String(input); + if (url.endsWith("/daemon/shutdown")) return Response.json({ ok: true }); + if (url.endsWith("/daemon/status")) { + statusCalls += 1; + stateFileExistedDuringPoll = stateFileExistedDuringPoll || existsSync(paths.statePath); + if (statusCalls === 1) return Response.json({ ...daemonState, ok: true }); + throw new Error("gone"); + } + throw new Error(`unexpected request: ${url}`); + }, + }); + + expect(statusCalls).toBe(2); + expect(stateFileExistedDuringPoll).toBe(true); + expect(existsSync(paths.statePath)).toBe(false); + expect(existsSync(paths.lockPath)).toBe(false); + }); + + test("does not signal recorded PID when endpoint shutdown fails", async () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeDaemonState(state(), { baseDir }); + writeFileSync(paths.lockPath, "123\n", "utf-8"); + const originalKill = process.kill; + let killed = false; + + (process as typeof process & { kill: typeof process.kill }).kill = (() => { + killed = true; + return true; + }) as typeof process.kill; + + try { + await cleanupDaemonState(state(), { + baseDir, + shutdownTimeoutMs: 1, + isAlive: () => true, + fetch: async () => { + throw new Error("endpoint is gone"); + }, + }); + } finally { + (process as typeof process & { kill: typeof process.kill }).kill = originalKill; + } + + expect(killed).toBe(false); + expect(existsSync(paths.statePath)).toBe(false); + expect(existsSync(paths.lockPath)).toBe(false); + }); + + test("retries shutdown before cleaning state when an unreachable daemon recovers", async () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + const daemonState = state(); + writeDaemonState(daemonState, { baseDir }); + writeFileSync(paths.lockPath, "123\n", "utf-8"); + let shutdownCalls = 0; + let statusCalls = 0; + + await cleanupDaemonState(daemonState, { + baseDir, + isAlive: () => true, + fetch: async (input) => { + const url = String(input); + if (url.endsWith("/daemon/shutdown")) { + shutdownCalls += 1; + if (shutdownCalls === 1) throw new Error("briefly unavailable"); + return Response.json({ ok: true }); + } + if (url.endsWith("/daemon/status")) { + statusCalls += 1; + if (statusCalls === 1) return Response.json({ ok: true, pid: daemonState.pid }); + throw new Error("gone"); + } + throw new Error(`unexpected request: ${url}`); + }, + }); + + expect(shutdownCalls).toBe(2); + expect(statusCalls).toBe(2); + expect(existsSync(paths.statePath)).toBe(false); + expect(existsSync(paths.lockPath)).toBe(false); + }); +}); + +describe("discoverDaemon", () => { + test("reports missing state", async () => { + clearEnv(); + const result = await discoverDaemon({ baseDir: tempBase() }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("missing"); + }); + + test("removes stale state", async () => { + clearEnv(); + const baseDir = tempBase(); + writeDaemonState(state(), { baseDir }); + const result = await discoverDaemon({ baseDir, isAlive: () => false }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("stale"); + }); + + test("returns active daemon client when capabilities and status match", async () => { + clearEnv(); + const baseDir = tempBase(); + writeDaemonState(state(), { baseDir }); + const result = await discoverDaemon({ + baseDir, + isAlive: (pid) => pid === 123, + fetch: async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/daemon/capabilities") return Response.json(getDaemonCapabilities()); + if (url.pathname === "/daemon/status") { + return Response.json({ + ok: true, + protocol: "plannotator-daemon", + protocolVersion: 1, + pid: 123, + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + startedAt: "2026-01-01T00:00:00.000Z", + activeSessionCount: 0, + sessionCount: 0, + }); + } + return Response.json({}, { status: 404 }); + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.status.pid).toBe(123); + expect(result.client.state.baseUrl).toBe("http://localhost:4321"); + }); + + test("rejects incompatible daemon capabilities", async () => { + clearEnv(); + const baseDir = tempBase(); + writeDaemonState(state(), { baseDir }); + const result = await discoverDaemon({ + baseDir, + isAlive: () => true, + fetch: async () => Response.json({ protocol: "other" }), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("incompatible"); + }); + + test("rejects local/remote daemon mode mismatch", async () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + const baseDir = tempBase(); + writeDaemonState(state(), { baseDir }); + const result = await discoverDaemon({ + baseDir, + isAlive: () => true, + fetch: async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/daemon/capabilities") return Response.json(getDaemonCapabilities()); + if (url.pathname === "/daemon/status") { + return Response.json({ + ok: true, + protocol: "plannotator-daemon", + protocolVersion: 1, + pid: 123, + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + startedAt: "2026-01-01T00:00:00.000Z", + activeSessionCount: 0, + sessionCount: 0, + }); + } + return Response.json({}, { status: 404 }); + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("mismatch"); + }); + + test("can bypass environment mismatch checks for management commands", async () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + const baseDir = tempBase(); + writeDaemonState(state(), { baseDir }); + const result = await discoverDaemon({ + baseDir, + validateEnvironment: false, + isAlive: () => true, + fetch: async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/daemon/capabilities") return Response.json(getDaemonCapabilities()); + if (url.pathname === "/daemon/status") { + return Response.json({ + ok: true, + protocol: "plannotator-daemon", + protocolVersion: 1, + pid: 123, + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + startedAt: "2026-01-01T00:00:00.000Z", + activeSessionCount: 0, + sessionCount: 0, + }); + } + return Response.json({}, { status: 404 }); + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.status.endpoint.isRemote).toBe(false); + }); + + test("rejects explicit port mismatch", async () => { + clearEnv(); + process.env.PLANNOTATOR_PORT = "9999"; + const baseDir = tempBase(); + writeDaemonState(state(), { baseDir }); + const result = await discoverDaemon({ + baseDir, + isAlive: () => true, + fetch: async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/daemon/capabilities") return Response.json(getDaemonCapabilities()); + if (url.pathname === "/daemon/status") { + return Response.json({ + ok: true, + protocol: "plannotator-daemon", + protocolVersion: 1, + pid: 123, + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + startedAt: "2026-01-01T00:00:00.000Z", + activeSessionCount: 0, + sessionCount: 0, + }); + } + return Response.json({}, { status: 404 }); + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("mismatch"); + }); +}); diff --git a/packages/server/daemon/client.ts b/packages/server/daemon/client.ts new file mode 100644 index 000000000..c9f2626c2 --- /dev/null +++ b/packages/server/daemon/client.ts @@ -0,0 +1,297 @@ +import { + createDaemonErrorResponse, + isCompatibleDaemonCapabilities, + type DaemonCancelSessionResponse, + type DaemonCreateSessionRequest, + type DaemonCreateSessionResponse, + type DaemonErrorResponse, + type DaemonSessionResultResponse, + type DaemonShutdownResponse, + type DaemonStatus, +} from "@plannotator/shared/daemon-protocol"; +import { getServerPort, isRemoteSession } from "../remote"; +import { readDaemonState, removeDaemonFiles, type DaemonState, type DaemonStateOptions } from "./state"; + +export interface DaemonClientOptions extends DaemonStateOptions { + fetch?: typeof fetch; + validateEnvironment?: boolean; + shutdownTimeoutMs?: number; +} + +export type DaemonDiscoveryResult = + | { ok: true; state: DaemonState; status: DaemonStatus; client: DaemonClient } + | { ok: false; code: "missing" | "stale" | "malformed" | "incompatible" | "unhealthy" | "mismatch"; message: string; state?: unknown }; + +export class DaemonClient { + readonly state: DaemonState; + private readonly fetchImpl: typeof fetch; + + constructor(state: DaemonState, options: Pick = {}) { + this.state = state; + this.fetchImpl = options.fetch ?? fetch; + } + + async capabilities(): Promise { + return this.getJson("/daemon/capabilities"); + } + + async status(): Promise { + return this.getJson("/daemon/status") as Promise; + } + + async listSessions(options: { clean?: boolean } = {}): Promise { + return this.getJson(options.clean ? "/daemon/sessions?clean=1" : "/daemon/sessions"); + } + + async createSession(request: DaemonCreateSessionRequest): Promise { + return this.requestJson("/daemon/sessions", { + method: "POST", + body: JSON.stringify(request), + }) as Promise; + } + + async waitForResult(id: string): Promise | DaemonErrorResponse> { + return this.getJson(`/daemon/sessions/${encodeURIComponent(id)}/result`) as Promise | DaemonErrorResponse>; + } + + async cancelSession(id: string): Promise { + return this.requestJson(`/daemon/sessions/${encodeURIComponent(id)}/cancel`, { + method: "POST", + body: "{}", + }) as Promise; + } + + async shutdown(): Promise { + return this.requestJson("/daemon/shutdown", { + method: "POST", + body: "{}", + }) as Promise; + } + + private async getJson(path: string): Promise { + return this.requestJson(path, { method: "GET" }); + } + + private async requestJson(path: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + if (init.body && !headers.has("content-type")) headers.set("content-type", "application/json"); + + const res = await this.fetchImpl(`${this.state.baseUrl}${path}`, { + ...init, + headers, + }); + try { + return await res.json(); + } catch { + return createDaemonErrorResponse("daemon-unhealthy", `Daemon returned non-JSON response with status ${res.status}.`); + } + } +} + +function stateBaseUrl(state: unknown): string | undefined { + const baseUrl = (state as { baseUrl?: unknown } | null)?.baseUrl; + return typeof baseUrl === "string" ? baseUrl : undefined; +} + +function statePid(state: unknown): number | undefined { + const pid = (state as { pid?: unknown } | null)?.pid; + return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : undefined; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type DaemonPollEvent = + | { kind: "missing-base-url" } + | { kind: "pid-exited" } + | { kind: "unreachable" } + | { kind: "status"; ok: boolean; pid?: unknown }; + +function defaultIsAlive(targetPid: number): boolean { + try { + process.kill(targetPid, 0); + return true; + } catch { + return false; + } +} + +async function pollDaemonStatus( + state: unknown, + options: DaemonClientOptions, + evaluate: (event: DaemonPollEvent) => boolean | undefined, +): Promise { + const fetchImpl = options.fetch ?? fetch; + const baseUrl = stateBaseUrl(state); + const pid = statePid(state); + const isAlive = options.isAlive ?? defaultIsAlive; + const deadline = Date.now() + (options.shutdownTimeoutMs ?? 3_000); + + if (!baseUrl) return evaluate({ kind: "missing-base-url" }) ?? false; + + while (Date.now() < deadline) { + if (pid && !isAlive(pid)) return evaluate({ kind: "pid-exited" }) ?? false; + try { + const res = await fetchImpl(`${baseUrl}/daemon/status`); + const status = await res.json().catch(() => null) as { pid?: unknown } | null; + const decision = evaluate({ kind: "status", ok: res.ok, pid: status?.pid }); + if (decision !== undefined) return decision; + } catch { + const decision = evaluate({ kind: "unreachable" }); + if (decision !== undefined) return decision; + } + await sleep(100); + } + + return false; +} + +async function waitForDaemonReachable( + state: unknown, + options: DaemonClientOptions = {}, +): Promise { + const pid = statePid(state); + return pollDaemonStatus(state, options, (event) => { + if (event.kind === "missing-base-url" || event.kind === "pid-exited") return false; + if (event.kind !== "status") return undefined; + if (event.ok && (!pid || event.pid === pid)) return true; + if (pid && event.pid !== pid) return false; + return undefined; + }); +} + +export async function waitForDaemonShutdown( + state: unknown, + options: DaemonClientOptions = {}, +): Promise { + const pid = statePid(state); + return pollDaemonStatus(state, options, (event) => { + switch (event.kind) { + case "missing-base-url": + case "pid-exited": + case "unreachable": + return true; + case "status": + if (!event.ok) return true; + if (pid && event.pid !== pid) return true; + return undefined; + } + }); +} + +export async function cleanupDaemonState(state: unknown, options: DaemonClientOptions = {}): Promise { + const fetchImpl = options.fetch ?? fetch; + const baseUrl = stateBaseUrl(state); + let shutdownAccepted = false; + if (baseUrl) { + let endpointResponded = false; + try { + const res = await fetchImpl(`${baseUrl}/daemon/shutdown`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + endpointResponded = true; + shutdownAccepted = res.ok; + if (!shutdownAccepted) { + if (res.status === 404 || res.status === 405) { + removeDaemonFiles(options); + return; + } + throw new Error(`The existing Plannotator daemon rejected shutdown with HTTP ${res.status}.`); + } + } catch (err) { + // Best effort only. Do not signal the recorded PID here; stale daemon + // state can outlive the process and the PID may now belong to something else. + if (!endpointResponded) { + if (await waitForDaemonReachable(state, options)) { + const retry = await fetchImpl(`${baseUrl}/daemon/shutdown`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + if (!retry.ok) { + throw new Error(`The existing Plannotator daemon rejected shutdown with HTTP ${retry.status}.`); + } + const stopped = await waitForDaemonShutdown(state, options); + if (!stopped) { + throw new Error("Timed out waiting for the existing Plannotator daemon to stop."); + } + } + removeDaemonFiles(options); + return; + } + throw err; + } + } + if (shutdownAccepted) { + const stopped = await waitForDaemonShutdown(state, options); + if (!stopped) { + throw new Error("Timed out waiting for the existing Plannotator daemon to stop."); + } + } + removeDaemonFiles(options); +} + +export async function discoverDaemon(options: DaemonClientOptions = {}): Promise { + const stateResult = readDaemonState(options); + if (stateResult.kind === "missing") { + return { ok: false, code: "missing", message: "No Plannotator daemon state found." }; + } + if (stateResult.kind === "malformed") { + removeDaemonFiles(options); + return { ok: false, code: "malformed", message: stateResult.error }; + } + if (stateResult.kind === "stale") { + removeDaemonFiles(options); + return { ok: false, code: "stale", message: `Stale Plannotator daemon state for PID ${stateResult.state.pid}.`, state: stateResult.state }; + } + if (stateResult.kind === "incompatible") { + return { ok: false, code: "incompatible", message: "The daemon state file is not compatible with this Plannotator version.", state: stateResult.state }; + } + + const client = new DaemonClient(stateResult.state, options); + try { + const caps = await client.capabilities(); + if (!isCompatibleDaemonCapabilities(caps)) { + return { ok: false, code: "incompatible", message: "The running daemon uses an incompatible protocol.", state: stateResult.state }; + } + + const status = await client.status(); + if (status.ok !== true || status.pid !== stateResult.state.pid) { + return { ok: false, code: "unhealthy", message: "The running daemon did not return a matching status.", state: stateResult.state }; + } + + if (options.validateEnvironment !== false) { + const desiredRemote = isRemoteSession(); + if (status.endpoint.isRemote !== desiredRemote) { + return { + ok: false, + code: "mismatch", + message: `The running Plannotator daemon was started in ${status.endpoint.isRemote ? "remote" : "local"} mode, but this command wants ${desiredRemote ? "remote" : "local"} mode. Run 'plannotator daemon stop' and retry.`, + state: stateResult.state, + }; + } + + const desiredPort = getServerPort(); + if (desiredPort !== 0 && status.endpoint.port !== desiredPort) { + return { + ok: false, + code: "mismatch", + message: `The running Plannotator daemon is on port ${status.endpoint.port}, but this command wants port ${desiredPort}. Run 'plannotator daemon stop' and retry.`, + state: stateResult.state, + }; + } + } + + return { ok: true, state: stateResult.state, status, client }; + } catch (err) { + return { + ok: false, + code: "unhealthy", + message: err instanceof Error ? err.message : "Could not reach the Plannotator daemon.", + state: stateResult.state, + }; + } +} diff --git a/packages/server/daemon/runtime.test.ts b/packages/server/daemon/runtime.test.ts new file mode 100644 index 000000000..e4e0adfe1 --- /dev/null +++ b/packages/server/daemon/runtime.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { readDaemonState } from "./state"; +import { startDaemonRuntime, type DaemonRuntime } from "./runtime"; + +let dirs: string[] = []; +let runtimes: DaemonRuntime[] = []; + +function tempBase(): string { + const dir = mkdtempSync(join(tmpdir(), "plannotator-daemon-runtime-")); + dirs.push(dir); + return dir; +} + +afterEach(async () => { + for (const runtime of runtimes) await runtime.stop(); + runtimes = []; + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +describe("startDaemonRuntime", () => { + test("starts an HTTP daemon and writes active state", async () => { + const baseDir = tempBase(); + const runtime = await startDaemonRuntime({ + baseDir, + hostname: "127.0.0.1", + port: 0, + createSession: (_request, { endpoint }) => runtime.store.create({ + id: "s1", + mode: "plan", + url: `${endpoint.baseUrl}/s/s1`, + project: "repo", + label: "plan", + }), + }); + runtimes.push(runtime); + + const state = readDaemonState({ baseDir, isAlive: (pid) => pid === process.pid }); + expect(state.kind).toBe("active"); + if (state.kind !== "active") return; + expect(state.state.port).toBe(runtime.server.port); + + const caps = await fetch(`${runtime.state.baseUrl}/daemon/capabilities`); + expect((await caps.json()).multiSession).toBe(true); + }); + + test("rejects a second daemon for the same state directory", async () => { + const baseDir = tempBase(); + const runtime = await startDaemonRuntime({ + baseDir, + hostname: "127.0.0.1", + port: 0, + createSession: (_request, { endpoint }) => runtime.store.create({ + id: "s1", + mode: "plan", + url: `${endpoint.baseUrl}/s/s1`, + project: "repo", + label: "plan", + }), + }); + runtimes.push(runtime); + + await expect(startDaemonRuntime({ + baseDir, + hostname: "127.0.0.1", + port: 0, + createSession: () => { + throw new Error("should not create"); + }, + })).rejects.toThrow("lock"); + }); + + test("shutdown route stops daemon and removes state", async () => { + const baseDir = tempBase(); + const runtime = await startDaemonRuntime({ + baseDir, + hostname: "127.0.0.1", + port: 0, + createSession: (_request, { endpoint }) => runtime.store.create({ + id: "s1", + mode: "plan", + url: `${endpoint.baseUrl}/s/s1`, + project: "repo", + label: "plan", + }), + }); + + const res = await fetch(`${runtime.state.baseUrl}/daemon/shutdown`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect((await res.json()).shuttingDown).toBe(true); + for (let attempt = 0; attempt < 20 && readDaemonState({ baseDir }).kind !== "missing"; attempt++) { + await Bun.sleep(10); + } + expect(readDaemonState({ baseDir }).kind).toBe("missing"); + }); + + test("logs unhandled request errors through the daemon error handler", async () => { + const baseDir = tempBase(); + const originalError = console.error; + const errorMock = mock(() => {}); + console.error = errorMock as typeof console.error; + const runtime = await startDaemonRuntime({ + baseDir, + hostname: "127.0.0.1", + port: 0, + createSession: (_request, { endpoint, store }) => store.create({ + id: "s1", + mode: "plan", + url: `${endpoint.baseUrl}/s/s1`, + project: "repo", + label: "plan", + handleRequest: () => { + throw new Error("session boom"); + }, + }), + }); + runtimes.push(runtime); + + try { + const create = await fetch(`${runtime.state.baseUrl}/daemon/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", cwd: process.cwd(), plan: "# Plan" } }), + }); + expect(create.status).toBe(201); + + const res = await fetch(`${runtime.state.baseUrl}/s/s1/api/plan`); + expect(res.status).toBe(500); + expect(await res.text()).toBe("Internal Plannotator daemon error"); + expect(errorMock).toHaveBeenCalled(); + } finally { + console.error = originalError; + } + }); +}); diff --git a/packages/server/daemon/runtime.ts b/packages/server/daemon/runtime.ts new file mode 100644 index 000000000..39fea8730 --- /dev/null +++ b/packages/server/daemon/runtime.ts @@ -0,0 +1,114 @@ +import { getServerHostname, getServerPort, isRemoteSession } from "../remote"; +import { acquireDaemonLock, createDaemonState, removeDaemonState, writeDaemonState, type DaemonLock, type DaemonState, type DaemonStateOptions } from "./state"; +import { DaemonSessionStore, type DaemonSessionRecord } from "./session-store"; +import { createDaemonFetchHandler, type DaemonFetchContext } from "./server"; +import type { DaemonCreateSessionRequest } from "@plannotator/shared/daemon-protocol"; + +export interface StartDaemonRuntimeOptions extends DaemonStateOptions { + createSession: ( + request: DaemonCreateSessionRequest, + context: DaemonFetchContext, + ) => DaemonSessionRecord | Promise; + onShutdown?: () => void | Promise; + hostname?: string; + port?: number; + binaryVersion?: string; +} + +export interface DaemonRuntime { + state: DaemonState; + store: DaemonSessionStore; + server: ReturnType; + stop: () => Promise; +} + +function getRemoteSource(): DaemonState["remoteSource"] { + if (process.env.PLANNOTATOR_REMOTE !== undefined) return "env"; + if (process.env.SSH_TTY || process.env.SSH_CONNECTION) return "ssh"; + return "local"; +} + +export async function startDaemonRuntime(options: StartDaemonRuntimeOptions): Promise { + const lockResult = acquireDaemonLock(options); + if (!lockResult.ok) { + throw new Error(lockResult.message); + } + + let lock: DaemonLock | undefined = lockResult.lock; + const store = new DaemonSessionStore(); + const isRemote = isRemoteSession(); + const hostname = options.hostname ?? getServerHostname(); + const requestedPort = options.port ?? getServerPort(); + let runtime: DaemonRuntime | undefined; + let cleanupTimer: ReturnType | undefined; + let server: ReturnType | undefined; + let handler: ReturnType | undefined; + let stopping = false; + + try { + server = Bun.serve({ + hostname, + port: requestedPort, + fetch: (req, server) => { + if (stopping) return new Response("Daemon is stopping", { status: 503 }); + if (!handler) return new Response("Daemon is starting", { status: 503 }); + return handler(req, { + disableIdleTimeout: () => server.timeout(req, 0), + }); + }, + error: (error) => { + console.error("[Plannotator daemon] Unhandled request error:", error); + return new Response("Internal Plannotator daemon error", { status: 500 }); + }, + }); + + const state = createDaemonState({ + port: server.port!, + hostname, + isRemote, + remoteSource: getRemoteSource(), + binaryVersion: options.binaryVersion, + requestedPort, + }); + handler = createDaemonFetchHandler({ + state, + store, + createSession: options.createSession, + onShutdown: async () => { + await runtime?.stop(); + await options.onShutdown?.(); + }, + }); + writeDaemonState(state, options); + cleanupTimer = setInterval(() => { + void store.cleanupExpired(); + }, 60_000); + + const activeServer = server; + runtime = { + state, + store, + server: activeServer, + stop: async () => { + if (stopping) return; + stopping = true; + activeServer.stop(); + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = undefined; + } + await store.cancelAll(); + lock?.release(); + lock = undefined; + removeDaemonState(options); + }, + }; + + return runtime; + } catch (err) { + if (cleanupTimer) clearInterval(cleanupTimer); + server?.stop(); + lock.release(); + throw err; + } +} diff --git a/packages/server/daemon/server.test.ts b/packages/server/daemon/server.test.ts new file mode 100644 index 000000000..4661c43e6 --- /dev/null +++ b/packages/server/daemon/server.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, test } from "bun:test"; +import { PLANNOTATOR_DAEMON_PROTOCOL, PLANNOTATOR_DAEMON_PROTOCOL_VERSION } from "@plannotator/shared/daemon-protocol"; +import { createDaemonState } from "./state"; +import { DaemonSessionStore } from "./session-store"; +import { createDaemonFetchHandler } from "./server"; + +function makeHandler() { + const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => 1_000 }); + const state = createDaemonState({ + pid: 123, + port: 4321, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + startedAt: "2026-01-01T00:00:00.000Z", + }); + const handler = createDaemonFetchHandler({ + state, + store, + createSession: () => store.create({ + id: "s1", + mode: "plan", + url: `${state.baseUrl}/s/s1`, + project: "repo", + label: "plan-repo", + htmlContent: "Plan", + handleRequest: (_req, url) => Response.json({ path: url.pathname }), + }), + }); + return { handler, store }; +} + +describe("daemon HTTP router", () => { + test("serves public capabilities", async () => { + const { handler } = makeHandler(); + const res = await handler(new Request("http://127.0.0.1:4321/daemon/capabilities")); + const body = await res.json(); + expect(body.protocol).toBe(PLANNOTATOR_DAEMON_PROTOCOL); + expect(body.protocolVersion).toBe(PLANNOTATOR_DAEMON_PROTOCOL_VERSION); + expect(body.multiSession).toBe(true); + }); + + test("serves the favicon at the daemon root", async () => { + const { handler } = makeHandler(); + const res = await handler(new Request("http://127.0.0.1:4321/favicon.svg")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("image/svg+xml"); + }); + + test("reports daemon status with active session count", async () => { + const { handler, store } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + const res = await handler(new Request("http://127.0.0.1:4321/daemon/status")); + const body = await res.json(); + expect(body.pid).toBe(123); + expect(body.endpoint.baseUrl).toBe("http://localhost:4321"); + expect(body.activeSessionCount).toBe(1); + expect(body.sessionCount).toBe(1); + store.complete("s1", { approved: true }); + const afterComplete = await handler(new Request("http://127.0.0.1:4321/daemon/status")); + const afterCompleteBody = await afterComplete.json(); + expect(afterCompleteBody.activeSessionCount).toBe(0); + expect(afterCompleteBody.sessionCount).toBe(1); + }); + + test("creates and lists sessions", async () => { + const { handler } = makeHandler(); + const create = await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + expect(create.status).toBe(201); + const created = await create.json(); + expect(created.session.id).toBe("s1"); + + const list = await handler(new Request("http://127.0.0.1:4321/daemon/sessions")); + const body = await list.json(); + expect(body.sessions).toHaveLength(1); + expect(body.sessions[0].url).toBe("http://localhost:4321/s/s1"); + }); + + test("disables idle timeout while creating sessions", async () => { + const { handler } = makeHandler(); + let timeoutDisabled = 0; + + const create = await handler( + new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + }), + { disableIdleTimeout: () => { timeoutDisabled += 1; } }, + ); + + expect(create.status).toBe(201); + expect(timeoutDisabled).toBe(1); + }); + + test("cleans expired sessions when requested by list route", async () => { + let now = 1_000; + const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => now }); + const state = createDaemonState({ + pid: 123, + port: 4321, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + startedAt: "2026-01-01T00:00:00.000Z", + }); + const handler = createDaemonFetchHandler({ + state, + store, + createSession: () => store.create({ + id: "s1", + mode: "plan", + url: `${state.baseUrl}/s/s1`, + project: "repo", + label: "plan-repo", + ttlMs: 100, + }), + }); + + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + now = 1_101; + const list = await handler(new Request("http://127.0.0.1:4321/daemon/sessions?clean=1")); + const body = await list.json(); + + expect(body.sessions).toHaveLength(0); + expect(store.get("s1")).toBeUndefined(); + }); + + test("serves session HTML with API base injection", async () => { + const { handler } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + const res = await handler(new Request("http://127.0.0.1:4321/s/s1")); + const html = await res.text(); + expect(html).toContain("window.__PLANNOTATOR_API_BASE__ = apiBase"); + expect(html).toContain('apiBase = "/s/s1/api"'); + expect(html).toContain("window.fetch"); + expect(html).toContain("window.EventSource"); + expect(html).toContain("input instanceof Request"); + expect(html).toContain("window.EventSource.OPEN = OriginalEventSource.OPEN"); + expect(html.indexOf("window.__PLANNOTATOR_API_BASE__")).toBeGreaterThan(html.indexOf("const literal")); + expect(html.indexOf("window.__PLANNOTATOR_API_BASE__")).toBeLessThan(html.indexOf("")); + }); + + test("routes session-scoped API paths to the owning session", async () => { + const { handler } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + const res = await handler(new Request("http://127.0.0.1:4321/s/s1/api/plan")); + const body = await res.json(); + expect(body.path).toBe("/api/plan"); + }); + + test("does not route session paths that only prefix-match api", async () => { + const { handler, store } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + let routed = 0; + const record = store.get("s1"); + if (record) { + record.handleRequest = () => { + routed += 1; + return Response.json({ routed: true }); + }; + } + + const res = await handler(new Request("http://127.0.0.1:4321/s/s1/api-docs")); + const text = await res.text(); + + expect(routed).toBe(0); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(text).toContain("Plan"); + }); + + test("passes request context through session-scoped API paths", async () => { + const { handler, store } = makeHandler(); + let timeoutDisabled = 0; + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + const record = store.get("s1"); + if (record) { + record.handleRequest = (_req, _url, context) => { + context?.disableIdleTimeout?.(); + return Response.json({ ok: true }); + }; + } + + await handler( + new Request("http://127.0.0.1:4321/s/s1/api/external-annotations/stream"), + { disableIdleTimeout: () => { timeoutDisabled += 1; } }, + ); + + expect(timeoutDisabled).toBe(1); + }); + + test("does not route root API paths by spoofable referer", async () => { + const { handler } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + const res = await handler(new Request("http://127.0.0.1:4321/api/plan", { + headers: { referer: "http://127.0.0.1:4321/s/s1" }, + })); + expect(res.status).toBe(404); + }); + + test("rejects non-JSON session creation requests", async () => { + const { handler } = makeHandler(); + const res = await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + const body = await res.json(); + expect(res.status).toBe(415); + expect(body.error.code).toBe("invalid-request"); + }); + + test("cancels sessions and returns result status", async () => { + const { handler, store } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + const cancel = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/cancel", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + })); + expect((await cancel.json()).session.status).toBe("cancelled"); + + const result = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/result")); + const body = await result.json(); + expect(body.session.status).toBe("cancelled"); + expect(body.session.error).toBe("Session cancelled."); + expect(store.get("s1")).toBeDefined(); + }); + + test("disables idle timeout while waiting for session results", async () => { + const { handler, store } = makeHandler(); + let timeoutDisabled = 0; + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + + const resultPromise = handler( + new Request("http://127.0.0.1:4321/daemon/sessions/s1/result"), + { disableIdleTimeout: () => { timeoutDisabled += 1; } }, + ); + store.complete("s1", { approved: true }); + const body = await (await resultPromise).json(); + + expect(timeoutDisabled).toBe(1); + expect(body.result.approved).toBe(true); + }); + + test("rejects simple POST control requests without JSON content type", async () => { + const { handler } = makeHandler(); + await handler(new Request("http://127.0.0.1:4321/daemon/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }), + })); + + const cancel = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/cancel", { + method: "POST", + })); + const shutdown = await handler(new Request("http://127.0.0.1:4321/daemon/shutdown", { + method: "POST", + })); + + expect(cancel.status).toBe(415); + expect((await cancel.json()).error.code).toBe("invalid-request"); + expect(shutdown.status).toBe(415); + expect((await shutdown.json()).error.code).toBe("invalid-request"); + }); +}); diff --git a/packages/server/daemon/server.ts b/packages/server/daemon/server.ts new file mode 100644 index 000000000..f945a5cf2 --- /dev/null +++ b/packages/server/daemon/server.ts @@ -0,0 +1,249 @@ +import { + createDaemonErrorResponse, + getDaemonCapabilities, + type DaemonCreateSessionRequest, + type DaemonEndpoint, + type DaemonStatus, +} from "@plannotator/shared/daemon-protocol"; +import type { DaemonState } from "./state"; +import { DaemonSessionStore, type DaemonSessionRecord } from "./session-store"; +import type { SessionRequestContext } from "../session-handler"; +import { handleFavicon } from "../shared-handlers"; + +const RESULT_DELETE_GRACE_MS = 2_000; + +export interface DaemonServerOptions { + state: DaemonState; + store?: DaemonSessionStore; + createSession: ( + request: DaemonCreateSessionRequest, + context: DaemonFetchContext, + ) => DaemonSessionRecord | Promise; + onShutdown?: () => void | Promise; +} + +export interface DaemonFetchContext { + endpoint: DaemonEndpoint; + store: DaemonSessionStore; +} + +function json(data: unknown, init?: ResponseInit): Response { + return Response.json(data, init); +} + +function stripSessionApiPath(url: URL, sessionId: string): URL { + const next = new URL(url.toString()); + const prefix = `/s/${sessionId}/api`; + next.pathname = `/api${url.pathname.slice(prefix.length)}`; + return next; +} + +function sessionFromPath(pathname: string): { id: string; rest: string } | null { + const match = pathname.match(/^\/s\/([^/]+)(\/.*)?$/); + if (!match) return null; + return { + id: decodeURIComponent(match[1]), + rest: match[2] || "/", + }; +} + +function isJsonRequest(req: Request): boolean { + const contentType = req.headers.get("content-type") ?? ""; + return contentType.split(";")[0].trim().toLowerCase() === "application/json"; +} + +function injectApiBase(html: string, apiBaseScript: string): string { + const marker = ""; + const index = html.lastIndexOf(marker); + if (index === -1) return `${apiBaseScript}${html}`; + return `${html.slice(0, index)}${apiBaseScript}${html.slice(index)}`; +} + +function createApiBaseScript(apiBase: string): string { + return ``; +} + +export function createDaemonFetchHandler(options: DaemonServerOptions) { + const store = options.store ?? new DaemonSessionStore(); + const endpoint: DaemonEndpoint = { + hostname: options.state.hostname, + port: options.state.port, + baseUrl: options.state.baseUrl, + isRemote: options.state.isRemote, + }; + + const context: DaemonFetchContext = { endpoint, store }; + + return async function daemonFetch(req: Request, requestContext?: SessionRequestContext): Promise { + const url = new URL(req.url); + + if (url.pathname === "/daemon/capabilities" && req.method === "GET") { + return json(getDaemonCapabilities()); + } + + if (url.pathname === "/favicon.svg" && req.method === "GET") { + return handleFavicon(); + } + + if (url.pathname === "/daemon/status" && req.method === "GET") { + const status: DaemonStatus = { + ok: true, + protocol: options.state.protocol, + protocolVersion: options.state.protocolVersion, + pid: options.state.pid, + endpoint, + startedAt: options.state.startedAt, + activeSessionCount: store.activeCount(), + sessionCount: store.totalCount(), + }; + return json(status); + } + + if (url.pathname === "/daemon/sessions" && req.method === "GET") { + if (url.searchParams.get("clean") === "1") { + await store.cleanupExpired(); + } + return json({ ok: true, sessions: store.list() }); + } + + if (url.pathname === "/daemon/sessions" && req.method === "POST") { + if (!isJsonRequest(req)) { + return json(createDaemonErrorResponse("invalid-request", "Daemon session requests must use application/json."), { status: 415 }); + } + let body: DaemonCreateSessionRequest; + try { + body = await req.json() as DaemonCreateSessionRequest; + } catch { + return json(createDaemonErrorResponse("invalid-request", "Invalid daemon session request JSON."), { status: 400 }); + } + try { + requestContext?.disableIdleTimeout?.(); + const record = await options.createSession(body, context); + return json({ ok: true, session: store.summary(record, { includeRemoteShare: true }) }, { status: 201 }); + } catch (err) { + return json( + createDaemonErrorResponse("internal-error", err instanceof Error ? err.message : "Failed to create session."), + { status: 500 }, + ); + } + } + + const sessionRoute = url.pathname.match(/^\/daemon\/sessions\/([^/]+)(?:\/([^/]+))?$/); + if (sessionRoute) { + const id = decodeURIComponent(sessionRoute[1]); + const action = sessionRoute[2] ?? ""; + const record = store.get(id); + if (!record) { + return json(createDaemonErrorResponse("session-not-found", `Session not found: ${id}`), { status: 404 }); + } + + if (!action && req.method === "GET") { + return json({ ok: true, session: store.summary(record) }); + } + + if (action === "result" && req.method === "GET") { + requestContext?.disableIdleTimeout?.(); + const completed = await store.waitForResult(id); + const response = json({ ok: true, session: store.summary(completed), result: completed.result ?? null }); + const timer = setTimeout(() => void store.delete(id), RESULT_DELETE_GRACE_MS); + timer.unref?.(); + return response; + } + + if (action === "cancel" && req.method === "POST") { + if (!isJsonRequest(req)) { + return json(createDaemonErrorResponse("invalid-request", "Daemon cancel requests must use application/json."), { status: 415 }); + } + let body: { reason?: unknown } = {}; + try { + body = await req.json() as { reason?: unknown }; + } catch { + return json(createDaemonErrorResponse("invalid-request", "Invalid daemon cancel request JSON."), { status: 400 }); + } + const cancelled = await store.cancel(id, typeof body.reason === "string" ? body.reason : undefined); + return json({ ok: true, session: store.summary(cancelled ?? record) }); + } + + if (!action && req.method === "DELETE") { + await store.delete(id); + return json({ ok: true }); + } + } + + if (url.pathname === "/daemon/shutdown" && req.method === "POST") { + if (!isJsonRequest(req)) { + return json(createDaemonErrorResponse("invalid-request", "Daemon shutdown requests must use application/json."), { status: 415 }); + } + const timer = setTimeout(() => { + void Promise.resolve(options.onShutdown?.()).catch(() => {}); + }, 0); + timer.unref?.(); + return json({ ok: true, shuttingDown: true }); + } + + const browserSession = sessionFromPath(url.pathname); + if (browserSession) { + const record = store.get(browserSession.id); + if (!record) { + return new Response("Session not found", { status: 404 }); + } + const sessionApiPath = `/s/${browserSession.id}/api`; + if (url.pathname === sessionApiPath || url.pathname.startsWith(`${sessionApiPath}/`)) { + if (!record.handleRequest) { + return new Response("Session has no API handler", { status: 404 }); + } + const scopedUrl = stripSessionApiPath(url, browserSession.id); + return record.handleRequest(new Request(scopedUrl.toString(), req), scopedUrl, requestContext); + } + if (record.htmlContent) { + const apiBase = `/s/${record.id}/api`; + const apiBaseScript = createApiBaseScript(apiBase); + return new Response(injectApiBase(record.htmlContent, apiBaseScript), { + headers: { "Content-Type": "text/html" }, + }); + } + } + + return new Response("Not found", { status: 404 }); + }; +} diff --git a/packages/server/daemon/session-factory.test.ts b/packages/server/daemon/session-factory.test.ts new file mode 100644 index 000000000..94081bca6 --- /dev/null +++ b/packages/server/daemon/session-factory.test.ts @@ -0,0 +1,540 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { DaemonSessionStore } from "./session-store"; +import { createDaemonSessionFactory } from "./session-factory"; +import type { DaemonFetchContext } from "./server"; + +let dirs: string[] = []; +const originalHome = process.env.HOME; + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; +} + +function run(command: string[], cwd: string): void { + const result = Bun.spawnSync(command, { cwd, stdout: "ignore", stderr: "pipe" }); + if (result.exitCode !== 0) { + throw new Error(`${command.join(" ")} failed: ${new TextDecoder().decode(result.stderr).trim()}`); + } +} + +afterEach(() => { + process.env.HOME = originalHome; + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +describe("createDaemonSessionFactory", () => { + test("creates a daemon-owned plan session and completes through the store", async () => { + const home = tempDir("plannotator-daemon-home-"); + const cwd = tempDir("plannotator-daemon-cwd-"); + process.env.HOME = home; + + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + const record = await factory({ + request: { + action: "plan", + origin: "opencode", + cwd, + plan: "# Test Plan\n\nDo the thing.", + availableAgents: [ + { name: "build", description: "Build agent", mode: "primary" }, + { name: "hidden", mode: "primary", hidden: true }, + { name: "helper", mode: "subagent" }, + ], + }, + }, context); + + expect(record.expiresAt).toBeDefined(); + + const planResponse = await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/plan"), + new URL("http://127.0.0.1:4321/api/plan"), + ); + const planBody = await planResponse.json(); + expect(planBody.plan).toContain("Do the thing."); + expect(planBody.projectRoot).toBe(cwd); + + const agentsResponse = await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/agents"), + new URL("http://127.0.0.1:4321/api/agents"), + ); + const agentsBody = await agentsResponse.json(); + expect(agentsBody.agents).toEqual([{ id: "build", name: "build", description: "Build agent" }]); + + await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/approve", { + method: "POST", + body: JSON.stringify({ planSave: { enabled: false } }), + }), + new URL("http://127.0.0.1:4321/api/approve"), + ); + + const completed = await store.waitForResult<{ approved: boolean }>(record.id); + expect(completed.status).toBe("completed"); + expect(completed.result?.approved).toBe(true); + }); + + test("cancelled daemon sessions settle decision watchers without becoming failed", async () => { + const home = tempDir("plannotator-daemon-home-"); + const cwd = tempDir("plannotator-daemon-cwd-"); + process.env.HOME = home; + + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + const record = await factory({ + request: { + action: "plan", + origin: "opencode", + cwd, + plan: "# Test Plan", + }, + }, context); + + await store.cancel(record.id, "Caller exited."); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(store.get(record.id)?.status).toBe("cancelled"); + expect(store.get(record.id)?.error).toBe("Caller exited."); + }); + + test("uses request timeout for active session TTL and allows disabled timeout", async () => { + const cwd = tempDir("plannotator-daemon-cwd-"); + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + const timed = await factory({ + request: { + action: "plan", + origin: "opencode", + cwd, + plan: "# Timed", + timeoutMs: 12_000, + }, + }, context); + expect(timed.expiresAt).toBe("1970-01-01T00:01:13.000Z"); + + const noTimeout = await factory({ + request: { + action: "plan", + origin: "opencode", + cwd, + plan: "# Untimed", + timeoutMs: null, + }, + }, context); + expect(noTimeout.expiresAt).toBeUndefined(); + }); + + test("archive sessions reject approve and deny endpoints without throwing", async () => { + const home = tempDir("plannotator-daemon-home-"); + const cwd = tempDir("plannotator-daemon-cwd-"); + process.env.HOME = home; + + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Archive", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + const record = await factory({ + request: { + action: "archive", + origin: "opencode", + cwd, + }, + }, context); + + const approve = await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/approve", { method: "POST", body: "{}" }), + new URL("http://127.0.0.1:4321/api/approve"), + ); + const deny = await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/deny", { method: "POST", body: "{}" }), + new URL("http://127.0.0.1:4321/api/deny"), + ); + + expect(approve.status).toBe(404); + expect((await approve.json()).error).toContain("Archive sessions"); + expect(deny.status).toBe(404); + expect((await deny.json()).error).toContain("Archive sessions"); + }); + + test("rejects daemon session requests without an explicit cwd", async () => { + const store = new DaemonSessionStore(); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + await expect(factory({ + request: { + action: "plan", + origin: "opencode", + plan: "# Missing cwd", + }, + }, context)).rejects.toThrow("Daemon session requests must include cwd."); + }); + + test("rejects plan file requests outside the session cwd", async () => { + const cwd = tempDir("plannotator-daemon-cwd-"); + const outside = tempDir("plannotator-daemon-outside-"); + writeFileSync(join(outside, "secret.md"), "# Secret", "utf-8"); + const store = new DaemonSessionStore(); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + await expect(factory({ + request: { + action: "plan", + origin: "opencode", + cwd, + planFilePath: join(outside, "secret.md"), + }, + }, context)).rejects.toThrow("Plugin plan file must resolve inside cwd."); + }); + + test("rejects non-markdown plan file requests", async () => { + const cwd = tempDir("plannotator-daemon-cwd-"); + writeFileSync(join(cwd, "PLAN.txt"), "# Plan", "utf-8"); + const store = new DaemonSessionStore(); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + await expect(factory({ + request: { + action: "plan", + origin: "opencode", + cwd, + planFilePath: "PLAN.txt", + }, + }, context)).rejects.toThrow("Plugin plan file must be a markdown file"); + }); + + test("resolves relative plan save paths against the request cwd", async () => { + const home = tempDir("plannotator-daemon-home-"); + const cwd = tempDir("plannotator-daemon-cwd-"); + process.env.HOME = home; + + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + const record = await factory({ + request: { + action: "plan", + origin: "opencode", + cwd, + plan: "# Saved Plan\n\nStore this under the session cwd.", + }, + }, context); + + const response = await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/approve", { + method: "POST", + body: JSON.stringify({ planSave: { enabled: true, customPath: "./plans" } }), + }), + new URL("http://127.0.0.1:4321/api/approve"), + ); + const body = await response.json(); + + expect(body.savedPath.startsWith(join(cwd, "plans"))).toBe(true); + expect(existsSync(body.savedPath)).toBe(true); + + const completed = await store.waitForResult<{ savedPath?: string }>(record.id); + expect(completed.result?.savedPath).toBe(body.savedPath); + }); + + test("returns remote share notices for the foreground client to print", async () => { + const home = tempDir("plannotator-daemon-home-"); + const cwd = tempDir("plannotator-daemon-cwd-"); + process.env.HOME = home; + + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + shareBaseUrl: "https://share.example.test", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "0.0.0.0", + port: 4321, + baseUrl: "http://localhost:4321", + isRemote: true, + }, + store, + }; + + const record = await factory({ + request: { + action: "plan", + origin: "opencode", + cwd, + plan: "# Remote Plan\n\nOpen locally.", + }, + }, context); + + expect(store.summary(record).remoteShare).toBeUndefined(); + const summary = store.summary(record, { includeRemoteShare: true }); + expect(summary.remoteShare?.url.startsWith("https://share.example.test/#")).toBe(true); + expect(summary.remoteShare?.verb).toBe("review the plan"); + expect(summary.remoteShare?.noun).toBe("plan only"); + expect(summary.remoteShare?.size).toMatch(/B|KB/); + }); + + test("returns remote share notices for review sessions", async () => { + const home = tempDir("plannotator-daemon-home-"); + const cwd = tempDir("plannotator-daemon-cwd-"); + process.env.HOME = home; + run(["git", "init"], cwd); + run(["git", "config", "user.email", "test@example.com"], cwd); + run(["git", "config", "user.name", "Test User"], cwd); + writeFileSync(join(cwd, "file.txt"), "before\n", "utf-8"); + run(["git", "add", "file.txt"], cwd); + run(["git", "commit", "-m", "initial"], cwd); + writeFileSync(join(cwd, "file.txt"), "after\n", "utf-8"); + + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + shareBaseUrl: "https://share.example.test", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "0.0.0.0", + port: 4321, + baseUrl: "http://localhost:4321", + isRemote: true, + }, + store, + }; + + const record = await factory({ + request: { + action: "review", + origin: "opencode", + cwd, + }, + }, context); + + const summary = store.summary(record, { includeRemoteShare: true }); + expect(summary.remoteShare?.url.startsWith("https://share.example.test/#")).toBe(true); + expect(summary.remoteShare?.verb).toBe("review changes"); + expect(summary.remoteShare?.noun).toBe("diff only"); + }); + + test("preserves at-reference annotate resolution through the daemon", async () => { + const home = tempDir("plannotator-daemon-home-"); + const cwd = tempDir("plannotator-daemon-cwd-"); + process.env.HOME = home; + writeFileSync(join(cwd, "README.md"), "# Notes\n\nReview this.", "utf-8"); + + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + const record = await factory({ + request: { + action: "annotate", + origin: "opencode", + cwd, + args: "@README.md", + }, + }, context); + + const planResponse = await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/plan"), + new URL("http://127.0.0.1:4321/api/plan"), + ); + const planBody = await planResponse.json(); + + expect(planBody.plan).toContain("Review this."); + expect(planBody.filePath).toBe(join(cwd, "README.md")); + }); + + test("uses structured annotate filePath verbatim when args are absent", async () => { + const home = tempDir("plannotator-daemon-home-"); + const cwd = tempDir("plannotator-daemon-cwd-"); + process.env.HOME = home; + const filePath = join(cwd, "Feature --gate spec.md"); + writeFileSync(filePath, "# Feature Spec\n\nDo not strip the filename.", "utf-8"); + + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + const record = await factory({ + request: { + action: "annotate", + origin: "pi", + cwd, + filePath, + }, + }, context); + + const planResponse = await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/plan"), + new URL("http://127.0.0.1:4321/api/plan"), + ); + const planBody = await planResponse.json(); + + expect(planBody.plan).toContain("Do not strip the filename."); + expect(planBody.filePath).toBe(filePath); + }); + + test("treats direct rawHtml annotate requests as HTML render targets", async () => { + const cwd = tempDir("plannotator-daemon-cwd-"); + const store = new DaemonSessionStore({ now: () => 1_000 }); + const factory = createDaemonSessionFactory({ + planHtmlContent: "Plan", + reviewHtmlContent: "Review", + }); + const context: DaemonFetchContext = { + endpoint: { + hostname: "127.0.0.1", + port: 4321, + baseUrl: "http://127.0.0.1:4321", + isRemote: false, + }, + store, + }; + + const record = await factory({ + request: { + action: "annotate", + origin: "opencode", + cwd, + filePath: "inline.html", + rawHtml: "

Inline HTML

", + }, + }, context); + + const planResponse = await record.handleRequest!( + new Request("http://127.0.0.1:4321/api/plan"), + new URL("http://127.0.0.1:4321/api/plan"), + ); + const planBody = await planResponse.json(); + + expect(planBody.renderAs).toBe("html"); + expect(planBody.rawHtml).toContain("Inline HTML"); + expect(planBody.plan).toBe(""); + }); +}); diff --git a/packages/server/daemon/session-factory.ts b/packages/server/daemon/session-factory.ts new file mode 100644 index 000000000..96807b8b9 --- /dev/null +++ b/packages/server/daemon/session-factory.ts @@ -0,0 +1,672 @@ +import { existsSync, realpathSync, rmSync, statSync } from "fs"; +import { tmpdir } from "os"; +import { basename, isAbsolute, relative, resolve } from "path"; +import type { DaemonCreateSessionRequest } from "@plannotator/shared/daemon-protocol"; +import { parseAnnotateArgs } from "@plannotator/shared/annotate-args"; +import { resolveAtReference } from "@plannotator/shared/at-reference"; +import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config"; +import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; +import { parseRemoteUrl } from "@plannotator/shared/repo"; +import { + hasMarkdownFiles, + resolveMarkdownFile, + resolveUserPath, +} from "@plannotator/shared/resolve-file"; +import { parseReviewArgs } from "@plannotator/shared/review-args"; +import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; +import { isConvertedSource, urlToMarkdown } from "@plannotator/shared/url-to-markdown"; +import { createWorktree, ensureObjectAvailable, fetchRef } from "@plannotator/shared/worktree"; +import { createWorktreePool, type WorktreePool } from "@plannotator/shared/worktree-pool"; +import type { + PluginAnnotateRequest, + PluginGoalSetupRequest, + PluginPlanRequest, + PluginReviewRequest, +} from "@plannotator/shared/plugin-protocol"; +import { normalizeGoalSetupBundle } from "@plannotator/shared/goal-setup"; +import { createPlannotatorSession } from "../index"; +import { createAnnotateSession } from "../annotate"; +import { createGoalSetupSession } from "../goal-setup"; +import { createReviewSession } from "../review"; +import { detectProjectName } from "../project"; +import { createRemoteShareNotice } from "../share-url"; +import { + gitRuntime, + prepareLocalReviewDiff, + type DiffType, +} from "../vcs"; +import { + checkPRAuth, + fetchPR, + getCliInstallUrl, + getCliName, + getDisplayRepo, + getMRLabel, + getMRNumberLabel, + parsePRUrl, +} from "../pr"; +import { + createDaemonSessionId, + type DaemonSessionRecord, +} from "./session-store"; +import type { DaemonFetchContext } from "./server"; + +export interface DaemonSessionFactoryOptions { + planHtmlContent: string; + reviewHtmlContent: string; + sharingEnabled?: boolean; + shareBaseUrl?: string; + pasteApiUrl?: string; + ttlMs?: number; +} + +const DEFAULT_SESSION_TTL_MS = 96 * 60 * 60 * 1000; +const SESSION_TIMEOUT_GRACE_MS = 60_000; + +type AnnotateInput = { + markdown: string; + filePath: string; + mode: "annotate" | "annotate-folder" | "annotate-last"; + folderPath?: string; + sourceInfo?: string; + sourceConverted?: boolean; + gate?: boolean; + rawHtml?: string; + renderHtml?: boolean; +}; + +function getRequestCwd(request: { cwd?: string }): string { + if (!request.cwd) { + throw new Error("Daemon session requests must include cwd."); + } + return resolve(request.cwd); +} + +function makeSessionUrl(baseUrl: string, id: string): string { + return `${baseUrl.replace(/\/$/, "")}/s/${encodeURIComponent(id)}`; +} + +function registerSessionDecision( + context: DaemonFetchContext, + id: string, + waitForDecision: () => Promise, + dispose: () => void | Promise, + mapResult: (result: TResult) => TStored = (result) => result as unknown as TStored, +): () => void | Promise { + let releaseDecisionWait: (() => void) | undefined; + const disposed = new Promise((_, reject) => { + releaseDecisionWait = () => reject(new Error("Session disposed.")); + }); + + void Promise.race([waitForDecision(), disposed]) + .then((result) => context.store.complete(id, mapResult(result))) + .catch((err) => { + if (context.store.get(id)?.status === "active") { + context.store.fail(id, err instanceof Error ? err.message : String(err)); + } + }); + + return () => { + releaseDecisionWait?.(); + releaseDecisionWait = undefined; + return dispose(); + }; +} + +function resolvePlanFilePath(planFilePath: string, cwd: string): string { + const requestedPath = isAbsolute(planFilePath) + ? planFilePath + : resolve(cwd, planFilePath); + const cwdReal = realpathSync(cwd); + const planReal = realpathSync(requestedPath); + const relativePath = relative(cwdReal, planReal); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + throw new Error("Plugin plan file must resolve inside cwd."); + } + if (!/\.(?:md|mdx)$/i.test(planReal)) { + throw new Error("Plugin plan file must be a markdown file (.md or .mdx)."); + } + return planReal; +} + +async function readPlanRequest(request: PluginPlanRequest, cwd: string): Promise { + if (typeof request.plan === "string" && request.plan.trim()) return request.plan; + if (!request.planFilePath) { + throw new Error("Plugin plan requests must include a non-empty plan or planFilePath."); + } + const planPath = resolvePlanFilePath(request.planFilePath, cwd); + const plan = await Bun.file(planPath).text(); + if (!plan.trim()) { + throw new Error("Plugin plan requests must include a non-empty plan or planFilePath."); + } + return plan; +} + +async function runProcess( + command: string[], + options: { cwd?: string; env?: Record } = {}, +): Promise<{ exitCode: number; stderr: string }> { + const proc = Bun.spawn(command, { + cwd: options.cwd, + env: options.env, + stdout: "ignore", + stderr: "pipe", + }); + const stderrStream = proc.stderr; + const [exitCode, stderr] = await Promise.all([ + proc.exited, + stderrStream ? new Response(stderrStream).text() : Promise.resolve(""), + ]); + return { exitCode, stderr: stderr.trim() }; +} + +async function resolveAnnotateInput( + request: PluginAnnotateRequest, + cwd: string, + defaultMode: "annotate" | "annotate-last" = "annotate", +): Promise { + 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 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 filePath = structuredFilePath.trim().length > 0 ? structuredFilePath : ""; + let folderPath = request.folderPath; + let mode: "annotate" | "annotate-folder" | "annotate-last" = request.mode ?? defaultMode; + let sourceInfo = request.sourceInfo; + let sourceConverted = request.sourceConverted ?? false; + + if (folderPath) { + const resolvedFolder = isAbsolute(folderPath) ? folderPath : resolveUserPath(folderPath, cwd); + folderPath = resolvedFolder; + filePath = resolvedFolder; + markdown = directMarkdown ? markdown : ""; + mode = "annotate-folder"; + } else if (!directMarkdown && typeof rawHtml !== "string") { + const rawFilePath = parsedArgs?.rawFilePath || structuredFilePath; + if (!rawFilePath) { + throw new Error("Plugin annotate requests must include args, markdown, filePath, folderPath, or rawHtml."); + } + + const parsedFilePath = parsedArgs?.filePath || structuredFilePath; + const isUrl = /^https?:\/\//i.test(parsedFilePath); + + if (isUrl) { + const result = await urlToMarkdown(parsedFilePath, { + useJina: request.useJina ?? resolveUseJina(request.noJina === true, loadConfig()), + jinaApiKey: request.jinaApiKey, + }); + markdown = result.markdown; + sourceConverted = isConvertedSource(result.source); + filePath = parsedFilePath; + sourceInfo = parsedFilePath; + } else { + const folderCandidate = resolveAtReference(rawFilePath, (candidate) => { + try { + return statSync(resolveUserPath(candidate, cwd)).isDirectory(); + } catch { + return false; + } + }); + + if (folderCandidate !== null) { + const resolvedTarget = resolveUserPath(folderCandidate, cwd); + if (!hasMarkdownFiles(resolvedTarget, FILE_BROWSER_EXCLUDED, /\.(mdx?|html?)$/i)) { + throw new Error(`No markdown or HTML files found in ${resolvedTarget}`); + } + folderPath = resolvedTarget; + filePath = resolvedTarget; + markdown = ""; + mode = "annotate-folder"; + } else { + const htmlCandidate = resolveAtReference(rawFilePath, (candidate) => { + const resolved = resolveUserPath(candidate, cwd); + return /\.html?$/i.test(resolved) && existsSync(resolved); + }); + + if (htmlCandidate !== null) { + const resolvedTarget = resolveUserPath(htmlCandidate, cwd); + const htmlFile = Bun.file(resolvedTarget); + if (htmlFile.size > 10 * 1024 * 1024) { + throw new Error(`File too large (${Math.round(htmlFile.size / 1024 / 1024)}MB, max 10MB): ${resolvedTarget}`); + } + const html = await htmlFile.text(); + if (renderHtml) { + rawHtml = html; + markdown = ""; + } else { + markdown = htmlToMarkdown(html); + sourceConverted = true; + } + filePath = resolvedTarget; + sourceInfo = basename(resolvedTarget); + } else { + let resolved = resolveMarkdownFile(parsedFilePath, cwd); + if (resolved.kind === "not_found" && rawFilePath !== parsedFilePath) { + resolved = resolveMarkdownFile(rawFilePath, cwd); + } + if (resolved.kind === "ambiguous") { + throw new Error(`Ambiguous filename "${resolved.input}" found ${resolved.matches.length} matches.`); + } + if (resolved.kind === "not_found" || resolved.kind === "unavailable") { + throw new Error(`File not found: ${resolved.input}`); + } + filePath = resolved.path; + markdown = await Bun.file(filePath).text(); + } + } + } + } + + if (!filePath) filePath = mode === "annotate-last" ? "last-message" : "document"; + return { + markdown, + filePath, + mode, + ...(folderPath && { folderPath }), + ...(sourceInfo && { sourceInfo }), + sourceConverted, + gate, + ...(rawHtml !== undefined && { rawHtml }), + renderHtml, + }; +} + +async function prepareReviewInput(request: PluginReviewRequest, cwd: string) { + const reviewArgs = parseReviewArgs(request.args ?? ""); + const urlArg = request.prUrl ?? reviewArgs.prUrl; + + let rawPatch: string; + let gitRef: string; + let error: string | undefined; + let gitContext: Awaited>["gitContext"] | undefined; + let prMetadata: Awaited>["metadata"] | undefined; + let diffType: DiffType | undefined; + let base: string | undefined; + let agentCwd: string | undefined; + let worktreePool: WorktreePool | undefined; + let onCleanup: (() => void | Promise) | undefined; + let localWarning: string | undefined; + + if (urlArg) { + const prRef = parsePRUrl(urlArg); + if (!prRef) { + throw new Error(`Invalid PR/MR URL: ${urlArg}`); + } + + try { + await checkPRAuth(prRef); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("not found") || msg.includes("ENOENT")) { + const cliName = getCliName(prRef); + const cliUrl = getCliInstallUrl(prRef); + throw new Error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed. Install it from ${cliUrl}`); + } + throw err; + } + + const pr = await fetchPR(prRef); + rawPatch = pr.rawPatch; + gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`; + prMetadata = pr.metadata; + + const useLocal = request.useLocal ?? reviewArgs.useLocal; + if (useLocal && prMetadata) { + let localPath: string | undefined; + let sessionDir: string | undefined; + try { + const repoDir = 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 = resolve(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`); + const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid; + localPath = resolve(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"], { 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 {} + + if (isSameRepo) { + 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, + }); + onCleanup = async () => { + try { + if (worktreePool) await worktreePool.cleanup(gitRuntime); + } catch {} + try { rmSync(sessionDir!, { recursive: true, force: true }); } catch {} + }; + } 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 }), + }; + + const cloneResult = await runProcess( + [cli, "repo", "clone", prRepo, localPath, "--", "--depth=1", "--no-checkout"], + { env: cloneEnv }, + ); + if (cloneResult.exitCode !== 0) { + throw new Error(`${cli} repo clone failed: ${cloneResult.stderr}`); + } + + const fetchResult = await runProcess( + ["git", "fetch", "--depth=200", "origin", fetchRefStr], + { cwd: localPath }, + ); + if (fetchResult.exitCode !== 0) { + throw new Error(`Failed to fetch PR head ref: ${fetchResult.stderr}`); + } + + const checkoutResult = await runProcess(["git", "checkout", "FETCH_HEAD"], { cwd: localPath }); + if (checkoutResult.exitCode !== 0) { + throw new Error(`git checkout FETCH_HEAD failed: ${checkoutResult.stderr}`); + } + + const baseFetch = await runProcess(["git", "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 runProcess(["git", "branch", "--", prMetadata.baseBranch, prMetadata.baseSha], { cwd: localPath }); + await runProcess(["git", "update-ref", `refs/remotes/origin/${prMetadata.baseBranch}`, prMetadata.baseSha], { cwd: localPath }); + onCleanup = () => { try { rmSync(sessionDir!, { recursive: true, force: true }); } catch {} }; + } + + agentCwd = localPath; + if (isSameRepo) { + worktreePool = createWorktreePool( + { sessionDir, repoDir, isSameRepo }, + { path: localPath, prUrl: prMetadata.url, number: prNumber, ready: true }, + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + localWarning = `Warning: --local checkout failed; using the remote diff instead.\n${message}`; + console.error(localWarning); + if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {} + agentCwd = undefined; + worktreePool = undefined; + onCleanup = undefined; + } + } + + return { + rawPatch, + gitRef, + error, + gitContext, + prMetadata, + diffType, + base, + agentCwd, + worktreePool, + onCleanup, + localWarning, + }; + } + + const config = loadConfig(); + const diffResult = await prepareLocalReviewDiff({ + cwd, + vcsType: request.vcsType ?? reviewArgs.vcsType, + requestedDiffType: request.diffType as DiffType | undefined, + requestedBase: request.defaultBranch, + configuredDiffType: resolveDefaultDiffType(config), + hideWhitespace: config.diffOptions?.hideWhitespace ?? false, + }); + return { + rawPatch: diffResult.rawPatch, + gitRef: diffResult.gitRef, + error: diffResult.error, + gitContext: diffResult.gitContext, + diffType: diffResult.diffType, + base: diffResult.base, + prMetadata, + agentCwd, + worktreePool, + onCleanup, + localWarning, + }; +} + +export function createDaemonSessionFactory(options: DaemonSessionFactoryOptions) { + return async function createSession( + createRequest: DaemonCreateSessionRequest, + context: DaemonFetchContext, + ): Promise { + const request = createRequest.request; + const cwd = getRequestCwd(request); + const project = (await detectProjectName(cwd)) ?? "_unknown"; + const id = createDaemonSessionId(); + const url = makeSessionUrl(context.endpoint.baseUrl, id); + const ttlMs = request.timeoutMs === null + ? undefined + : request.timeoutMs !== undefined + ? request.timeoutMs + SESSION_TIMEOUT_GRACE_MS + : options.ttlMs ?? DEFAULT_SESSION_TTL_MS; + const sharingEnabled = request.sharingEnabled ?? options.sharingEnabled ?? true; + const shareBaseUrl = request.shareBaseUrl ?? options.shareBaseUrl; + const pasteApiUrl = request.pasteApiUrl ?? options.pasteApiUrl; + + if (request.action === "plan") { + const plan = await readPlanRequest(request, cwd); + const remoteShare = context.endpoint.isRemote && sharingEnabled + ? await createRemoteShareNotice(plan, shareBaseUrl, "review the plan", "plan only").catch(() => undefined) + : undefined; + const session = await createPlannotatorSession({ + cwd, + plan, + origin: request.origin, + permissionMode: request.permissionMode, + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + htmlContent: options.planHtmlContent, + opencodeClient: request.availableAgents + ? { app: { agents: async () => ({ data: request.availableAgents }) } } + : undefined, + }); + const record = context.store.create({ + id, + mode: "plan", + url, + project, + label: `plugin-plan-${request.origin}-${project}`, + origin: request.origin, + ttlMs, + htmlContent: session.htmlContent, + handleRequest: session.handleRequest, + dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), + remoteShare, + }); + return record; + } + + if (request.action === "archive") { + const session = await createPlannotatorSession({ + cwd, + plan: "", + origin: request.origin, + mode: "archive", + customPlanPath: request.customPlanPath, + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + htmlContent: options.planHtmlContent, + }); + const record = context.store.create({ + id, + mode: "archive", + url, + project, + label: `plugin-archive-${request.origin}-${project}`, + origin: request.origin, + ttlMs, + htmlContent: session.htmlContent, + handleRequest: session.handleRequest, + dispose: registerSessionDecision( + context, + id, + () => session.waitForDone?.() ?? Promise.resolve(), + () => session.dispose(), + () => ({ opened: true }), + ), + }); + return record; + } + + if (request.action === "annotate" || request.action === "annotate-last") { + const input = await resolveAnnotateInput(request, cwd, request.action); + const remoteShare = context.endpoint.isRemote && sharingEnabled && input.markdown + ? await createRemoteShareNotice(input.markdown, shareBaseUrl, "annotate", "document only").catch(() => undefined) + : undefined; + const session = await createAnnotateSession({ + cwd, + ...input, + origin: request.origin, + sharingEnabled, + shareBaseUrl, + pasteApiUrl, + htmlContent: options.planHtmlContent, + }); + const record = context.store.create({ + id, + mode: "annotate", + url, + project, + label: input.folderPath + ? `plugin-annotate-${request.origin}-${basename(input.folderPath)}` + : `plugin-annotate-${request.origin}-${input.mode === "annotate-last" ? "last" : basename(input.filePath)}`, + origin: request.origin, + ttlMs, + htmlContent: session.htmlContent, + handleRequest: session.handleRequest, + dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose(), (result) => ({ + ...result, + filePath: input.filePath, + mode: input.mode, + })), + remoteShare, + }); + return record; + } + + if (request.action === "review") { + const input = await prepareReviewInput(request, cwd); + const sessionError = [input.error, input.localWarning].filter(Boolean).join("\n\n") || undefined; + const remoteShare = context.endpoint.isRemote && sharingEnabled + ? await createRemoteShareNotice(input.rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => undefined) + : undefined; + let session: Awaited>; + try { + session = await createReviewSession({ + cwd, + rawPatch: input.rawPatch, + gitRef: input.gitRef, + error: sessionError, + origin: request.origin, + diffType: input.gitContext ? (input.diffType ?? "unstaged") : undefined, + gitContext: input.gitContext, + initialBase: input.base, + prMetadata: input.prMetadata, + agentCwd: input.agentCwd, + worktreePool: input.worktreePool, + sharingEnabled, + shareBaseUrl, + htmlContent: options.reviewHtmlContent, + opencodeClient: request.availableAgents + ? { app: { agents: async () => ({ data: request.availableAgents }) } } + : undefined, + onCleanup: input.onCleanup, + }); + } catch (err) { + await Promise.resolve(input.onCleanup?.()).catch(() => {}); + throw err; + } + session.setServerUrl(url); + const record = context.store.create({ + id, + mode: "review", + url, + project, + label: input.prMetadata + ? `plugin-${getMRLabel(input.prMetadata).toLowerCase()}-review-${getDisplayRepo(input.prMetadata)}${getMRNumberLabel(input.prMetadata)}` + : `plugin-review-${request.origin}-${project}`, + origin: request.origin, + ttlMs, + htmlContent: session.htmlContent, + handleRequest: session.handleRequest, + dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), + remoteShare, + }); + return record; + } + + if (request.action === "goal-setup") { + const bundle = normalizeGoalSetupBundle(request.bundle, request.stage); + const session = await createGoalSetupSession({ + cwd, + bundle, + origin: request.origin, + htmlContent: options.planHtmlContent, + }); + const record = context.store.create({ + id, + mode: "goal-setup", + url, + project, + label: `goal-setup-${bundle.stage}-${request.goalSlug || project}`, + origin: request.origin, + ttlMs, + htmlContent: session.htmlContent, + handleRequest: session.handleRequest, + dispose: registerSessionDecision(context, id, () => session.waitForDecision(), () => session.dispose()), + }); + return record; + } + + throw new Error(`Unsupported daemon session action: ${(request as { action?: string }).action}`); + }; +} diff --git a/packages/server/daemon/session-store.test.ts b/packages/server/daemon/session-store.test.ts new file mode 100644 index 000000000..c22a64bf4 --- /dev/null +++ b/packages/server/daemon/session-store.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import { DaemonSessionStore } from "./session-store"; + +describe("DaemonSessionStore", () => { + test("creates stable session summaries", () => { + const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => 1_000 }); + const session = store.create({ + mode: "plan", + url: "http://localhost:1234/s/s1", + project: "repo", + label: "plan-repo", + origin: "claude-code", + ttlMs: 60_000, + }); + + expect(session.id).toBe("s1"); + expect(session.status).toBe("active"); + expect(store.activeCount()).toBe(1); + expect(store.totalCount()).toBe(1); + expect(store.list()).toEqual([ + { + id: "s1", + mode: "plan", + status: "active", + url: "http://localhost:1234/s/s1", + project: "repo", + label: "plan-repo", + origin: "claude-code", + createdAt: "1970-01-01T00:00:01.000Z", + updatedAt: "1970-01-01T00:00:01.000Z", + expiresAt: "1970-01-01T00:01:01.000Z", + }, + ]); + }); + + test("waiters resolve when a session completes and routing payloads are retained for result delivery", async () => { + let now = 1_000; + const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => now }); + let disposed = false; + store.create({ + mode: "review", + url: "http://x/s/s1", + project: "repo", + label: "review", + htmlContent: "", + handleRequest: () => new Response("ok"), + dispose: () => { disposed = true; }, + }); + const waiting = store.waitForResult<{ approved: boolean }>("s1"); + now = 2_000; + store.complete("s1", { approved: true }); + const result = await waiting; + + expect(result.status).toBe("completed"); + expect(result.result).toEqual({ approved: true }); + expect(result.updatedAt).toBe("1970-01-01T00:00:02.000Z"); + expect(result.expiresAt).toBe("1970-01-01T00:01:02.000Z"); + expect(store.activeCount()).toBe(0); + expect(store.list()).toEqual([]); + expect(disposed).toBe(true); + expect(store.get("s1")?.htmlContent).toBe(""); + expect(store.get("s1")?.handleRequest).toBeDefined(); + expect(store.get("s1")?.dispose).toBeUndefined(); + }); + + test("failed sessions dispose resources while retaining result delivery payloads", async () => { + const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => 1_000 }); + let disposed = false; + store.create({ + mode: "review", + url: "http://x/s/s1", + project: "repo", + label: "review", + htmlContent: "", + handleRequest: () => new Response("ok"), + dispose: () => { disposed = true; }, + }); + const waiting = store.waitForResult("s1"); + store.fail("s1", "Boom."); + const result = await waiting; + + expect(result.status).toBe("failed"); + expect(result.error).toBe("Boom."); + expect(disposed).toBe(true); + expect(store.get("s1")?.htmlContent).toBe(""); + expect(store.get("s1")?.handleRequest).toBeDefined(); + }); + + test("waiters resolve when a session is cancelled", async () => { + const store = new DaemonSessionStore({ idFactory: () => "s1" }); + let disposed = false; + store.create({ + mode: "annotate", + url: "http://x/s/s1", + project: "repo", + label: "annotate", + handleRequest: () => new Response("ok"), + dispose: () => { disposed = true; }, + }); + const waiting = store.waitForResult("s1"); + await store.cancel("s1", "User cancelled."); + const result = await waiting; + + expect(result.status).toBe("cancelled"); + expect(result.error).toBe("User cancelled."); + expect(disposed).toBe(true); + expect(store.get("s1")?.handleRequest).toBeUndefined(); + }); + + test("cleanupExpired marks active expired sessions and disposes them", async () => { + const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => 1_000 }); + let disposed = false; + store.create({ + mode: "archive", + url: "http://x/s/s1", + project: "repo", + label: "archive", + ttlMs: 100, + dispose: () => { disposed = true; }, + }); + const waiting = store.waitForResult("s1"); + const expired = await store.cleanupExpired(1_101); + const result = await waiting; + + expect(expired).toHaveLength(1); + expect(result.status).toBe("expired"); + expect(disposed).toBe(true); + expect(store.get("s1")).toBeUndefined(); + }); + + test("cleanupExpired removes terminal sessions after their TTL", async () => { + const store = new DaemonSessionStore({ idFactory: () => "s1", now: () => 1_000 }); + store.create({ + mode: "plan", + url: "http://x/s/s1", + project: "repo", + label: "plan", + ttlMs: 100, + }); + store.complete("s1", { approved: true }); + + const expired = await store.cleanupExpired(61_001); + + expect(expired).toHaveLength(1); + expect(expired[0].status).toBe("completed"); + expect(store.get("s1")).toBeUndefined(); + }); + + test("delete rejects waiters and disposes", async () => { + const store = new DaemonSessionStore({ idFactory: () => "s1" }); + let disposed = false; + store.create({ + mode: "plan", + url: "http://x/s/s1", + project: "repo", + label: "plan", + dispose: () => { disposed = true; }, + }); + const waiting = store.waitForResult("s1"); + await store.delete("s1"); + + await expect(waiting).rejects.toThrow("Session deleted: s1"); + expect(disposed).toBe(true); + expect(store.get("s1")).toBeUndefined(); + }); +}); diff --git a/packages/server/daemon/session-store.ts b/packages/server/daemon/session-store.ts new file mode 100644 index 000000000..129c8c0bd --- /dev/null +++ b/packages/server/daemon/session-store.ts @@ -0,0 +1,271 @@ +import type { + DaemonRemoteShareNotice, + DaemonSessionMode, + DaemonSessionStatus, + DaemonSessionSummary, +} from "@plannotator/shared/daemon-protocol"; +import type { SessionRequestHandler } from "../session-handler"; + +export interface DaemonSessionRecord { + id: string; + mode: DaemonSessionMode; + status: DaemonSessionStatus; + url: string; + project: string; + label: string; + origin?: string; + createdAt: string; + updatedAt: string; + expiresAt?: string; + result?: TResult; + error?: string; + remoteShare?: DaemonRemoteShareNotice; + htmlContent?: string; + handleRequest?: SessionRequestHandler; + dispose?: () => void | Promise; + disposed?: boolean; +} + +export interface CreateDaemonSessionInput { + id?: string; + mode: DaemonSessionMode; + url: string; + project: string; + label: string; + origin?: string; + ttlMs?: number; + now?: number; + htmlContent?: string; + handleRequest?: SessionRequestHandler; + dispose?: () => void | Promise; + result?: TResult; + remoteShare?: DaemonRemoteShareNotice; +} + +export interface DaemonSessionStoreOptions { + idFactory?: () => string; + now?: () => number; +} + +type Waiter = { + resolve: (record: DaemonSessionRecord) => void; + reject: (err: Error) => void; +}; + +const TERMINAL_STATUSES = new Set([ + "completed", + "cancelled", + "expired", + "failed", +]); +const TERMINAL_SESSION_TTL_MS = 60_000; + +function iso(ms: number): string { + return new Date(ms).toISOString(); +} + +export function createDaemonSessionId(): string { + return `sess_${Date.now().toString(36)}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; +} + +export class DaemonSessionStore { + private sessions = new Map(); + private waiters = new Map[]>(); + private readonly idFactory: () => string; + private readonly now: () => number; + + constructor(options: DaemonSessionStoreOptions = {}) { + this.idFactory = options.idFactory ?? createDaemonSessionId; + this.now = options.now ?? (() => Date.now()); + } + + create(input: CreateDaemonSessionInput): DaemonSessionRecord { + const now = input.now ?? this.now(); + const id = input.id ?? this.idFactory(); + const record: DaemonSessionRecord = { + id, + mode: input.mode, + status: input.result === undefined ? "active" : "completed", + url: input.url, + project: input.project, + label: input.label, + ...(input.origin && { origin: input.origin }), + createdAt: iso(now), + updatedAt: iso(now), + ...(input.ttlMs !== undefined && { expiresAt: iso(now + input.ttlMs) }), + ...(input.htmlContent && { htmlContent: input.htmlContent }), + ...(input.handleRequest && { handleRequest: input.handleRequest }), + ...(input.dispose && { dispose: input.dispose }), + ...(input.result !== undefined && { result: input.result }), + ...(input.remoteShare && { remoteShare: input.remoteShare }), + }; + this.sessions.set(id, record); + if (TERMINAL_STATUSES.has(record.status)) this.resolveWaiters(record); + return record; + } + + get(id: string): DaemonSessionRecord | undefined { + return this.sessions.get(id) as DaemonSessionRecord | undefined; + } + + list(): DaemonSessionSummary[] { + return [...this.sessions.values()] + .filter((record) => !TERMINAL_STATUSES.has(record.status)) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map((record) => this.summary(record)); + } + + activeCount(): number { + return [...this.sessions.values()].filter((record) => !TERMINAL_STATUSES.has(record.status)).length; + } + + totalCount(): number { + return this.sessions.size; + } + + summary(record: DaemonSessionRecord, options: { includeRemoteShare?: boolean } = {}): DaemonSessionSummary { + return { + id: record.id, + mode: record.mode, + status: record.status, + url: record.url, + project: record.project, + label: record.label, + ...(record.origin && { origin: record.origin }), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + ...(record.expiresAt && { expiresAt: record.expiresAt }), + ...(record.error && { error: record.error }), + ...(options.includeRemoteShare && record.remoteShare && { remoteShare: record.remoteShare }), + }; + } + + complete(id: string, result: TResult): DaemonSessionRecord | undefined { + const record = this.sessions.get(id) as DaemonSessionRecord | undefined; + if (!record || TERMINAL_STATUSES.has(record.status)) return record; + record.status = "completed"; + record.result = result; + const now = this.now(); + record.updatedAt = iso(now); + record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); + this.resolveWaiters(record); + void this.disposeResources(record); + return record; + } + + fail(id: string, error: string): DaemonSessionRecord | undefined { + const record = this.sessions.get(id); + if (!record || TERMINAL_STATUSES.has(record.status)) return record; + record.status = "failed"; + record.error = error; + const now = this.now(); + record.updatedAt = iso(now); + record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); + this.resolveWaiters(record); + void this.disposeResources(record); + return record; + } + + async cancel(id: string, reason = "Session cancelled."): Promise { + const record = this.sessions.get(id); + if (!record || TERMINAL_STATUSES.has(record.status)) return record; + record.status = "cancelled"; + record.error = reason; + const now = this.now(); + record.updatedAt = iso(now); + record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); + this.resolveWaiters(record); + await this.disposeRecord(record); + return record; + } + + waitForResult(id: string): Promise> { + const record = this.sessions.get(id) as DaemonSessionRecord | undefined; + if (!record) return Promise.reject(new Error(`Session not found: ${id}`)); + if (TERMINAL_STATUSES.has(record.status)) return Promise.resolve(record); + return new Promise((resolve, reject) => { + const waiters = this.waiters.get(id) ?? []; + waiters.push({ resolve: resolve as (record: DaemonSessionRecord) => void, reject }); + this.waiters.set(id, waiters); + }); + } + + async delete(id: string): Promise { + const record = this.sessions.get(id); + if (!record) return false; + this.sessions.delete(id); + this.rejectWaiters(id, new Error(`Session deleted: ${id}`)); + await this.disposeRecord(record); + return true; + } + + async cleanupExpired(now = this.now()): Promise { + const expired: DaemonSessionRecord[] = []; + for (const record of [...this.sessions.values()]) { + if (!record.expiresAt) continue; + if (new Date(record.expiresAt).getTime() > now) continue; + if (TERMINAL_STATUSES.has(record.status)) { + expired.push(record); + await this.removeRecord(record); + continue; + } + record.status = "expired"; + record.error = "Session expired."; + record.updatedAt = iso(now); + expired.push(record); + this.resolveWaiters(record); + await this.removeRecord(record); + } + return expired; + } + + async cancelAll(reason = "Daemon shutting down."): Promise { + const records = [...this.sessions.values()]; + for (const record of records) { + if (!TERMINAL_STATUSES.has(record.status)) { + record.status = "cancelled"; + record.error = reason; + const now = this.now(); + record.updatedAt = iso(now); + record.expiresAt = iso(now + TERMINAL_SESSION_TTL_MS); + this.resolveWaiters(record); + } + await this.disposeRecord(record); + } + } + + private resolveWaiters(record: DaemonSessionRecord): void { + const waiters = this.waiters.get(record.id) ?? []; + this.waiters.delete(record.id); + for (const waiter of waiters) waiter.resolve(record); + } + + private rejectWaiters(id: string, err: Error): void { + const waiters = this.waiters.get(id) ?? []; + this.waiters.delete(id); + for (const waiter of waiters) waiter.reject(err); + } + + private async removeRecord(record: DaemonSessionRecord): Promise { + this.sessions.delete(record.id); + await this.disposeRecord(record); + } + + private async disposeRecord(record: DaemonSessionRecord): Promise { + await this.disposeResources(record); + record.htmlContent = undefined; + record.handleRequest = undefined; + } + + private async disposeResources(record: DaemonSessionRecord): Promise { + if (record.disposed) return; + record.disposed = true; + const dispose = record.dispose; + record.dispose = undefined; + try { + await dispose?.(); + } catch { + // Best-effort cleanup; callers observe session status separately. + } + } +} diff --git a/packages/server/daemon/start-command.test.ts b/packages/server/daemon/start-command.test.ts new file mode 100644 index 000000000..631d53524 --- /dev/null +++ b/packages/server/daemon/start-command.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { getDaemonStartCommand } from "./start-command"; + +describe("getDaemonStartCommand", () => { + test("uses Bun plus the source entry when running from TypeScript", () => { + expect(getDaemonStartCommand( + ["bun", "apps/hook/server/index.ts"], + "/usr/local/bin/bun", + "/repo/plannotator", + )).toEqual([ + "/usr/local/bin/bun", + "/repo/plannotator/apps/hook/server/index.ts", + "daemon", + "start", + "--foreground", + ]); + }); + + test("keeps absolute source entries absolute", () => { + expect(getDaemonStartCommand( + ["bun", "/repo/plannotator/apps/hook/server/index.ts"], + "/usr/local/bin/bun", + "/other", + )).toEqual([ + "/usr/local/bin/bun", + "/repo/plannotator/apps/hook/server/index.ts", + "daemon", + "start", + "--foreground", + ]); + }); + + test("uses the executable itself for compiled Bun binaries", () => { + expect(getDaemonStartCommand(["bun", "/$bunfs/root/index"], "/usr/local/bin/plannotator")).toEqual([ + "/usr/local/bin/plannotator", + "daemon", + "start", + "--foreground", + ]); + }); +}); diff --git a/packages/server/daemon/start-command.ts b/packages/server/daemon/start-command.ts new file mode 100644 index 000000000..200ae7fda --- /dev/null +++ b/packages/server/daemon/start-command.ts @@ -0,0 +1,14 @@ +import { isAbsolute, resolve } from "path"; + +export function getDaemonStartCommand( + argv: string[] = process.argv, + execPath = process.execPath, + cwd = process.cwd(), +): string[] { + const entry = argv[1]; + if (entry && /\.(?:[cm]?[jt]s)$/.test(entry)) { + const resolvedEntry = isAbsolute(entry) ? entry : resolve(cwd, entry); + return [execPath, resolvedEntry, "daemon", "start", "--foreground"]; + } + return [execPath, "daemon", "start", "--foreground"]; +} diff --git a/packages/server/daemon/state.test.ts b/packages/server/daemon/state.test.ts new file mode 100644 index 000000000..7bb2d712b --- /dev/null +++ b/packages/server/daemon/state.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + acquireDaemonLock, + createDaemonState, + getDaemonPaths, + readDaemonState, + removeDaemonFiles, + removeDaemonState, + writeDaemonState, +} from "./state"; + +let dirs: string[] = []; + +function tempBase(): string { + const dir = mkdtempSync(join(tmpdir(), "plannotator-daemon-state-")); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + dirs = []; +}); + +describe("daemon state", () => { + test("reads missing state", () => { + expect(readDaemonState({ baseDir: tempBase() })).toEqual({ kind: "missing" }); + }); + + test("writes and reads active state", () => { + const baseDir = tempBase(); + const state = createDaemonState({ + pid: 123, + port: 19432, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + startedAt: "2026-01-01T00:00:00.000Z", + }); + writeDaemonState(state, { baseDir }); + + expect(readDaemonState({ baseDir, isAlive: (pid) => pid === 123 })).toEqual({ + kind: "active", + path: getDaemonPaths({ baseDir }).statePath, + state, + }); + }); + + test("uses localhost URLs for local daemon sessions", () => { + const state = createDaemonState({ + pid: 123, + port: 19432, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + }); + + expect(state.baseUrl).toBe("http://localhost:19432"); + }); + + test("classifies dead daemon state as stale", () => { + const baseDir = tempBase(); + const state = createDaemonState({ + pid: 123, + port: 19432, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + }); + writeDaemonState(state, { baseDir }); + + const result = readDaemonState({ baseDir, isAlive: () => false }); + expect(result.kind).toBe("stale"); + }); + + test("classifies malformed JSON", () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeFileSync(paths.statePath, "{ nope", "utf-8"); + const result = readDaemonState({ baseDir }); + expect(result.kind).toBe("malformed"); + }); + + test("classifies incompatible protocol state", () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeFileSync(paths.statePath, JSON.stringify({ protocol: "old" }), "utf-8"); + const result = readDaemonState({ baseDir }); + expect(result.kind).toBe("incompatible"); + }); + + test("removes state", () => { + const baseDir = tempBase(); + writeDaemonState(createDaemonState({ + pid: 123, + port: 19432, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + }), { baseDir }); + removeDaemonState({ baseDir }); + expect(readDaemonState({ baseDir })).toEqual({ kind: "missing" }); + }); + + test("removes state and lock files together", () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeDaemonState(createDaemonState({ + pid: 123, + port: 19432, + hostname: "127.0.0.1", + isRemote: false, + remoteSource: "local", + }), { baseDir }); + writeFileSync(paths.lockPath, "123\n", "utf-8"); + removeDaemonFiles({ baseDir }); + expect(readDaemonState({ baseDir })).toEqual({ kind: "missing" }); + expect(acquireDaemonLock({ baseDir }).ok).toBe(true); + }); +}); + +describe("daemon lock", () => { + test("acquires and releases lock", () => { + const baseDir = tempBase(); + const result = acquireDaemonLock({ baseDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.lock.path).toBe(getDaemonPaths({ baseDir }).lockPath); + result.lock.release(); + expect(acquireDaemonLock({ baseDir }).ok).toBe(true); + }); + + test("release does not remove a replacement lock", () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + const result = acquireDaemonLock({ baseDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + writeFileSync(paths.lockPath, "999\n", "utf-8"); + result.lock.release(); + + const next = acquireDaemonLock({ baseDir, isAlive: (pid) => pid === 999 }); + expect(next.ok).toBe(false); + if (next.ok) return; + expect(next.code).toBe("locked"); + expect(next.pid).toBe(999); + }); + + test("rejects live lock", () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeFileSync(paths.lockPath, "999\n", "utf-8"); + const result = acquireDaemonLock({ baseDir, isAlive: (pid) => pid === 999 }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("locked"); + expect(result.pid).toBe(999); + }); + + test("clears stale lock", () => { + const baseDir = tempBase(); + const paths = getDaemonPaths({ baseDir }); + writeFileSync(paths.lockPath, "999\n", "utf-8"); + const result = acquireDaemonLock({ baseDir, isAlive: () => false }); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/server/daemon/state.ts b/packages/server/daemon/state.ts new file mode 100644 index 000000000..802088f27 --- /dev/null +++ b/packages/server/daemon/state.ts @@ -0,0 +1,243 @@ +import { + PLANNOTATOR_DAEMON_PROTOCOL, + PLANNOTATOR_DAEMON_PROTOCOL_VERSION, +} from "@plannotator/shared/daemon-protocol"; +import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, closeSync, statSync, type Stats } from "fs"; +import { homedir } from "os"; +import { dirname, join } from "path"; + +export interface DaemonState { + protocol: typeof PLANNOTATOR_DAEMON_PROTOCOL; + protocolVersion: typeof PLANNOTATOR_DAEMON_PROTOCOL_VERSION; + pid: number; + port: number; + hostname: string; + baseUrl: string; + startedAt: string; + isRemote: boolean; + remoteSource: "env" | "ssh" | "local"; + requestedPort?: number; + binaryVersion?: string; +} + +export interface DaemonPaths { + dir: string; + statePath: string; + lockPath: string; +} + +export interface DaemonStateOptions { + baseDir?: string; + isAlive?: (pid: number) => boolean; +} + +export type DaemonStateReadResult = + | { kind: "missing" } + | { kind: "malformed"; path: string; error: string } + | { kind: "stale"; path: string; state: DaemonState } + | { kind: "incompatible"; path: string; state: unknown } + | { kind: "active"; path: string; state: DaemonState }; + +export interface DaemonLock { + path: string; + release: () => void; +} + +export type DaemonLockResult = + | { ok: true; lock: DaemonLock } + | { ok: false; code: "locked"; message: string; pid?: number } + | { ok: false; code: "failed"; message: string }; + +function defaultIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function getDaemonPaths(options: DaemonStateOptions = {}): DaemonPaths { + const dir = options.baseDir ?? join(homedir(), ".plannotator"); + return { + dir, + statePath: join(dir, "daemon.json"), + lockPath: join(dir, "daemon.lock"), + }; +} + +export function isDaemonState(value: unknown): value is DaemonState { + const state = value as Partial | null; + return ( + !!state && + state.protocol === PLANNOTATOR_DAEMON_PROTOCOL && + state.protocolVersion === PLANNOTATOR_DAEMON_PROTOCOL_VERSION && + typeof state.pid === "number" && + Number.isInteger(state.pid) && + state.pid > 0 && + typeof state.port === "number" && + Number.isInteger(state.port) && + state.port > 0 && + state.port < 65536 && + typeof state.hostname === "string" && + typeof state.baseUrl === "string" && + typeof state.startedAt === "string" && + typeof state.isRemote === "boolean" + ); +} + +export function readDaemonState(options: DaemonStateOptions = {}): DaemonStateReadResult { + const paths = getDaemonPaths(options); + if (!existsSync(paths.statePath)) return { kind: "missing" }; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(paths.statePath, "utf-8")); + } catch (err) { + return { + kind: "malformed", + path: paths.statePath, + error: err instanceof Error ? err.message : "Could not parse daemon state", + }; + } + + if (!isDaemonState(parsed)) { + return { kind: "incompatible", path: paths.statePath, state: parsed }; + } + + const isAlive = options.isAlive ?? defaultIsAlive; + if (!isAlive(parsed.pid)) { + return { kind: "stale", path: paths.statePath, state: parsed }; + } + + return { kind: "active", path: paths.statePath, state: parsed }; +} + +export function writeDaemonState(state: DaemonState, options: DaemonStateOptions = {}): void { + const paths = getDaemonPaths(options); + mkdirSync(dirname(paths.statePath), { recursive: true }); + writeFileSync(paths.statePath, JSON.stringify(state, null, 2), "utf-8"); +} + +export function removeDaemonState(options: DaemonStateOptions = {}): void { + const paths = getDaemonPaths(options); + rmSync(paths.statePath, { force: true }); +} + +export function removeDaemonFiles(options: DaemonStateOptions = {}): void { + const paths = getDaemonPaths(options); + rmSync(paths.statePath, { force: true }); + rmSync(paths.lockPath, { force: true }); +} + +function readLockPid(path: string): number | undefined { + try { + const raw = readFileSync(path, "utf-8").trim(); + const pid = parseInt(raw, 10); + return Number.isInteger(pid) && pid > 0 ? pid : undefined; + } catch { + return undefined; + } +} + +function sameLockFile(left: Stats, right: Stats): boolean { + return left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeMs === right.mtimeMs; +} + +export function acquireDaemonLock(options: DaemonStateOptions = {}): DaemonLockResult { + const paths = getDaemonPaths(options); + mkdirSync(paths.dir, { recursive: true }); + const isAlive = options.isAlive ?? defaultIsAlive; + + for (let attempt = 0; attempt < 5; attempt += 1) { + let fd: number | undefined; + try { + fd = openSync(paths.lockPath, "wx"); + writeFileSync(fd, `${process.pid}\n`, "utf-8"); + closeSync(fd); + fd = undefined; + return { + ok: true, + lock: { + path: paths.lockPath, + release: () => { + if (readLockPid(paths.lockPath) === process.pid) { + rmSync(paths.lockPath, { force: true }); + } + }, + }, + }; + } catch (err) { + if (fd !== undefined) { + try { closeSync(fd); } catch {} + } + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + return { + ok: false, + code: "failed", + message: err instanceof Error ? err.message : "Could not acquire daemon lock", + }; + } + + let before: Stats; + try { + before = statSync(paths.lockPath); + } catch { + continue; + } + const lockPid = readLockPid(paths.lockPath); + if (lockPid && isAlive(lockPid)) { + return { + ok: false, + code: "locked", + pid: lockPid, + message: `A Plannotator daemon lock is already held by PID ${lockPid}.`, + }; + } + + try { + const after = statSync(paths.lockPath); + if (sameLockFile(before, after)) { + rmSync(paths.lockPath, { force: true }); + } + } catch {} + } + } + + return { + ok: false, + code: "failed", + message: "Could not acquire daemon lock after retrying stale lock cleanup.", + }; +} + +export function createDaemonState(input: { + pid?: number; + port: number; + hostname: string; + isRemote: boolean; + remoteSource: DaemonState["remoteSource"]; + startedAt?: string; + binaryVersion?: string; + requestedPort?: number; +}): DaemonState { + const baseHost = input.isRemote + ? input.hostname === "0.0.0.0" ? "localhost" : input.hostname + : "localhost"; + return { + protocol: PLANNOTATOR_DAEMON_PROTOCOL, + protocolVersion: PLANNOTATOR_DAEMON_PROTOCOL_VERSION, + pid: input.pid ?? process.pid, + port: input.port, + hostname: input.hostname, + baseUrl: `http://${baseHost}:${input.port}`, + startedAt: input.startedAt ?? new Date().toISOString(), + isRemote: input.isRemote, + remoteSource: input.remoteSource, + ...(input.binaryVersion && { binaryVersion: input.binaryVersion }), + ...(input.requestedPort !== undefined && { requestedPort: input.requestedPort }), + }; +} diff --git a/packages/server/external-annotations.test.ts b/packages/server/external-annotations.test.ts index a4bda3cf9..70fa98c02 100644 --- a/packages/server/external-annotations.test.ts +++ b/packages/server/external-annotations.test.ts @@ -15,4 +15,19 @@ describe("external annotations SSE", () => { expect(disableIdleTimeout).toHaveBeenCalledTimes(1); expect(res?.headers.get("content-type")).toBe("text/event-stream"); }); + + test("dispose closes active streams", async () => { + const handler = createExternalAnnotationHandler("plan"); + const res = await handler.handle( + new Request("http://localhost/api/external-annotations/stream"), + new URL("http://localhost/api/external-annotations/stream"), + ); + + const reader = res!.body!.getReader(); + await reader.read(); + handler.dispose(); + const next = await reader.read(); + + expect(next.done).toBe(true); + }); }); diff --git a/packages/server/external-annotations.ts b/packages/server/external-annotations.ts index 0f49be66f..1610f5fcf 100644 --- a/packages/server/external-annotations.ts +++ b/packages/server/external-annotations.ts @@ -35,6 +35,7 @@ export interface ExternalAnnotationHandler { ) => Promise; /** Push annotations directly into the store (bypasses HTTP, reuses same validation). */ addAnnotations: (body: unknown) => { ids: string[] } | { error: string }; + dispose: () => void; } // --------------------------------------------------------------------------- @@ -52,19 +53,27 @@ export function createExternalAnnotationHandler( mode: "plan" | "review", ): ExternalAnnotationHandler { const store: AnnotationStore = createAnnotationStore(); - const subscribers = new Set(); + const subscribers = new Map | null }>(); const encoder = new TextEncoder(); const transform = mode === "plan" ? transformPlanInput : transformReviewInput; + let disposed = false; + + const removeSubscriber = (controller: ReadableStreamDefaultController) => { + const subscription = subscribers.get(controller); + if (subscription?.heartbeatTimer) clearInterval(subscription.heartbeatTimer); + subscribers.delete(controller); + }; // Wire store mutations → SSE broadcast store.onMutation((event: ExternalAnnotationEvent) => { + if (disposed) return; const data = encoder.encode(serializeSSEEvent(event)); - for (const controller of subscribers) { + for (const controller of subscribers.keys()) { try { controller.enqueue(data); } catch { // Controller closed — clean up on next iteration - subscribers.delete(controller); + removeSubscriber(controller); } } }); @@ -86,12 +95,13 @@ export function createExternalAnnotationHandler( if (url.pathname === STREAM && req.method === "GET") { options?.disableIdleTimeout?.(); - let heartbeatTimer: ReturnType | null = null; let ctrl: ReadableStreamDefaultController; const stream = new ReadableStream({ start(controller) { ctrl = controller; + const subscription = { heartbeatTimer: null as ReturnType | null }; + subscribers.set(controller, subscription); // Send current state as snapshot const snapshot: ExternalAnnotationEvent = { @@ -100,22 +110,18 @@ export function createExternalAnnotationHandler( }; controller.enqueue(encoder.encode(serializeSSEEvent(snapshot))); - subscribers.add(controller); - // Heartbeat to keep connection alive - heartbeatTimer = setInterval(() => { + subscription.heartbeatTimer = setInterval(() => { try { controller.enqueue(encoder.encode(HEARTBEAT_COMMENT)); } catch { // Stream closed - if (heartbeatTimer) clearInterval(heartbeatTimer); - subscribers.delete(controller); + removeSubscriber(controller); } }, HEARTBEAT_INTERVAL_MS); }, cancel() { - if (heartbeatTimer) clearInterval(heartbeatTimer); - subscribers.delete(ctrl); + removeSubscriber(ctrl); }, }); @@ -203,5 +209,17 @@ export function createExternalAnnotationHandler( // Not handled — pass through return null; }, + + dispose(): void { + disposed = true; + for (const controller of Array.from(subscribers.keys())) { + removeSubscriber(controller); + try { + controller.close(); + } catch { + // Stream may already be closed by the client. + } + } + }, }; } diff --git a/packages/server/goal-setup.test.ts b/packages/server/goal-setup.test.ts index 2049881c6..59d7e291f 100644 --- a/packages/server/goal-setup.test.ts +++ b/packages/server/goal-setup.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { normalizeGoalSetupBundle } from "@plannotator/shared/goal-setup"; -import { startGoalSetupServer, type GoalSetupServerResult } from "./goal-setup"; +import { + startGoalSetupServer, + createGoalSetupSession, + type GoalSetupServerResult, +} from "./goal-setup"; let server: GoalSetupServerResult | null = null; @@ -9,23 +13,35 @@ afterEach(() => { server = null; }); -describe("goal setup server", () => { - test("serves interview bundle and resolves submitted answers", async () => { - const bundle = normalizeGoalSetupBundle({ - stage: "interview", - title: "Goal setup", - questions: [{ id: "scope", prompt: "Scope?" }], - }); +const interviewBundle = () => + normalizeGoalSetupBundle({ + stage: "interview", + title: "Goal setup", + questions: [{ id: "scope", prompt: "Scope?" }], + }); + +const factsBundle = () => + normalizeGoalSetupBundle({ + stage: "facts", + title: "Facts review", + facts: [{ id: "f1", text: "The app uses Bun.", accepted: false, removed: false, automatedVerification: false }], + }); + +function makeRequest(path: string, init?: RequestInit): { req: Request; url: URL } { + const fullUrl = `http://localhost${path}`; + return { req: new Request(fullUrl, init), url: new URL(fullUrl) }; +} +describe("goal setup standalone server", () => { + test("serves interview bundle and resolves submitted answers", async () => { + const bundle = interviewBundle(); server = await startGoalSetupServer({ bundle, htmlContent: "", origin: "claude-code", }); - const plan = await fetch(`${server.url}/api/goal-setup`).then((res) => - res.json() - ); + const plan = await fetch(`${server.url}/api/goal-setup`).then((res) => res.json()); expect(plan.mode).toBe("goal-setup"); expect(plan.goalSetup.questions[0].id).toBe("scope"); @@ -34,22 +50,96 @@ describe("goal setup server", () => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - answers: [ - { - questionId: "scope", - selectedOptionIds: [], - customAnswer: "", - answer: "UI, server, and skill text.", - completed: true, - }, - ], + answers: [{ questionId: "scope", selectedOptionIds: [], customAnswer: "", answer: "Everything.", completed: true }], }), }).then((res) => res.json()); expect(submitted.ok).toBe(true); const result = await decision; expect(result.result?.stage).toBe("interview"); + }); +}); + +describe("goal setup daemon session", () => { + test("serves interview bundle via handleRequest", async () => { + const session = await createGoalSetupSession({ bundle: interviewBundle(), htmlContent: "" }); + + const { req, url } = makeRequest("/api/goal-setup"); + const response = await session.handleRequest(req, url); + const data = await response.json(); + + expect(data.mode).toBe("goal-setup"); + expect(data.goalSetup.questions[0].id).toBe("scope"); + session.dispose(); + }); + + test("resolves submitted interview answers", async () => { + const session = await createGoalSetupSession({ bundle: interviewBundle(), htmlContent: "" }); + + const decision = session.waitForDecision(); + const { req, url } = makeRequest("/api/goal-setup/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + answers: [{ questionId: "scope", selectedOptionIds: [], customAnswer: "", answer: "Ship it.", completed: true }], + }), + }); + + const response = await session.handleRequest(req, url); + expect((await response.json()).ok).toBe(true); + + const result = await decision; + expect(result.result?.stage).toBe("interview"); if (result.result?.stage !== "interview") throw new Error("expected interview"); - expect(result.result.answers[0].answer).toBe("UI, server, and skill text."); + expect(result.result.answers[0].answer).toBe("Ship it."); + }); + + test("resolves submitted facts", async () => { + const session = await createGoalSetupSession({ bundle: factsBundle(), htmlContent: "" }); + + const decision = session.waitForDecision(); + const { req, url } = makeRequest("/api/goal-setup/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + facts: [{ factId: "f1", accepted: true, removed: false, text: "The app uses Bun.", automatedVerification: true }], + }), + }); + + const response = await session.handleRequest(req, url); + expect((await response.json()).ok).toBe(true); + + const result = await decision; + expect(result.result?.stage).toBe("facts"); + }); + + test("resolves exit on /api/exit", async () => { + const session = await createGoalSetupSession({ bundle: interviewBundle(), htmlContent: "" }); + + const decision = session.waitForDecision(); + const { req, url } = makeRequest("/api/exit", { method: "POST" }); + await session.handleRequest(req, url); + + const result = await decision; + expect(result.exit).toBe(true); + expect(result.result).toBeUndefined(); + }); + + test("dispose resolves as exit", async () => { + const session = await createGoalSetupSession({ bundle: interviewBundle(), htmlContent: "" }); + + const decision = session.waitForDecision(); + session.dispose(); + + const result = await decision; + expect(result.exit).toBe(true); + }); + + test("returns 404 for unknown routes", async () => { + const session = await createGoalSetupSession({ bundle: interviewBundle(), htmlContent: "" }); + const { req, url } = makeRequest("/api/unknown"); + const response = await session.handleRequest(req, url); + expect(response.status).toBe(404); + session.dispose(); }); }); diff --git a/packages/server/goal-setup.ts b/packages/server/goal-setup.ts index 57d9c99cd..4f9fe35f5 100644 --- a/packages/server/goal-setup.ts +++ b/packages/server/goal-setup.ts @@ -25,6 +25,7 @@ import { } from "./shared-handlers"; import { detectGitUser, getServerConfig, saveConfig } from "./config"; import { isWSL } from "./browser"; +import type { SessionRequestHandler } from "./session-handler"; export { handleServerReady as handleGoalSetupServerReady } from "./shared-handlers"; @@ -46,6 +47,22 @@ export interface GoalSetupServerResult { stop: () => void; } +export interface GoalSetupSessionOptions { + cwd?: string; + bundle: GoalSetupBundle; + htmlContent: string; + origin?: Origin; +} + +export interface GoalSetupSession { + htmlContent: string; + handleRequest: SessionRequestHandler; + waitForDecision: () => Promise<{ result?: GoalSetupResult; exit?: boolean }>; + dispose: () => void; +} + +type GoalSetupDecision = { result?: GoalSetupResult; exit?: boolean }; + const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; @@ -75,33 +92,119 @@ function coerceFacts(body: unknown): GoalSetupFactResult[] { return facts as GoalSetupFactResult[]; } -export async function startGoalSetupServer( - options: GoalSetupServerOptions -): Promise { - const { bundle, htmlContent, origin = "claude-code", onReady } = options; - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); - const wslFlag = await isWSL(); - const repoInfo = await getRepoInfo(); - const gitUser = detectGitUser(); +interface GoalSetupHandlerContext { + bundle: GoalSetupBundle; + origin: Origin; + cwd: string; + wslFlag: boolean; + repoInfo: Awaited>; + gitUser: ReturnType; +} +function createGoalSetupDecision() { let settled = false; - let resolveDecision: (result: { - result?: GoalSetupResult; - exit?: boolean; - }) => void; - const decisionPromise = new Promise<{ - result?: GoalSetupResult; - exit?: boolean; - }>((resolve) => { + let resolveDecision: (result: GoalSetupDecision) => void; + const promise = new Promise((resolve) => { resolveDecision = resolve; }); - - const resolveOnce = (result: { result?: GoalSetupResult; exit?: boolean }) => { + const resolveOnce = (result: GoalSetupDecision) => { if (settled) return; settled = true; resolveDecision(result); }; + return { promise, resolveOnce }; +} + +function createGoalSetupRouteHandler( + ctx: GoalSetupHandlerContext, + resolveOnce: (result: GoalSetupDecision) => void, +): (req: Request, url: URL) => Promise { + return async (req, url) => { + if ((url.pathname === "/api/plan" || url.pathname === "/api/goal-setup") && req.method === "GET") { + return Response.json({ + plan: "", + origin: ctx.origin, + mode: "goal-setup", + goalSetup: ctx.bundle, + repoInfo: ctx.repoInfo, + projectRoot: ctx.cwd, + isWSL: ctx.wslFlag, + serverConfig: getServerConfig(ctx.gitUser), + sharingEnabled: false, + }); + } + + if (url.pathname === "/api/config" && req.method === "POST") { + try { + const body = (await req.json()) as { + displayName?: string; + diffOptions?: Record; + conventionalComments?: boolean; + conventionalLabels?: unknown[] | null; + }; + 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.conventionalLabels !== undefined) toSave.conventionalLabels = body.conventionalLabels; + if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters[0]); + return Response.json({ ok: true }); + } catch { + return Response.json({ error: "Invalid request" }, { status: 400 }); + } + } + + if (url.pathname === "/api/image") return handleImage(req); + if (url.pathname === "/api/upload" && req.method === "POST") return handleUpload(req); + + if (url.pathname === "/api/goal-setup/submit" && req.method === "POST") { + try { + const body = await req.json(); + const result = + ctx.bundle.stage === "interview" + ? createInterviewResult(ctx.bundle, coerceAnswers(body)) + : createFactsResult(ctx.bundle, coerceFacts(body)); + resolveOnce({ result }); + return Response.json({ ok: true, result }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to submit result"; + return Response.json({ error: message }, { status: 400 }); + } + } + + if (url.pathname === "/api/exit" && req.method === "POST") { + resolveOnce({ exit: true }); + return Response.json({ ok: true }); + } + + if (url.pathname === "/favicon.svg") return handleFavicon(); + + return null; + }; +} + +async function buildHandlerContext( + bundle: GoalSetupBundle, + origin: Origin, + cwd: string, +): Promise { + const wslFlag = await isWSL(); + const repoInfo = await getRepoInfo(); + const gitUser = detectGitUser(cwd); + return { bundle, origin, cwd, wslFlag, repoInfo, gitUser }; +} + +// --- Standalone Server (pre-daemon CLI path) --- + +export async function startGoalSetupServer( + options: GoalSetupServerOptions, +): Promise { + const { bundle, htmlContent, origin = "claude-code", onReady } = options; + const isRemote = isRemoteSession(); + const configuredPort = getServerPort(); + const ctx = await buildHandlerContext(bundle, origin, process.cwd()); + const { promise, resolveOnce } = createGoalSetupDecision(); + const routeHandler = createGoalSetupRouteHandler(ctx, resolveOnce); let server: ReturnType | null = null; @@ -110,122 +213,38 @@ export async function startGoalSetupServer( server = Bun.serve({ hostname: getServerHostname(), port: configuredPort, - async fetch(req) { const url = new URL(req.url); - - if ( - (url.pathname === "/api/plan" || - url.pathname === "/api/goal-setup") && - req.method === "GET" - ) { - return Response.json({ - plan: "", - origin, - mode: "goal-setup", - goalSetup: bundle, - repoInfo, - projectRoot: process.cwd(), - isWSL: wslFlag, - serverConfig: getServerConfig(gitUser), - sharingEnabled: false, - }); - } - - if (url.pathname === "/api/config" && req.method === "POST") { - try { - const body = (await req.json()) as { - displayName?: string; - diffOptions?: Record; - conventionalComments?: boolean; - conventionalLabels?: unknown[] | null; - }; - 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.conventionalLabels !== undefined) { - toSave.conventionalLabels = body.conventionalLabels; - } - if (Object.keys(toSave).length > 0) { - saveConfig(toSave as Parameters[0]); - } - return Response.json({ ok: true }); - } catch { - return Response.json({ error: "Invalid request" }, { status: 400 }); - } - } - - if (url.pathname === "/api/image") return handleImage(req); - if (url.pathname === "/api/upload" && req.method === "POST") { - return handleUpload(req); - } - - if ( - url.pathname === "/api/goal-setup/submit" && - req.method === "POST" - ) { - try { - const body = await req.json(); - const result = - bundle.stage === "interview" - ? createInterviewResult(bundle, coerceAnswers(body)) - : createFactsResult(bundle, coerceFacts(body)); - resolveOnce({ result }); - return Response.json({ ok: true, result }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to submit result"; - return Response.json({ error: message }, { status: 400 }); - } - } - - if (url.pathname === "/api/exit" && req.method === "POST") { - resolveOnce({ exit: true }); - return Response.json({ ok: true }); - } - - if (url.pathname === "/favicon.svg") return handleFavicon(); - + const response = await routeHandler(req, url); + if (response) return response; return new Response(htmlContent, { headers: { "Content-Type": "text/html" }, }); }, - error(err) { console.error("[plannotator] Goal setup server error:", err); return new Response( `Internal Server Error: ${err instanceof Error ? err.message : String(err)}`, - { status: 500, headers: { "Content-Type": "text/plain" } } + { status: 500, headers: { "Content-Type": "text/plain" } }, ); }, }); - break; } catch (err: unknown) { const isAddressInUse = err instanceof Error && err.message.includes("EADDRINUSE"); - if (isAddressInUse && attempt < MAX_RETRIES) { await Bun.sleep(RETRY_DELAY_MS); continue; } - if (isAddressInUse) { const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : ""; throw new Error( - `Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}` + `Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`, ); } - throw err; } } @@ -242,7 +261,29 @@ export async function startGoalSetupServer( port, url: serverUrl, isRemote, - waitForDecision: () => decisionPromise, + waitForDecision: () => promise, stop: () => server.stop(), }; } + +// --- Daemon Session (routed through daemon server) --- + +export async function createGoalSetupSession( + options: GoalSetupSessionOptions, +): Promise { + const { cwd = process.cwd(), bundle, htmlContent, origin = "claude-code" } = options; + const ctx = await buildHandlerContext(bundle, origin, cwd); + const { promise, resolveOnce } = createGoalSetupDecision(); + const routeHandler = createGoalSetupRouteHandler(ctx, resolveOnce); + + const handleRequest: SessionRequestHandler = async (req, url) => { + return (await routeHandler(req, url)) ?? new Response("Not found", { status: 404 }); + }; + + return { + htmlContent, + handleRequest, + waitForDecision: () => promise, + dispose: () => resolveOnce({ exit: true }), + }; +} diff --git a/packages/server/image.test.ts b/packages/server/image.test.ts index 3fe748d9c..283248cea 100644 --- a/packages/server/image.test.ts +++ b/packages/server/image.test.ts @@ -4,9 +4,24 @@ * Run: bun test packages/server/image.test.ts */ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; +import { join } from "node:path"; import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; +import { handleImage } from "./shared-handlers"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "plannotator-image-")); + dirs.push(dir); + return dir; +} describe("UPLOAD_DIR", () => { test("uses os.tmpdir(), not hardcoded /tmp", () => { @@ -61,3 +76,57 @@ describe("validateUploadExtension", () => { expect(result.ext).toBe("png"); }); }); + +describe("handleImage", () => { + test("resolves relative image paths against the session cwd before process cwd", async () => { + const cwd = tempDir(); + const session = tempDir(); + await Bun.write(join(cwd, "mock.png"), "wrong"); + await Bun.write(join(session, "mock.png"), "right"); + const originalCwd = process.cwd(); + + try { + process.chdir(cwd); + const response = await handleImage(new Request("http://localhost/api/image?path=mock.png"), session); + expect(await response.text()).toBe("right"); + } finally { + process.chdir(originalCwd); + } + }); + + test("does not fall back to session cwd when an explicit base is supplied", async () => { + const base = tempDir(); + const session = tempDir(); + const cwd = tempDir(); + await Bun.write(join(session, "mock.png"), "wrong"); + await Bun.write(join(cwd, "mock.png"), "also wrong"); + const originalCwd = process.cwd(); + + try { + process.chdir(cwd); + const response = await handleImage( + new Request(`http://localhost/api/image?path=mock.png&base=${encodeURIComponent(base)}`), + session, + ); + + expect(response.status).toBe(404); + } finally { + process.chdir(originalCwd); + } + }); + + test("does not fall back to process cwd when the session cwd misses", async () => { + const cwd = tempDir(); + const session = tempDir(); + await Bun.write(join(cwd, "mock.png"), "wrong"); + const originalCwd = process.cwd(); + + try { + process.chdir(cwd); + const response = await handleImage(new Request("http://localhost/api/image?path=mock.png"), session); + expect(response.status).toBe(404); + } finally { + process.chdir(originalCwd); + } + }); +}); diff --git a/packages/server/index.ts b/packages/server/index.ts index 8b1356a49..c23e144c6 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -13,7 +13,6 @@ */ import type { Origin } from "@plannotator/shared/agents"; -import { resolve } from "path"; import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; import { openEditorDiff } from "./ide"; import { @@ -47,10 +46,11 @@ import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; import { handleDoc, handleDocExists, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc, handleFileBrowserFiles } from "./reference-handlers"; -import { warmFileListCache } from "@plannotator/shared/resolve-file"; +import { resolveUserPath, warmFileListCache } from "@plannotator/shared/resolve-file"; import { createEditorAnnotationHandler } from "./editor-annotations"; import { createExternalAnnotationHandler } from "./external-annotations"; import { isWSL } from "./browser"; +import type { SessionRequestHandler } from "./session-handler"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -63,6 +63,8 @@ export { type VaultNode, buildFileTree } from "@plannotator/shared/reference-com // --- Types --- export interface ServerOptions { + /** Working directory for repo/project-relative behavior */ + cwd?: string; /** The plan markdown content */ plan: string; /** Origin identifier (e.g., "claude-code", "opencode") */ @@ -108,6 +110,14 @@ export interface ServerResult { stop: () => void; } +export interface PlannotatorSession { + htmlContent: string; + handleRequest: SessionRequestHandler; + waitForDecision: ServerResult["waitForDecision"]; + waitForDone?: () => Promise; + dispose: () => void; +} + // --- Server Implementation --- const MAX_RETRIES = 5; @@ -122,19 +132,22 @@ const RETRY_DELAY_MS = 500; * - Obsidian/Bear integrations * - Port conflict retries */ -export async function startPlannotatorServer( +export async function createPlannotatorSession( options: ServerOptions -): Promise { - const { plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, onReady, mode, customPlanPath } = options; +): Promise { + const { cwd = process.cwd(), plan, origin, htmlContent, permissionMode, sharingEnabled = true, shareBaseUrl, pasteApiUrl, mode, customPlanPath } = options; + const resolvePlanStoragePath = (customPath?: string | null): string | undefined => { + if (!customPath?.trim()) return undefined; + return resolveUserPath(customPath, cwd); + }; + const archiveCustomPath = resolvePlanStoragePath(customPlanPath); - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); const wslFlag = await isWSL(); - const gitUser = detectGitUser(); + const gitUser = detectGitUser(cwd); // Side-channel pre-warm: kick off the code-file walk now so the // renderer's POST /api/doc/exists lands on warm cache. - void warmFileListCache(process.cwd(), "code"); + void warmFileListCache(cwd, "code"); // --- Archive mode setup --- let archivePlans: ArchivedPlan[] = []; @@ -143,9 +156,9 @@ export async function startPlannotatorServer( let donePromise: Promise | undefined; if (mode === "archive") { - archivePlans = listArchivedPlans(customPlanPath ?? undefined); + archivePlans = listArchivedPlans(archiveCustomPath); initialArchivePlan = archivePlans.length > 0 - ? readArchivedPlan(archivePlans[0].filename, customPlanPath ?? undefined) ?? "" + ? readArchivedPlan(archivePlans[0].filename, archiveCustomPath) ?? "" : ""; donePromise = new Promise((resolve) => { resolveDone = resolve; }); } @@ -182,8 +195,8 @@ export async function startPlannotatorServer( }>; if (mode !== "archive") { - repoInfo = await getRepoInfo(); - project = (await detectProjectName()) ?? "_unknown"; + repoInfo = await getRepoInfo(cwd); + project = (await detectProjectName(cwd)) ?? "_unknown"; const historyResult = saveToHistory(project, slug, plan); currentPlanPath = historyResult.path; previousPlan = @@ -204,17 +217,7 @@ export async function startPlannotatorServer( decisionPromise = new Promise(() => {}); } - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - hostname: getServerHostname(), - port: configuredPort, - - async fetch(req, server) { - const url = new URL(req.url); + const handleRequest: SessionRequestHandler = async (req, url, context) => { // API: Get a specific plan version from history if (url.pathname === "/api/plan/version") { @@ -245,7 +248,7 @@ export async function startPlannotatorServer( // API: List archived plans (from ~/.plannotator/plans/) // Cached for session lifetime — new plans won't appear during a single review if (url.pathname === "/api/archive/plans" && req.method === "GET") { - const customPath = url.searchParams.get("customPath") || undefined; + const customPath = resolvePlanStoragePath(url.searchParams.get("customPath")); if (!cachedArchivePlans) cachedArchivePlans = listArchivedPlans(customPath); return Response.json({ plans: cachedArchivePlans }); } @@ -256,7 +259,7 @@ export async function startPlannotatorServer( if (!filename) { return Response.json({ error: "Missing filename parameter" }, { status: 400 }); } - const customPath = url.searchParams.get("customPath") || undefined; + const customPath = resolvePlanStoragePath(url.searchParams.get("customPath")); const content = readArchivedPlan(filename, customPath); if (content === null) { return Response.json({ error: "Plan not found" }, { status: 404 }); @@ -284,17 +287,17 @@ export async function startPlannotatorServer( serverConfig: getServerConfig(gitUser), }); } - return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: process.cwd(), isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); + return Response.json({ plan, origin, permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, repoInfo, previousPlan, versionInfo, projectRoot: cwd, isWSL: wslFlag, serverConfig: getServerConfig(gitUser) }); } // API: Serve a linked markdown document if (url.pathname === "/api/doc" && req.method === "GET") { - return handleDoc(req); + return handleDoc(req, { projectRoot: cwd }); } // API: Batch existence check for code-file paths the renderer detected if (url.pathname === "/api/doc/exists" && req.method === "POST") { - return handleDocExists(req); + return handleDocExists(req, { projectRoot: cwd }); } // API: Hook status for the Settings Hooks tab @@ -337,7 +340,7 @@ export async function startPlannotatorServer( // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - return handleImage(req); + return handleImage(req, cwd); } // API: Upload image -> save to temp -> return path @@ -387,7 +390,7 @@ export async function startPlannotatorServer( // API: List markdown files in a directory as a tree if (url.pathname === "/api/reference/files" && req.method === "GET") { - return handleFileBrowserFiles(req); + return handleFileBrowserFiles(req, cwd); } // API: Get available agents (OpenCode only) @@ -408,7 +411,7 @@ export async function startPlannotatorServer( // API: External annotations (SSE-based, for any external tool) const externalResponse = await externalAnnotations?.handle(req, url, { - disableIdleTimeout: () => server.timeout(req, 0), + disableIdleTimeout: () => context?.disableIdleTimeout?.(), }); if (externalResponse) return externalResponse; @@ -426,13 +429,13 @@ export async function startPlannotatorServer( // Run integrations in parallel — they're independent const promises: Promise[] = []; if (body.obsidian?.vaultPath && body.obsidian?.plan) { - promises.push(saveToObsidian(body.obsidian).then(r => { results.obsidian = r; })); + promises.push(saveToObsidian(body.obsidian, { cwd }).then(r => { results.obsidian = r; })); } if (body.bear?.plan) { - promises.push(saveToBear(body.bear).then(r => { results.bear = r; })); + promises.push(saveToBear(body.bear, { cwd }).then(r => { results.bear = r; })); } if (body.octarine?.plan && body.octarine?.workspace) { - promises.push(saveToOctarine(body.octarine).then(r => { results.octarine = r; })); + promises.push(saveToOctarine(body.octarine, { cwd }).then(r => { results.octarine = r; })); } await Promise.allSettled(promises); @@ -451,6 +454,10 @@ export async function startPlannotatorServer( // API: Approve plan if (url.pathname === "/api/approve" && req.method === "POST") { + if (mode === "archive") { + return Response.json({ error: "Archive sessions do not support approval." }, { status: 404 }); + } + // Check for note integrations and optional feedback let feedback: string | undefined; let agentSwitch: string | undefined; @@ -486,20 +493,20 @@ export async function startPlannotatorServer( // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; - planSaveCustomPath = body.planSave.customPath; + planSaveCustomPath = resolvePlanStoragePath(body.planSave.customPath); } // Run integrations in parallel — they're independent const integrationResults: Record = {}; const integrationPromises: Promise[] = []; if (body.obsidian?.vaultPath && body.obsidian?.plan) { - integrationPromises.push(saveToObsidian(body.obsidian).then(r => { integrationResults.obsidian = r; })); + integrationPromises.push(saveToObsidian(body.obsidian, { cwd }).then(r => { integrationResults.obsidian = r; })); } if (body.bear?.plan) { - integrationPromises.push(saveToBear(body.bear).then(r => { integrationResults.bear = r; })); + integrationPromises.push(saveToBear(body.bear, { cwd }).then(r => { integrationResults.bear = r; })); } if (body.octarine?.plan && body.octarine?.workspace) { - integrationPromises.push(saveToOctarine(body.octarine).then(r => { integrationResults.octarine = r; })); + integrationPromises.push(saveToOctarine(body.octarine, { cwd }).then(r => { integrationResults.octarine = r; })); } await Promise.allSettled(integrationPromises); @@ -534,6 +541,10 @@ export async function startPlannotatorServer( // API: Deny with feedback if (url.pathname === "/api/deny" && req.method === "POST") { + if (mode === "archive") { + return Response.json({ error: "Archive sessions do not support denial." }, { status: 404 }); + } + let feedback = "Plan rejected by user"; let planSaveEnabled = true; // default to enabled for backwards compat let planSaveCustomPath: string | undefined; @@ -547,7 +558,7 @@ export async function startPlannotatorServer( // Capture plan save settings if (body.planSave !== undefined) { planSaveEnabled = body.planSave.enabled; - planSaveCustomPath = body.planSave.customPath; + planSaveCustomPath = resolvePlanStoragePath(body.planSave.customPath); } } catch { // Use default feedback @@ -572,6 +583,41 @@ export async function startPlannotatorServer( return new Response(htmlContent, { headers: { "Content-Type": "text/html" }, }); + }; + + return { + htmlContent, + handleRequest, + waitForDecision: () => decisionPromise, + ...(donePromise && { waitForDone: () => donePromise }), + dispose: () => { + externalAnnotations?.dispose(); + }, + }; +} + +export async function startPlannotatorServer( + options: ServerOptions +): Promise { + const { onReady } = options; + const session = await createPlannotatorSession(options); + const isRemote = isRemoteSession(); + const configuredPort = getServerPort(); + + // Start server with retry logic + let server: ReturnType | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + server = Bun.serve({ + hostname: getServerHostname(), + port: configuredPort, + + async fetch(req, server) { + const url = new URL(req.url); + return session.handleRequest(req, url, { + disableIdleTimeout: () => server.timeout(req, 0), + }); }, error(err) { @@ -618,8 +664,11 @@ export async function startPlannotatorServer( port, url: serverUrl, isRemote, - waitForDecision: () => decisionPromise, - ...(donePromise && { waitForDone: () => donePromise }), - stop: () => server.stop(), + waitForDecision: session.waitForDecision, + ...(session.waitForDone && { waitForDone: session.waitForDone }), + stop: () => { + server.stop(); + session.dispose(); + }, }; } diff --git a/packages/server/integrations.test.ts b/packages/server/integrations.test.ts index 9215276fd..c1aca5a46 100644 --- a/packages/server/integrations.test.ts +++ b/packages/server/integrations.test.ts @@ -4,15 +4,32 @@ * Run: bun test packages/server/integrations.test.ts */ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { extractTitle, extractTags, + saveToObsidian, stripH1, buildHashtags, buildBearContent, } from "./integrations"; +let tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "plannotator-integrations-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + tempDirs = []; +}); + describe("extractTitle", () => { test("extracts plain H1", () => { expect(extractTitle("# My Plan\n\nContent")).toBe("My Plan"); @@ -169,4 +186,32 @@ describe("extractTags", () => { const tags = await extractTags("# One Two Three Four\n\n```go\n```\n```python\n```\n```ruby\n```\n```swift\n```"); expect(tags.length).toBeLessThanOrEqual(7); }); + + test("uses the provided cwd for project tags", async () => { + const root = tempDir(); + const cwd = join(root, "RepoB"); + mkdirSync(cwd); + + const tags = await extractTags("# Simple Plan\n\nContent", { cwd }); + expect(tags).toContain("repob"); + }); +}); + +describe("saveToObsidian", () => { + test("resolves relative vault paths against the provided cwd", async () => { + const root = tempDir(); + const cwd = join(root, "RepoB"); + const vault = join(cwd, "vault"); + mkdirSync(vault, { recursive: true }); + + const result = await saveToObsidian({ + vaultPath: "vault", + folder: "plans", + plan: "# Relative Vault Plan\n\nContent", + }, { cwd }); + + expect(result.success).toBe(true); + expect(result.path?.startsWith(join(vault, "plans"))).toBe(true); + expect(existsSync(result.path!)).toBe(true); + }); }); diff --git a/packages/server/integrations.ts b/packages/server/integrations.ts index 1f7b4b770..b1f123b4d 100644 --- a/packages/server/integrations.ts +++ b/packages/server/integrations.ts @@ -26,15 +26,19 @@ import { resolveUserPath } from "@plannotator/shared/resolve-file"; export type { ObsidianConfig, BearConfig, OctarineConfig, IntegrationResult }; export { detectObsidianVaults, extractTitle, generateFrontmatter, generateFilename, generateOctarineFrontmatter, stripH1, buildHashtags, buildBearContent }; +export interface IntegrationOptions { + cwd?: string; +} + /** * Extract tags from markdown content using simple heuristics * Includes project name detection (git repo or directory name) */ -export async function extractTags(markdown: string): Promise { +export async function extractTags(markdown: string, options: IntegrationOptions = {}): Promise { const tags = new Set(["plannotator"]); // Add project name tag (git repo name or directory fallback) - const projectName = await detectProjectName(); + const projectName = await detectProjectName(options.cwd); if (projectName) { tags.add(projectName); } @@ -95,6 +99,7 @@ export async function extractTags(markdown: string): Promise { */ export async function saveToObsidian( config: ObsidianConfig, + options: IntegrationOptions = {}, ): Promise { try { const { vaultPath, folder, plan } = config; @@ -103,7 +108,7 @@ export async function saveToObsidian( return { success: false, error: "Vault path is required" }; } - const normalizedVault = resolveUserPath(vaultPath); + const normalizedVault = resolveUserPath(vaultPath, options.cwd); // Validate vault path exists and is a directory if (!existsSync(normalizedVault)) { @@ -139,7 +144,7 @@ export async function saveToObsidian( const filePath = join(targetFolder, filename); // Generate content with frontmatter and backlink - const tags = await extractTags(plan); + const tags = await extractTags(plan, options); const frontmatter = generateFrontmatter(tags); const content = `${frontmatter}\n\n[[Plannotator Plans]]\n\n${plan}`; @@ -158,6 +163,7 @@ export async function saveToObsidian( */ export async function saveToBear( config: BearConfig, + options: IntegrationOptions = {}, ): Promise { try { const { plan, customTags, tagPosition = "append" } = config; @@ -165,7 +171,7 @@ export async function saveToBear( const title = extractTitle(plan); const body = stripH1(plan); - const tags = customTags?.trim() ? undefined : await extractTags(plan); + const tags = customTags?.trim() ? undefined : await extractTags(plan, options); const hashtags = buildHashtags(customTags, tags ?? []); const content = buildBearContent(body, hashtags, tagPosition); @@ -186,6 +192,7 @@ export async function saveToBear( */ export async function saveToOctarine( config: OctarineConfig, + options: IntegrationOptions = {}, ): Promise { try { const { plan } = config; @@ -198,7 +205,7 @@ export async function saveToOctarine( const basename = filename.replace(/\.md$/, ""); const path = folder ? `${folder}/${basename}` : basename; - const tags = await extractTags(plan); + const tags = await extractTags(plan, options); const frontmatter = generateOctarineFrontmatter(tags); const content = `${frontmatter}\n\n${plan}`; diff --git a/packages/server/project.ts b/packages/server/project.ts index e31e94a28..f13f18d64 100644 --- a/packages/server/project.ts +++ b/packages/server/project.ts @@ -20,10 +20,10 @@ export { sanitizeTag, extractRepoName, extractDirName } from "@plannotator/share * 2. Current directory name (fallback) * 3. null (if nothing useful found) */ -export async function detectProjectName(): Promise { +export async function detectProjectName(cwd = process.cwd()): Promise { // Try git repo name first try { - const result = await $`git rev-parse --show-toplevel`.quiet().nothrow(); + const result = await $`git -C ${cwd} rev-parse --show-toplevel`.quiet().nothrow(); if (result.exitCode === 0) { const repoName = extractRepoName(result.stdout.toString()); if (repoName) return repoName; @@ -34,7 +34,6 @@ export async function detectProjectName(): Promise { // Fallback to current directory name try { - const cwd = process.cwd(); const dirName = extractDirName(cwd); if (dirName) return dirName; } catch { diff --git a/packages/server/reference-handlers.test.ts b/packages/server/reference-handlers.test.ts new file mode 100644 index 000000000..379feb128 --- /dev/null +++ b/packages/server/reference-handlers.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { handleFileBrowserFiles } from "./reference-handlers"; + +let tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "plannotator-reference-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + tempDirs = []; +}); + +describe("handleFileBrowserFiles", () => { + test("resolves relative directories against the session project root", async () => { + const root = tempDir(); + const project = join(root, "project"); + const docs = join(project, "docs"); + mkdirSync(docs, { recursive: true }); + writeFileSync(join(docs, "guide.md"), "# Guide\n", "utf-8"); + + const res = await handleFileBrowserFiles( + new Request("http://localhost/api/file-browser/files?dirPath=docs"), + project, + ); + const bodyText = JSON.stringify(await res.json()); + + expect(res.status).toBe(200); + expect(bodyText).toContain("guide.md"); + }); +}); diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index af18848d0..b7fb9aebe 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -24,17 +24,23 @@ import { preloadFile } from "@pierre/diffs/ssr"; // --- Route handlers --- +export interface ReferenceHandlerOptions { + projectRoot?: string; +} + /** Serve a linked markdown document. Resolves absolute, relative, or bare filename paths. */ -export async function handleDoc(req: Request): Promise { +export async function handleDoc(req: Request, options: ReferenceHandlerOptions = {}): Promise { const url = new URL(req.url); const requestedPath = url.searchParams.get("path"); if (!requestedPath) { return Response.json({ error: "Missing path parameter" }, { status: 400 }); } + const projectRoot = options.projectRoot ?? process.cwd(); + // Side-channel: kick off a code-file walk for the project root so that any // /api/doc/exists POST issued by the rendered linked-doc lands on warm cache. - void warmFileListCache(process.cwd(), "code"); + void warmFileListCache(projectRoot, "code"); // If a base directory is provided, try resolving relative to it first // (used by annotate mode to resolve paths relative to the source file). @@ -43,7 +49,7 @@ export async function handleDoc(req: Request): Promise { // server (see annotate.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; + const resolvedBase = base ? resolveUserPath(base, projectRoot) : null; if ( resolvedBase && !isAbsoluteUserPath(requestedPath) && @@ -64,7 +70,6 @@ export async function handleDoc(req: Request): Promise { } // 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)) { @@ -188,7 +193,7 @@ export async function handleDoc(req: Request): Promise { * resolved base before passing it to `resolveCodeFile` (or filter `r.path` * before recording a found result). Mirror in apps/pi-extension/server/reference.ts. */ -export async function handleDocExists(req: Request): Promise { +export async function handleDocExists(req: Request, options: ReferenceHandlerOptions = {}): Promise { let body: unknown; try { body = await req.json(); @@ -202,12 +207,12 @@ export async function handleDocExists(req: Request): Promise { if (paths.length > 500) { return Response.json({ error: "Too many paths (max 500)" }, { status: 400 }); } + const projectRoot = options.projectRoot ?? process.cwd(); const baseRaw = (body as { base?: unknown })?.base; const baseDir = typeof baseRaw === "string" && baseRaw.length > 0 - ? resolveUserPath(baseRaw) + ? resolveUserPath(baseRaw, projectRoot) : undefined; - const projectRoot = process.cwd(); const results: Record< string, | { status: "found"; resolved: string } @@ -357,7 +362,7 @@ export async function handleObsidianDoc(req: Request): Promise { // --- File Browser --- /** List markdown files in a directory as a nested tree. */ -export async function handleFileBrowserFiles(req: Request): Promise { +export async function handleFileBrowserFiles(req: Request, projectRoot = process.cwd()): Promise { const url = new URL(req.url); const dirPath = url.searchParams.get("dirPath"); if (!dirPath) { @@ -367,7 +372,7 @@ export async function handleFileBrowserFiles(req: Request): Promise { ); } - const resolvedDir = resolveUserPath(dirPath); + const resolvedDir = resolveUserPath(dirPath, projectRoot); if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { return Response.json({ error: "Invalid directory path" }, { status: 400 }); } diff --git a/packages/server/repo.ts b/packages/server/repo.ts index def9a4715..17f12b3cd 100644 --- a/packages/server/repo.ts +++ b/packages/server/repo.ts @@ -13,9 +13,9 @@ import { parseRemoteUrl, parseRemoteHost, getDirName } from "@plannotator/shared /** * Get current git branch */ -async function getCurrentBranch(): Promise { +async function getCurrentBranch(cwd = process.cwd()): Promise { try { - const result = await $`git rev-parse --abbrev-ref HEAD`.quiet().nothrow(); + const result = await $`git -C ${cwd} rev-parse --abbrev-ref HEAD`.quiet().nothrow(); if (result.exitCode === 0) { const branch = result.stdout.toString().trim(); return branch && branch !== "HEAD" ? branch : undefined; @@ -33,17 +33,17 @@ async function getCurrentBranch(): Promise { * 2. Fall back to git repo root directory name * 3. Fall back to current working directory name */ -export async function getRepoInfo(): Promise { +export async function getRepoInfo(cwd = process.cwd()): Promise { let branch: string | undefined; // Try git remote URL first try { - const result = await $`git remote get-url origin`.quiet().nothrow(); + const result = await $`git -C ${cwd} remote get-url origin`.quiet().nothrow(); if (result.exitCode === 0) { const remoteUrl = result.stdout.toString().trim(); const orgRepo = parseRemoteUrl(remoteUrl); if (orgRepo) { - branch = await getCurrentBranch(); + branch = await getCurrentBranch(cwd); const host = parseRemoteHost(remoteUrl) ?? undefined; return { display: orgRepo, branch, host }; } @@ -54,11 +54,11 @@ export async function getRepoInfo(): Promise { // Fallback: git repo root name try { - const result = await $`git rev-parse --show-toplevel`.quiet().nothrow(); + const result = await $`git -C ${cwd} rev-parse --show-toplevel`.quiet().nothrow(); if (result.exitCode === 0) { const repoName = getDirName(result.stdout.toString()); if (repoName) { - branch = await getCurrentBranch(); + branch = await getCurrentBranch(cwd); return { display: repoName, branch }; } } @@ -68,7 +68,7 @@ export async function getRepoInfo(): Promise { // Final fallback: current directory (no branch - not a git repo) try { - const dirName = getDirName(process.cwd()); + const dirName = getDirName(cwd); if (dirName) { return { display: dirName }; } diff --git a/packages/server/review-agent-cwd.test.ts b/packages/server/review-agent-cwd.test.ts new file mode 100644 index 000000000..6dbe6dba0 --- /dev/null +++ b/packages/server/review-agent-cwd.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { resolveReviewScopedAgentCwd } from "./review"; + +describe("resolveReviewScopedAgentCwd", () => { + test("uses the current PR pool checkout in PR mode", () => { + const worktreePool = { + resolve: (url: string) => url === "https://example.com/pr/2" ? "/tmp/pr-2" : undefined, + }; + + expect(resolveReviewScopedAgentCwd({ + isPRMode: true, + prUrl: "https://example.com/pr/2", + worktreePool, + agentCwd: "/tmp/original-pr", + currentDiffType: "uncommitted", + gitContextCwd: "/repo", + })).toBe("/tmp/pr-2"); + }); + + test("falls back to the mutable local PR checkout when the pool has no entry", () => { + const worktreePool = { + resolve: () => undefined, + }; + + expect(resolveReviewScopedAgentCwd({ + isPRMode: true, + prUrl: "https://example.com/pr/other-repo", + worktreePool, + agentCwd: "/tmp/original-pr", + currentDiffType: "uncommitted", + gitContextCwd: "/repo", + })).toBe("/tmp/original-pr"); + }); + + test("does not invent local access for PR pool misses without an agent cwd", () => { + const worktreePool = { + resolve: () => undefined, + }; + + expect(resolveReviewScopedAgentCwd({ + isPRMode: true, + prUrl: "https://example.com/pr/other-repo", + worktreePool, + currentDiffType: "uncommitted", + gitContextCwd: "/repo", + })).toBeUndefined(); + }); + + test("keeps non-PR local review fallback behavior", () => { + expect(resolveReviewScopedAgentCwd({ + isPRMode: false, + agentCwd: "/tmp/local-review", + currentDiffType: "uncommitted", + gitContextCwd: "/repo", + })).toBe("/tmp/local-review"); + + expect(resolveReviewScopedAgentCwd({ + isPRMode: false, + currentDiffType: "uncommitted", + gitContextCwd: "/repo", + })).toBe("/repo"); + }); +}); diff --git a/packages/server/review.ts b/packages/server/review.ts index c31646398..88d73bc1a 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -49,6 +49,7 @@ import { type PRMetadata, type PRReviewFileComment, type PRStackTree, type PRLis import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai"; import { isWSL } from "./browser"; import { handleCodeNavResolve, extractChangedFiles } from "./code-nav"; +import type { SessionRequestHandler } from "./session-handler"; // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; @@ -60,6 +61,8 @@ export { handleServerReady as handleReviewServerReady } from "./shared-handlers" // --- Types --- export interface ReviewServerOptions { + /** Working directory for repo/project-relative behavior */ + cwd?: string; /** Raw git diff patch string */ rawPatch: string; /** Git ref used for the diff (e.g., "HEAD", "main..HEAD", "--staged") */ @@ -119,23 +122,41 @@ export interface ReviewServerResult { stop: () => void; } +export interface ReviewSession { + htmlContent: string; + handleRequest: SessionRequestHandler; + waitForDecision: ReviewServerResult["waitForDecision"]; + setServerUrl: (url: string) => void; + dispose: () => void; +} + +export interface ResolveReviewScopedAgentCwdOptions { + isPRMode: boolean; + prUrl?: string; + worktreePool?: Pick; + agentCwd?: string; + currentDiffType: DiffType; + gitContextCwd?: string; +} + +export function resolveReviewScopedAgentCwd( + options: ResolveReviewScopedAgentCwdOptions, +): string | undefined { + if (options.isPRMode && options.prUrl && options.worktreePool) { + return options.worktreePool.resolve(options.prUrl) ?? options.agentCwd; + } + return options.agentCwd ?? resolveVcsCwd(options.currentDiffType, options.gitContextCwd); +} + // --- Server Implementation --- const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; -/** - * Start the Code Review server - * - * Handles: - * - Remote detection and port configuration - * - API routes (/api/diff, /api/feedback) - * - Port conflict retries - */ -export async function startReviewServer( +export async function createReviewSession( options: ReviewServerOptions -): Promise { - const { htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl, onReady } = options; +): Promise { + const { cwd = process.cwd(), htmlContent, origin, gitContext, sharingEnabled = true, shareBaseUrl } = options; let prMetadata = options.prMetadata; const isPRMode = !!prMetadata; @@ -169,6 +190,7 @@ export async function startReviewServer( const detectedCompareTarget = (): string => gitContext?.defaultBranch || gitContext?.compareTarget?.fallback || "main"; let currentBase = options.initialBase || detectedCompareTarget(); let baseEverSwitched = false; + let currentAgentCwd = options.agentCwd; const resolveReviewBase = (requestedBase?: string): string => { return resolveBaseBranch(requestedBase, detectedCompareTarget()); @@ -186,12 +208,18 @@ export async function startReviewServer( // Agent jobs — background process manager (late-binds serverUrl via getter) let serverUrl = ""; + const resolveScopedAgentCwd = (): string | undefined => { + return resolveReviewScopedAgentCwd({ + isPRMode, + prUrl: prMetadata?.url, + worktreePool: options.worktreePool, + agentCwd: currentAgentCwd, + currentDiffType, + gitContextCwd: gitContext?.cwd, + }); + }; const resolveAgentCwd = (): string => { - if (options.worktreePool && prMetadata) { - const poolPath = options.worktreePool.resolve(prMetadata.url); - if (poolPath) return poolPath; - } - return options.agentCwd ?? resolveVcsCwd(currentDiffType, gitContext?.cwd) ?? process.cwd(); + return resolveScopedAgentCwd() ?? cwd; }; const agentJobs = createAgentJobHandler({ mode: "review", @@ -200,7 +228,7 @@ export async function startReviewServer( async buildCommand(provider, config) { const cwd = resolveAgentCwd(); - const hasAgentLocalAccess = !!options.worktreePool || !!options.agentCwd || !!gitContext; + const hasAgentLocalAccess = !!resolveScopedAgentCwd(); const userMessageOptions = { defaultBranch: currentBase, hasLocalAccess: hasAgentLocalAccess, prDiffScope: currentPRDiffScope }; // Snapshot the diff context at launch — stored on the job so @@ -342,7 +370,7 @@ export async function startReviewServer( const claudePath = Bun.which("claude"); const provider = await createProvider({ type: "claude-agent-sdk", - cwd: process.cwd(), + cwd, ...(claudePath && { claudeExecutablePath: claudePath }), }); aiRegistry.register(provider); @@ -358,7 +386,7 @@ export async function startReviewServer( const codexPath = Bun.which("codex"); const provider = await createProvider({ type: "codex-sdk", - cwd: process.cwd(), + cwd, ...(codexPath && { codexExecutablePath: codexPath }), }); aiRegistry.register(provider); @@ -373,7 +401,7 @@ export async function startReviewServer( if (piPath) { const provider = await createProvider({ type: "pi-sdk", - cwd: process.cwd(), + cwd, piExecutablePath: piPath, } as PiSDKConfig); if (provider instanceof PiSDKProvider) { @@ -392,7 +420,7 @@ export async function startReviewServer( if (opencodePath) { const provider = await createProvider({ type: "opencode-sdk", - cwd: process.cwd(), + cwd, }); if (provider instanceof OpenCodeProvider) { await provider.fetchModels(); @@ -412,16 +440,14 @@ export async function startReviewServer( }); } - const isRemote = isRemoteSession(); - const configuredPort = getServerPort(); const wslFlag = await isWSL(); - const gitUser = detectGitUser(); + const gitUser = detectGitUser(cwd); // Detect repo info (cached for this session) // In PR mode, derive from metadata instead of local git let repoInfo = isPRMode && prMetadata ? { display: getDisplayRepo(prMetadata), branch: `${getMRLabel(prMetadata)} ${getMRNumberLabel(prMetadata)}` } - : await getRepoInfo(); + : await getRepoInfo(cwd); if (gitContext?.repository?.displayFallback) { repoInfo = { ...repoInfo, @@ -434,7 +460,7 @@ export async function startReviewServer( const platformUser = prRef ? await getPRUser(prRef) : null; let prStackInfo = prMetadata ? getPRStackInfo(prMetadata) : null; let prDiffScopeOptions = prMetadata - ? getPRDiffScopeOptions(prMetadata, !!(options.worktreePool || options.agentCwd)) + ? getPRDiffScopeOptions(prMetadata, !!resolveScopedAgentCwd()) : []; // Fetch full stack tree (best-effort — always try in PR mode so root PRs @@ -450,7 +476,7 @@ export async function startReviewServer( const resolved = resolveStackInfo(prMetadata, prStackTree, prStackInfo); if (resolved && !prStackInfo) { prStackInfo = resolved; - prDiffScopeOptions = getPRDiffScopeOptions(prMetadata, !!(options.worktreePool || options.agentCwd)); + prDiffScopeOptions = getPRDiffScopeOptions(prMetadata, !!resolveScopedAgentCwd()); } } @@ -485,17 +511,7 @@ export async function startReviewServer( resolveDecision = resolve; }); - // Start server with retry logic - let server: ReturnType | null = null; - - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - try { - server = Bun.serve({ - hostname: getServerHostname(), - port: configuredPort, - - async fetch(req, server) { - const url = new URL(req.url); + const handleRequest: SessionRequestHandler = async (req, url, context) => { // API: Get tour result if (url.pathname.match(/^\/api\/tour\/[^/]+$/) && req.method === "GET") { @@ -535,7 +551,7 @@ export async function startReviewServer( shareBaseUrl, repoInfo, isWSL: wslFlag, - ...(options.agentCwd && { agentCwd: options.agentCwd }), + ...(currentAgentCwd && { agentCwd: currentAgentCwd }), ...(isPRMode && { prMetadata, platformUser, @@ -654,14 +670,14 @@ export async function startReviewServer( } const fullStackOption = prDiffScopeOptions.find((option) => option.id === "full-stack"); - if (!fullStackOption?.enabled || !(options.worktreePool || options.agentCwd)) { + const fullStackCwd = resolveScopedAgentCwd(); + if (!fullStackOption?.enabled || !fullStackCwd) { return Response.json( { error: "Full stack diff requires a stacked PR and a local checkout" }, { status: 400 }, ); } - const fullStackCwd = (options.worktreePool && prMetadata ? options.worktreePool.resolve(prMetadata.url) : undefined) ?? options.agentCwd; const result = await runPRFullStackDiff(gitRuntime, prMetadata, fullStackCwd); if (result.error) { @@ -756,24 +772,26 @@ export async function startReviewServer( prStackTreeCache.set(body.url, prStackTree); } - // Ensure worktree for the new PR (pool creates a fresh one, no shared-state mutation) - let hasLocalForNewPR = false; + // Ensure local access for the new PR. Same-repo sessions use a + // per-PR pool; cross-repo --local sessions reuse the mutable clone. + let agentCwdForNewPR: string | null = null; if (options.worktreePool) { try { - await options.worktreePool.ensure(gitRuntime, pr.metadata); - hasLocalForNewPR = true; - } catch { - // Pool creation failed — full-stack will be disabled - } + const entry = await options.worktreePool.ensure(gitRuntime, pr.metadata); + agentCwdForNewPR = entry.path; + } catch {} } else if (options.agentCwd) { - hasLocalForNewPR = await checkoutPRHead(gitRuntime, pr.metadata, options.agentCwd); + if (await checkoutPRHead(gitRuntime, pr.metadata, options.agentCwd)) { + agentCwdForNewPR = options.agentCwd; + } } prStackInfo = resolveStackInfo(pr.metadata, prStackTree, prStackInfo); prDiffScopeOptions = prStackInfo - ? getPRDiffScopeOptions(pr.metadata, hasLocalForNewPR) + ? getPRDiffScopeOptions(pr.metadata, !!agentCwdForNewPR) : []; + currentAgentCwd = agentCwdForNewPR ?? undefined; // Fetch viewed files for the new PR let switchedViewedFiles: string[] = []; @@ -801,6 +819,7 @@ export async function startReviewServer( prDiffScope: currentPRDiffScope, prDiffScopeOptions, repoInfo, + agentCwd: agentCwdForNewPR, ...(switchedViewedFiles.length > 0 && { viewedFiles: switchedViewedFiles }), ...(currentError ? { error: currentError } : {}), }); @@ -846,7 +865,7 @@ export async function startReviewServer( // Full-stack PR mode uses local git for file expansion because // the patch is no longer the platform's layer diff. - const fileContentCwd = (options.worktreePool && prMetadata) ? options.worktreePool.resolve(prMetadata.url) : options.agentCwd; + const fileContentCwd = resolveScopedAgentCwd(); if ( isPRMode && currentPRDiffScope === "full-stack" && @@ -905,22 +924,21 @@ export async function startReviewServer( // API: Code navigation (search-based symbol resolution) if (url.pathname === "/api/code-nav/resolve" && req.method === "POST") { - const hasCodeNavAccess = !!gitContext || !!options.agentCwd || !!options.worktreePool; - if (!hasCodeNavAccess) { + const navCwd = resolveScopedAgentCwd(); + if (!navCwd) { return Response.json( { error: "Code navigation requires local access" }, { status: 400 }, ); } - const navCwd = resolveAgentCwd(); const changedFiles = extractChangedFiles(currentPatch); return handleCodeNavResolve(req, navCwd, changedFiles); } // API: Code navigation file preview (read file from working tree) if (url.pathname === "/api/code-nav/file" && req.method === "GET") { - const hasCodeNavAccess = !!gitContext || !!options.agentCwd || !!options.worktreePool; - if (!hasCodeNavAccess) { + const navCwd = resolveScopedAgentCwd(); + if (!navCwd) { return Response.json({ error: "Code navigation requires local access" }, { status: 400 }); } const filePath = url.searchParams.get("path"); @@ -931,7 +949,6 @@ export async function startReviewServer( return Response.json({ error: "Invalid path" }, { status: 400 }); } try { - const navCwd = resolveAgentCwd(); const content = await Bun.file(`${navCwd}/${filePath}`).text(); return Response.json({ content }); } catch { @@ -985,7 +1002,7 @@ export async function startReviewServer( // API: Serve images (local paths or temp uploads) if (url.pathname === "/api/image") { - return handleImage(req); + return handleImage(req, cwd); } // API: Upload image -> save to temp -> return path @@ -1011,13 +1028,13 @@ export async function startReviewServer( // API: External annotations (SSE-based, for any external tool) const externalResponse = await externalAnnotations.handle(req, url, { - disableIdleTimeout: () => server.timeout(req, 0), + disableIdleTimeout: () => context?.disableIdleTimeout?.(), }); if (externalResponse) return externalResponse; // API: Agent jobs (background review agents) const agentResponse = await agentJobs.handle(req, url, { - disableIdleTimeout: () => server.timeout(req, 0), + disableIdleTimeout: () => context?.disableIdleTimeout?.(), }); if (agentResponse) return agentResponse; @@ -1150,6 +1167,65 @@ export async function startReviewServer( return new Response(htmlContent, { headers: { "Content-Type": "text/html" }, }); + }; + + const exitHandler = () => agentJobs.killAll(); + process.once("exit", exitHandler); + + return { + htmlContent, + handleRequest, + waitForDecision: () => decisionPromise, + setServerUrl: (url) => { + serverUrl = url; + }, + dispose: () => { + process.removeListener("exit", exitHandler); + externalAnnotations.dispose(); + agentJobs.killAll(); + aiSessionManager.disposeAll(); + aiRegistry.disposeAll(); + // 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 */ } + } + }, + }; +} + +/** + * Start the Code Review server + * + * Handles: + * - Remote detection and port configuration + * - API routes (/api/diff, /api/feedback) + * - Port conflict retries + */ +export async function startReviewServer( + options: ReviewServerOptions +): Promise { + const { onReady } = options; + const session = await createReviewSession(options); + const isRemote = isRemoteSession(); + const configuredPort = getServerPort(); + + // Start server with retry logic + let server: ReturnType | null = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + server = Bun.serve({ + hostname: getServerHostname(), + port: configuredPort, + + async fetch(req, server) { + const url = new URL(req.url); + return session.handleRequest(req, url, { + disableIdleTimeout: () => server.timeout(req, 0), + }); }, error(err) { @@ -1171,6 +1247,8 @@ export async function startReviewServer( continue; } + session.dispose(); + if (isAddressInUse) { const hint = isRemote ? " (set PLANNOTATOR_PORT to use different port)" : ""; throw new Error(`Port ${configuredPort} in use after ${MAX_RETRIES} retries${hint}`); @@ -1185,9 +1263,8 @@ export async function startReviewServer( } const port = server.port!; - serverUrl = `http://localhost:${port}`; - const exitHandler = () => agentJobs.killAll(); - process.once("exit", exitHandler); + const serverUrl = `http://localhost:${port}`; + session.setServerUrl(serverUrl); // Notify caller that server is ready if (onReady) { @@ -1198,20 +1275,10 @@ export async function startReviewServer( port, url: serverUrl, isRemote, - waitForDecision: () => decisionPromise, + waitForDecision: session.waitForDecision, stop: () => { - process.removeListener("exit", exitHandler); - agentJobs.killAll(); - aiSessionManager.disposeAll(); - aiRegistry.disposeAll(); + session.dispose(); server.stop(); - // Invoke cleanup callback (e.g., remove temp worktree) - if (options.onCleanup) { - try { - const result = options.onCleanup(); - if (result instanceof Promise) result.catch(() => {}); - } catch { /* best effort */ } - } }, }; } diff --git a/packages/server/session-handler.ts b/packages/server/session-handler.ts new file mode 100644 index 000000000..c335ea190 --- /dev/null +++ b/packages/server/session-handler.ts @@ -0,0 +1,9 @@ +export interface SessionRequestContext { + disableIdleTimeout?: () => void; +} + +export type SessionRequestHandler = ( + req: Request, + url: URL, + context?: SessionRequestContext, +) => Response | Promise; diff --git a/packages/server/share-url.ts b/packages/server/share-url.ts index 9b7528fc9..a15dc1ed5 100644 --- a/packages/server/share-url.ts +++ b/packages/server/share-url.ts @@ -6,6 +6,7 @@ */ import { compress } from "@plannotator/shared/compress"; +import type { DaemonRemoteShareNotice } from "@plannotator/shared/daemon-protocol"; const DEFAULT_SHARE_BASE = "https://share.plannotator.ai"; @@ -33,6 +34,29 @@ export function formatSize(bytes: number): string { return kb < 100 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`; } +export async function createRemoteShareNotice( + content: string, + shareBaseUrl: string | undefined, + verb: string, + noun: string +): Promise { + const url = await generateRemoteShareUrl(content, shareBaseUrl); + return { + url, + verb, + noun, + size: formatSize(new TextEncoder().encode(url).length), + }; +} + +export function formatRemoteShareNotice(notice: DaemonRemoteShareNotice): string { + return ( + `\n Open this link on your local machine to ${notice.verb}:\n` + + ` ${notice.url}\n\n` + + ` (${notice.size} — ${notice.noun}, annotations added in browser)\n\n` + ); +} + /** * Generate a remote share URL and write it to stderr for the user. * Silently does nothing on failure. @@ -43,11 +67,7 @@ export async function writeRemoteShareLink( verb: string, noun: string ): Promise { - const shareUrl = await generateRemoteShareUrl(content, shareBaseUrl); - const size = formatSize(new TextEncoder().encode(shareUrl).length); - process.stderr.write( - `\n Open this link on your local machine to ${verb}:\n` + - ` ${shareUrl}\n\n` + - ` (${size} — ${noun}, annotations added in browser)\n\n` - ); + process.stderr.write(formatRemoteShareNotice( + await createRemoteShareNotice(content, shareBaseUrl, verb, noun), + )); } diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts index 83675d3d5..f4e482c51 100644 --- a/packages/server/shared-handlers.ts +++ b/packages/server/shared-handlers.ts @@ -7,39 +7,47 @@ */ import { mkdirSync } from "fs"; +import { isAbsolute, resolve as resolvePath } from "path"; import { openBrowser } from "./browser"; import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; import { saveDraft, loadDraft, deleteDraft } from "./draft"; import { FAVICON_SVG } from "@plannotator/shared/favicon"; /** Serve images from local paths or temp uploads. Used by all 3 servers. */ -export async function handleImage(req: Request): Promise { +export async function handleImage(req: Request, defaultBase?: string): Promise { const url = new URL(req.url); const imagePath = url.searchParams.get("path"); if (!imagePath) { return new Response("Missing path parameter", { status: 400 }); } - const validation = validateImagePath(imagePath); - if (!validation.valid) { - return new Response(validation.error!, { status: 403 }); + + const requestBase = url.searchParams.get("base") || undefined; + const candidates = [] as string[]; + if (isAbsolute(imagePath)) { + candidates.push(imagePath); + } else if (requestBase) { + candidates.push(resolvePath(requestBase, imagePath)); + } else if (defaultBase) { + candidates.push(resolvePath(defaultBase, imagePath)); + } else { + candidates.push(imagePath); } + + let lastValidationError: string | undefined; try { - const file = Bun.file(validation.resolved); - if (await file.exists()) { - return new Response(file); - } - // If not found and a base directory is provided, try resolving relative to it - const base = url.searchParams.get("base"); - if (base && !imagePath.startsWith("/")) { - const { resolve: resolvePath } = await import("path"); - const fromBase = resolvePath(base, imagePath); - const baseValidation = validateImagePath(fromBase); - if (baseValidation.valid) { - const baseFile = Bun.file(baseValidation.resolved); - if (await baseFile.exists()) { - return new Response(baseFile); - } + for (const candidate of candidates) { + const validation = validateImagePath(candidate); + if (!validation.valid) { + lastValidationError = validation.error; + continue; } + const file = Bun.file(validation.resolved); + if (await file.exists()) { + return new Response(file); + } + } + if (lastValidationError) { + return new Response(lastValidationError, { status: 403 }); } return new Response("File not found", { status: 404 }); } catch { diff --git a/packages/shared/config.test.ts b/packages/shared/config.test.ts new file mode 100644 index 000000000..2f209bca9 --- /dev/null +++ b/packages/shared/config.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "child_process"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { detectGitUser } from "./config"; + +const dirs: string[] = []; + +function tempRepo(name: string, gitUser: string): string { + const dir = mkdtempSync(join(tmpdir(), `plannotator-config-${name}-`)); + dirs.push(dir); + execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", gitUser], { cwd: dir }); + return dir; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("detectGitUser", () => { + test("reads git identity from the provided cwd", () => { + const first = tempRepo("first", "First Repo User"); + const second = tempRepo("second", "Second Repo User"); + const originalCwd = process.cwd(); + + try { + process.chdir(first); + expect(detectGitUser(second)).toBe("Second Repo User"); + } finally { + process.chdir(originalCwd); + } + }); +}); diff --git a/packages/shared/config.ts b/packages/shared/config.ts index f37fc9001..d9e32865f 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -8,7 +8,7 @@ import { homedir } from "os"; import { join } from "path"; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; export type DefaultDiffType = 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all'; export type DiffLineBgIntensity = 'subtle' | 'normal' | 'strong'; @@ -173,9 +173,12 @@ export function saveConfig(partial: Partial): void { * Detect the git user name from `git config user.name`. * Returns null if git is unavailable, not in a repo, or user.name is not set. */ -export function detectGitUser(): string | null { +export function detectGitUser(cwd = process.cwd()): string | null { try { - const name = execSync("git config user.name", { encoding: "utf-8", timeout: 3000 }).trim(); + const name = execFileSync("git", ["-C", cwd, "config", "user.name"], { + encoding: "utf-8", + timeout: 3000, + }).trim(); return name || null; } catch { return null; diff --git a/packages/shared/daemon-protocol.test.ts b/packages/shared/daemon-protocol.test.ts new file mode 100644 index 000000000..2e1c73db1 --- /dev/null +++ b/packages/shared/daemon-protocol.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { + PLANNOTATOR_DAEMON_PROTOCOL, + PLANNOTATOR_DAEMON_PROTOCOL_VERSION, + createDaemonErrorResponse, + getDaemonCapabilities, + isCompatibleDaemonCapabilities, +} from "./daemon-protocol"; + +describe("daemon protocol", () => { + test("exposes versioned multi-session HTTP capabilities", () => { + const capabilities = getDaemonCapabilities(); + expect(capabilities.protocol).toBe(PLANNOTATOR_DAEMON_PROTOCOL); + expect(capabilities.protocolVersion).toBe(PLANNOTATOR_DAEMON_PROTOCOL_VERSION); + expect(capabilities.transport).toBe("http"); + expect(capabilities.multiSession).toBe(true); + expect(capabilities.features).toContain("session-create"); + expect(capabilities.features).toContain("session-result-wait"); + }); + + test("validates compatible capabilities", () => { + expect(isCompatibleDaemonCapabilities(getDaemonCapabilities())).toBe(true); + expect(isCompatibleDaemonCapabilities({ ...getDaemonCapabilities(), protocolVersion: 999 })).toBe(false); + expect(isCompatibleDaemonCapabilities({ ...getDaemonCapabilities(), transport: "stdio" })).toBe(false); + expect(isCompatibleDaemonCapabilities({ ...getDaemonCapabilities(), multiSession: false })).toBe(false); + }); + + test("wraps daemon errors with stable protocol metadata", () => { + const response = createDaemonErrorResponse("daemon-unreachable", "No daemon"); + expect(response.ok).toBe(false); + expect(response.protocol).toBe(PLANNOTATOR_DAEMON_PROTOCOL); + expect(response.error.code).toBe("daemon-unreachable"); + expect(response.error.message).toBe("No daemon"); + }); +}); diff --git a/packages/shared/daemon-protocol.ts b/packages/shared/daemon-protocol.ts new file mode 100644 index 000000000..7dc423fb7 --- /dev/null +++ b/packages/shared/daemon-protocol.ts @@ -0,0 +1,161 @@ +import type { PluginRequest, PluginSessionMode } from "./plugin-protocol"; + +export const PLANNOTATOR_DAEMON_PROTOCOL = "plannotator-daemon"; +export const PLANNOTATOR_DAEMON_PROTOCOL_VERSION = 1; +export const PLANNOTATOR_DAEMON_MIN_CLIENT_VERSION = 1; + +export const PLANNOTATOR_DAEMON_FEATURES = [ + "capabilities", + "status", + "sessions", + "session-create", + "session-result-wait", + "session-cancel", + "shutdown", +] as const; + +export type DaemonFeature = (typeof PLANNOTATOR_DAEMON_FEATURES)[number]; +export type DaemonSessionMode = PluginSessionMode; +export type DaemonSessionStatus = + | "pending" + | "active" + | "completed" + | "cancelled" + | "expired" + | "failed"; + +export interface DaemonCapabilities { + protocol: typeof PLANNOTATOR_DAEMON_PROTOCOL; + protocolVersion: typeof PLANNOTATOR_DAEMON_PROTOCOL_VERSION; + minClientVersion: typeof PLANNOTATOR_DAEMON_MIN_CLIENT_VERSION; + features: DaemonFeature[]; + transport: "http"; + multiSession: true; +} + +export interface DaemonEndpoint { + hostname: string; + port: number; + baseUrl: string; + isRemote: boolean; +} + +export interface DaemonStatus { + ok: true; + protocol: typeof PLANNOTATOR_DAEMON_PROTOCOL; + protocolVersion: typeof PLANNOTATOR_DAEMON_PROTOCOL_VERSION; + pid: number; + endpoint: DaemonEndpoint; + startedAt: string; + activeSessionCount: number; + sessionCount: number; +} + +export interface DaemonSessionSummary { + id: string; + mode: DaemonSessionMode; + status: DaemonSessionStatus; + url: string; + project: string; + label: string; + origin?: string; + createdAt: string; + updatedAt: string; + expiresAt?: string; + error?: string; + remoteShare?: DaemonRemoteShareNotice; +} + +export interface DaemonRemoteShareNotice { + url: string; + verb: string; + noun: string; + size: string; +} + +export interface DaemonCreateSessionRequest { + request: PluginRequest; +} + +export interface DaemonCreateSessionResponse { + ok: true; + session: DaemonSessionSummary; +} + +export interface DaemonSessionResultResponse { + ok: true; + session: DaemonSessionSummary; + result: T; +} + +export interface DaemonCancelSessionResponse { + ok: true; + session: DaemonSessionSummary; +} + +export interface DaemonShutdownResponse { + ok: true; + shuttingDown: true; +} + +export type DaemonErrorCode = + | "daemon-unreachable" + | "daemon-stale" + | "daemon-unhealthy" + | "daemon-incompatible" + | "daemon-locked" + | "session-not-found" + | "session-cancelled" + | "session-expired" + | "invalid-request" + | "internal-error"; + +export interface DaemonErrorResponse { + ok: false; + protocol: typeof PLANNOTATOR_DAEMON_PROTOCOL; + protocolVersion: typeof PLANNOTATOR_DAEMON_PROTOCOL_VERSION; + error: { + code: DaemonErrorCode; + message: string; + }; +} + +export type DaemonResponse = T | DaemonErrorResponse; + +export function getDaemonCapabilities(): DaemonCapabilities { + return { + protocol: PLANNOTATOR_DAEMON_PROTOCOL, + protocolVersion: PLANNOTATOR_DAEMON_PROTOCOL_VERSION, + minClientVersion: PLANNOTATOR_DAEMON_MIN_CLIENT_VERSION, + features: [...PLANNOTATOR_DAEMON_FEATURES], + transport: "http", + multiSession: true, + }; +} + +export function createDaemonErrorResponse( + code: DaemonErrorCode, + message: string, +): DaemonErrorResponse { + return { + ok: false, + protocol: PLANNOTATOR_DAEMON_PROTOCOL, + protocolVersion: PLANNOTATOR_DAEMON_PROTOCOL_VERSION, + error: { code, message }, + }; +} + +export function isCompatibleDaemonCapabilities( + value: unknown, +): value is DaemonCapabilities { + const caps = value as Partial | null; + return ( + !!caps && + caps.protocol === PLANNOTATOR_DAEMON_PROTOCOL && + caps.protocolVersion === PLANNOTATOR_DAEMON_PROTOCOL_VERSION && + typeof caps.minClientVersion === "number" && + caps.minClientVersion <= PLANNOTATOR_DAEMON_PROTOCOL_VERSION && + caps.transport === "http" && + caps.multiSession === true + ); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 21c1f17b8..979a2e818 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -30,6 +30,10 @@ "./agent-jobs": "./agent-jobs.ts", "./config": "./config.ts", "./prompts": "./prompts.ts", + "./plugin-binary": "./plugin-binary.ts", + "./plugin-client": "./plugin-client.ts", + "./plugin-protocol": "./plugin-protocol.ts", + "./daemon-protocol": "./daemon-protocol.ts", "./improvement-hooks": "./improvement-hooks.ts", "./pfm-reminder": "./pfm-reminder.ts", "./worktree": "./worktree.ts", diff --git a/packages/shared/plugin-binary.test.ts b/packages/shared/plugin-binary.test.ts index 241c64617..8f525cfe1 100644 --- a/packages/shared/plugin-binary.test.ts +++ b/packages/shared/plugin-binary.test.ts @@ -229,8 +229,13 @@ describe("plugin binary install and capabilities", () => { test("parses and validates plugin capabilities", () => { const capabilities = getPluginCapabilities(); + const rolloutCompatible = { + ...capabilities, + multiSessionDaemon: false, + }; expect(parsePluginCapabilities(JSON.stringify(capabilities))).toEqual(capabilities); + expect(parsePluginCapabilities(JSON.stringify(rolloutCompatible))).toEqual(rolloutCompatible); expect(isCompatiblePluginBinary(capabilities)).toBe(true); expect(parsePluginCapabilities(JSON.stringify({ ...capabilities, diff --git a/packages/shared/plugin-client.ts b/packages/shared/plugin-client.ts index 790d39ca2..f807dd556 100644 --- a/packages/shared/plugin-client.ts +++ b/packages/shared/plugin-client.ts @@ -421,7 +421,7 @@ async function runPluginCommand(result.stdout); diff --git a/packages/shared/plugin-protocol.test.ts b/packages/shared/plugin-protocol.test.ts index 2948f2b59..0079d8305 100644 --- a/packages/shared/plugin-protocol.test.ts +++ b/packages/shared/plugin-protocol.test.ts @@ -18,7 +18,7 @@ describe("plugin protocol", () => { minClientVersion: PLANNOTATOR_PLUGIN_MIN_CLIENT_VERSION, features: [...PLANNOTATOR_PLUGIN_FEATURES], daemonReady: true, - multiSessionDaemon: false, + multiSessionDaemon: true, }); }); diff --git a/packages/shared/plugin-protocol.ts b/packages/shared/plugin-protocol.ts index 1feb0c8d5..1007b40d1 100644 --- a/packages/shared/plugin-protocol.ts +++ b/packages/shared/plugin-protocol.ts @@ -15,7 +15,8 @@ export const PLANNOTATOR_PLUGIN_FEATURES = [ export type PluginFeature = (typeof PLANNOTATOR_PLUGIN_FEATURES)[number]; export type PluginClientOrigin = Extract; -export type PluginSessionMode = "plan" | "review" | "annotate" | "archive"; +export type PluginRequestOrigin = Origin; +export type PluginSessionMode = "plan" | "review" | "annotate" | "archive" | "goal-setup"; export interface PluginCapabilities { protocol: typeof PLANNOTATOR_PLUGIN_PROTOCOL; @@ -27,11 +28,12 @@ export interface PluginCapabilities { } export interface PluginBaseRequest { - origin: PluginClientOrigin; + origin: PluginRequestOrigin; cwd?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; + timeoutMs?: number | null; } export interface PluginAgentInfo { @@ -60,6 +62,9 @@ export interface PluginReviewRequest extends PluginBaseRequest { export interface PluginAnnotateRequest extends PluginBaseRequest { args?: string; + noJina?: boolean; + useJina?: boolean; + jinaApiKey?: string; markdown?: string; filePath?: string; mode?: "annotate" | "annotate-folder" | "annotate-last"; @@ -75,12 +80,19 @@ export interface PluginArchiveRequest extends PluginBaseRequest { customPlanPath?: string | null; } +export interface PluginGoalSetupRequest extends PluginBaseRequest { + bundle: unknown; + stage: "interview" | "facts"; + goalSlug?: string; +} + export type PluginRequest = | ({ action: "plan" } & PluginPlanRequest) | ({ action: "review" } & PluginReviewRequest) | ({ action: "annotate" } & PluginAnnotateRequest) | ({ action: "annotate-last" } & PluginAnnotateRequest) - | ({ action: "archive" } & PluginArchiveRequest); + | ({ action: "archive" } & PluginArchiveRequest) + | ({ action: "goal-setup" } & PluginGoalSetupRequest); export interface PluginSessionInfo { mode: PluginSessionMode; @@ -153,7 +165,7 @@ export function getPluginCapabilities(): PluginCapabilities { minClientVersion: PLANNOTATOR_PLUGIN_MIN_CLIENT_VERSION, features: [...PLANNOTATOR_PLUGIN_FEATURES], daemonReady: true, - multiSessionDaemon: false, + multiSessionDaemon: true, }; } diff --git a/packages/shared/url-to-markdown.test.ts b/packages/shared/url-to-markdown.test.ts index 15bc1cbd3..5e4280d28 100644 --- a/packages/shared/url-to-markdown.test.ts +++ b/packages/shared/url-to-markdown.test.ts @@ -4,6 +4,7 @@ import { urlToMarkdown } from "./url-to-markdown"; // Track fetch calls to verify headers and URL selection let fetchCalls: { url: string; headers: Record }[] = []; const originalFetch = globalThis.fetch; +const originalJinaApiKey = process.env.JINA_API_KEY; beforeEach(() => { fetchCalls = []; @@ -11,6 +12,11 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch; + if (originalJinaApiKey === undefined) { + delete process.env.JINA_API_KEY; + } else { + process.env.JINA_API_KEY = originalJinaApiKey; + } }); /** @@ -106,6 +112,66 @@ test("content negotiation: falls through to Jina when server returns HTML", asyn expect(fetchCalls[1].url).toContain("r.jina.ai"); }); +test("Jina fetch uses the per-request API key when provided", async () => { + let callCount = 0; + globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => { + const headers = init?.headers as Record | undefined; + fetchCalls.push({ url: String(url), headers: headers ?? {} }); + callCount++; + + if (callCount === 1) { + return Promise.resolve( + new Response("Hi", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ); + } + return Promise.resolve( + new Response("# From Jina", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + ); + }) as typeof fetch; + + await urlToMarkdown("https://example.com/page", { + useJina: true, + jinaApiKey: "request-key", + }); + + expect(fetchCalls[1].headers.Authorization).toBe("Bearer request-key"); +}); + +test("Jina fetch does not read a stale process API key implicitly", async () => { + process.env.JINA_API_KEY = "daemon-start-key"; + let callCount = 0; + globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => { + const headers = init?.headers as Record | undefined; + fetchCalls.push({ url: String(url), headers: headers ?? {} }); + callCount++; + + if (callCount === 1) { + return Promise.resolve( + new Response("Hi", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ); + } + return Promise.resolve( + new Response("# From Jina", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + ); + }) as typeof fetch; + + await urlToMarkdown("https://example.com/page", { useJina: true }); + + expect(fetchCalls[1].headers.Authorization).toBeUndefined(); +}); + test("content negotiation: skipped for local URLs", async () => { let callCount = 0; globalThis.fetch = mock((_url: string | URL | Request, init?: RequestInit) => { diff --git a/packages/shared/url-to-markdown.ts b/packages/shared/url-to-markdown.ts index b6c96f554..5a54d4834 100644 --- a/packages/shared/url-to-markdown.ts +++ b/packages/shared/url-to-markdown.ts @@ -10,6 +10,8 @@ import { htmlToMarkdown } from "./html-to-markdown"; export interface UrlToMarkdownOptions { /** Whether to use Jina Reader (true) or plain fetch+Turndown (false). */ useJina: boolean; + /** Optional Jina Reader API key to use for this request. */ + jinaApiKey?: string; } export interface UrlToMarkdownResult { @@ -103,7 +105,7 @@ export async function urlToMarkdown( if (options.useJina && !local) { try { - const markdown = await fetchViaJina(url); + const markdown = await fetchViaJina(url, options.jinaApiKey); return { markdown, source: "jina" }; } catch (err) { process.stderr.write( @@ -257,7 +259,7 @@ async function fetchViaContentNegotiation(url: string): Promise { } /** Fetch via Jina Reader — returns markdown directly. */ -async function fetchViaJina(url: string): Promise { +async function fetchViaJina(url: string, apiKey?: string): Promise { // Strip fragment (never sent to server) and encode for Jina's path-based API const cleanUrl = url.split("#")[0]; const jinaUrl = `https://r.jina.ai/${cleanUrl}`; @@ -265,7 +267,6 @@ async function fetchViaJina(url: string): Promise { Accept: "text/plain", }; - const apiKey = process.env.JINA_API_KEY; if (apiKey) { headers.Authorization = `Bearer ${apiKey}`; } diff --git a/packages/ui/components/ImageThumbnail.test.ts b/packages/ui/components/ImageThumbnail.test.ts new file mode 100644 index 000000000..d90412eaa --- /dev/null +++ b/packages/ui/components/ImageThumbnail.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { getImageSrc } from "./ImageThumbnail"; + +const originalWindow = globalThis.window; + +function setWindow(value: Partial) { + Object.defineProperty(globalThis, "window", { + value, + configurable: true, + }); +} + +afterEach(() => { + Object.defineProperty(globalThis, "window", { + value: originalWindow, + configurable: true, + }); +}); + +describe("getImageSrc", () => { + test("uses the root API base by default", () => { + setWindow({}); + expect(getImageSrc("/tmp/screen shot.png")).toBe("/api/image?path=%2Ftmp%2Fscreen%20shot.png"); + }); + + test("uses the daemon-scoped API base for local image resources", () => { + setWindow({ __PLANNOTATOR_API_BASE__: "/s/sess_123/api" }); + expect(getImageSrc("/tmp/screen shot.png")).toBe("/s/sess_123/api/image?path=%2Ftmp%2Fscreen%20shot.png"); + expect(getImageSrc("images/mock.png", "/repo")).toBe("/s/sess_123/api/image?path=images%2Fmock.png&base=%2Frepo"); + }); + + test("leaves remote image URLs untouched", () => { + setWindow({ __PLANNOTATOR_API_BASE__: "/s/sess_123/api" }); + expect(getImageSrc("https://example.com/image.png")).toBe("https://example.com/image.png"); + }); +}); diff --git a/packages/ui/components/ImageThumbnail.tsx b/packages/ui/components/ImageThumbnail.tsx index 605c714cb..6dd98cafb 100644 --- a/packages/ui/components/ImageThumbnail.tsx +++ b/packages/ui/components/ImageThumbnail.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { apiPath } from '../utils/api'; /** * Get the display URL for an image path or URL @@ -7,7 +8,7 @@ export const getImageSrc = (path: string, base?: string): string => { if (path.startsWith('http://') || path.startsWith('https://')) { return path; // Remote URL, use directly } - let url = `/api/image?path=${encodeURIComponent(path)}`; + let url = `${apiPath('/image')}?path=${encodeURIComponent(path)}`; if (base && !path.startsWith('/')) { url += `&base=${encodeURIComponent(base)}`; } diff --git a/packages/ui/utils/api.test.ts b/packages/ui/utils/api.test.ts new file mode 100644 index 000000000..2544a36f6 --- /dev/null +++ b/packages/ui/utils/api.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { apiPath, getApiBase, getApiOriginAndBase } from "./api"; + +const originalWindow = globalThis.window; + +function setWindow(value: Partial) { + Object.defineProperty(globalThis, "window", { + value, + configurable: true, + }); +} + +afterEach(() => { + Object.defineProperty(globalThis, "window", { + value: originalWindow, + configurable: true, + }); +}); + +describe("api base helpers", () => { + test("defaults to root API base", () => { + setWindow({}); + expect(getApiBase()).toBe("/api"); + expect(apiPath("/plan")).toBe("/api/plan"); + expect(apiPath("/api/plan")).toBe("/api/plan"); + }); + + test("uses daemon-injected API base", () => { + setWindow({ __PLANNOTATOR_API_BASE__: "/s/s1/api" }); + expect(getApiBase()).toBe("/s/s1/api"); + expect(apiPath("/plan")).toBe("/s/s1/api/plan"); + expect(apiPath("plan")).toBe("/s/s1/api/plan"); + expect(apiPath("/api/plan")).toBe("/s/s1/api/plan"); + expect(apiPath("/api/")).toBe("/s/s1/api"); + }); + + test("trims trailing slash from injected API base", () => { + setWindow({ __PLANNOTATOR_API_BASE__: "/s/s1/api/" }); + expect(apiPath("/upload")).toBe("/s/s1/api/upload"); + }); + + test("builds absolute origin plus API base for agent instructions", () => { + setWindow({ + __PLANNOTATOR_API_BASE__: "/s/s1/api", + location: { origin: "http://localhost:1234" } as Location, + }); + expect(getApiOriginAndBase()).toBe("http://localhost:1234/s/s1/api"); + }); +}); diff --git a/packages/ui/utils/api.ts b/packages/ui/utils/api.ts new file mode 100644 index 000000000..4091e50dd --- /dev/null +++ b/packages/ui/utils/api.ts @@ -0,0 +1,45 @@ +declare global { + interface Window { + __PLANNOTATOR_API_BASE__?: string; + } +} + +function normalizeBase(base: string | undefined): string { + if (!base) return "/api"; + const trimmed = base.trim(); + if (!trimmed) return "/api"; + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} + +function normalizePath(path: string): string { + if (!path) return ""; + const prefixed = path.startsWith("/") ? path : `/${path}`; + return prefixed.length > 1 && prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed; +} + +export function getApiBase(): string { + if (typeof window === "undefined") return "/api"; + return normalizeBase(window.__PLANNOTATOR_API_BASE__); +} + +export function apiPath(path: string): string { + const normalized = normalizePath(path); + if (normalized === "/api") return getApiBase(); + if (normalized.startsWith("/api/")) { + return `${getApiBase()}${normalized.slice("/api".length)}`; + } + return `${getApiBase()}${normalized}`; +} + +export function apiFetch(input: string, init?: RequestInit): Promise { + return fetch(apiPath(input), init); +} + +export function apiEventSource(path: string, init?: EventSourceInit): EventSource { + return new EventSource(apiPath(path), init); +} + +export function getApiOriginAndBase(): string { + if (typeof window === "undefined") return "/api"; + return `${window.location.origin}${getApiBase()}`; +} diff --git a/packages/ui/utils/planAgentInstructions.test.ts b/packages/ui/utils/planAgentInstructions.test.ts new file mode 100644 index 000000000..de8dfcb63 --- /dev/null +++ b/packages/ui/utils/planAgentInstructions.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { buildPlanAgentInstructions } from "./planAgentInstructions"; + +describe("buildPlanAgentInstructions", () => { + test("uses the provided API base instead of assuming root /api routes", () => { + const instructions = buildPlanAgentInstructions("http://localhost:1234/s/s1/api"); + + expect(instructions).toContain("curl -s http://localhost:1234/s/s1/api/plan"); + expect(instructions).toContain("http://localhost:1234/s/s1/api/external-annotations"); + expect(instructions).not.toContain("/s/s1/api/api/"); + }); +}); diff --git a/packages/ui/utils/planAgentInstructions.ts b/packages/ui/utils/planAgentInstructions.ts index 25987c9e0..5b72986c2 100644 --- a/packages/ui/utils/planAgentInstructions.ts +++ b/packages/ui/utils/planAgentInstructions.ts @@ -1,7 +1,7 @@ /** * Builds the clipboard payload that teaches an external agent (Claude Code, * Codex, custom scripts, etc.) how to post annotations into a live Plannotator - * **plan-review** session via the /api/external-annotations HTTP API. + * **plan-review** session via the external-annotations HTTP API. * * Plan mode and code-review mode have different annotation shapes (plan uses * `originalText` for inline highlighting; review uses `filePath` + line ranges @@ -12,11 +12,11 @@ * it top-to-bottom and start posting in 30 seconds. Edit freely — this file is * the single source of truth for the agent-facing contract surface. * - * The only dynamic value is `origin`, which is interpolated at click time from - * `window.location.origin` so the agent gets the correct base URL whether the - * server is running on a random local port or the fixed remote port (19432). + * The only dynamic value is `apiBase`, which is interpolated at click time from + * the runtime API base so the agent gets the correct session URL whether the + * UI is served from a standalone root server or the long-running daemon. */ -export function buildPlanAgentInstructions(origin: string): string { +export function buildPlanAgentInstructions(apiBase: string): string { return `# Plannotator — External Annotations You can submit review feedback on the user's current plan-review session by POSTing annotations to a small HTTP API. The user will see them immediately — inline highlights on the plan and entries in a sidebar — and can accept, edit, or delete them. @@ -24,7 +24,7 @@ You can submit review feedback on the user's current plan-review session by POST This is one-way submission. Any tool can post: linters, agents, scripts. The user does not see who you are unless you tell them via \`text\` or \`author\`. ## Base URL -${origin} +${apiBase} All endpoints below are relative to that base. No authentication. @@ -38,7 +38,7 @@ There is no "send" or "done" step — each POST is live the moment it lands. ## Reading the plan \`\`\`sh -curl -s ${origin}/api/plan | jq -r .plan +curl -s ${apiBase}/plan | jq -r .plan \`\`\` **Line numbers do not apply and cannot be referenced.** The renderer pins your comments to the plan by matching the \`originalText\` field as a verbatim substring of the rendered text. Quote the exact phrase, never say "line 12." @@ -53,7 +53,7 @@ You have exactly two shapes to choose from: ## Posting an inline comment \`\`\`sh -curl -s ${origin}/api/external-annotations \\ +curl -s ${apiBase}/external-annotations \\ -H 'Content-Type: application/json' \\ -d '{ "source": "claude-code", @@ -68,7 +68,7 @@ curl -s ${origin}/api/external-annotations \\ ## Posting a global comment \`\`\`sh -curl -s ${origin}/api/external-annotations \\ +curl -s ${apiBase}/external-annotations \\ -H 'Content-Type: application/json' \\ -d '{ "source": "claude-code", @@ -92,7 +92,7 @@ Both endpoints return \`201 {"ids": [""]}\` on success, \`400 {"error": ". ## Batching \`\`\`sh -curl -s ${origin}/api/external-annotations \\ +curl -s ${apiBase}/external-annotations \\ -H 'Content-Type: application/json' \\ -d '{ "annotations": [ @@ -109,16 +109,16 @@ Batches are atomic: if any item fails validation, the whole batch is rejected wi \`\`\`sh # List everything (yours and others') -curl -s ${origin}/api/external-annotations | jq +curl -s ${apiBase}/external-annotations | jq # Delete one annotation by id — works on any source, including the user's -curl -s -X DELETE "${origin}/api/external-annotations?id=" +curl -s -X DELETE "${apiBase}/external-annotations?id=" # Delete all annotations from one source — the standard cleanup before reposting -curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code" +curl -s -X DELETE "${apiBase}/external-annotations?source=claude-code" # Delete everything in the session -curl -s -X DELETE ${origin}/api/external-annotations +curl -s -X DELETE ${apiBase}/external-annotations \`\`\` You have full delete authority. Use it responsibly. @@ -128,14 +128,14 @@ You have full delete authority. Use it responsibly. If you re-run on the same session, your previous annotations are still there. POSTing again will create duplicates. Standard pattern: \`\`\`sh -curl -s -X DELETE "${origin}/api/external-annotations?source=claude-code" -curl -s ${origin}/api/external-annotations -H 'Content-Type: application/json' -d '{ ...fresh annotations... }' +curl -s -X DELETE "${apiBase}/external-annotations?source=claude-code" +curl -s ${apiBase}/external-annotations -H 'Content-Type: application/json' -d '{ ...fresh annotations... }' \`\`\` This is why \`source\` matters. Pick a stable identifier and stick with it. ## Notes -- The plan can change underneath you. If the user denies and resubmits, refetch \`/api/plan\` — your prior \`originalText\` substrings may no longer match. +- The plan can change underneath you. If the user denies and resubmits, refetch \`${apiBase}/plan\` — your prior \`originalText\` substrings may no longer match. - No idempotency. Posting the same annotation twice creates two entries. - This API is local to the user's machine. Treat it as a UI surface, not a public service. `; From 82f3c912a07240a268bc086afe16ff96a3efdf4a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 26 May 2026 17:39:33 -0700 Subject: [PATCH 2/2] Add daemon debug shell and simulator (#738) 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 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} + + ))} + +
+