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. `;