From f6e1a362270b677d1f8353f48754727520d9a4bb Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 18 May 2026 10:31:52 -0700 Subject: [PATCH 1/8] Add long-running Plannotator daemon runtime Single daemon process per machine manages session lifecycle, serves browser UIs at /s/, and exposes control APIs. CLI commands auto-start the daemon and create sessions via HTTP. Includes session store, auth tokens, lock files, event broadcasting, and goal-setup daemon integration. --- .github/workflows/release.yml | 33 +- .gitignore | 2 - AGENTS.md | 21 + apps/hook/README.md | 15 +- apps/hook/dev-mock-api.ts | 159 -- apps/hook/server/cli.test.ts | 6 +- apps/hook/server/cli.ts | 5 +- apps/hook/server/index.ts | 1688 +++++------------ apps/marketing/src/lib/shortcutReference.ts | 4 +- apps/opencode-plugin/README.md | 12 + apps/opencode-plugin/binary-client.test.ts | 21 + apps/pi-extension/README.md | 12 + apps/pi-extension/index.ts | 5 +- apps/skills/plannotator-setup-goal/SKILL.md | 109 +- .../src/editor-annotations.ts | 14 +- apps/vscode-extension/src/extension.ts | 3 +- bun.lock | 6 +- docs/single-binary-runtime.md | 48 +- packages/editor/App.tsx | 172 +- packages/editor/components/AppHeader.tsx | 69 +- packages/editor/shortcuts.ts | 27 - packages/review-editor/App.tsx | 3 +- packages/review-editor/hooks/usePRStack.ts | 1 + packages/server/annotate.ts | 111 +- packages/server/daemon/client.test.ts | 441 +++++ packages/server/daemon/client.ts | 297 +++ packages/server/daemon/runtime.test.ts | 141 ++ packages/server/daemon/runtime.ts | 114 ++ packages/server/daemon/server.test.ts | 306 +++ packages/server/daemon/server.ts | 249 +++ .../server/daemon/session-factory.test.ts | 540 ++++++ packages/server/daemon/session-factory.ts | 672 +++++++ packages/server/daemon/session-store.test.ts | 166 ++ packages/server/daemon/session-store.ts | 271 +++ packages/server/daemon/start-command.test.ts | 41 + packages/server/daemon/start-command.ts | 14 + packages/server/daemon/state.test.ts | 171 ++ packages/server/daemon/state.ts | 243 +++ packages/server/external-annotations.test.ts | 15 + packages/server/external-annotations.ts | 40 +- packages/server/goal-setup.test.ts | 132 +- packages/server/goal-setup.ts | 257 +-- packages/server/image.test.ts | 71 +- packages/server/index.ts | 135 +- packages/server/integrations.test.ts | 47 +- packages/server/integrations.ts | 19 +- packages/server/package.json | 11 +- packages/server/project.ts | 5 +- packages/server/reference-handlers.test.ts | 37 + packages/server/reference-handlers.ts | 23 +- packages/server/repo.ts | 16 +- packages/server/review-agent-cwd.test.ts | 63 + packages/server/review.ts | 217 ++- packages/server/session-handler.ts | 9 + packages/server/sessions.ts | 2 +- packages/server/share-url.ts | 34 +- packages/server/shared-handlers.ts | 46 +- packages/shared/config.test.ts | 35 + packages/shared/config.ts | 9 +- packages/shared/daemon-protocol.test.ts | 35 + packages/shared/daemon-protocol.ts | 161 ++ packages/shared/package.json | 4 + packages/shared/plugin-binary.test.ts | 5 + packages/shared/plugin-client.ts | 2 +- packages/shared/plugin-protocol.test.ts | 2 +- packages/shared/plugin-protocol.ts | 20 +- packages/shared/url-to-markdown.test.ts | 66 + packages/shared/url-to-markdown.ts | 7 +- packages/ui/components/CommentPopover.tsx | 63 +- packages/ui/components/ConfirmDialog.tsx | 32 +- packages/ui/components/ImageThumbnail.test.ts | 36 + packages/ui/components/ImageThumbnail.tsx | 3 +- packages/ui/components/core/button.tsx | 44 - packages/ui/components/core/textarea.tsx | 26 - packages/ui/package.json | 6 +- packages/ui/shortcuts/index.ts | 1 - packages/ui/theme.css | 104 - packages/ui/utils/api.test.ts | 49 + packages/ui/utils/api.ts | 45 + .../ui/utils/planAgentInstructions.test.ts | 12 + packages/ui/utils/planAgentInstructions.ts | 34 +- 81 files changed, 5855 insertions(+), 2337 deletions(-) create mode 100644 packages/server/daemon/client.test.ts create mode 100644 packages/server/daemon/client.ts create mode 100644 packages/server/daemon/runtime.test.ts create mode 100644 packages/server/daemon/runtime.ts create mode 100644 packages/server/daemon/server.test.ts create mode 100644 packages/server/daemon/server.ts create mode 100644 packages/server/daemon/session-factory.test.ts create mode 100644 packages/server/daemon/session-factory.ts create mode 100644 packages/server/daemon/session-store.test.ts create mode 100644 packages/server/daemon/session-store.ts create mode 100644 packages/server/daemon/start-command.test.ts create mode 100644 packages/server/daemon/start-command.ts create mode 100644 packages/server/daemon/state.test.ts create mode 100644 packages/server/daemon/state.ts create mode 100644 packages/server/reference-handlers.test.ts create mode 100644 packages/server/review-agent-cwd.test.ts create mode 100644 packages/server/session-handler.ts create mode 100644 packages/shared/config.test.ts create mode 100644 packages/shared/daemon-protocol.test.ts create mode 100644 packages/shared/daemon-protocol.ts create mode 100644 packages/ui/components/ImageThumbnail.test.ts delete mode 100644 packages/ui/components/core/button.tsx delete mode 100644 packages/ui/components/core/textarea.tsx create mode 100644 packages/ui/utils/api.test.ts create mode 100644 packages/ui/utils/api.ts create mode 100644 packages/ui/utils/planAgentInstructions.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d5571467..1db79114f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,22 +170,29 @@ jobs: local ok=0 for _ in $(seq 1 60); do - if curl -sf "http://127.0.0.1:${port}${endpoint}" -o /dev/null 2>/dev/null; then - ok=1 - break + local sessions + sessions="$(curl -sf "http://127.0.0.1:${port}/daemon/sessions" 2>/dev/null || true)" + if [ -n "$sessions" ]; then + local session_url + session_url="$(python3 -c 'import json,sys; data=json.load(sys.stdin); sessions=data.get("sessions") or []; print(sessions[0].get("url","") if sessions else "")' <<< "$sessions")" + if [ -n "$session_url" ] && curl -sf "${session_url}${endpoint}" -o /dev/null 2>/dev/null; then + ok=1 + break + fi fi sleep 0.5 done kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true + PLANNOTATOR_PORT="$port" "$BINARY" daemon stop >/dev/null 2>&1 || true if [ "$ok" = "0" ]; then - echo "FAIL: ${label} did not respond on :${port}${endpoint}" + echo "FAIL: ${label} did not expose a daemon-scoped ${endpoint}" exit 1 fi - echo "OK: ${label} responded on :${port}${endpoint}" + echo "OK: ${label} exposed daemon-scoped ${endpoint}" } # 2. review: exercises server startup, bundled HTML, git diff, and HTTP. @@ -232,9 +239,14 @@ jobs: try { for ($i = 0; $i -lt 60; $i++) { try { - Invoke-WebRequest -Uri "http://127.0.0.1:$Port$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null - $ok = $true - break + $sessionsResponse = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/daemon/sessions" -UseBasicParsing -TimeoutSec 1 + $sessionsBody = $sessionsResponse.Content | ConvertFrom-Json + if ($sessionsBody.sessions.Count -gt 0) { + $sessionUrl = $sessionsBody.sessions[0].url + Invoke-WebRequest -Uri "$sessionUrl$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null + $ok = $true + break + } } catch { if ($process.HasExited) { break @@ -247,6 +259,7 @@ jobs: Stop-Process -Id $process.Id -Force Wait-Process -Id $process.Id -ErrorAction SilentlyContinue } + & $binary daemon stop *> $null Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue } @@ -255,10 +268,10 @@ jobs: Get-Content $stdout -ErrorAction SilentlyContinue Write-Host "stderr:" Get-Content $stderr -ErrorAction SilentlyContinue - throw "FAIL: $Label did not respond on :$Port$Endpoint" + throw "FAIL: $Label did not expose a daemon-scoped $Endpoint" } - Write-Host "OK: $Label responded on :$Port$Endpoint" + Write-Host "OK: $Label exposed daemon-scoped $Endpoint" } # 2. review: exercises server startup, bundled HTML, git diff, and HTTP. diff --git a/.gitignore b/.gitignore index ccdb004de..9d3f75a12 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,4 @@ opencode.json plannotator-local # Local research/reference docs (not for repo) /reference/ -# Local goal setup packages generated by the setup-goal skill. -/goals/ *.bun-build diff --git a/AGENTS.md b/AGENTS.md index de0f32123..96af73406 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ plannotator/ │ │ ├── index.ts # startPlannotatorServer(), handleServerReady() │ │ ├── review.ts # startReviewServer(), handleReviewServerReady() │ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady() +│ │ ├── daemon/ # Long-running daemon runtime, state, client, and session store │ │ ├── storage.ts # Re-exports from @plannotator/shared/storage │ │ ├── share-url.ts # Server-side share URL generation for remote sessions │ │ ├── remote.ts # isRemoteSession(), getServerPort() @@ -99,6 +100,8 @@ Plannotator has one server implementation: Claude Code runs this server through the released `plannotator` binary entrypoint. OpenCode and Pi do not package their own server implementations; they call the same binary through the plugin protocol in `packages/shared/plugin-protocol.ts`. Runtime-agnostic logic (store, validation, types) lives in `packages/shared/`. +Daemon-backed commands run through one long-running `plannotator` process per user/machine environment. `plannotator daemon start|status|stop` manage that lifecycle, while normal plan/review/annotate/archive commands auto-start a compatible daemon and create session-scoped browser URLs at `/s/`. Browser API calls must use `/s//api/...`; root `/api/...` routes are not a daemon session boundary. + ## Installation **Via plugin marketplace** (when repo is public): @@ -216,6 +219,24 @@ During normal plan review, an Archive sidebar tab provides the same browsing via ## Server API +### Daemon Runtime (`packages/server/daemon/`) + +The daemon is the single long-running Bun server used by normal plan/review/annotate/archive commands. It owns a session store and exposes browser sessions at `/s/`. Session browser APIs are scoped under `/s//api/...`; root `/api/...` is not a valid daemon session API boundary. + +| Endpoint | Method | Purpose | +| --------------------- | ------ | ------------------------------------------ | +| `/daemon/capabilities` | GET | Return daemon protocol/capability metadata | +| `/daemon/status` | GET | Return daemon process, endpoint, and session counts | +| `/daemon/sessions` | GET | List active sessions (`?clean=1` also reaps expired sessions before listing) | +| `/daemon/sessions` | POST | Create a plan/review/annotate/archive session from a plugin-protocol request | +| `/daemon/sessions/:id` | GET | Fetch a session summary | +| `/daemon/sessions/:id/result` | GET | Wait for a session decision/result | +| `/daemon/sessions/:id/cancel` | POST | Cancel a session and dispose its resources | +| `/daemon/sessions/:id` | DELETE | Delete a session record | +| `/daemon/shutdown` | POST | Ask the daemon to stop | +| `/s/:id` | GET | Serve the browser HTML for a session | +| `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler | + ### Plan Server (`packages/server/index.ts`) | Endpoint | Method | Purpose | diff --git a/apps/hook/README.md b/apps/hook/README.md index 7336fdec0..51866cfcc 100644 --- a/apps/hook/README.md +++ b/apps/hook/README.md @@ -23,7 +23,7 @@ curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && d Released binaries ship with SHA256 sidecars and [SLSA build provenance](https://slsa.dev/) attestations from v0.17.2 onwards. See the [installation docs](https://plannotator.ai/docs/getting-started/installation/#verifying-your-install) for version pinning and verification commands. -The released binary owns Plannotator's browser server runtime for Claude Code, OpenCode, and Pi. See [Single Binary Runtime](../../docs/single-binary-runtime.md) for the plugin client boundary and daemon-next design. +The released binary owns Plannotator's browser server runtime for Claude Code, OpenCode, and Pi. See [Single Binary Runtime](../../docs/single-binary-runtime.md) for the plugin client boundary and daemon runtime design. --- @@ -84,6 +84,19 @@ When Claude Code calls `ExitPlanMode`, this hook intercepts and: | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | | `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. | +## Daemon Runtime + +Plan, review, annotate, and archive sessions are created through one long-running `plannotator` daemon. Normal commands auto-start a compatible daemon when needed. + +```bash +plannotator daemon status +plannotator daemon stop +plannotator daemon start +plannotator sessions +``` + +`daemon status` reports the daemon PID, endpoint, protocol version, and active session count. If the running daemon was started with different remote/port settings, stop it and retry with the desired `PLANNOTATOR_REMOTE` / `PLANNOTATOR_PORT` values. + ## Remote / Devcontainer Usage When running Claude Code in a remote environment (SSH, devcontainer, WSL), set `PLANNOTATOR_REMOTE=1` (or `true`) and these environment variables: diff --git a/apps/hook/dev-mock-api.ts b/apps/hook/dev-mock-api.ts index 7f2bcf595..65e837cd1 100644 --- a/apps/hook/dev-mock-api.ts +++ b/apps/hook/dev-mock-api.ts @@ -552,9 +552,6 @@ This change lands in section 3 of the contributor guide alongside the updated re const USE_DIFF_DEMO = process.env.VITE_DIFF_DEMO === "1" || process.env.VITE_DIFF_DEMO === "true"; -const GOAL_SETUP_DEMO = process.env.VITE_GOAL_SETUP_DEMO; -const USE_GOAL_SETUP_DEMO = - GOAL_SETUP_DEMO === "interview" || GOAL_SETUP_DEMO === "facts"; const PLAN_V1 = USE_DIFF_DEMO ? PLAN_V1_DIFF_TEST : PLAN_V1_DEFAULT; const PLAN_V2 = USE_DIFF_DEMO ? PLAN_V2_DIFF_TEST : PLAN_V2_DEFAULT; @@ -629,153 +626,6 @@ export function devMockApi(): Plugin { if (req.url === '/api/plan') { res.setHeader('Content-Type', 'application/json'); - if (USE_GOAL_SETUP_DEMO) { - res.end(JSON.stringify({ - plan: '', - origin: 'claude-code', - mode: 'goal-setup', - sharingEnabled: false, - goalSetup: GOAL_SETUP_DEMO === "facts" ? { - stage: "facts", - title: "Interactive goal setup facts", - goalSlug: "interactive-goal-setup-ui", - facts: [ - { - id: "skill-batch", - text: "The setup-goal skill should package all interview questions into one Plannotator UI session.", - accepted: false, - removed: false, - recommendedAutomatedVerification: true, - automatedVerification: true, - }, - { - id: "facts-verify", - text: "Each fact can be accepted, edited, removed, commented on, and marked for automated verification.", - accepted: false, - removed: false, - recommendedAutomatedVerification: true, - automatedVerification: true, - }, - { - id: "header-submit", - text: "Goal setup submission should use the Plannotator app header action area instead of local form buttons.", - accepted: false, - removed: false, - recommendedAutomatedVerification: false, - automatedVerification: false, - }, - { - id: "question-modes", - text: "The interview UI should cover text answers, single-select choices, multi-select choices, and custom option entry.", - accepted: false, - removed: false, - recommendedAutomatedVerification: true, - automatedVerification: true, - }, - { - id: "previous", - text: "Previously accepted facts remain visible in the facts review with their accepted state preserved.", - accepted: true, - removed: false, - recommendedAutomatedVerification: false, - automatedVerification: false, - }, - { - id: "bulk-accept", - text: "The facts UI provides a single action to accept every visible fact while keeping the review open for final edits.", - accepted: false, - removed: false, - recommendedAutomatedVerification: true, - automatedVerification: true, - }, - { - id: "copy-export", - text: "The interview and facts UIs can copy the current state as raw JSON or markdown for provenance and debugging.", - accepted: false, - removed: false, - recommendedAutomatedVerification: false, - automatedVerification: false, - }, - ], - } : { - stage: "interview", - title: "Interactive goal setup interview", - goalSlug: "interactive-goal-setup-ui", - questions: [ - { - id: "objective", - prompt: "What is the primary outcome of this goal?", - description: "One sentence that captures what 'done' looks like.", - answerMode: "text", - recommendedAnswer: "A bundled goal setup UI where agents launch one browser session for interview Q&A and a second for facts acceptance, replacing multi-turn chat prompting.", - }, - { - id: "audience", - prompt: "Which inferred audience assumption should change?", - description: "The agent should not need basic confirmation here; only change this if the default is wrong.", - answerMode: "single", - recommendedAnswer: "Developers using Claude Code with Plannotator installed.", - recommendedOptionIds: ["devs-cc"], - options: [ - { id: "devs-cc", label: "Developers on Claude Code" }, - { id: "devs-oc", label: "Developers on OpenCode" }, - { id: "devs-all", label: "All Plannotator users" }, - ], - }, - { - id: "scope", - prompt: "Which inferred scope items should stay or be added?", - description: "Recommended items are based on the code paths the agent can infer. Add only missing nuance.", - answerMode: "multi-custom", - recommendedAnswer: "Skill text, interactive UI, server endpoints, and tests.", - recommendedOptionIds: ["skill", "ui", "server", "tests"], - options: [ - { id: "skill", label: "Skill text" }, - { id: "ui", label: "Interactive UI" }, - { id: "server", label: "Server endpoints" }, - { id: "tests", label: "Tests and fixtures" }, - ], - }, - { - id: "launch", - prompt: "What rollout constraint should override the default?", - description: "Default is the smallest useful launch; choose a broader option only if runtime parity matters immediately.", - answerMode: "single", - recommendedOptionIds: ["claude-only"], - options: [ - { id: "claude-only", label: "Claude Code only" }, - { id: "all-runtimes", label: "All runtimes (Claude Code, OpenCode, Pi)" }, - { id: "prototype", label: "Prototype behind a dev flag" }, - ], - }, - { - id: "risk", - prompt: "Which risks should the plan explicitly address?", - answerMode: "multi", - recommendedOptionIds: ["runtime-parity", "data-loss"], - options: [ - { id: "runtime-parity", label: "Runtime parity", description: "Bun and Pi server endpoints stay mirrored." }, - { id: "data-loss", label: "Answer data loss", description: "Edited answers survive until submission." }, - { id: "header-actions", label: "Header action placement", description: "Submit/close matches existing patterns." }, - ], - }, - { - id: "facts-ux", - prompt: "How should fact review work?", - answerMode: "text", - recommendedAnswer: "Vertical list with per-fact accept, edit, remove, comment, and automated-verification toggle. Accepted facts hidden by default on re-review.", - }, - { - id: "out-of-scope", - prompt: "Anything explicitly out of scope?", - answerMode: "custom", - required: false, - }, - ], - }, - })); - return; - } res.end(JSON.stringify({ plan: undefined, // Editor uses its own DIFF_DEMO_PLAN_CONTENT origin: 'claude-code', @@ -786,15 +636,6 @@ export function devMockApi(): Plugin { return; } - if (req.url === '/api/goal-setup/submit' && req.method === 'POST') { - req.on('data', () => {}); - req.on('end', () => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ ok: true })); - }); - return; - } - if (req.url === '/api/plan/versions') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ 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/marketing/src/lib/shortcutReference.ts b/apps/marketing/src/lib/shortcutReference.ts index 03f989dff..80663451c 100644 --- a/apps/marketing/src/lib/shortcutReference.ts +++ b/apps/marketing/src/lib/shortcutReference.ts @@ -1,11 +1,11 @@ -import { planReviewSurface, annotateSurface, goalSetupSurface } from '../../../../packages/editor/shortcuts'; +import { planReviewSurface, annotateSurface } from '../../../../packages/editor/shortcuts'; import { codeReviewSurface } from '../../../../packages/review-editor/shortcuts'; import { listRegistryShortcutSections } from '../../../../packages/ui/shortcuts'; import type { ShortcutSurface } from '../../../../packages/ui/shortcuts'; const slugify = (value: string) => value.toLowerCase().replace(/\s+/g, '-'); -const allSurfaces: ShortcutSurface[] = [planReviewSurface, annotateSurface, goalSetupSurface, codeReviewSurface]; +const allSurfaces: ShortcutSurface[] = [planReviewSurface, annotateSurface, codeReviewSurface]; export const shortcutReferenceSurfaces = allSurfaces.map((surface) => ({ ...surface, 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/skills/plannotator-setup-goal/SKILL.md b/apps/skills/plannotator-setup-goal/SKILL.md index 303c3b775..9f4f7ac3e 100644 --- a/apps/skills/plannotator-setup-goal/SKILL.md +++ b/apps/skills/plannotator-setup-goal/SKILL.md @@ -13,131 +13,46 @@ Turn an idea into a goal package at `goals//` through structured discovery State back what the user wants in your own words. If the conversation already has rich context, summarize it. If the goal is bare or vague, do minimal shallow exploration of the codebase to ground your understanding. Keep it to 2-3 sentences. Wait for the user to confirm or correct before continuing. -Create the goal directory once the slug is clear: +### 2. Interview (grill me) -```bash -mkdir -p goals/ -``` - -Use `goals//` for both working JSON files and final docs. The JSON files are provenance and iteration state; the markdown files are the human-readable authoritative goal package. - -**Browser session patience rule:** Plannotator goal setup is a user-driven browser session. After launching an interview or facts command, be absolutely patient and keep waiting on the user until they submit, dismiss, or explicitly ask you to stop. Do not close, kill, restart, refresh, or open a second copy just because the UI is idle or the user is taking time. Never close and reopen the session as a way to update state; if a rerun is needed after the prior session ends, update the working JSON file and launch a new command from that file. - -### 2. Interview Bundle - -Build a compact bundle of questions that can derive every "fact" this goal should produce. Package the questions together so the user can answer them quickly in the Plannotator goal setup UI. For each question, include your recommended answer and use options when they make answering faster. - -Do not ask obvious confirmation questions. If the answer can be inferred from the user's request, from the conversation, or from shallow codebase exploration, infer it and move on. If an obvious area has meaningful nuance, present the inferred answer as a recommendation with options or a custom "add/correct this" path rather than asking the user to restate the obvious. - -Question areas that usually matter: +Interview the user such that you can derive every "fact" this goal should produce, & until you reach a complete shared understanding of the desired outcomes. The following questions areas should help you determine facts about the outcome. - What the feature/change is - Who it's for - What problem it solves - What behavior changes - What success looks like -- What's in and out of scope (the most important area to determine facts) +- What's in and out of scope (The most important area to determine facts) - What edge cases to consider - What constraints or precedent apply -**If a question can be answered by exploring the codebase, explore the codebase instead of asking.** Only include questions where the user's judgment is actually needed. Prefer fewer, higher-leverage questions over exhaustive obvious ones. - -Write the interview bundle before showing it to the user: - -`goals//interview.json` - -```json -{ - "stage": "interview", - "title": "Short human-readable title", - "goalSlug": "", - "questions": [ - { - "id": "scope", - "prompt": "What should be in scope?", - "description": "Optional clarification.", - "answerMode": "multi-custom", - "recommendedAnswer": "Your recommended answer.", - "recommendedOptionIds": ["ui", "server"], - "options": [ - { "id": "ui", "label": "UI" }, - { "id": "server", "label": "Server" } - ], - "required": true - } - ] -} -``` - -Supported `answerMode` values: `text`, `single`, `multi`, `custom`, `single-custom`, `multi-custom`. +Ask questions **one at a time**, waiting for feedback before continuing. For each question, provide your recommended answer. Use the question/answer tool if available. -Run this as a monitored foreground process and wait patiently for the browser session to finish. The command may appear idle while the user is reading, editing, or asking questions; leave it running: +**If a question can be answered by exploring the codebase, explore the codebase instead of asking.** -```bash -plannotator setup-goal interview goals//interview.json --json -``` - -The command returns JSON on stdout with the submitted answers. Write that exact result to `goals//interview-result.json` before continuing. A convenient pattern is: - -```bash -plannotator setup-goal interview goals//interview.json --json | tee goals//interview-result.json -``` - -If the user revises after the session finishes, update `interview.json` and rerun the command instead of reconstructing the whole bundle from memory. If the session is dismissed, stop and tell the user the goal setup was closed. - -Before moving to facts, read every answer and note carefully: - -- If the user wrote questions, uncertainty, "not sure", "needs context", or similar concerns in an answer or note, stop and address those questions in chat. Do not proceed to facts until the user has enough context or you have rerun a revised interview bundle. -- If the user skipped a question with a note, treat the note as intentional feedback, not as an empty answer. Answer the note, refine the question, or make a documented assumption before proceeding. -- If the user skipped a question without a note, proceed only if the missing answer is non-blocking; otherwise ask the smallest possible follow-up in chat. +Stop when you feel confident in being able to describe the facts of the goal outcome. Don't pad. ### 3. Fact Sheet A fact is a simple description of each outcome of a goal. It should be easily testable and verifiable. A fact may describe the function of a specific feature or aspect of a system. A fact may determine specific UI and UX. Again, a fact is literally anything that can be tested and verified in automated or manual testing. Keep fact language simple. In a way, a fact sheet is a design spec, but less verbose & using language the human user can easily visualize & rationalize. -Prepare a facts review bundle from `goals//interview-result.json`. Each fact should include whether automated verification is recommended and preselected. - -Write the facts review bundle before showing it to the user. If revising after a prior facts pass, start from `facts-review.json` and `facts-result.json`, include previously accepted facts with `"accepted": true`, and preserve their state. - -`goals//facts-review.json` - -```json -{ - "stage": "facts", - "title": "Short human-readable title", - "goalSlug": "", - "facts": [ - { - "id": "fact-1", - "text": "The accepted fact text.", - "accepted": false, - "removed": false, - "recommendedAutomatedVerification": true, - "automatedVerification": true - } - ] -} -``` - -Run this as a monitored foreground process and wait patiently for the browser session to finish. The command may appear idle while the user is reviewing, editing, or asking questions; leave it running: +Create the goal directory and write `goals//facts.md` — a flat list of bulleted facts. Each fact is one line. Add a minimal note only when the fact can't be stated clearly on its own. ```bash -plannotator setup-goal facts goals//facts-review.json --json +mkdir -p goals/ ``` -The command returns JSON on stdout with accepted/edited/removed facts plus automated verification selections. Write that exact result to `goals//facts-result.json`. A convenient pattern is: +Gate the fact sheet with Plannotator: ```bash -plannotator setup-goal facts goals//facts-review.json --json | tee goals//facts-result.json +plannotator annotate goals//facts.md --gate ``` -Write `goals//facts.md` as a flat readable list of accepted facts. Each fact is one line; add a minimal note only when the fact cannot be stated clearly on its own. Also write `goals//facts.meta.json` preserving each accepted fact's `id`, final `text`, `comment`, `recommendedAutomatedVerification`, and `automatedVerification` value. - -If the user edits or removes facts in the UI, apply that result directly. If the session is dismissed, stop and tell the user the facts review was closed. +If denied, revise from feedback and re-gate until approved. ### 4. Plan -Explore the codebase. Discover and validate implementation paths toward each accepted fact. Treat facts with `automatedVerification: true` as requiring concrete automated checks unless you document a blocker. Trace through code, identify files and systems involved, surface risks and unknowns. Refine until you have a confident order of operations. +Explore the codebase. Discover and validate implementation paths toward each fact. Trace through code, identify files and systems involved, surface risks and unknowns. Refine until you have a confident order of operations. Write `goals//plan.md`: 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/editor/App.tsx b/packages/editor/App.tsx index c8e12d2e1..c351adc82 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -57,6 +57,7 @@ import { useArchive } from '@plannotator/ui/hooks/useArchive'; import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; import { useExternalAnnotationHighlights } from '@plannotator/ui/hooks/useExternalAnnotationHighlights'; +import { getApiOriginAndBase } from '@plannotator/ui/utils/api'; import { buildPlanAgentInstructions } from '@plannotator/ui/utils/planAgentInstructions'; import { useFileBrowser } from '@plannotator/ui/hooks/useFileBrowser'; import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; @@ -68,12 +69,6 @@ import type { ArchivedPlan } from '@plannotator/ui/components/sidebar/ArchiveBro import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; import { CodeFilePopout, type CodeFileAnnotationInput } from '@plannotator/ui/components/CodeFilePopout'; import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; -import { - GoalSetupSurface, - type GoalSetupActionState, - type GoalSetupSurfaceHandle, -} from '@plannotator/ui/components/goal-setup/GoalSetupSurface'; -import type { GoalSetupBundle } from '@plannotator/shared/goal-setup'; // Demo content toggle. Default: the original Real-time Collaboration plan. // Opt-in diff-engine stress test: `VITE_DIFF_DEMO=1 bun run dev:hook` swaps // in the 20-case Auth Service Refactor test plan. dev-mock-api.ts reads the @@ -135,6 +130,23 @@ const App: React.FC = () => { // icon → labels hidden — fallback below that const planAreaRef = useRef(null); const [actionsLabelMode, setActionsLabelMode] = useState('full'); + // useLayoutEffect + synchronous getBoundingClientRect so the initial + // bucket is set before the browser paints. Otherwise narrow viewports + // get a one-frame flash of "Global comment"/"Copy plan" labels before + // the ResizeObserver callback collapses them. + useLayoutEffect(() => { + const el = planAreaRef.current; + if (!el) return; + const bucket = (w: number): ActionsLabelMode => + w >= 800 ? 'full' : w >= 680 ? 'short' : 'icon'; + setActionsLabelMode(bucket(el.getBoundingClientRect().width)); + const ro = new ResizeObserver(([entry]) => { + const next = bucket(entry.contentRect.width); + setActionsLabelMode((prev) => (prev === next ? prev : next)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); const [isApiMode, setIsApiMode] = useState(false); const [origin, setOrigin] = useState(null); const [gitUser, setGitUser] = useState(); @@ -143,14 +155,6 @@ const App: React.FC = () => { const [annotateMode, setAnnotateMode] = useState(false); const [gate, setGate] = useState(false); const [annotateSource, setAnnotateSource] = useState<'file' | 'message' | 'folder' | null>(null); - const [goalSetupBundle, setGoalSetupBundle] = useState(null); - const goalSetupSurfaceRef = useRef(null); - const [goalSetupAction, setGoalSetupAction] = useState({ - canSubmit: false, - isSubmitting: false, - submitted: false, - submitLabel: 'Submit', - }); const [sourceInfo, setSourceInfo] = useState(); const [sourceConverted, setSourceConverted] = useState(false); const [renderAs, setRenderAs] = useState<'markdown' | 'html'>('markdown'); @@ -172,7 +176,6 @@ const App: React.FC = () => { const [wideModeType, setWideModeType] = useState(null); const wideModeSnapshotRef = useRef(null); const lastAppliedTocEnabledRef = useRef(uiPrefs.tocEnabled); - const goalSetupMode = goalSetupBundle !== null; useEffect(() => { document.title = repoInfo ? `${repoInfo.display} · Plannotator` : "Plannotator"; @@ -552,7 +555,7 @@ const App: React.FC = () => { const activeSection = useActiveSection(containerRef, headingCount, scrollViewport); const { editorAnnotations, deleteEditorAnnotation } = useEditorAnnotations(); - const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: isApiMode && !goalSetupMode }); + const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations({ enabled: isApiMode }); // Drive DOM highlights for SSE-delivered external annotations. Disabled // while a linked doc overlay is open (Viewer DOM is hidden) and while the @@ -560,7 +563,7 @@ const App: React.FC = () => { const { reset: resetExternalHighlights } = useExternalAnnotationHighlights({ viewerRef, externalAnnotations, - enabled: isApiMode && !goalSetupMode && !linkedDocHook.isActive && !isPlanDiffActive, + enabled: isApiMode && !linkedDocHook.isActive && !isPlanDiffActive, planKey: markdown, }); @@ -640,32 +643,12 @@ const App: React.FC = () => { setRenderAs, ); - // useLayoutEffect + synchronous getBoundingClientRect so the initial - // bucket is set before the browser paints. Otherwise narrow viewports - // get a one-frame flash of "Global comment"/"Copy plan" labels before - // the ResizeObserver callback collapses them. - useLayoutEffect(() => { - if (isLoading && !isSharedSession) return; - - const el = planAreaRef.current; - if (!el) return; - const bucket = (w: number): ActionsLabelMode => - w >= 800 ? 'full' : w >= 680 ? 'short' : 'icon'; - setActionsLabelMode(bucket(el.getBoundingClientRect().width)); - const ro = new ResizeObserver(([entry]) => { - const next = bucket(entry.contentRect.width); - setActionsLabelMode((prev) => (prev === next ? prev : next)); - }); - ro.observe(el); - return () => ro.disconnect(); - }, [isLoading, isSharedSession]); - // Auto-save annotation drafts const { draftBanner, restoreDraft, dismissDraft } = useAnnotationDraft({ annotations: allAnnotations, codeAnnotations, globalAttachments, - isApiMode: isApiMode && !goalSetupMode, + isApiMode, isSharedSession, submitted: !!submitted, }); @@ -732,16 +715,12 @@ const App: React.FC = () => { if (!res.ok) throw new Error('Not in API mode'); return res.json(); }) - .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive' | 'goal-setup'; goalSetup?: GoalSetupBundle; filePath?: string; sourceInfo?: string; sourceConverted?: boolean; gate?: boolean; renderAs?: 'html' | 'markdown'; rawHtml?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { + .then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; sourceConverted?: boolean; gate?: boolean; renderAs?: 'html' | 'markdown'; rawHtml?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => { // Initialize config store with server-provided values (config file > cookie > default) configStore.init(data.serverConfig); // gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable setGitUser(data.serverConfig?.gitUser); - if (data.mode === 'goal-setup' && data.goalSetup) { - setGoalSetupBundle(data.goalSetup); - setMarkdown(''); - setSharingEnabled(false); - } else if (data.mode === 'archive') { + if (data.mode === 'archive') { // Archive mode: show first archived plan or clear demo content setMarkdown(data.plan || ''); if (data.archivePlans) archive.init(data.archivePlans); @@ -766,7 +745,7 @@ const App: React.FC = () => { if (data.mode === 'annotate-folder') { sidebar.open('files'); } - if (data.mode === 'annotate' || data.mode === 'annotate-last' || data.mode === 'annotate-folder') { + if (data.mode && data.mode !== 'archive') { setAnnotateSource(data.mode === 'annotate-last' ? 'message' : data.mode === 'annotate-folder' ? 'folder' : 'file'); } setSourceInfo(data.sourceInfo ?? undefined); @@ -802,7 +781,7 @@ const App: React.FC = () => { if (data.origin) { setOrigin(data.origin); // For Claude Code, check if user needs to configure permission mode - if (data.origin === 'claude-code' && data.mode !== 'goal-setup' && needsPermissionModeSetup()) { + if (data.origin === 'claude-code' && needsPermissionModeSetup()) { setShowPermissionModeSetup(true); } // Load saved permission mode preference @@ -1107,43 +1086,22 @@ const App: React.FC = () => { } }, []); - const handleGoalSetupSubmit = useCallback(() => { - goalSetupSurfaceRef.current?.submit(); - }, []); - - const handleGoalSetupExit = useCallback(async () => { - setIsExiting(true); - try { - const res = await fetch('/api/exit', { method: 'POST' }); - if (res.ok) { - setSubmitted('exited'); - } else { - throw new Error('Failed to exit'); - } - } catch { - setIsExiting(false); - } - }, []); - // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle Cmd/Ctrl+Enter if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey)) return; - const target = e.target as HTMLElement | null; - const tag = target?.tagName; - const isTextField = tag === 'INPUT' || tag === 'TEXTAREA' || Boolean(target?.isContentEditable); - - // Let active confirmation dialogs own Cmd/Ctrl+Enter and Escape. - if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return; + // Don't intercept if typing in an input/textarea + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; // Don't intercept if any modal is open if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; // Don't intercept if already submitted, submitting, or exiting - if (submitted || isSubmitting || isExiting || goalSetupAction.isSubmitting) return; + if (submitted || isSubmitting || isExiting) return; // Don't intercept in demo/share mode (no API) if (!isApiMode) return; @@ -1151,17 +1109,6 @@ const App: React.FC = () => { // Don't submit while viewing a linked doc if (linkedDocHook.isActive) return; - if (goalSetupMode) { - if (document.querySelector('[data-comment-popover="true"]')) return; - if (isTextField && !target?.closest('.goal-shell')) return; - e.preventDefault(); - if (goalSetupAction.canSubmit) goalSetupSurfaceRef.current?.submit(); - return; - } - - // Don't intercept if typing in an input/textarea outside goal setup. - if (isTextField) return; - e.preventDefault(); // Annotate mode: gate-enabled + no annotations → approve (empty stdout). @@ -1201,8 +1148,8 @@ const App: React.FC = () => { }, [ showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, - submitted, isSubmitting, isExiting, goalSetupAction.isSubmitting, isApiMode, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode, - gate, hasAnyAnnotations, goalSetupMode, goalSetupAction.canSubmit, + submitted, isSubmitting, isExiting, isApiMode, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode, + gate, hasAnyAnnotations, origin, getAgentWarning, ]); @@ -1480,10 +1427,10 @@ const App: React.FC = () => { // Agent Instructions — copy a clipboard payload teaching external agents // (Claude Code, Codex, etc.) how to POST annotations into this session via - // /api/external-annotations. The instruction body lives in a separate module + // the session API base. The instruction body lives in a separate module // (utils/agentInstructions.ts) so it's easy to edit independently of UI code. const handleCopyAgentInstructions = async () => { - const payload = buildPlanAgentInstructions(window.location.origin); + const payload = buildPlanAgentInstructions(getApiOriginAndBase()); try { await navigator.clipboard.writeText(payload); toast.success('Agent instructions copied'); @@ -1670,14 +1617,6 @@ const App: React.FC = () => { const annotateReaderMaxWidth = canUseWideMode && wideModeType === 'wide' ? null : planMaxWidth; - if (isLoading && !isSharedSession) { - return ( - -
- - ); - } - return ( @@ -1686,10 +1625,6 @@ const App: React.FC = () => { isApiMode={isApiMode} annotateMode={annotateMode} archiveMode={archive.archiveMode} - goalSetupMode={goalSetupMode} - goalSetupCanSubmit={goalSetupAction.canSubmit} - goalSetupIsSubmitting={goalSetupAction.isSubmitting} - goalSetupSubmitLabel={goalSetupAction.submitLabel} gate={gate} isSharedSession={isSharedSession} origin={origin} @@ -1710,8 +1645,6 @@ const App: React.FC = () => { onCallbackFeedback={handleCallbackFeedback} onCallbackApprove={handleCallbackApprove} onAnnotateExit={handleHeaderAnnotateExit} - onGoalSetupExit={handleGoalSetupExit} - onGoalSetupSubmit={handleGoalSetupSubmit} onAnnotateFeedback={handleHeaderAnnotateFeedback} onAnnotateApprove={handleHeaderAnnotateApprove} onFeedback={handleHeaderFeedback} @@ -1734,7 +1667,7 @@ const App: React.FC = () => { onSaveToBear={handleSaveToBear} onSaveToOctarine={handleSaveToOctarine} appVersion={typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'} - agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode && !goalSetupMode} + agentInstructionsEnabled={isApiMode && !archive.archiveMode && !annotateMode} obsidianConfigured={isObsidianConfigured()} bearConfigured={getBearSettings().enabled} octarineConfigured={isOctarineConfigured()} @@ -1759,7 +1692,7 @@ const App: React.FC = () => { {/* Tater sprites — inside content wrapper so z-0 stacking context applies */} {taterMode && } {/* Left Sidebar: collapsed tab flags (when sidebar is closed) */} - {wideModeType === null && !sidebar.isOpen && !goalSetupMode && ( + {wideModeType === null && !sidebar.isOpen && ( { )} {/* Left Sidebar: open state (TOC or Version Browser) */} - {sidebar.isOpen && !goalSetupMode && ( + {sidebar.isOpen && ( <> { isSelectingVersion={planDiff.isSelectingVersion} fetchingVersion={planDiff.fetchingVersion} onFetchVersions={planDiff.fetchVersions} - showArchiveTab={isApiMode && !annotateMode && !goalSetupMode} + showArchiveTab={isApiMode && !annotateMode} archivePlans={archive.plans} selectedArchiveFile={archive.selectedFile} onArchiveSelect={archive.select} @@ -1822,7 +1755,7 @@ const App: React.FC = () => { {/* Document Area */} @@ -1843,7 +1776,7 @@ const App: React.FC = () => { truth there. Hidden in plan diff or archive mode, or when sticky actions are disabled. remountToken re-anchors the ResizeObserver when Viewer swaps content (linked docs). */} - {!goalSetupMode && !isPlanDiffActive && !archive.archiveMode && uiPrefs.stickyActionsEnabled && ( + {!isPlanDiffActive && !archive.archiveMode && uiPrefs.stickyActionsEnabled && ( { )} {/* Annotation Toolstrip (hidden during plan diff and archive mode) */} - {!goalSetupMode && !isPlanDiffActive && !archive.archiveMode && ( + {!isPlanDiffActive && !archive.archiveMode && (
{ )} {/* Plan Diff View — rendered when diff data exists, hidden when inactive */} - {goalSetupBundle && ( -
- setSubmitted('approved')} - /> -
- )} - - {planDiff.diffBlocks && planDiff.diffStats && !goalSetupMode && ( + {planDiff.diffBlocks && planDiff.diffStats && (
{
)} {/* Folder annotation empty state — shown before user picks a file */} - {annotateSource === 'folder' && !markdown && !linkedDocHook.isActive && !goalSetupMode && ( + {annotateSource === 'folder' && !markdown && !linkedDocHook.isActive && (

Select a file to annotate

@@ -1917,7 +1838,7 @@ const App: React.FC = () => {
)} {/* Normal Plan View — always mounted, hidden during diff mode */} -
+
{canUseWideMode && !isPlanDiffActive && !archive.archiveMode && (
{ {/* Resize Handle */} - {isPanelOpen && wideModeType === null && !goalSetupMode && } + {isPanelOpen && wideModeType === null && } {/* Annotation Panel */} { title={ archive.archiveMode ? 'Archive Closed' : submitted === 'exited' ? 'Session Closed' - : goalSetupMode ? 'Answers Submitted' : submitted === 'approved' ? (annotateMode ? 'Approved' : 'Plan Approved') : annotateMode ? 'Annotations Sent' @@ -2201,8 +2121,6 @@ const App: React.FC = () => { ? 'Annotation session closed without feedback.' : archive.archiveMode ? 'You can reopen with plannotator archive.' - : goalSetupMode - ? `${agentName} will use your answers to continue.` : submitted === 'approved' ? (annotateMode ? `${agentName} will proceed.` diff --git a/packages/editor/components/AppHeader.tsx b/packages/editor/components/AppHeader.tsx index 45a5ba477..ad3ca9b74 100644 --- a/packages/editor/components/AppHeader.tsx +++ b/packages/editor/components/AppHeader.tsx @@ -13,10 +13,6 @@ interface AppHeaderProps { isApiMode: boolean; annotateMode: boolean; archiveMode: boolean; - goalSetupMode: boolean; - goalSetupCanSubmit: boolean; - goalSetupIsSubmitting: boolean; - goalSetupSubmitLabel: string; gate: boolean; isSharedSession: boolean; origin: Origin | null; @@ -45,8 +41,6 @@ interface AppHeaderProps { onCallbackFeedback: () => void; onCallbackApprove: () => void; onAnnotateExit: () => void; - onGoalSetupExit: () => void; - onGoalSetupSubmit: () => void; onAnnotateFeedback: () => void; onAnnotateApprove: () => void; onFeedback: () => void; @@ -81,10 +75,6 @@ export const AppHeader = React.memo(({ isApiMode, annotateMode, archiveMode, - goalSetupMode, - goalSetupCanSubmit, - goalSetupIsSubmitting, - goalSetupSubmitLabel, gate, isSharedSession, origin, @@ -105,8 +95,6 @@ export const AppHeader = React.memo(({ onCallbackFeedback, onCallbackApprove, onAnnotateExit, - onGoalSetupExit, - onGoalSetupSubmit, onAnnotateFeedback, onAnnotateApprove, onFeedback, @@ -180,28 +168,7 @@ export const AppHeader = React.memo(({ )} - {isApiMode && !linkedDocIsActive && goalSetupMode && ( - <> - - -
- - )} - - {isApiMode && (!linkedDocIsActive || annotateMode) && !archiveMode && !goalSetupMode && ( + {isApiMode && (!linkedDocIsActive || annotateMode) && !archiveMode && ( <> {annotateMode ? ( <> @@ -263,21 +230,19 @@ export const AppHeader = React.memo(({ )} {/* Annotations panel toggle */} - {!goalSetupMode && ( - - )} + {/* Settings dialog (controlled, button hidden — opened from PlanHeaderMenu) */}
@@ -308,9 +273,9 @@ export const AppHeader = React.memo(({ sharingEnabled={canShareCurrentSession} isApiMode={isApiMode} agentInstructionsEnabled={agentInstructionsEnabled} - obsidianConfigured={!goalSetupMode && obsidianConfigured} - bearConfigured={!goalSetupMode && bearConfigured} - octarineConfigured={!goalSetupMode && octarineConfigured} + obsidianConfigured={obsidianConfigured} + bearConfigured={bearConfigured} + octarineConfigured={octarineConfigured} />
diff --git a/packages/editor/shortcuts.ts b/packages/editor/shortcuts.ts index cad6d008b..4b9347aec 100644 --- a/packages/editor/shortcuts.ts +++ b/packages/editor/shortcuts.ts @@ -5,7 +5,6 @@ import { createShortcutRegistry, createShortcutScopeHook, defineShortcutScope, - goalSetupShortcuts, imageAnnotatorShortcuts, inputMethodShortcuts, viewerShortcuts, @@ -108,29 +107,3 @@ export const annotateSurface: ShortcutSurface = { description: 'Shortcuts surfaced by the standalone annotation UI.', registry: annotateSettingsShortcutRegistry, }; - -const goalSetupEditorSettingsShortcuts = defineShortcutScope({ - id: 'goal-setup-editor-settings', - title: 'Goal Setup', - shortcuts: { - submitGoalSetup: { - description: 'Submit answers / facts', - bindings: ['Mod+Enter'], - section: 'Actions', - hint: 'Submits the bundled interview or facts review.', - displayOrder: 10, - }, - }, -}); - -export const goalSetupSettingsShortcutRegistry = createShortcutRegistry([ - goalSetupEditorSettingsShortcuts, - goalSetupShortcuts, -] as const); - -export const goalSetupSurface: ShortcutSurface = { - slug: 'goal-setup', - title: 'Goal setup', - description: 'Shortcuts surfaced by the bundled goal-setup interview and facts review.', - registry: goalSetupSettingsShortcutRegistry, -}; 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/package.json b/packages/server/package.json index d5f894661..928814a1b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@plannotator/server", - "version": "0.19.18", + "version": "0.19.17", "private": true, "description": "Shared server implementation for Plannotator plugins", "main": "index.ts", @@ -11,7 +11,6 @@ "./annotate": "./annotate.ts", "./remote": "./remote.ts", "./browser": "./browser.ts", - "./goal-setup": "./goal-setup.ts", "./storage": "./storage.ts", "./git": "./git.ts", "./p4": "./p4.ts", @@ -19,6 +18,14 @@ "./repo": "./repo.ts", "./share-url": "./share-url.ts", "./sessions": "./sessions.ts", + "./session-handler": "./session-handler.ts", + "./daemon/state": "./daemon/state.ts", + "./daemon/session-store": "./daemon/session-store.ts", + "./daemon/server": "./daemon/server.ts", + "./daemon/client": "./daemon/client.ts", + "./daemon/runtime": "./daemon/runtime.ts", + "./daemon/session-factory": "./daemon/session-factory.ts", + "./daemon/start-command": "./daemon/start-command.ts", "./project": "./project.ts", "./pr": "./pr.ts" }, 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/sessions.ts b/packages/server/sessions.ts index f703fe844..9a109cc11 100644 --- a/packages/server/sessions.ts +++ b/packages/server/sessions.ts @@ -20,7 +20,7 @@ export interface SessionInfo { pid: number; port: number; url: string; - mode: "plan" | "review" | "annotate" | "archive" | "goal-setup"; + mode: "plan" | "review" | "annotate" | "archive"; project: string; startedAt: string; label: string; 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/CommentPopover.tsx b/packages/ui/components/CommentPopover.tsx index c1307910a..d56e50f6b 100644 --- a/packages/ui/components/CommentPopover.tsx +++ b/packages/ui/components/CommentPopover.tsx @@ -18,16 +18,10 @@ interface CommentPopoverProps { initialText?: string; /** Called on submit with comment text and optional images */ onSubmit: (text: string, images?: ImageAttachment[]) => void; - /** Optional live draft observer for submit paths outside the popover. */ - onDraftChange?: (text: string, images?: ImageAttachment[]) => void; /** Called when popover is closed/cancelled */ onClose: () => void; /** Opt-in: persist text + images across close/reopen, keyed by this string. Cleared on submit. */ draftKey?: string; - /** Whether image attachments are available in this comment surface. */ - allowImages?: boolean; - /** Whether submitting empty text is allowed, for editors that support clearing. */ - allowEmptySubmit?: boolean; } const MAX_POPOVER_WIDTH = 384; @@ -70,32 +64,19 @@ export const CommentPopover: React.FC = ({ isGlobal, initialText = '', onSubmit, - onDraftChange, onClose, draftKey, - allowImages = true, - allowEmptySubmit = false, }) => { const [mode, setMode] = useState<'popover' | 'dialog'>('popover'); const initialDraft = draftKey ? draftStore.get(draftKey) : undefined; const [text, setText] = useState(initialDraft?.text ?? initialText); - const [images, setImages] = useState(allowImages ? initialDraft?.images ?? [] : []); + const [images, setImages] = useState(initialDraft?.images ?? []); const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean; width: number } | null>(null); const textareaRef = useRef(null); const popoverRef = useRef(null); const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef); - useEffect(() => { - const nextDraft = draftKey ? draftStore.get(draftKey) : undefined; - setText(nextDraft?.text ?? initialText); - setImages(allowImages ? nextDraft?.images ?? [] : []); - }, [draftKey, initialText, allowImages]); - - useCommentDraftSync(draftKey, text, allowImages ? images : []); - - useEffect(() => { - onDraftChange?.(text, allowImages ? images : undefined); - }, [allowImages, images, onDraftChange, text]); + useCommentDraftSync(draftKey, text, images); // Reset drag when anchor changes (new annotation) or mode switches useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]); @@ -150,12 +131,11 @@ export const CommentPopover: React.FC = ({ }, [mode, onClose]); const handleSubmit = useCallback(() => { - const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0; - if (text.trim() || (allowImages && images.length > 0) || canSubmitEmpty) { + if (text.trim() || images.length > 0) { if (draftKey) draftStore.delete(draftKey); - onSubmit(text, allowImages && images.length > 0 ? images : undefined); + onSubmit(text, images.length > 0 ? images : undefined); } - }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText]); + }, [text, images, onSubmit, draftKey]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -179,10 +159,7 @@ export const CommentPopover: React.FC = ({ ? `"${contextText.length > 50 ? contextText.slice(0, 50) + '...' : contextText}"` : 'Comment'; - const canSubmit = - text.trim().length > 0 || - (allowImages && images.length > 0) || - (allowEmptySubmit && initialText.trim().length > 0); + const canSubmit = text.trim().length > 0 || images.length > 0; if (mode === 'dialog') { return createPortal( @@ -245,14 +222,12 @@ export const CommentPopover: React.FC = ({ {/* Footer */}
- {allowImages && ( - setImages((prev) => [...prev, img])} - onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} - variant="inline" - /> - )} + setImages((prev) => [...prev, img])} + onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} + variant="inline" + />
{submitHint} @@ -343,14 +318,12 @@ export const CommentPopover: React.FC = ({ {/* Footer */}
- {allowImages && ( - setImages((prev) => [...prev, img])} - onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} - variant="inline" - /> - )} + setImages((prev) => [...prev, img])} + onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} + variant="inline" + />
{submitHint} diff --git a/packages/ui/components/ConfirmDialog.tsx b/packages/ui/components/ConfirmDialog.tsx index e18158e65..dafb88c57 100644 --- a/packages/ui/components/ConfirmDialog.tsx +++ b/packages/ui/components/ConfirmDialog.tsx @@ -2,7 +2,7 @@ * Reusable confirmation dialog component */ -import React, { useEffect } from 'react'; +import React from 'react'; export interface ConfirmDialogProps { isOpen: boolean; @@ -31,25 +31,6 @@ export const ConfirmDialog: React.FC = ({ showCancel = false, wide = false, }) => { - useEffect(() => { - if (!isOpen) return; - const handleKey = (event: KeyboardEvent) => { - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - event.stopPropagation(); - if (onConfirm) onConfirm(); - else onClose(); - } - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - onClose(); - } - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [isOpen, onConfirm, onClose]); - if (!isOpen) return null; const iconColors = { @@ -76,15 +57,8 @@ export const ConfirmDialog: React.FC = ({ }; return ( -
-
+
+
{icons[variant]} 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/components/core/button.tsx b/packages/ui/components/core/button.tsx deleted file mode 100644 index bed67bb2f..000000000 --- a/packages/ui/components/core/button.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; - -type ButtonVariant = 'primary' | 'outline' | 'ghost' | 'icon' | 'danger'; -type ButtonSize = 'sm' | 'md' | 'icon'; - -export interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: ButtonVariant; - size?: ButtonSize; - active?: boolean; -} - -function cx(...classes: Array): string { - return classes.filter(Boolean).join(' '); -} - -export const Button = React.forwardRef(({ - className, - variant = 'outline', - size = 'md', - active = false, - type = 'button', - ...props -}, ref) => ( -