A plan review UI for Claude Code that intercepts ExitPlanMode via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files.
Reusing the document UI (theme / markdown / editor / settings / comments / layout) in the commercial Workspaces app? Read
packages/ui/README.mdFIRST. It explains the published@plannotator/ui+@plannotator/corepackages and the host-override seams a host plugs its own backend into viaconfigurePlannotatorUI(). A prior from-scratch reimplementation of this UI broke the app and was reverted — do not rebuild it or recreatepackages/document-ui. Add a seam to@plannotator/uiinstead, keep Plannotator's app unchanged, and never delete working code until a human confirms parity in the browser.
plannotator/
├── apps/
│ ├── hook/ # Claude Code plugin (no commands/ — core skills installed to ~/.claude/skills act as slash commands)
│ │ ├── .claude-plugin/plugin.json
│ │ ├── hooks/hooks.json # PermissionRequest hook config
│ │ ├── server/index.ts # Entry point (plan + review + annotate + archive subcommands)
│ │ └── dist/ # Built single-file apps (index.html, review.html)
│ ├── opencode-plugin/ # OpenCode plugin
│ │ ├── commands/ # Slash command stubs (review, annotate, last — plugin intercepts execution)
│ │ ├── index.ts # OpenCode 1 entry with submit_plan tool + review/annotate event handlers
│ │ ├── server.ts # OpenCode 2 adapter (experimental V2 plugin API)
│ │ ├── plannotator.html # Built plan review app
│ │ └── review-editor.html # Built code review app
│ ├── amp-plugin/ # Amp plugin
│ │ ├── plannotator.ts # Native Amp command-palette integration
│ │ └── README.md # Install and local development notes
│ ├── droid-plugin/ # Droid plugin
│ │ ├── .factory-plugin/plugin.json
│ │ ├── commands/ # Slash command entrypoints
│ │ └── lib/ # Shared command wrapper helpers
│ ├── marketing/ # Marketing site, docs, and blog (plannotator.ai)
│ │ └── astro.config.mjs # Astro 5 static site with content collections
│ ├── kiro-cli/ # Kiro CLI integration source (consumed by scripts/install.sh; auto-detected via ~/.kiro)
│ │ ├── agents/plannotator.json # Example Kiro custom agent
│ │ └── skills/ # Kiro-specific skill packages (review, annotate); setup-goal + visual-explainer install from apps/skills/extra
│ ├── paste-service/ # Paste service for short URL sharing
│ │ ├── core/ # Platform-agnostic logic (handler, storage interface, cors)
│ │ ├── stores/ # Storage backends (fs, kv, s3)
│ │ └── targets/ # Deployment entries (bun.ts, cloudflare.ts)
│ ├── review/ # Standalone review server (for development)
│ │ ├── index.html
│ │ ├── index.tsx
│ │ └── vite.config.ts
│ ├── guides-show/ # guides.show — portable Guided Review viewer (multi-file CDN build) + Cloudflare Worker (viewer/, worker/, share/, build/); the Worker is the only host target, self-hosting = deploying it under your own account
│ ├── vscode-extension/ # VS Code extension — opens plans in editor tabs
│ │ ├── bin/ # Router scripts (open-in-vscode, xdg-open)
│ │ ├── src/ # extension.ts, cookie-proxy.ts, ipc-server.ts, panel-manager.ts, editor-annotations.ts, vscode-theme.ts
│ │ └── package.json # Extension manifest (publisher: backnotprop)
│ └── skills/ # Agent skills (agentskills.io format)
│ ├── core/ # CORE skills (single-sourced) — installed to ~/.claude/skills and ~/.agents/skills (Codex)
│ │ ├── plannotator/ # Knowledge layer: model-invocable CLI reference (subcommands, flags, exit codes) an agent loads on generic "use Plannotator" intent; freshness-guarded against apps/hook/server/cli.ts by plannotator-skill-reference.test.ts
│ │ ├── plannotator-review/ # Lightweight: opens review UI
│ │ ├── plannotator-annotate/ # Lightweight: opens annotate UI
│ │ └── plannotator-last/ # Lightweight: annotates last message
│ └── extra/ # EXTRA skills — NOT default-installed (except Kiro); add via `npx skills add backnotprop/plannotator/apps/skills/extra --global`
│ ├── plannotator-compound/ # Research analysis agent (map-reduce over denied plans)
│ ├── plannotator-setup-goal/ # Goal package scaffolder for /goal workflows
│ └── plannotator-visual-explainer/ # Visual HTML generator (plans, diagrams, PR explainers) with Plannotator theming
├── packages/
│ ├── server/ # Shared server implementation
│ │ ├── index.ts # startPlannotatorServer(), handleServerReady()
│ │ ├── review.ts # startReviewServer(), handleReviewServerReady()
│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady()
│ │ ├── storage.ts # Re-exports from @plannotator/shared/storage
│ │ ├── share-url.ts # Server-side share URL generation for remote sessions
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
│ │ ├── browser.ts # openBrowser()
│ │ ├── draft.ts # Re-exports from @plannotator/shared/draft
│ │ ├── integrations.ts # Obsidian, Bear integrations
│ │ ├── ide.ts # VS Code diff integration (openEditorDiff)
│ │ ├── editor-annotations.ts # VS Code editor annotation endpoints
│ │ └── project.ts # Project name detection for tags
│ ├── ui/ # Shared React components + theme
│ │ ├── theme.css # Single source of truth for color tokens + Tailwind bridge
│ │ ├── components/ # Viewer, Toolbar, Settings, etc.
│ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.)
│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser
│ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below)
│ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator
│ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks
│ │ │ ├── index.ts # Barrel — re-exports engine + scopes from both subfolders
│ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationMode, annotationPanel, annotationToolbar, commentPopover, documentView, goalSetup, htmlAnnotate, imageAnnotator, inputMethod, sidebar, viewer, vimSelection)
│ │ │ └── code-review/ # Scopes for review-editor surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog)
│ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator)
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts
│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts
│ │ └── types.ts
│ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints)
│ ├── core/ # @plannotator/core — browser-safe, zero-dep universal slice (pure utils + types) shared by ui + shared; published so @plannotator/ui can be installed standalone. `shared` re-exports the moved modules via one-line shims so Plannotator is unchanged.
│ ├── shared/ # Node/git/server logic + cross-runtime types (re-exports browser-safe modules from @plannotator/core)
│ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only)
│ │ ├── draft.ts # Annotation draft persistence (node:fs only)
│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName)
│ ├── guide-viewer/ # @plannotator/guide-viewer — the Guided Review chain (GuideView → GuideSectionCard → GuideFileCard → GuideViewportManager) behind a narrow GuideHost context; used by review-editor (ReviewGuideHost + AllFilesCodeView) and by the guides.show viewer (readOnly). Also home of diffParser, DiffFile, and the two markdown renderers.
│ ├── editor/ # Plan review app
│ │ ├── App.tsx # Main plan review app
│ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries
│ └── review-editor/ # Code review UI
│ ├── App.tsx # Main review app
│ ├── shortcuts.ts # codeReviewSurface — composes code-review scopes into the review registry
│ ├── components/ # DiffViewer, FileTree, ReviewSidebar
│ ├── dock/ # Dockview center panel infrastructure
│ ├── demoData.ts # Demo diff for standalone mode
│ └── index.css # Review-specific styles
├── .claude-plugin/marketplace.json # For marketplace install
└── legacy/ # Old pre-monorepo code (reference only)
There are two separate server implementations with the same API surface:
- Bun server (
packages/server/) — used by both Claude Code (apps/hook/) and OpenCode (apps/opencode-plugin/). These plugins import directly from@plannotator/server. - Pi server (
apps/pi-extension/server/) — a standalone Node.js server for the Pi extension. It mirrors the Bun server's API but usesnode:httpprimitives instead of Bun'sRequest/ResponseAPIs.
When adding or modifying server endpoints, both implementations must be updated. Runtime-agnostic logic (store, validation, types) lives in packages/shared/ and is imported by both.
Via plugin marketplace (when repo is public):
/plugin marketplace add backnotprop/plannotator
Local testing:
claude --plugin-dir ./apps/hook| Variable | Description |
|---|---|
PLANNOTATOR_REMOTE |
Set to 1 / true for remote mode, 0 / false for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. Remote ready messages also render a terminal QR code of the advertised URL when stderr is a TTY and the advertised host is overridden away from localhost (a QR of a localhost URL scans to nowhere), so another device can join without retyping the URL. |
PLANNOTATOR_AGENT_TERMINAL_REMOTE |
Set to 1 / true to enable the annotate-mode agent terminal while PLANNOTATOR_REMOTE is active or the session is published with --tailscale. Off by default in both cases because the session is reachable by network peers and the PTY token is not an auth boundary. |
PLANNOTATOR_PORT |
Fixed port to use. Default: random locally, 19432 for remote sessions. |
PLANNOTATOR_URL_HOST |
Display-only hostname for advertised session URLs (issue #657), e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of http://localhost:<port>. Host only — bare hostname, IPv4, or bracketed IPv6 ([fd7a::1]); the runtime-chosen port is always appended, and anything carrying a scheme, port, path, credentials, or whitespace warns once on stderr and falls back to localhost. Strictly display-only and remote-only: binding stays governed by PLANNOTATOR_REMOTE; a local session ignores the override (localhost is advertised and opened, since only loopback is bound) with a once-per-process stderr warning to set PLANNOTATOR_REMOTE=1, and spawned agent-review jobs keep a pinned http://127.0.0.1:<port> API URL so a tailnet-only hostname cannot break local jobs. The sentinel auto resolves the host from Tailscale once per process, at first use in a remote session: tailscale status --json → Self.DNSName (trailing dot stripped), falling back to the single tailscale ip -4 CGNAT (100.64.0.0/10) address; detection failure warns once on stderr and falls back to localhost, and detection never changes binding — auto is as display-only as any explicit host. Can also be set via ~/.plannotator/config.json ({ "urlHost": "host" } or { "urlHost": "auto" }); the env var takes precedence, and an empty-but-set env var (PLANNOTATOR_URL_HOST=) suppresses a config-file urlHost. Default: unset (localhost). |
PLANNOTATOR_BROWSER |
Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
PLANNOTATOR_AI |
Set to disabled to disable Ask AI and the Review Agents / Guided Review execution surfaces, including provider and agent-job endpoints. Persisted guide data is retained and its server APIs remain available, but the in-app history browser is hidden while AI is disabled. External agents can still open reviews and submit annotations. The explicit annotate-mode agent terminal is separate and remains controlled by its own settings. Default: enabled. |
PLANNOTATOR_SHARE |
Set to disabled to turn off URL sharing entirely, including Guided Review share links (the review UI hides "Create share link", POST /api/guide/:jobId/share answers 403 { error: "sharing disabled" }, and plannotator guide share refuses with exit 1). Default: enabled. Can also be set via ~/.plannotator/config.json ({ "share": "disabled" }); the env var takes precedence. |
PLANNOTATOR_SHARE_URL |
Custom base URL for share links (self-hosted portal). Default: https://share.plannotator.ai. |
PLANNOTATOR_PASTE_URL |
Base URL of the paste service API for short URL sharing. Default: https://plannotator-paste.plannotator.workers.dev. |
PLANNOTATOR_ORIGIN |
Explicit agent-origin override at the top of the detection chain. Valid values: claude-code, amp, droid, opencode, codex, copilot-cli, gemini-cli, kiro-cli, pi, oh-my-pi. Invalid values silently fall through to env-based detection. Unset by default. |
PLANNOTATOR_JINA |
Set to 0 / false to disable Jina Reader for URL annotation, or 1 / true to enable. Default: enabled. Can also be set via ~/.plannotator/config.json ({ "jina": false }) or per-invocation via --no-jina. |
PLANNOTATOR_ANNOTATE_HISTORY |
Set to 0 / false to disable ALL annotate-session writes to the data dir: per-file version history (no copies of annotated files are written; the annotate version diff is unavailable) AND the durable submitted-feedback records (#678) that single-local-file annotate sessions otherwise write to history/{project}/{slug}/submissions/ before deleting the draft on submit. Disabling it keeps annotate sessions fully stateless but also gives up that submit crash-recovery record. URL and annotate-last sessions never write either kind of data regardless of this flag. Folder sessions write no submitted-feedback records, but they do participate in per-file version history: the first time a session serves a file through /api/doc it snapshots that file (lazily, memoized per resolved path for the life of the server), which is what powers the per-file version diff when a folder file is reopened later; setting this flag to 0 disables those folder snapshots too. Setting it to 0 additionally suppresses feedback archive records for every annotate surface (single file, folder, URL, live app, annotate-last), so "fully stateless annotate session" stays literally true regardless of PLANNOTATOR_FEEDBACK_HISTORY. Raw-HTML and live-app pinpoints write their element context (selector, ancestor path, allowlisted attributes, visible text, a collapsed HTML skeleton, and the live route) into the submission records and drafts too; form values and inline handlers are never captured. Default: enabled. Can also be set via ~/.plannotator/config.json ({ "annotateHistory": false }); the env var takes precedence. |
PLANNOTATOR_FEEDBACK_HISTORY |
Set to 0 / false to stop archiving submitted feedback under ~/.plannotator/feedback/ (or PLANNOTATOR_DATA_DIR). Default: enabled, which appends one record per submission at decision-settlement time on all three surfaces and in both runtimes: plan approve/deny, code review Send Feedback / Approve (LGTM) / Close, and every annotate submit / approve / close. A review posted straight to GitHub or GitLab with POST /api/pr-action is delivered to the platform and is not archived locally yet. Note that this writes the user's own feedback text, the document and code excerpts it quotes, and per-annotation metadata to disk, and nothing prunes the directory (same policy as plans/, history/, and guides/); delete ~/.plannotator/feedback/ or a project subdirectory to forget, or set this to 0 to never write. Code-review records carry diff IDENTITY only (vcsType, diffType, base, gitRef, snapshotId, cwd, PR metadata, changed-file count, patch byte count), never the patch bytes; plan records carry the decision text plus a reference to the history/{project}/{slug}/NNN.md version the decision was made on, never a second copy of the plan. Externally sourced annotations (linters, review agents, WebMCP browser agents) are included but keep their source / author tags, so source == null selects the reviewer's own comments; agent job outputs (guides, tours) are not archived. This knob governs only the new archive: the planSave decision snapshots in plans/ and the #678 annotate submission records under history/ are unaffected. Annotate surfaces honor PLANNOTATOR_ANNOTATE_HISTORY as well. Can also be set via ~/.plannotator/config.json ({ "feedbackHistory": false }); the env var takes precedence. |
PLANNOTATOR_GUIDE_VIEWER_URL |
Base URL of the portable Guided Review viewer that exported guides pin (default https://guides.show/v1/). Must be https: (or http: on localhost for local viewer builds — bun run --cwd apps/guides-show serve:local); anything else is ignored. Read by the export endpoints of both servers and by plannotator guide export (which also accepts --viewer-url). |
PLANNOTATOR_GUIDE_SHARE_URL |
Base URL of the guide host that Guided Review share links are created on: the review UI's "Create share link", plannotator guide share, and plannotator guide unshare upload to and delete from it (default https://guides.show; the origin of your own deployment of its Cloudflare Worker otherwise, see the apps/guides-show README). Must be http(s); credentials, query and fragment are dropped and a trailing slash is trimmed; an invalid value warns once on stderr and falls back to the default so a share setting can never break a server launch or CLI run. An empty-but-set env var counts as unset. Can also be set via ~/.plannotator/config.json ({ "guideShareUrl": "https://guides.example.com" }); the env var takes precedence; there is no per-invocation flag. Resolved by resolveGuideShareUrl in packages/shared/config.ts. Whether sharing is allowed at all is PLANNOTATOR_SHARE (disabled turns guide share links off entirely). Removal always goes to the host a saved guide's record names, never merely the currently configured URL, so changing this after sharing does not strand a link. |
PLANNOTATOR_GUIDE_HISTORY |
Set to 0 / false to disable persisting successful Guided Reviews (no guide copies are written to the data dir; the "Previous guides" list is then never populated, though already-saved guides remain readable and listed). Note that a persisted guide includes a full copy of the diff it was generated against — history/.../guides/{id}.patch beside the {id}.json envelope, uncapped, as large as the diff — because that patch is what a later portable export or share link renders (the diff is captured when the guide job launches, never re-read from the working tree). Deleting a guide removes both files; nothing prunes the directory otherwise. Turning this flag off skips the patch copy too, at the cost of exports and share links for guides from that session once the server exits. Default: enabled. Can also be set via ~/.plannotator/config.json ({ "guideHistory": false }); the env var takes precedence. |
PLANNOTATOR_CURSOR_SANDBOX |
Set to 0 / false / disabled to stop passing --sandbox enabled when launching Cursor's agent CLI for review jobs — the flag pair is omitted entirely, deferring to the user's own Cursor Agent sandbox configuration. For systems where Cursor's sandbox cannot start (NixOS, AppArmor-restricted Linux). Default: enabled (--sandbox enabled is passed). Can also be set via ~/.plannotator/config.json ({ "cursorSandbox": false }); the env var takes precedence. Note: opting out means the review job's write protection relies on --mode ask plus the user's own Cursor configuration. |
PLANNOTATOR_TODO_PROVIDER |
Set to off / 0 / false / disabled to stop mirroring the approved plan checklist into an editable todo provider during execution. Default: enabled, which syncs only when a provider is detected (currently pi-todos: detected when its todo directory exists — <cwd>/.pi/todos by default, or wherever PI_TODO_PATH redirects it when set). The repo-implied <cwd>/.pi/todos must realpath to a location inside the project or the provider reads as absent and never writes, so a symlink committed into a hostile repo cannot redirect todo writes out of it; an explicitly set PI_TODO_PATH is the user's own choice and is honored verbatim, including outside the project. The mirror is additive — the progress widget is unaffected either way — and sync is one-way, so provider-side edits never feed back into plan execution. Can also be set via ~/.plannotator/config.json ({ "todoProvider": "off" }); the env var takes precedence. |
JINA_API_KEY |
Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. |
PLANNOTATOR_DATA_DIR |
Override the base data directory. Supports ~ expansion. Default: ~/.plannotator. When unset, an existing ~/.plannotator always wins; if it doesn't exist and $XDG_DATA_HOME is set to an absolute path, $XDG_DATA_HOME/plannotator is used; otherwise ~/.plannotator (the XDG spec's implicit ~/.local/share default is deliberately not applied). All data (plans, history, drafts, config, hooks, sessions, debug logs, IPC registry) is stored under this directory. |
PLANNOTATOR_FILE_BROWSER_MAX_FILES |
File-discovery limit: regular files inspected by CLI markdown/folder resolution and startup code-file warming, supported files returned by the file browser, and directories scanned during multi-repo workspace discovery (symlinks may point outside the workspace, so the budget — not the root — bounds that walk). Must be a positive integer; invalid, zero, or negative values use the default of 5000. |
PLANNOTATOR_GLIMPSE |
Set to 0 / false to disable the Glimpse native window even when glimpseui is installed. Default: enabled. Can also be set via ~/.plannotator/config.json ({ "glimpse": false }). |
PLANNOTATOR_GLIMPSE_WIDTH |
Width in pixels for the Glimpse native window. Default: 1280. |
PLANNOTATOR_GLIMPSE_HEIGHT |
Height in pixels for the Glimpse native window. Default: 900. |
PLANNOTATOR_VERIFY_ATTESTATION |
Read by the install scripts only, not by the runtime binary. Set to 1 / true to have scripts/install.sh / install.ps1 / install.cmd run gh attestation verify on every install. Off by default. Can also be set persistently via ~/.plannotator/config.json ({ "verifyAttestation": true }) or per-invocation via --verify-attestation. Requires the gh CLI, but not a login: the attestation bundle is fetched from GitHub's public attestations API (single unauthenticated attempt, never retried; the endpoint allows 60 requests/hour per IP) and verified with --bundle; the extraction needs one JSON tool on PATH (node, python3, or jq). gh's authenticated fetch is the fallback whenever the bundle path is unavailable or does not complete (missing extractor, fetch failure, or a gh that cannot verify the fetched bundle, e.g. an older gh without --bundle). Verification still needs network on every run because the Sigstore TUF trust root is fetched per-run; that failure is reported as connectivity, distinct from a real provenance failure, and both fail closed. |
PLANNOTATOR_SKIP_CODEX_INSTALL |
Read by the install scripts only. Set to 1 / true to skip writing the Codex integration (hooks.json / config.toml under CODEX_HOME, and the Codex-home stale-skill cleanup) even when Codex is detected. The installer reports the honest state ("Codex: detected, skipped (...)" vs "not detected" vs installed) and never removes an integration a previous install wired. Also settable via ~/.plannotator/config.json ({ "skipInstall": { "codex": true } }); precedence is --skip-codex flag > env var > config. Off by default. |
PLANNOTATOR_SKIP_GEMINI_INSTALL |
Read by the install scripts only. Same opt-out shape for the Gemini CLI integration (~/.gemini policy file, settings hook, slash commands). Config key: skipInstall.gemini; flag: --skip-gemini. Off by default. |
PLANNOTATOR_SKIP_KIRO_INSTALL |
Read by the install scripts only. Same opt-out shape for the Kiro CLI integration (~/.kiro skills and agent, including the ~/.kiro stale-skill sweep). Config key: skipInstall.kiro; flag: --skip-kiro. Off by default. |
PLANNOTATOR_SKIP_OPENCODE_INSTALL |
Read by the install scripts only. Do-not-write switch for the OpenCode integration (command stubs under ~/.config/opencode/commands, the OpenCode plugin cache clear, and the stale command-stub sweep). OpenCode has no detection leg, so there is no detected/not-detected reporting, just a skip note. Config key: skipInstall.opencode; flag: --skip-opencode. Off by default. |
PLANNOTATOR_SKIP_SKILLS_INSTALL |
Read by the install scripts only. Set to 1 / true to skip the skills/slash-command sparse checkout entirely — no git clone of the release tag, so nothing is written to any skill or command scope (~/.claude/skills, ~/.agents/skills, the OpenCode command stubs, the Gemini .toml commands, ~/.kiro), the extras are not offered, and the skill-scope cleanup sweeps stay suspended (skip means do-not-write, never remove). The binary, sem sidecar, agent-terminal runtime, hooks, and per-agent config still install, and git stops being a hard requirement. The installer reports Skills: skipped (...) and the closing banner stops claiming the /plannotator-* commands are ready. Unlike the per-agent opt-outs this is not one agent's home — it covers every scope the checkout writes. Config key: skipInstall.skills; flags: --skip-skills (bash/cmd), -SkipSkills (PowerShell); precedence is flag > env var > config. Used by the install-script-smoke CI job, which installs a synthetic v9.9.9 whose tag has no GitHub counterpart. Off by default. |
PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL |
Set to 1 / true to skip installing the managed Node/WebTUI runtime used by compiled Bun builds for the annotate-mode agent terminal. Read by plannotator install-runtime agent-terminal, which the installers call automatically. |
PLANNOTATOR_MINIMAL |
Read by the install scripts only, not by the runtime binary. Set to 1 / true / yes to have scripts/install.sh / install.ps1 / install.cmd install only the plannotator binary, skipping the sem sidecar, the agent-terminal runtime, all per-agent skills, hooks, slash commands, and config, and the CallDiff runtime even when its opt-in is set. Equivalent to the --minimal (aliased to --binary-only) flag; --no-minimal overrides it. Off by default. |
PLANNOTATOR_SKIP_SEM_INSTALL |
Read by the install scripts only. Set to 1 / true to skip installing the optional sem semantic-diff sidecar (used by code review). Off by default. |
PLANNOTATOR_INSTALL_CALLDIFF |
Read by the install scripts only. Set to 1 / true / yes to ALSO install the optional pinned, pruned CallDiff core used by code review's Call Flow analysis (about 5 MB on macOS arm64, Node.js 22+). The runtime is strictly opt-in and is NOT installed by default. The normal path is in-app: enabling Call flow consents to one background install of core plus exactly the language packs required by the current changed files; later missing languages install automatically under the same consent, while the Languages list supports install-ahead. Each target gets one automatic attempt per review session; a failed target then requires an explicit Retry in that session. Equivalent to --with-call-flow (PowerShell: -WithCallFlow) or { "installCallFlow": true } in ~/.plannotator/config.json; precedence is flag > env var > config. --minimal always excludes it. Off by default. |
PLANNOTATOR_CALLDIFF_PATH |
Development override for a built CallDiff 0.4.1 package root containing dist/run.js; the exact pinned Tree-sitter core and any desired optional grammars must already exist under its node_modules. Managed language-pack installation is disabled for overrides. Normal installs use the selective managed core and grammar cache under the Plannotator data directory. |
Config-only settings (~/.plannotator/config.json): Some settings have no env-var equivalent and are toggled by editing the config file directly:
markdownExtensions(array of strings, default none) — extra file extensions the annotate path treats as markdown, e.g.{ "markdownExtensions": [".livemd"] }for Livebook notebooks (#1307). A listed extension is accepted everywhere.mdis on that path: CLI target resolution (plannotator annotate notes.livemd), folder discovery and the file browser,/api/docplus relative and wiki-link navigation between sibling docs, the 2MBMAX_ANNOTATABLE_FILE_BYTEScap, and per-file version history. Listed extensions render as markdown (frontmatter stripped), never as raw HTML, and they only widen the set: nothing built in is removed. Entries must start with a dot and be free of path separators, globs, and whitespace (".livemd", not"livemd"or"*.livemd"); invalid entries are dropped silently, built-in extensions are deduplicated, and the dotenv family can never be registered:.envitself plus any entry ending in.envor starting with.env.(such as.prod.envor.env.local) is denied, because annotate copies file contents into the data dir (the same reason.envis excluded from the built-in set). The value is read fromconfig.jsononce per process. Predicates stay pure inpackages/core/annotatable.ts, which is browser-safe and cannot read config; the node-side resolver that threads the normalized list into them ispackages/shared/markdown-extensions.ts(vendored to Pi), and the annotate/api/planpayload ships the same list to the renderer so it can linkify links to sibling documents. Not applied to plan write (ALLOWED_PLAN_EXTENSIONSinapps/pi-extension/tool-scope.ts) or to Edit Mode source save (SOURCE_SAVE_FILE_REGEXinpackages/core/source-save.ts), which keep their own narrower allowlists.agentTerminalSide("left"/"right"/"hidden", default"left"): which edge the annotate-mode Agent TUI docks against, or"hidden"to keep it out of the layout entirely (#1050). Type and guard live inpackages/core/agent-terminal.ts:63-83, the config declaration inpackages/shared/config.ts:116. Unrecognized values are silently ignored rather than warned about:getServerConfig()omits the key behindisAgentTerminalSide(packages/shared/config.ts:374) andresolveAnnotateAgentTerminalSideindependently falls back to"left"(packages/core/agent-terminal.ts:90-94)."hidden"is a default, not a lock: the terminal can still be opened for the session from the sidebar rail, the Shift-Shift shortcut, or a message routed to the agent, none of which rewrite the preference, and an opened"hidden"terminal docks left (packages/core/agent-terminal.ts:101-105). Settings is the only way back from"hidden". Two UI surfaces write the key (the terminal's own Display popover Position control and Settings → General → "Agent TUI Position"), and the Display popover's reset button restores"left". The side only decides where the terminal docks when opened; it never auto-opens (packages/editor/App.tsx:523), it is not rendered below 1024px or in wide mode (packages/editor/agentTerminalLayout.ts:14,:73), and"right"visually displaces the annotations/AI right panel while preserving its state (packages/editor/agentTerminalLayout.ts:53-57).agentTerminalDefaultAgent(string agent id, e.g."claude"or"codex", default""meaning no recorded choice): which agent the annotate-mode Agent TUI preselects when the panel opens (#1050). Validation istypeof === "string"only, with no enum and no check against installed agents, so an unknown or currently unavailable id is inert rather than an error:resolveAnnotateAgentIduses the saved id only when it appears among the available agents and otherwise takes the first available one (packages/ui/utils/annotateAgentTerminal.ts:45-54). It is written only by the "save as default" checkbox in the terminal's agent picker (packages/editor/components/AnnotateAgentTerminalPanel.tsx:257); there is no Settings control for it. An empty string deletes the cookie and reads as unset, though the server allowlist will still write""intoconfig.json, where it is then ignored.- Precedence for both agent-terminal keys follows the settings registry (
packages/ui/config/settings.ts) and its resolver (packages/ui/config/configStore.ts:3-5): server config file > cookie > built-in default.config.jsonis the durable, cross-browser store; the cookie (plannotator-annotate-agent-terminal-side,plannotator-annotate-agent-terminal-default) is the browser-local fallback. There is no one-time cookie-to-config migration: those two cookie names were deliberately kept unchanged so a pre-registry cookie stays readable, and its value only reachesconfig.jsonif the user changes the setting again. The sync runs one direction at startup, withinit()stamping a valid config value back into the cookie (packages/ui/config/configStore.ts:157-161). Neither key has an env-var equivalent, and only the annotate servers allowlist them onPOST /api/config(packages/server/annotate.ts:726-727, mirrored inapps/pi-extension/server/serverAnnotate.ts:690-691), so setting them has no effect on plan or review sessions. pfmReminder(true/false, defaultfalse) — when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, hex swatches, wiki-links). Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (improve-contextPreToolUse hook inapps/hook/server/index.ts), OpenCode (experimental.chat.system.transforminapps/opencode-plugin/index.ts), and Pi (before_agent_startinapps/pi-extension/index.ts).
Legacy: SSH_TTY and SSH_CONNECTION are still detected when PLANNOTATOR_REMOTE is unset. Set PLANNOTATOR_REMOTE=1 / true to force remote mode or 0 / false to force local mode.
Devcontainer/SSH usage:
export PLANNOTATOR_REMOTE=1
export PLANNOTATOR_PORT=9999Tailnet sessions (--tailscale): plannotator review --tailscale (also annotate and annotate-last/last; other subcommands reject the flag with a clear error; Bun CLI only, not mirrored to Pi) publishes the session over the user's tailnet instead of remote mode: the server stays loopback-bound and the CLI orchestrates tailscale serve --bg --https=<port> http://127.0.0.1:<port>, so devices on the tailnet reach the session over HTTPS while nothing listens beyond localhost and nothing is ever public (serve, never funnel). The advertised HTTPS URL prints on stderr with a terminal QR code (TTY only), and a publishing failure exits 1 — or 2 under a strict annotate gate (--require-approval / --result-file), where 1 is reserved for "the reviewer did not approve" and a publish failure is a startup failure like any other — with an actionable message instead of leaving the loopback server hanging (CLI missing, daemon down or logged out, unparsable serve status output — which fails closed, or no serve URL matching the session port). A pre-existing serve mapping on the chosen port — background or foreground session, which Tailscale prefers — aborts rather than being stolen, and mappings on other ports are never touched. Mappings the process creates are cleaned up on normal exit and on SIGINT/SIGTERM/SIGHUP (all routed through process.exit so exit handlers run; the SIGHUP route is installed by enableTailscaleServe only once a mapping exists — an unconditional SIGHUP listener would override the ignored disposition nohup depends on, so plain non-tailscale sessions keep no SIGHUP listener and nohup plannotator review & survives terminal close); a failed teardown retries once and then warns with the exact manual command, and a hard kill (SIGKILL) or reboot can leave the mapping — tailscale serve --bg state persists — so remove it with tailscale serve --https=<port> off. Combined with PLANNOTATOR_REMOTE/SSH detection, --tailscale wins and forces local mode with a stderr notice — the wide 0.0.0.0 bind would only broaden exposure (urlHost is also suppressed for the run; the advertised URL comes from serve). The annotate agent terminal is off by default in --tailscale sessions, exactly like remote mode, because the session is reachable across the tailnet and the PTY token is not an auth boundary; enable it with PLANNOTATOR_AGENT_TERMINAL_REMOTE=1. Orchestration lives in packages/server/tailscale-serve.ts on shared parsers in packages/shared/tailscale.ts.
Claude calls ExitPlanMode
↓
PermissionRequest hook fires
↓
Bun server reads plan from stdin JSON (tool_input.plan)
↓
Server starts on random port, opens browser
↓
User reviews plan, optionally adds annotations
↓
Approve → stdout: {"hookSpecificOutput":{"decision":{"behavior":"allow"}}}
Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","message":"..."}}}
User runs /plannotator-review command
↓
Claude Code: plannotator review subcommand runs
OpenCode: event handler intercepts command
↓
VCS provider captures local changes (Git, GitButler, JJ, or P4 where supported). When review runs from a
non-VCS parent that contains nested Git/JJ/GitButler repos, child diffs are combined with
folder-prefixed paths.
↓
Review server starts, opens browser with diff viewer
↓
User annotates code, provides feedback
↓
Send Feedback → feedback sent to agent session
Approve → approved prompt sent to agent session (with the note/annotations when approving with notes)
The agent-destination review header uses the same adaptive split control the annotate surfaces
adopted: a ghost-X Close plus DecisionControl (packages/ui/components/DecisionControl.tsx)
rendered from the pure buildDecisionSpec mapping — Approve with no annotations,
Send Feedback otherwise, with Request changes… / Send with a note… and the explicit
Approve, discard n annotations… confirm behind the caret. One submitPrimaryDecision()
callback serves the header primary, the global Mod+Enter handler, and the compact primary row.
Transport routing is pure in packages/review-editor/reviewDecision.ts and single-endpoint:
every decision POSTs /api/feedback with approved as the only fork; a change-request note
becomes a scope:'general' CodeAnnotation (sentinel filePath ''/0/0, riding the export's
## General section) with a one-render deferred submit — zero server change. Approve-carrying
menu items (Approve with notes, Approve with a note…) are capability-gated on the
server-sent approvalNotesSupported advert, which rides every diff payload (/api/diff,
/api/diff/switch, /api/pr-diff-scope, /api/pr-switch, both runtimes) and reads as false
when absent, so an old server renders no approve-carrying items. For the OpenCode CLI bridge
the advert additionally requires the plugin's own supportsApprovalNotes: true declaration on
the opencode-review stdin JSON (the binary and plugin version independently; an old plugin
omits it and the advert fails closed, so a new binary can never hand an old bridge a note it
would discard). A capable session's approvals post buildReviewApprovalBody: bare approve
sends feedback: '' (the old 'LGTM - no changes requested.' placeholder is gone — a bare
approval now archives as lgtm with no sidecar), "Approve with a note…" sends the note as the
feedback, and "Approve with notes" sends the live annotations plus their export (a note, if
both are ever present, is folded in ahead of the export — never dropped). The four agent-facing
decision consumers (Claude Code CLI, OpenCode native + CLI bridge, Pi) emit approvals through
the shared composeReviewApprovedMessage (packages/shared/prompts.ts, vendored to Pi):
a bare approval is the plain approved prompt; an approval carrying feedback uses the
approved-with-notes framing (prompts.review.approvedWithNotes, default
DEFAULT_REVIEW_APPROVED_WITH_NOTES_PROMPT — "non-blocking guidance, do not revise or
reopen"), because the bare prompt plus a change-request-shaped export would read as a
contradiction. The legacy placeholder is filtered there so a stale built client cannot get
filler framed as guidance. The standalone dev server (apps/review/server) is the exception:
it emits the raw decision JSON with the feedback unfiltered and does not route through the
composer. Compact/touch rows are generated from the same spec, so a visible positive decision
exists in every state; composer rows open DecisionNoteDialog. Platform (PR) mode renders the
same ghost-X + DecisionControl shape from buildDecisionSpec's platform arm, with no
composer items ever: every menu action opens the existing ReviewSubmissionDialog (per-target
state, retry, "leave PR open" toggle — whose general-comment textarea is the only note field on
that side), and the self-approval mute is preserved — muted primary/items with the "You can't
approve your own {PR/MR}" reason, Request changes… / Post comments, then… always live.
Interaction-model changes worth knowing (F8 and siblings): the agent-mode Approve primary
follows the FeedbackButton responsive pattern and is icon-only below the lg breakpoint,
where the old ApproveButton showed a compact OK label — the title carries the accessible
name, and compact/touch rows keep full labels. Approving despite annotations is now two clicks
(caret → Approve, discard n annotations… → Discard & approve) instead of the old dimmed
one-click Approve with its warning dialog, and Mod+Enter never stacks with the removed
approve-warning dialog — an open confirm dialog owns Mod+Enter outright (the
data-plannotator-confirm-dialog sentinel guard in the app's keydown effect; without it one
keystroke over the discard confirm would post two contradictory decisions). Accepted edge: the
compact DecisionNoteDialog keeps its draft locally and discards it if the item behind it leaves
the live spec (the dialog closes), while the desktop popover composer keeps drafts keyed by item
id — an intentional asymmetry, not a bug.
The review sidebar carries the durable human producer for review-level comments:
"+ General comment" renders in the Annotations tab's General section header (even with zero
general comments) AND in the all-empty state, opening the shared DecisionNoteField in a small
anchored popover whose width clamps to the resizable panel (200-600px persisted) so it never
clips inside the sidebar's overflow-x: hidden scroll area. Composer state (open + draft) lives
in ReviewSidebar, shared by both placements: the draft survives a dismissal, a placement flip
(an external annotation arriving mid-sentence moves the button from the empty state to the
section header), and a tab switch; collapsing the sidebar discards it. The producer is
deliberately present in platform (PR) mode too — a session-level comment there rides the posted
review body through the pre-existing scope:'general' handling in buildFileScopedBody /
ReviewSubmissionDialog. Unlike the header composer's submit note (one-submit lifetime), a sidebar
general comment goes through addCodeAnnotationsWithHistory — undoable, draft-persisted,
deletable — and both producers share one shape factory, createGeneralReviewComment in
reviewDecision.ts: scope:'general', sentinel filePath ''/0/0, review-note- UUID id, and
deliberately no PR context, so the comment passes every PR scope predicate and survives an
in-place PR switch. Creating one raises totalAnnotationCount, which is what flips the header
control to Send Feedback — the control is state-driven, not wired to the button. The
feedback archive records each annotation's scope (additive scope?: string in
packages/shared/feedback-archive.ts's normalizer, vendored to Pi), so a review-level general
comment stays distinguishable from a line comment in index.jsonl.
The default code-review diff is since-base — a composite of merge-base(base, HEAD) vs the working tree plus untracked files ("everything a PR would show if you committed and pushed now"). It can render as a three-section git status panel (Committed / Changes / Untracked) via SectionsPanel, with a Tree | Git status | Commits toggle (PanelViewToggle). The Commits segment (git-local sessions only) is a linear --first-parent history rail (CommitsPanel): clicking a commit opens its own diff (commit:<sha>, vs its first parent) as the all-files view headed by the commit message rendered as markdown. The Commits view is a self-contained detour: entering it memoizes the previously active diff, exiting to Tree restores that diff verbatim (exiting to Git status resets to since-base as always), the memo clears whenever any non-commit diff is applied, and a reload that serves a commit-family diff with a non-Commits panel view snaps once to the session default so the commit diff cannot outlive the visit. The toggle never writes the persisted reviewPanelView/defaultDiffType pair (no server writes from a toggle click), but it does record a cookie-only last-used memo (reviewPanelViewLastUsed, sections | tree — never commits; the Commits view is session-only). A review OPENS on caller-pinned flags (plannotator review --base <ref> / --diff-type <id>, session-only: openStatePinned on /api/diff skips the first-run dialog WITHOUT consuming its cookie, disables the panel-pair self-heal, and never persists anything) ?? session choice ?? last-used memo ?? persisted reviewPanelView (cookie-only, written only by Settings and ReviewSetupDialog through setReviewPanelView(), which also syncs the memo so an explicit choice is never shadowed by a stale one — except the App self-heal, which passes recordLastUsed: false to repair the diff half of a conflicted pair without touching the memo). The first-run initializer marks review-setup-seen when it seeds the cookie-only Tree choice, not only on dismiss, so it is genuinely one-time per browser and cannot overwrite a returning reviewer's persisted or last-used view; it inherits the resolved defaultDiffType without a server config write. The persisted pair is coupled: the Sections view only renders since-base, so choosing a classic diff default snaps the persisted view to Tree and vice-versa (enforced in ReviewSetupDialog, the Settings Git tab, and the App first-run initializer).
Staging display invariant: useGitAdd's stagedFiles is the EFFECTIVE staged set (sections-sidecar snapshot + session stage/unstage overrides) and is the only source any surface may render staging state from. The sidecar entry's staged flag is a snapshot — ORing it back in makes files unstaged mid-session render as staged (and inverts the next toggle).
since-base is only offered when the base ref actually resolves — on a repo whose trunk isn't discoverable (trunk, no origin/HEAD) getGitContext omits it and the default falls through to uncommitted, so committed branch work is never silently hidden. The since-base patch/sections/fingerprint/file-content paths all degrade to HEAD together when merge-base fails for a resolvable-but-unrelated base. First-run shows ReviewSetupDialog (replaces the removed DiffTypeSetupDialog), which initializes an unseen reviewer's panel to Tree once while preserving the resolved diff default, and is reopenable from the review header menu. The one-time dialog chain is guide intro → look-and-feel → review setup → Edit Mode → token hover cards; none of the dialogs stack. The token hover announcement is last and additionally skips a session where hover cards cannot run at all (no live workspace), WITHOUT consuming its cookie, and never shows to a reviewer whose trigger is already non-default (which after the boolean-to-trigger migration is exactly the early adopter who turned cards off). Analysis layers no longer add a startup dialog: Semantic Changes retains its enabled default, while Call Flow remains disabled until the user explicitly enables it in Settings, which is also consent for its managed runtime installation.
GitButler is a distinct VCS provider, ordered after JJ and before Git in both Bun and Pi. It is selected only while symbolic HEAD is refs/heads/gitbutler/workspace (or legacy gitbutler/integration) and the repository has GitButler's local target-ref configuration; a leftover database or an ordinary branch with the reserved name is not detection. An active workspace requires but >= 0.21.0 on PATH, and a missing/incompatible CLI is an explicit error rather than a fallback to ordinary Git staging against the synthetic workspace commit. --gitbutler forces this provider; --git remains the escape hatch.
The default gitbutler:workspace view is GitButler's reported merge base versus the working tree plus untracked files, so it includes every applied committed change and assigned/unassigned worktree change. Multi-branch stack views are committed-only merge-base→stack-tip Git diffs; branch views are committed-only first-parent segment diffs. Client IDs encode branch-name anchors, never GitButler's transient CLI IDs. Do not concatenate independent GitButler hunks: their bases can differ. Assigned worktree hunks stay in Workspace until GitButler exposes an authoritative combined stack diff.
GitButler assignment is not the Git index, so the provider never opts into stage/unstage. Git-status sections, commit history, remote-base discovery/fetch, and the first-run Git setup remain vcsType: "git" only. File expansion uses the exact object range for committed views and merge-base/working-tree pair for Workspace; fingerprints cover the visible Git content plus canonical stack/branch topology. Nested multi-repo mode maps only workspace-current to GitButler; staged/unstaged/last modes are unavailable when a GitButler child is present.
Ask AI's "changes under review" context for code review is generated by the shared agent-review prompt machine (buildAgentReviewUserMessage / buildAgentReviewUserMessageForTarget in packages/server/agent-review-message.ts) — the same machine the launchable review jobs use — and is delivered on the user's messages, not the system prompt. The review server computes it for the current view (buildCurrentAiReviewContext in packages/server/review.ts, mirrored in apps/pi-extension/server/serverReview.ts) and ships it as aiReviewContext in the diff payloads (/api/diff and the switch/PR endpoints). The client (packages/review-editor) latches it onto each question via buildReviewContextPreamble (packages/ui/utils/aiPrompt.ts): the full block on the first message and whenever the view changes, a short reminder otherwise (never re-pasting a large diff). This keeps the agent looking at exactly the on-screen changeset across every mode (uncommitted/untracked, branch, merge-base, stacked-PR full-stack, hide-whitespace, PR worktrees, workspace, GitButler, jj). The code-review system prompt (buildCodeReviewPrompt in packages/ai/context.ts) is intentionally role-only.
Ask AI providers are detected independently from installed/authenticated local CLIs, then the UI picks a default from the detected Plannotator origin. The mapping lives in packages/core/agents.ts (re-exported via the packages/shared/agents.ts shim) and is applied by packages/ui/utils/aiProvider.ts:
| Origin | Preferred Ask AI provider |
|---|---|
claude-code |
claude-agent-sdk |
amp |
no dedicated provider; fallback to saved/server default |
droid |
no dedicated provider; fallback to saved/server default |
codex |
codex-sdk |
opencode |
opencode-sdk |
pi |
pi-sdk |
copilot-cli |
no dedicated provider; fallback to saved/server default |
gemini-cli |
no dedicated provider; fallback to saved/server default |
Automatic resolution is session-only and never writes a preference. Explicit per-origin choices are persisted in cookies, so a user can override the automatic match for one agent without changing the default for another.
Codex transport note: the
codex-sdkprovider id is a stable identifier only — it no longer uses@openai/codex-sdk/codex exec. It drives a long-livedcodex app-serverprocess over JSON-RPC (packages/ai/providers/codex-app-server.ts), which respects the user's/enterprise-managed approval policy and supports interactive Allow/Deny approvals. The id stayscodex-sdkto preserve saved cookie preferences, theagents.tsmapping, and the UI reasoning-effort gate.
OpenCode transport note: the
opencode-sdkprovider spawns its ownopencode serveper process on an OS-assigned port (port: 0) and never attaches to a server it did not spawn (an attached server can't be cleaned up by us, and opencode's per-directory instances accumulate in it without eviction). The spawned server is closed on dispose and on process exit. Model discovery is deferred behind the provider initializer (?activate=from the model picker, or the first opencode session) exactly like Codex — nothing spawns at server boot, so the picker lists opencode with an empty model list until first activation. Regression-pinned bypackages/ai/providers/opencode-sdk.test.ts.
User runs /plannotator-annotate <file.md | file.html | https://... | folder/>
↓
Claude Code: plannotator annotate subcommand runs
OpenCode/Pi: event handler intercepts command
↓
Input type detected:
.md/.mdx/.txt → file read from disk
plain-text config/data formats (.yaml .yml .json .jsonc .json5 .toml .ini .cfg .conf .properties .csv .tsv .log .xml .env.example)
→ read from disk, rendered as plain text exactly like .txt (.env itself is
deliberately excluded — it commonly holds secrets and annotate history
copies file contents; source-code extensions stay with code review)
All single-file annotate reads and /api/doc document serves are capped at
2MB (`MAX_ANNOTATABLE_FILE_BYTES` in `packages/core/annotatable.ts`) —
larger files get a clear "File too large to annotate (max 2MB)" error.
Extra extensions listed in `markdownExtensions` (config-only setting,
e.g. `.livemd`) join this set and render as markdown, frontmatter stripped.
.html/.htm → file read, rendered as raw HTML by default (or converted to markdown with --markdown)
https:// → fetched via Jina Reader (default) or fetch+Turndown (--no-jina)
http://localhost:* (also 127.x and [::1])
→ LIVE app annotation by default when a quick probe returns HTML:
the running app is mirrored through a loopback reverse proxy and
annotated in place (see "Live app annotation" below). --static
forces the classic conversion pipeline; --app forces live mode
and fails loudly when it cannot apply.
folder/ → file browser opened, files converted on demand
↓
Annotate server starts (reuses plan editor HTML with mode:"annotate")
↓
User annotates content, provides feedback
↓
Send Feedback → annotations sent to agent session
Done / Approve (gate) → positive decision recorded (see the decision control below)
Every annotate surface's header decision is one adaptive split control, DecisionControl
(packages/ui/components/DecisionControl.tsx), rendered from the pure buildDecisionSpec
state→spec mapping (packages/ui/utils/decisionSpec.ts) beside a ghost-X Close: Done (or
Approve in gate mode) with nothing to send, Send Feedback otherwise, with the alternate
decisions and the in-place note composer behind the caret. One submitPrimaryDecision() callback
serves the header primary, the global Mod+Enter handler, and the compact primary row, so
keyboard and header can never disagree. Transport routing is pure in
packages/editor/annotateDecision.ts: Done and every note post /api/feedback (a note becomes
a GLOBAL_COMMENT at submit time with a one-render deferred submit — zero server change), so
formatAnnotateOutcome shapes and strict-gate exit codes are byte-identical to the old
keyboard-only zero submit; only gate-mode approvals reach /api/approve. The non-gated empty
menu carries a single composer, "Send a note…" (maintainer ruling: the old "Done with a note…" /
"Request changes…" pair differed only by framing on the same transport and was collapsed into
one item); the approval-framing sentence (buildCompleteAnnotateFeedback's approvalFraming)
now serves only the non-gated discard path, and the only confirm left is the
explicit Done/Approve, discard n annotations… menu item (plus the pre-existing
close-with-content warning). Compact/touch rows are generated from the same spec, so a visible
positive decision exists in every state; composer rows open DecisionNoteDialog. The header flip
predicate is hasFeedbackToSend, so feedback already delivered through the agent terminal shows
the positive primary rather than a stale Send Feedback.
Slash-command hosts forward raw user words to plannotator annotate verbatim (on Claude Code through a bash-substitution prefix that runs before the model sees anything), so non-strict invocations resolve their arguments in three tiers. The shared logic lives in packages/shared/annotate-target.ts (vendored to Pi) and is wired into the CLI's annotate branch plus the OpenCode and Pi command parsers:
- A single-token invocation runs the classic pipeline unchanged: a bare correct path behaves exactly as before, and a lone typo'd path still fails with
File not foundand exit1. - With several tokens, each token is probed; exactly one naming an existing file, URL, or folder proceeds with it (
annotate look at notes.md pleaseopensnotes.md). Two or more resolving tokens error naming every candidate rather than guessing, which also meansannotate a.md b.md(previously: silently openeda.md, ignoringb.md) is now that error. Bare directory names only count as targets when they are the sole argument, so a stray word matching a directory (or.) cannot hijack the fast path; unrecognized dash-prefixed tokens disable the tolerance entirely so a typo'd flag (--no-jna) errors the way it always did instead of being silently skipped. - When nothing resolves, the CLI emits an agent-addressed handoff that echoes the words tried and asks the reading agent to re-run with a concrete target (content flags such as
--markdown/--no-jina/--render-htmlare echoed for the re-run; transport flags are not). In plain mode the handoff goes to stdout with exit0, because a non-zero exit from a Claude Code bang-prefix skill aborts the prompt before the model sees any output; with--json/--hookit goes to stderr with exit1so machine-readable stdout stays reserved for decision records. OpenCode and Pi surface the same message as a host notification.
Strict invocations (--require-approval / --result-file) bypass all three tiers: args[1] is the target and a typo'd path stays a startup failure with exit 2.
The bang prefix in the Claude Code skill is deliberate: #872 (commit aac5aacb, "restore /plannotator-* bash execution on Claude Code") put it back so the slash command never depends on the model choosing to run the binary. Argument-shape problems belong here in the CLI's resolution, not in the skill templates.
Direct plannotator annotate invocations may add --require-approval and/or --result-file <path> only with --gate --json; both reject --hook and are not shared with OpenCode/Pi slash-command parsing. When neither strict option is present, single-target invocations keep the legacy plaintext, JSON, hook, and exit behavior unchanged; multi-token invocations go through the tolerant tiers described under "Tolerant argument resolution" above.
Strict decisions use one newline-terminated JSON record on stdout and, when requested, identical bytes in the result file. Exit codes follow the grep convention: approval exits 0; with --require-approval, annotated and dismissed decisions are published before exiting 1 (negative human outcome); usage/startup/validation failures — bad flag combinations, strict flags outside annotate --gate --json, a missing --result-file parent, a pre-existing or dangling-symlink destination, and every annotate startup failure (missing path, unreachable URL, empty folder, ambiguous name, missing file, oversized file) — exit 2 (the gate itself was misconfigured or could not start). Those startup sites exit 1 as before for non-strict invocations, with one deliberate exception: the multi-token zero-resolve handoff is not a startup failure, so in plain non-strict mode it prints on stdout and exits 0 (under --json/--hook it stays stderr + exit 1). Under a strict flag 1 is reserved for "the reviewer did not approve", so a typo'd path must never masquerade as a rejection. Post-decision publication failures (destination appears between validation and publish, hard links unavailable) also exit 2: the result file was not published, so they present as environment errors — "the gate could not publish its result" — never as a reviewer outcome, and never as approval (still fail-closed, since only 0 means approved). The stdout decision record is written before result-file publication and is still emitted whenever the decision itself completed; only a stdout write failure leaves no record anywhere. Signal deaths keep 128+n. Result paths resolve from the invocation working directory, require an existing parent and absent destination, and publish via a flushed/closed 0600 same-directory temporary file plus an atomic no-clobber hard link—never copy or overwrite fallback (the 0600 mode is a no-op on Windows, and the atomic link/rename is not followed by a parent-directory fsync, so publication is atomic but not crash-durable). Keep reviewed sources at stable project paths; unique result and diagnostic log files may use a narrow temporary directory. Explicit Close emits dismissed; missing results or process/browser failures are recovery cases, never approval.
Local direct structured gates (--gate --json, not --hook, not remote) advertise a client lease in /api/plan and serve /api/annotate/client-lease (SSE, ANNOTATE_CLIENT_LEASE_STREAM_PATH). Each open stream is one connected review surface; the server heartbeats every 5s and, once at least one client has connected, starts a 30s reconnect grace when the last one disconnects. A reconnect inside the grace continues the same review; expiry resolves the gate as the same dismissed decision an explicit Close produces, except that it keeps the saved annotation draft so an abandoned review can still be recovered. Approve, feedback, explicit exit, and server stop all cancel a pending expiry. Whichever producer settles the session first wins: every one of them (each connected surface and the expiry itself) goes through a single one-shot settlement, so a decision arriving after the session already resolved is rejected with 409 rather than deleting the draft and reporting success for an outcome the caller never received. Page lifecycle events are deliberately not used: pagehide/beforeunload also fire on reload and navigation, so they cannot distinguish abandonment from a reconnect. A session that never receives its first client never auto-dismisses, so browser-launch failures still need a caller-side timeout, and remote/shared sessions keep the capability off because tunnel disconnects would read as abandonment — as do --tailscale-published sessions, which force local mode but are reached through the serve proxy, whose disconnects would read the same way.
Live local app annotation (spec: adr/research/SPIKE-local-app-annotation-20260810.md, section 7; phase 1 shipped it on the Bun path, phase 2 brought the Pi extension to parity). plannotator annotate http://localhost:5173 probes the loopback URL (3s timeout, accept: text/html) and, when the probe returns an HTML page, starts server mode "annotate-app" instead of converting the page: a dedicated loopback reverse proxy mirrors the whole dev-server origin on its own 127.0.0.1 port, injects <script src="/__plannotator__/bridge.js"> into every HTML response (streaming, exactly once per document), and passes WebSockets through so HMR keeps working. Every proxy DECISION — the injector state machine, loopback/Host/Origin predicates, CSP/X-Frame-Options policy, redirect rewrite, WS origin gate, bridge assembly, liveAppDraftIdentity — lives once in packages/shared/live-proxy-core.ts; packages/server/live-proxy.ts is the Bun transport over it and packages/shared/live-proxy-node.ts (vendored to Pi) is the node:http transport, whose WS passthrough replays the client's own handshake upstream over raw TCP and pipes the sockets byte-for-byte. The probe itself (timeout, < 500 status gate, final-response redirect rule) and every user-facing live-mode message are shared too, in packages/shared/live-probe.ts. The editor renders the proxied app full-viewport in an unsandboxed iframe and drives the same pinpoint annotation experience the srcdoc surface provides. --static forces the classic conversion; --app forces live and errors loudly wherever it cannot apply: non-loopback, https, unreachable, non-HTML, off-origin-redirecting, and non-URL (file/folder) targets. "Loopback" means localhost, ::1, or a LITERAL IPv4 address in 127.0.0.0/8 (isLoopbackHostname in live-proxy-core.ts, the single source of truth); DNS names like 127.0.0.1.evil.example are not loopback. Live eligibility is judged on the probe's FINAL response after redirects: a target that 302s off its own loopback origin (auth portal, tunnel splash, another local port) falls back to the static pipeline instead of opening a dead live surface, while same-server redirects stay live-eligible.
The advertised appUrl is the proxy under its LOCALHOST spelling with the target URL's own path and query (http://localhost:<B>/admin?tab=2): localhost keeps the framed app same-site with the editor page and shares the dev app's host-only localhost cookies and storage, which a 127.0.0.1 spelling would not (Safari ITP would then block all cookies in the cross-site iframe). The proxy still BINDS the 127.0.0.1 literal; browsers that resolve localhost to ::1 first fall back to IPv4 on the refused loopback connect.
Runtime coverage: the feature ships on the Bun server + Claude Code CLI path AND on the Pi extension (/plannotator-annotate http://localhost:5173 probes live-first through the shared live-probe module, recognizes --app/--static via parseAnnotateArgs's liveFlags opt-in, and surfaces the probe-fallback notice through Pi's notifier; apps/pi-extension/server/serverAnnotate.ts serves the same annotate-app /api/plan payload over the vendored Node live proxy). The OpenCode slash-command parser is still untouched: its URL targets keep static conversion, and it deliberately does NOT opt into liveFlags, so a typo'd --app there stays a visible "File not found" instead of being silently swallowed. Pi has no --tailscale, so its hard-off matrix is remote-mode only (command-side notify + server throw + unconditional loopback bind).
Session shape: the surface opens with pinpoint armed, and it is comment-only; the full interaction contract it shares with raw-HTML sessions is documented once under "## Annotation System" below. Two things are specific to live mode: vim navigation is off outright (vimModeEnabled={liveApp ? false : ...}, packages/editor/App.tsx:5484), because its keyboard cursor writes into the app's own DOM, and the viewer's floating input-method switch is not offered. Text drag-selection commenting is NOT disabled: it stays live in both the armed and Interact states, exactly as on raw HTML. Multi-page sessions are supported in one server session: annotations carry pageUrl (pathname + search, capped at 2048), restore filters to the current page, the export groups feedback under per-page headings while numbering stays global, and the bridge reports SPA navigations via a coalesced page-change message. Version history, durable submission records, URL sharing, portable HTML export, Obsidian/Bear save, and the annotate agent terminal are all off, exactly like URL sessions. Drafts, feedback, approve, gates, and the client lease work unchanged.
Security posture: the proxy binds 127.0.0.1 unconditionally and validates the Host header before touching upstream (DNS-rebinding blunting); PLANNOTATOR_URL_HOST is never applied to the proxy origin. WebSocket upgrades whose browser Origin header is present and does not name the proxy itself are refused before any upstream contact, so a hostile page's cross-site WS connect is never laundered into the origin-less shape dev servers trust as a non-browser client (Vite CVE-2025-24010 class); header-less non-browser clients pass. App CSP on HTML responses is dropped and replaced with a frame-ancestors policy listing exactly the editor origins (amending an arbitrary CSP for an injected script is unpredictable; dev servers rarely ship one; <meta http-equiv> CSP is a documented non-goal); X-Frame-Options is stripped only on those same HTML responses, so non-HTML responses keep whatever framing protection the app shipped. /__plannotator__/bridge.js embeds the per-session token, so its delivery refuses Sec-Fetch-Site values other than same-origin/none (defense in depth against off-origin <script src> token reads; header-less clients pass). Upstream redirect Locations are re-anchored onto the proxy by loopback-host-plus-port equivalence, never by string prefix. The injected bridge authenticates both message directions with a per-session token plus origin checks, and posts each outbound message once per listed editor origin (the browser delivers only the one matching the parent document, so an editor opened at 127.0.0.1 works too); the parent side keeps every existing size cap. Remote mode is a hard-off with three independent layers and no override env var: the CLI exits with a startup failure suggesting --static (on Pi, the command notifies with the same shared message), startAnnotateServer throws (both the Bun server and the Pi mirror), and neither transport's proxy bind ever follows PLANNOTATOR_REMOTE — both bind the 127.0.0.1 literal unconditionally, asserted at source level by their test suites. --tailscale sessions are hard-off the same way (CLI startup failure + server throw, keyed on tailnetPublished): the annotate server stays loopback-bound but is published across the tailnet through the serve proxy, and a live proxy would relay the user's authenticated dev app to every tailnet peer — the same reasoning that defaults the annotate agent terminal off for tailnet-published sessions. Release-note-worthy behavior change: under PLANNOTATOR_REMOTE, plannotator annotate http://localhost:... previously converted the page silently and now exits with that startup failure asking for --static.
Live restore resilience: a find-and-mark that resolves nothing in live mode keeps its record, seeded with unresolved placeholder targets from the durable anchor/text params, so the mutation-driven reconcile re-acquires the pin once a lazy route or data-dependent tree finishes rendering. Srcdoc restores keep the old fail-closed drop (a static document would never re-resolve).
Known limitations (documented, not bugs): hardcoded absolute origins in the app, origin-pinned CORS to secondary APIs, and OAuth redirect_uri flows land outside the proxy; frame-busting apps break the wrapper; content-encoded HTML that survives the encoding strip renders without the bridge; cross-page annotation clicks do not navigate; a redirect to a DIFFERENT loopback service (another port) is passed through un-rewritten and the iframe leaves the proxy, which is why the probe refuses such targets up front. Manual smoke loop: scripts/live-annotate-smoke.sh (not CI).
User runs plannotator archive (CLI)
↓
Server starts in mode:"archive", reads ~/.plannotator/plans/
↓
Browser opens read-only archive viewer
↓
User browses saved plan decisions with approved/denied badges
↓
Done → POST /api/done closes the browser
During normal plan review, an Archive sidebar tab provides the same browsing via linked doc overlay without leaving the current session.
| Endpoint | Method | Purpose |
|---|---|---|
/api/plan |
GET | Returns { plan, origin, previousPlan, versionInfo } (plan mode) or { plan, origin, mode: "archive", archivePlans } (archive mode) |
/api/plan/version |
GET | Fetch specific version (?v=N) |
/api/plan/versions |
GET | List all versions of current plan |
/api/archive/plans |
GET | List archived plan decisions (?customPath=) |
/api/archive/plan |
GET | Fetch archived plan content (?filename=&customPath=) |
/api/done |
POST | Close archive browser (archive mode only) |
/api/approve |
POST | Approve plan (body: planSave, agentSwitch, obsidian, bear, feedback) |
/api/deny |
POST | Deny plan (body: feedback, planSave) |
/api/save-notes |
POST | Save to external note apps (Obsidian, Bear, Octarine) |
/api/image |
GET | Serve image by path query param |
/api/upload |
POST | Upload image, returns { path, originalName } |
/api/obsidian/vaults |
GET | Detect available Obsidian vaults |
/api/skills |
GET | List global agent skills for comment skill references ({ skills: [{ name, root, description?, humanOnly, dir }] }) |
/api/skills/content |
GET | SKILL.md contents of one discovered skill for human-only feedback injection (?name=<skill>) returns { skill: { name, dir, path, content, truncated, humanOnly } }; the name is matched against discovery only, never used as a path |
/api/reference/obsidian/files |
GET | List vault markdown files as nested tree (?vaultPath=<path>) |
/api/reference/obsidian/doc |
GET | Read a vault markdown file (?vaultPath=<path>&path=<file>) |
/api/plan/vscode-diff |
POST | Open diff in VS Code (body: baseVersion) |
/api/doc |
GET | Serve linked .md/.mdx file (?path=<path>) |
/api/doc/exists |
POST | Batch-validate code-file paths (body: { paths: string[], base?: string }) returns { results: { [path]: { status: "found"|"ambiguous"|"missing"|"unavailable", … } } } |
/api/draft |
GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
/api/editor-annotations |
GET | List editor annotations (VS Code only) |
/api/editor-annotation |
POST/DELETE | Add or remove an editor annotation (VS Code only) |
/api/ai/capabilities |
GET | Check if AI features are available |
/api/ai/session |
POST | Create or fork an AI session |
/api/ai/query |
POST | Send a message and stream the response (SSE) |
/api/ai/abort |
POST | Abort the current query |
/api/ai/permission |
POST | Respond to a permission request |
/api/ai/sessions |
GET | List active sessions |
/api/external-annotations/stream |
GET | SSE stream for real-time external annotations |
/api/external-annotations |
GET | Snapshot of external annotations (polling fallback, ?since=N for version gating) |
/api/external-annotations |
POST | Add external annotations (single or batch { annotations: [...] }) |
/api/external-annotations |
PATCH | Update fields on a single annotation (?id=) |
/api/external-annotations |
DELETE | Remove by ?id=, ?source=, or clear all |
| Endpoint | Method | Purpose |
|---|---|---|
/api/diff |
GET | Returns { rawPatch, gitRef, snapshotId, origin, mode?, diffType, base, hideWhitespace, gitContext, agentCwd?, approvalNotesSupported, semanticDiff?, callFlow?, sections?, commitInfo?, generatedFiles?, baseBehindRemote? }. snapshotId identifies this diff snapshot; the client echoes it on /api/diff/fresh probes (also returned by the switch/PR endpoints). approvalNotesSupported is the approve-with-notes capability advert (echoed on /api/diff/switch, /api/pr-diff-scope, and /api/pr-switch too, so it survives a diff switch); absent reads as false. sections is the since-base sidecar (Committed/Changes/Untracked partition); commitInfo is the commit-metadata sidecar (subject, markdown body, author + avatar) present only while a commit:<sha> diff is active; generatedFiles lists the repo-relative paths that count as generated, which the client collapses by default GitHub-style (presentation-only: the patch is never filtered). Two-layer detection (packages/shared/generated-files.ts, vendored to Pi): built-in name defaults (DEFAULT_GENERATED_PATTERNS — lockfiles like bun.lock/package-lock.json/Cargo.lock, plus *.min.js/*.min.css/*.map, matched against the path's last segment) apply in every mode with no git needed, and explicit .gitattributes linguist-generated refines them in BOTH directions via one batched git check-attr --stdin at the review cwd (set/true marks any file, unset/false un-marks even a built-in name, unspecified keeps the default). Attribute refinement runs for plain local Git sessions only — PR worktrees, workspace, jj, GitButler, P4, and piped patches get the name-based defaults alone; baseBehindRemote flags that the diff base is behind its remote tip. Workspace mode returns mode: "workspace" with folder-prefixed paths and no gitContext. |
/api/diff/switch |
POST | Switch diff type, base branch, or whitespace mode (body: { diffType, base?, hideWhitespace?, explicitBase? } — diffType includes the commit:<sha> family). explicitBase: true marks a base the user picked from the picker — the server then honors it verbatim and permanently disables the bare-local-name → origin/* canonicalization for the session (echoed bases stay canonicalizable). Response includes semanticDiff?, callFlow?, sections?, commitInfo?, generatedFiles?, baseBehindRemote?, or { superseded: true } when a newer concurrent switch has taken over (client ignores it). |
/api/commits |
GET | One page of the branch's linear --first-parent history for the Commits panel (?limit=&before=) → { commits, hasMore, base }. Rows carry isHead / isPastBase (where the branch meets the active base) and best-effort author avatarUrl. Plain local git sessions only (PR/workspace/GitButler/jj/p4 → 400); computed against the active diff's cwd, so worktree sessions list the worktree's history. |
/api/diff/fresh |
GET | Cheap staleness probe: recomputes the VCS fingerprint captured with the current diff snapshot and returns { fresh, fingerprint?, baseBehindRemote?, agentCwd? }. Accepts ?snapshot=<id> — the client echoes the snapshotId it received with its diff, and a mismatch with the server's current snapshot reports stale PER CLIENT (covers the startup base upgrade and cross-tab switches even when the VCS fingerprint matches). baseBehindRemote is carried on every response (omitting it would flicker the "behind GitHub" banner); agentCwd re-advertises the PR checkout in PR mode. Unfingerprintable modes (e.g. P4) always report fresh to a matching snapshot. Polled by the UI's "Diff out of date · Refresh" notice. |
/api/fetch-base |
POST | Runs git fetch for the base's remote tracking ref, then re-queries the remote tip (fresh ls-remote) so narrow-refspec fetches report honestly. Backs the "Baseline is behind GitHub · Fetch" banner. Git-only, base-relative diff types only. |
/api/semantic-diff |
GET | Runs semantic diff for the active patch and returns parsed sem output or an unavailable/error response (?fileExt= / ?fileExts= optional). |
/api/call-flow |
GET | Runs snapshot-bound CallDiff analysis for the active Git review (?snapshot=<id> required). Returns bounded call trees, raw output, per-file impacts, and explicit skipped-language/file metadata for packs not yet installed. |
/api/call-flow/install |
POST | Starts or joins the selective install ({ languageIds?: [...] }; omission uses the current review's server-authored plan). The UI calls it automatically once per target per review session after Call flow consent; manual calls remain for Retry and install-ahead. The coordinator deduplicates/queues core and pack targets, a stale-tolerant data-dir lease serializes publication across server processes, Node >= 22 preflight runs before download, and the endpoint enforces the same-origin guard. |
/api/call-flow/install-status |
GET | Poll { state, stage?, languageIds?, currentLanguageId?, error?, reason? } across downloading / verifying / installing-deps / building. |
/api/review-analysis |
GET / POST | GET refreshes capability adverts without mutating settings; POST persists independent { semanticDiff, callFlow } booleans and returns adverts. |
/api/file-content |
GET | Returns { oldContent, newContent } for expandable diff context (?path=&oldPath=&base=) |
/api/git-add |
POST | Stage/unstage a file (body: { filePath, undo? }) |
/api/feedback |
POST | Submit review (body: feedback, annotations, agentSwitch) |
/api/image |
GET | Serve image by path query param |
/api/upload |
POST | Upload image, returns { path, originalName } |
/api/draft |
GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
/api/editor-annotations |
GET | List editor annotations (VS Code only) |
/api/editor-annotation |
POST/DELETE | Add or remove an editor annotation (VS Code only) |
/api/ai/capabilities |
GET | Check if AI features are available |
/api/ai/session |
POST | Create or fork an AI session |
/api/ai/query |
POST | Send a message and stream the response (SSE) |
/api/ai/abort |
POST | Abort the current query |
/api/ai/permission |
POST | Respond to a permission request |
/api/ai/sessions |
GET | List active sessions |
/api/external-annotations/stream |
GET | SSE stream for real-time external annotations |
/api/external-annotations |
GET | Snapshot of external annotations (polling fallback, ?since=N for version gating) |
/api/external-annotations |
POST | Add external annotations (single or batch { annotations: [...] }) |
/api/external-annotations |
PATCH | Update fields on a single annotation (?id=) |
/api/external-annotations |
DELETE | Remove by ?id=, ?source=, or clear all |
/api/agents/capabilities |
GET | Check available agent providers (claude, codex, tour, guide, cursor, opencode, pi, copilot) |
/api/agents/review-profiles |
GET | List launchable review profiles (enabled skills + builtin default) |
/api/agents/skills |
GET | List all discovered skills for the add-a-review picker (each flagged enabled) |
/api/agents/review-skills |
POST | Enable a skill as a review (body: { name }); writes review-skills.json |
/api/agents/guide-instructions |
GET/PUT | Read or replace the Guided Review standing instructions, stored in ${dataDir}/guide-instructions.md (trimmed, capped; blank PUT deletes). Guide launches whose body carries no instructions apply this stored text. |
/api/agents/jobs/stream |
GET | SSE stream for real-time agent job status updates |
/api/agents/jobs |
GET | Snapshot of agent jobs (polling fallback, ?since=N for version gating) |
/api/agents/jobs |
POST | Launch an agent job (body: { provider, command, label, engine?, model?, effort?, reasoningEffort?, thinking?, fastMode?, reviewProfileId?, repairOf?, instructions? }; instructions is guide-only reviewer text appended to the organizer prompt, capped at GUIDE_EXTRA_INSTRUCTIONS_MAX_CHARS; when absent, guide launches apply the server-stored standing instructions) |
/api/agents/jobs |
DELETE | Kill all running agent jobs |
/api/agents/jobs/:id |
DELETE | Kill a specific agent job |
/api/pr-diff-scope |
POST | Switch between layer and full-stack diff scope. Response includes semanticDiff?. |
/api/pr-list |
GET | List PRs for the current repo (cached 30s) |
/api/pr-switch |
POST | Switch to a different PR in-place (body: { url }). Response includes semanticDiff?. |
/api/tour/:jobId |
GET | Fetch Code Tour result (greeting, stops, checklist) for a completed tour job |
/api/tour/:jobId/checklist |
PUT | Persist checklist item state for a Code Tour |
/api/guide/:jobId |
GET | Fetch Guided Review result (ordered sections with overviews + file refs) for a completed guide job, or a persisted guide via the saved:{id} pseudo job id |
/api/guide/:jobId/reviewed |
PUT | Persist per-section reviewed state for a guide (live job ids write through to the job's autosaved file; saved:{id} ids persist directly) |
/api/guide/:jobId/export |
GET | Download a guide as one portable HTML file (Content-Disposition: attachment): live job ids resolve from the session's launch-time review (store fallback), saved:{id} from the store. 404 when the guide's diff was not retained (pre-portable envelopes). No size gate. /api/guide/:jobId/export-info returns { bytes, filename, languages } for the same resolution. |
/api/guide/:jobId/share |
POST | Create a share link for a guide on the guide host (resolveGuideShareUrl; guide share hosting contract adr/implementation/guide-share-hosting.md §7). Body { public?: boolean, ttlSeconds?: number }, every field optional and an empty body means the defaults: encrypted upload (the key lives only in the returned URL's #key= fragment; the host stores ciphertext) unless public: true stores the snapshot unencrypted so the hosted page can carry a title and og: tags. Resolves the guide exactly like /export (launch-time review, saved:{id} from the store). 200 { id, url, deleteToken, expiresAt?, bytes, recorded } where recorded says whether the saved envelope now remembers share: { id, url, createdAt, deleteToken, serviceUrl } (false without an envelope, e.g. guide history off, in which case only the one-time token can remove the link). 400 bad body, 403 { error: "sharing disabled" } when PLANNOTATOR_SHARE=disabled (also the same-origin guard as other mutating endpoints), 404 when the diff was not retained, 409 { error, url } when the envelope already records a link (one link per guide: the record is the only place the token lives, so a second upload would orphan the first), 502 { error } on a service failure (GuideShareError). |
/api/guide/:jobId/share |
DELETE | Remove the recorded share link: calls the host the record names (serviceUrl, never merely the currently configured share URL) with the stored delete token and clears the envelope record. 204; 404 when no record; a host 404 (already expired or removed elsewhere) still clears the record; other host errors 502. Same-origin guard. |
/api/guide/:jobId/share-info |
GET | { enabled, serviceUrl, existing?: { url, createdAt } }: whether sharing is on (resolveSharingEnabled), which host links go to, and the link the saved envelope already records, so the UI can hide "Create share link" or offer Remove link. |
/api/guides |
GET | List persisted guides for the current repo: [{ id, label, title, savedAt, progress: { reviewed, total }, moved }] — moved flags a stored head sha that differs from the head currently under review |
/api/guides/:id |
DELETE | Delete a persisted guide. A recorded share link is removed from its host first, best effort: the envelope is the only copy of the delete token, so a host failure is logged with the manual plannotator guide unshare command and the delete still proceeds |
/api/guide/:jobId/output |
GET | Fetch a failed guide job's captured raw output for manual repair (404 if none captured) |
/api/guide/:jobId/submit |
POST | Manually submit corrected guide JSON for a failed job (body: { payload }) |
/api/code-nav/resolve |
POST | Search for symbol definitions and references via ripgrep (body: { symbol, filePath, line, charStart, side, language? }) |
/api/code-nav/hover |
POST | Token hover card resolution: the same body and the same guards as /resolve, over the same ripgrep search, returning one enriched definition (kind, approximate signature, heuristic doc comment, short preview), an optional runner-up candidate, and a five-reference sample. backend: 'unavailable' (rg missing) is a normal 200 and the client renders nothing. source is always 'search' in Tier 0; the nullable symbolKind / signature / doc fields are what a later syntax- or index-backed tier would fill. Client-side the card is governed by two cookie-only settings (packages/ui/config/settings.ts, types in @plannotator/core/token-hover): tokenHoverTrigger (hover default / modifier, meaning the platform's primary modifier held, Cmd on macOS and Ctrl elsewhere via isModKeyHeld / modEventKey in packages/ui/utils/platform.ts / off, which withholds the handler props so the diff views wire no listeners at all) and tokenHoverDelay (150 / 300 default / 700 ms, the dwell before any request exists; 300 matches the VS Code hover delay). tokenHoverTrigger REPLACED the original tokenHoverCards boolean and re-reads its cookie as a migration on every load until the user touches the setting (false becomes off), never writing it back: a migrating read returns a value, so the registry's default-seeding write never fires, and resolution is pure and identical every time. Cmd rather than Alt because Alt is widely bound to push-to-talk dictation (an Alt gate would open cards while the user speaks) and because Cmd+hover is already VS Code's "tell me about this symbol" gesture; the navigable-target underline and the card under one held key is that composite gesture, not a collision, since handleCodeNavRequest dismisses the hover surface on every References invocation. Only the modifier ALONE arms: any other key while it is held (Cmd+C) disarms and closes, so a copy with the pointer parked on a token cannot pop a card. Both key branches read composedPath()[0], not event.target, because a window-level listener sees the shadow HOST and Pierre's edit-session editor is a contenteditable inside that root; typing suppresses ARMING only, never disarming, and keyup carries no typing guard at all. The pn-token-nav affordance is the other half of the held-key gesture: the diff views paint it from the pointer enter event, and the hook additionally reports arm/disarm through onModifierGate(armed, tokenElement) so a key pressed over a parked pointer (and the release after it) paints and unpaints it too. pn-token-hover (underline plus a cursor: pointer !important that beats Pierre's I-beam) is painted by BOTH diff views, gated on a hover handler actually being wired, so off and the portable viewer get no affordance. Click behavior is untouched by the trigger setting, and the #1461 Alt+click References alias is unrelated. The stored value stays modifier: the setting names the shape of the gate, not which key fills it. |
/api/code-nav/file |
GET | Read file from working tree for code-nav preview (?path=) |
| Endpoint | Method | Purpose |
|---|---|---|
/api/plan |
GET | Returns { plan, origin, mode: "annotate", filePath, sourceInfo?, gate, renderAs?, rawHtml?, previousPlan?, versionInfo?, diffCurrent?, diffHtml? }. The last four power the per-file version diff: previousPlan/versionInfo/diffCurrent for the markdown diff, diffHtml (the previous→current page rendered with inline <ins>/<del>) for --render-html files. A local rendered-HTML root is served from its CURRENT bytes on every read (readRootHtml), with the startup snapshot as the fallback when the file is missing, unreadable, or over the 2MB cap; when the served bytes differ from the snapshot, previousPlan/versionInfo still name the saved baseline and diffCurrent/diffHtml are recomputed against the served bytes (htmlDiff is pure; a GET never writes history), so a reload after an agent edit keeps the version diff. /api/doc carries the same recomputed previousPlan/versionInfo/diffHtml when it serves that root document (the in-app Refresh path, rootHtmlVersionDiff), and nothing extra for any other document. Live app sessions return { mode: "annotate-app", appUrl, targetUrl, liveToken, sharingEnabled: false, ... } instead: no rawHtml, no version fields (see "Live app annotation"). |
/api/plan/version |
GET | Fetch a specific stored version of the annotated file (?v=N) |
/api/plan/versions |
GET | List all stored versions of the annotated file |
/api/feedback |
POST | Submit annotations (body: feedback, annotations) |
/api/approve |
POST | Approve without feedback (review-gate UX, --gate) |
/api/exit |
POST | Close session without feedback |
/api/save-notes |
POST | Save to external note apps (Obsidian, Bear, Octarine) |
/api/html-assets/<token>/<path> |
GET | Serve relative support assets for raw HTML annotation sessions |
/api/share-html |
GET | Lazily prepare portable raw HTML for sharing (?path=<html-file> optional) |
/api/image |
GET | Serve image by path query param |
/api/upload |
POST | Upload image, returns { path, originalName } |
/api/doc |
GET | Serve linked .md/.mdx/.html file or code file (?path=<path>&base=<dir>) |
/api/doc/exists |
POST | Batch-validate code-file paths (body: { paths: string[], base?: string }) |
/api/skills |
GET | List global agent skills for comment skill references ({ skills: [{ name, root, description?, humanOnly, dir }] }) |
/api/skills/content |
GET | SKILL.md contents of one discovered skill for human-only feedback injection (?name=<skill>) returns { skill: { name, dir, path, content, truncated, humanOnly } }; the name is matched against discovery only, never used as a path |
/api/draft |
GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
/api/annotate/client-lease |
GET (SSE) | Client lease for local direct structured gates: each open stream is one connected review surface. 404 when the capability is not advertised. |
/api/agent-terminal/pty/<token> |
WebSocket | Tokenized PTY bridge for the optional annotate-mode agent terminal |
/api/ai/capabilities |
GET | Check if AI features are available |
/api/ai/session |
POST | Create or fork an AI session |
/api/ai/query |
POST | Send a message and stream the response (SSE) |
/api/ai/abort |
POST | Abort the current query |
/api/ai/permission |
POST | Respond to a permission request |
/api/ai/sessions |
GET | List active sessions |
/api/external-annotations/stream |
GET | SSE stream for real-time external annotations |
/api/external-annotations |
GET | Snapshot of external annotations (polling fallback, ?since=N for version gating) |
/api/external-annotations |
POST | Add external annotations (single or batch { annotations: [...] }) |
/api/external-annotations |
PATCH | Update fields on a single annotation (?id=) |
/api/external-annotations |
DELETE | Remove by ?id=, ?source=, or clear all |
All servers use random ports locally or fixed port (19432) in remote mode.
| Endpoint | Method | Purpose |
|---|---|---|
/api/paste |
POST | Store compressed plan data, returns { id } |
/api/paste/:id |
GET | Retrieve stored compressed data |
Runs as a separate service on port 19433 (self-hosted) or as a Cloudflare Worker (hosted).
Every plan is automatically saved to ~/.plannotator/history/{project}/{slug}/ on arrival, before the user sees the UI. Versions are numbered sequentially (001.md, 002.md, etc.). The slug is derived from the plan's first # Heading + today's date via generateSlug(), scoped by project name (git repo or cwd). Same heading on the same day = same slug = same plan being iterated on. Identical resubmissions are deduplicated (no new file if content matches the latest version).
This powers the version history API (/api/plan/version, /api/plan/versions) and the plan diff system.
Annotate mode also saves history on open, so the same version diff works when annotating a standalone .md/.txt/.html file (or any other supported plain-text file, e.g. .yaml/.json/.toml). It keys the slug by file path — annotate-{sanitized-basename}-{hash8} — rather than heading + date, so re-opening the same file groups its versions even as its content (and headings) change. Note this writes a copy of each annotated file's content under ~/.plannotator/history/ (or PLANNOTATOR_DATA_DIR); disable via PLANNOTATOR_ANNOTATE_HISTORY=0 or { "annotateHistory": false } in ~/.plannotator/config.json to keep annotate sessions stateless (the version diff is then unavailable, and the durable submitted-feedback records described in the env-var table are also skipped). Single-local-file annotate sessions additionally write each submitted decision to history/{project}/{slug}/submissions/{timestamp}.md BEFORE deleting the annotation draft, so feedback survives an agent-side timeout (#678); a failed record write keeps the draft as the recovery copy. For --render-html files the diff is rendered as the real page with inline <ins>/<del> highlights via htmlDiff() (packages/shared/html-diff.ts).
History saves independently of the planSave user setting (which controls decision snapshots in ~/.plannotator/plans/). Storage functions live in packages/shared/storage.ts (runtime-agnostic, re-exported by packages/server/storage.ts). Pi copies the shared files at build time. Slug format: {sanitized-heading}-YYYY-MM-DD (heading first for readability).
Every review submitted through a Plannotator decision is durably archived at
decision-settlement time, so a submission survives an agent-side timeout, a
closed terminal, or a planSave setting the user turned off. One deliberate
exception: a review posted straight to GitHub or GitLab with POST /api/pr-action is delivered to the platform and is not archived locally
yet (a named follow-up). Layout, per project (same {project} key as
history/):
${PLANNOTATOR_DATA_DIR}/feedback/{project}/
index.jsonl # append-only, authoritative, schema v1
records/2026-08-31T14-22-07-511Z-review-feedback.md # human-readable sidecar
The JSONL line is self-contained (v, ts, client, clientVersion?,
project, origin, surface, decision, target, feedback,
annotations, counts, recordFile) so an analyzer never has to open a
sidecar; the markdown sidecar exists because the rest of the data dir is
greppable markdown and is written only for records that carry content. Bare
approvals, LGTMs, and dismissals are decision-only lines with no sidecar.
This index is shared, not Plannotator-private. Several tools that share the
data dir append to the SAME feedback/{project}/index.jsonl, separated by the
client field on each line rather than by separate files. Known writers today:
plannotator (this repo) and plannotator-tui, the Rust terminal client;
herdr-annotate is reserved for a possible future Lite writer. Treat client
as an open set, never an enum to validate against. Practical consequences: the
line shape is a cross-tool contract, so fields are added, never repurposed;
other clients suffix their id onto their sidecar filenames
({stamp}-{surface}-{decision}-plannotator-tui.md), so the records/
directory holds more filename shapes than this repo writes and recordFile is
the only valid handle to a sidecar (nothing may parse the name); and unknown
fields must be ignored rather than rejected.
Two optional fields are declared in v1 but not populated here, so their names
are reserved across every client: target.agent ({ host?, session?, transcript? }) is the provenance for surfaces whose subject is an agent
session rather than a file or a diff, such as annotate-last, and
clientVersion is the writing client's own version where it knows it
(packages/shared has no runtime-agnostic version constant, so this repo
leaves it unset rather than reading package.json from a vendored module).
Everything is written by one shared module, packages/shared/feedback-archive.ts
(vendored to Pi as apps/pi-extension/generated/feedback-archive.ts), which
resolves the data dir per call and never throws: a failed archive write is
logged, degrades silently for the user, and keeps the annotation draft as the
recovery copy. The append happens BEFORE deleteDraft, generalizing the #678
ordering to every surface. Call sites: packages/server/index.ts
(/api/approve, /api/deny), packages/server/review.ts (/api/feedback,
/api/exit), packages/server/annotate.ts (persistSubmittedDecision,
/api/exit), and the three Pi mirrors in apps/pi-extension/server/.
Invariants worth keeping: records never contain patch bytes or a second copy of
the plan (identity and a version-file reference instead); repeat decisions
append rather than overwrite (unlike the legacy plans/ snapshot, which is
keyed by slug and status and is left alone); annotation source / author
provenance is preserved so external, agent, and WebMCP findings stay
distinguishable from the human's own comments; and feedback is listed in
PURGE_OWNED_TOP_LEVEL (packages/server/uninstall.ts) so uninstall purge
removes it. Controls and the privacy/retention note are in the
PLANNOTATOR_FEEDBACK_HISTORY row of the environment table above. The read
path in v1 is the files on disk (jq over index.jsonl, grep over
records/); there is no CLI reader or UI surface yet.
Details that surprise people:
- Index durability is a practical guarantee, not a formal one. One record
is always exactly one line, and the whole line is handed to a single
append-mode write. That write is not one syscall (
appendFileSyncloops internally until its buffer is drained); what holds in practice is that anO_APPENDwrite of a line-sized buffer completes without interleaving on a local filesystem. NFS and SMB do not promise even that, and a genuine interleave damages both records that raced, not just the later one. The backstop is the reader: unparsable lines are skipped, so everything else in the file still reads. With several clients writing one index, this caveat is worth knowing rather than assuming away. - Readers gate on structure, not version.
parseFeedbackIndexkeeps any line that parses and carries a numericv, so a newer writer's lines are still returned; an analyzer that depends on v1 semantics should filterv <= 1itself. Since fields are only added and never repurposed, av2would signal a real shape change rather than the arrival of new keys. - Folder-session records name the folder, not the open document. A folder
annotate session submits one body of feedback for the session, so
target.filePathis the session's folder; the per-document path is not part of the record. - URL-session records store the full URL, query string included, because that is the page that was reviewed. A URL carrying a token in its query is therefore written to disk; the opt-out is the control for that.
target.review.cwdis provenance, not a durable handle. A PR review started with--localrecords a per-PR pool checkout that is cleaned up when the session ends;target.review.prplusgitRefare the identity that survives.- Project bucketing prefers the caller's
projectoption (theprojectfield onReviewServerOptions, mirroring the annotate server), falling back to deriving a name from the review cwd. The fallback is wrong in PR mode, where there is nogitContextand--localpointsagentCwdatpool/pr-<n>, so every CLI entry point passesdetectProjectName(). - The test suite turns the archive off through the
tests/setup/feedback-archive-off.tspreload inbunfig.toml, because most server tests boot a real server without redirectingPLANNOTATOR_DATA_DIRand would otherwise write into the contributor's own data dir. Tests that need the archive opt back in inside their own test bodies.
When a user denies a plan and Claude resubmits, the UI shows what changed between versions. A +N/-M badge appears below the document card; clicking it toggles between normal view and diff view.
Diff engine (packages/ui/utils/planDiffEngine.ts): Uses the diff npm package (diffLines()) to compute line-level diffs. Groups consecutive remove+add into "modified" blocks. Returns PlanDiffBlock[] and PlanDiffStats.
Two view modes (toggle via PlanDiffModeSwitcher):
- Rendered (
PlanCleanDiffView): Color-coded left borders — green (added), red (removed/strikethrough), yellow (modified) - Raw (
PlanRawDiffView): Monospace+/-lines, git-style
State (packages/ui/hooks/usePlanDiff.ts): Manages base version selection, diff computation, and version fetching. The server sends previousPlan with the initial /api/plan response; the hook auto-diffs against it. Users can select any prior version from the sidebar Version Browser.
Diff annotations: The clean diff view supports block-level annotation — hover over added/removed/modified sections to annotate entire blocks. Annotations carry a diffContext field (added/removed/modified). Exported feedback includes [In diff content] labels.
Annotation hook (packages/ui/hooks/useAnnotationHighlighter.ts): Annotation infrastructure used by Viewer.tsx. Manages web-highlighter lifecycle, toolbar/popover state, annotation creation, text-based restoration, and scroll-to-selected. The diff view uses its own block-level hover system instead.
Sidebar (packages/ui/hooks/useSidebar.ts): Shared left sidebar with three tabs — Table of Contents, Version Browser, and Archive. The "Auto-open Sidebar" setting controls whether it opens on load (TOC tab only). In archive mode, the sidebar opens to the Archive tab automatically.
Decision record: adr/decisions/007-portable-guided-reviews-20260815.md; spec: adr/implementation/portable-guided-reviews.md.
A guide exports as ONE small HTML file (size ≈ the diff, never the app) that pins a specific viewer build on guides.show (viewer.<hash>.js/.css + SRI). Format lives in @plannotator/core/guide-format (versioned strict snapshot, createGuideHtml, fixtures + a compatibility test that must keep parsing every shipped fixture); the pinned build is the generated @plannotator/core/guide-viewer-manifest (regenerate with bun run --cwd apps/guides-show build:viewer && bun run --cwd apps/guides-show sync:manifest; CI fails if stale).
Invariants: the diff a guide describes is captured when the guide job LAUNCHES (buildCommand's launchReview, carried server-side like changedFilesSnapshot — never on the SSE-broadcast AgentJobInfo) and stored beside the saved guide as {id}.patch (patch written before the envelope that references it; deleted together). Exports never read the on-screen diff. /v1/ on guides.show is add-only (content-hashed, never overwritten or deleted) so exported files keep opening; the viewer is the same guide chain (@plannotator/guide-viewer) over AllFilesCodeView in readOnly mode — no drift by construction. Read-only hosts stub the annotation composer/popovers at build time (apps/guides-show/build/read-only-stubs-plugin.ts); do not add app-only surfaces to the guide chain without going through the GuideHost contract.
Viewer runtime invariants (apps/guides-show/viewer/): the highlight worker is fetched from guides.show and constructed locally, and HOW is decided by a live probe (portablePool.tsx: blob classic → blob module → data classic → data module; a one-line worker must answer), never by location.protocol — Chrome refuses blob module workers from file:// asynchronously (its console says "cross-origin redirects of the top-level worker script") but accepts classic ones, and headless checks with --allow-file-access-from-files hide that, so test file:// exports WITHOUT that flag. The worker bundle must stay import-free (check-budgets asserts it) so it can run as a classic worker. A pool that is not initialized within 4 s is dropped (PoolWatchdog) and the guide re-renders on the main thread; Pierre renders nothing while waiting on a pool whose workers died, so this is what keeps diffs from going blank. The exported document's plain-text fallback article is opacity 0 for the first 2.5 s (CSS-only reveal), so a cold viewer download shows the theme ground, then the guide skeleton, then the guide — never a flash of the fallback prose. The portable page must not set overflow on body/html: GuideViewportManager.findScrollRoot walks up for an overflow-y: auto|scroll ancestor to use as its IntersectionObserver root + scroll-event source, and an overflow on body propagates to the viewport without body itself scrolling — the manager would observe a never-scrolling element, mark everything "near", rank by distance from the middle of the whole document, and CodeViews would only mount on hover (requestMount). The manager now treats body/html as the window regardless, but keep the CSS clean too.
Three producers share the one pure export (createGuideHtml): the in-app Download portable guide button, plannotator guide export --id <saved>, and the agent path plannotator guide export --guide guide.json --patch guide.patch (packages/server/guide/guide-cli.ts, buildAuthoredGuideSnapshot) used by the standalone plannotator-guide agent skill (its own repo, plannotator/guides — deliberately NOT part of this repo or its installers). The authored form takes the same { title, intent, sections, unplacedFiles? } shape the in-app generator emits plus optional review { gitRef, base }, source, generator; it is STRICT where the in-app validator is lenient (a file not in the patch, or placed twice, is an error listing the patch's files — exit 1 — rather than a silently dropped chapter), fills source from git in cwd (origin → owner/repo, branch, head sha) unless the guide supplies one, and round-trips the built snapshot through the strict format parser so a bad source/generator fails at export time. --patch - reads stdin. The skill's worked example is the AUTHORED fixture in guide-cli.test.ts — keep them in step.
Contract: adr/implementation/guide-share-hosting.md (names, routes, shapes and error codes there are final; change them there first). A guide can also be shared as a link on a guide host instead of a downloaded file: the review header's Share menu offers Download portable guide (unchanged) and Create share link; the CLI has plannotator guide share --id <saved> | --guide g.json --patch p.patch | --snapshot s.json [--public] [--ttl 7d|24h|30m|3600] [--json] (stdout: the URL, or { id, url, deleteToken, expiresAt? } with --json; stderr: the size and the exact Delete with: plannotator guide unshare <id> --token <t> line) and plannotator guide unshare <id> --token <t> (removal goes to the host a saved guide's record names, else PLANNOTATOR_GUIDE_SHARE_URL / guideShareUrl). --id refuses with exit 1 while the saved guide already records a link. Exit codes match export (0 / 1 not found, invalid, service error / 2 usage). The upload itself is shareGuide / unshareGuide in packages/server/guide/guide-share.ts (vendored to Pi as apps/pi-extension/generated/guide-share.ts; both review servers expose the same /share, /share-info and DELETE /share endpoints). No upload ever happens without the Create click or the CLI command.
Two modes, encrypted by default. Encrypted stores encrypt(await compress(snapshot)) (the same @plannotator/core/crypto + @plannotator/core/compress pair plan share links use); the key is generated by the uploader and lives ONLY in the URL fragment (#key=<key>, GUIDE_SHARE_KEY_PARAM), which browsers never send to the server, so the host holds ciphertext it cannot read and the page it serves is a shell that fetches /api/g/<id> and decrypts in the browser. Plain (--public / "Allow link previews", { public: true }) stores the snapshot JSON (validated server-side with parseGuideSnapshotJson) so the hosted page can carry <title>, og:* and the guide text; use it when a chat app should unfurl the link. Both modes cap the stored body at MAX_SHARED_GUIDE_BYTES (25 MiB; 413), have no expiry unless ttlSeconds (CLI --ttl) sets one, and return a one-time deleteToken (16 random bytes base64url, stored as a hex SHA-256) that is the only way to remove the guide besides the saved-envelope record.
Where it lives: apps/guides-show/share/ (core/handler.ts is the pure request handler, core/storage.ts the GuideStore interface, stores/{r2,memory}.ts), served by the Cloudflare Worker (apps/guides-show/worker/index.ts, R2 bucket binding GUIDES = guides-show-guides). "Self-hostable" means deploying that Worker yourself and pointing PLANNOTATOR_GUIDE_SHARE_URL at it. Routes: POST /api/g (201 { id, url, deleteToken, expiresAt? }, 400/413/429), GET /g/<id> (HTML, Cache-Control: public, max-age=300, styled 404 page), GET /api/g/<id> (the stored body, CORS *), DELETE /api/g/<id> with Authorization: Bearer <deleteToken> (204/401/404), OPTIONS /api/g*. Upload guards: the size cap plus a per-IP rate limit on creation only (Cloudflare's [[ratelimits]] binding, 20 creates per minute per CF-Connecting-IP, 429 + Retry-After), optional in the Worker's Env and fail-open whenever it cannot resolve, so local runs and self-hosts without the binding are unlimited. Guides are kept indefinitely by decision (no host-imposed TTL, nothing prunes the bucket); only an upload's own ttlSeconds expires one, enforced on read.
Invariants: hosted pages of either mode carry <meta name=GUIDE_HOSTED_META_NAME> (plus <link rel=canonical>, robots noindex, og:*), which is how the viewer knows to add its client-side Download button (builds the portable file from the DOM-reconstructed viewer pin, never re-fetches, never includes the hosted meta); the encrypted shell (createGuideShellHtml) additionally carries <meta name=GUIDE_PAYLOAD_META_NAME> naming /api/g/<id> and NO title, intent or payload in the HTML; a plain page embeds the snapshot like an export and has no payload meta. The key must never appear anywhere but the fragment (not in a query string, not in a request, not in a stored record other than the uploader's own envelope). The uploader sends its viewer pin ({ js, css, jsIntegrity, cssIntegrity, langs? } from GUIDE_VIEWER_MANIFEST) and the host renders with it on its OWN /v1/ (baseUrl is never sent); without a pin the host uses its bundled manifest. Error pages name the serving host, never guides.show, and store failures answer 500 { error: "internal error" } with the real message logged host-side only. One link per guide in Plannotator (409 on a second upload); removal goes to the host the record names. Deploying the Worker with the guides-show-guides bucket is a separate, explicit step: no production deploy without a bucket, and never as part of a build.
Location: packages/ui/types.ts
enum AnnotationType {
DELETION = "DELETION",
COMMENT = "COMMENT",
GLOBAL_COMMENT = "GLOBAL_COMMENT",
}
interface ImageAttachment {
path: string; // temp file path
name: string; // human-readable label (e.g., "login-mockup")
}
interface Annotation {
id: string;
blockId: string;
startOffset: number;
endOffset: number;
type: AnnotationType;
text?: string; // For comment
originalText: string; // The selected text
createdA: number; // Timestamp
author?: string; // Tater identity
images?: ImageAttachment[]; // Attached images with names
source?: string; // External tool identifier (e.g., "eslint") — set when annotation comes from external API
diffContext?: 'added' | 'removed' | 'modified'; // Set when annotation created in plan diff view
htmlAnchor?: HtmlElementAnchor; // Raw-HTML pinpoint: serialized element anchor for reliable restoration
htmlAdditionalTargets?: HtmlAnnotationTarget[]; // Raw-HTML shift-click multi-select: extra elements this one comment covers
startMeta?: { parentTagName; parentIndex; textOffset };
endMeta?: { parentTagName; parentIndex; textOffset };
}
interface HtmlElementAnchor {
selector: string; // verified-unique CSS selector built in the viewer bridge
tagName: string;
text?: string; // normalized text snapshot; weak selectors fail closed against it
point?: { x: number; y: number }; // normalized (0..1) selected point inside the element's rect, used by placed markers to reproject against the element's current geometry
}
interface HtmlAnnotationTarget {
label?: string; // semantic label from the pinpoint hover cascade (e.g. "Button")
text: string; // capped element text, or an element description when text-less
anchor?: HtmlElementAnchor; // absent when anchoring failed closed
}
interface Block {
id: string;
type: "paragraph" | "heading" | "blockquote" | "list-item" | "code" | "hr" | "table" | "html" | "directive";
content: string;
level?: number; // For headings (1-6)
language?: string; // For code blocks
alertKind?: "note" | "tip" | "warning" | "caution" | "important"; // GitHub alerts (blockquote subtype)
order: number;
startLine: number;
}Location: packages/ui/utils/parser.ts
parseMarkdownToBlocks(markdown) splits markdown into Block objects. Handles:
- Headings (
#,##, etc.) with slug-derived anchor ids - Code blocks (``` with language extraction)
- List items (
-,*,1.) - Blockquotes (
>) — including GitHub alerts (> [!NOTE|TIP|WARNING|CAUTION|IMPORTANT]) which setalertKind - Horizontal rules (
---) - Tables (pipe-delimited) — rendered via
TableBlockwith aTableToolbar(copy as markdown/CSV) andTablePopoutoverlay - Raw HTML blocks (
<details>,<summary>, etc.) — rendered viaHtmlBlockthroughmarked+ DOMPurify - Directive containers (
:::kind ... :::) — rendered viaCallout - Paragraphs (default) with inline extras: bare URL autolinks,
@mentions/#issue-refs, emoji shortcodes, smart punctuation
exportAnnotations(blocks, annotations, globalAttachments) generates human-readable feedback for Claude. Images are referenced by name: [image-name] /tmp/path.... Annotations with diffContext include [In diff content] labels.
Selection mode: User selects text → toolbar appears → choose annotation type Redline mode: User selects text → auto-creates DELETION annotation
Text highlighting uses web-highlighter library. Code blocks use manual <mark> wrapping (web-highlighter can't select inside <pre>).
Annotation undo/redo: each plan, annotate, or code-review app keeps one bounded 50-action stack for the active surface. Mod+Z undoes; Mod+Shift+Z and Mod+Y redo. Only local human annotation mutations are recorded, and a new mutation discards the redo branch. Native inputs, textareas, contenteditable regions, CodeMirror, dialogs/popovers, the Image Annotator, and an open Review Edit Mode session keep ownership of their own history shortcuts. External/agent writes do not enter annotation history; direct source changes, draft/share restore, refresh or diff replacement, identity changes, navigation to another document/message/review context, submission, and other baseline replacements clear it. The Image Annotator keeps a separate stroke undo/redo stack while its overlay is open.
Raw-HTML annotate: the sandboxed viewer never mutates the visited page's DOM. Committed annotations render as numbered placed comment markers plus overlay-projected highlight rectangles inside a shadow-rooted fixed overlay host: the durable anchor data (element selector, text snapshot, normalized selected point) is persisted, and the markers/highlights are disposable projections re-resolved from it on every reconcile. Shift-click multi-select joins additional elements to one comment (htmlAdditionalTargets).
Element context (agent-facing). A pinpoint also captures a bounded description of the element at click time, elementContext on the annotation (HtmlElementContext in packages/ui/types.ts; extra targets carry a smaller one as context), built by buildElementContext in the bridge and re-validated by parseHtmlElementContext at the parent trust boundary (useHtmlAnnotation.ts). It is purely descriptive, never read by restore. Fields: tag, id, author classes, an ancestor path, explicit-or-implicit role, accessible name, an ALLOWLISTED attribute set (href/src scrubbed of query and fragment, data: truncated to its media type), rendered text (300), a collapsed HTML outline (600 chars; two child levels, then one, then a per-tag count, whichever first fits), child count, viewport rect, nearest landmark and heading, a component hint from data-component/data-testid ancestry (deliberately no React fiber reads), and in live-app sessions the page route and title. Never captured: form values, on* handlers, style, script/style/template contents, full innerHTML. Hard cap 2 KiB serialized per primary (1 KiB per extra target), shedding outline → text → attrs → classes → path → heading → landmark → component. The export (elementContextExportBlock in packages/ui/utils/parser.ts) prints a 4-backtick html fence of the outline plus selector / path / role · name · component / attrs / text / box / near lines under the comment; a text-less pinpoint's placeholder quote ([element: Navigation]) becomes Feedback on the <nav> element — "Primary" in the heading, while a pinpoint with real quoted text keeps its quote line and gains the block. Annotations without the field export byte-identically. The grouped live-app export omits the route line (the ## Page: heading carries it); exportAnnotationEntry (one annotation, no number) includes it; it is a pure helper for hosts and tests, and the annotation panel's card chrome is unchanged. Share links drop elementContext exactly like anchors. The feedback archive records identity only (elementTag, elementSelector, elementPath, elementRole, elementName, pageUrl), never the outline. No BRIDGE_PROTOCOL_VERSION bump: the field is additive in both directions.
HTML and live-app interaction model: raw-HTML sessions and live app sessions (mode: "annotate-app") share one contract. Both open with pinpoint armed (htmlAnnotateArmed defaults to true, packages/editor/App.tsx:493; live sessions open armed like every other HTML surface, App.tsx:2871). Esc walks a ladder instead of exiting outright: a pending draft closes first, then the pinpoint hover outline clears, and only then does Esc drop the surface to Interact, where the bridge goes passive so clicks, forms, text selection, and SPA navigation reach the page natively (packages/ui/components/html-viewer/bridge-script.ts:3066-3080; committed markers stay visible and a marker click still opens its comment, and in Interact an open drag-comment draft still closes before Esc is handed back to the page). Vim owns its own ladder and is skipped here. The header pen button re-arms (packages/editor/components/AppHeader.tsx:386-404, aria-pressed), as does Mod+Shift+A (packages/ui/shortcuts/plan-review/htmlAnnotate.shortcuts.ts:13-21), which the bridge mirrors inside the iframe on the capture phase and forwards to the parent (bridge-script.ts:3085-3091), so the chord works whichever document owns focus. Text drag-selection commenting is always live, on both surfaces and in both states, ungated from the armed flag and from the input method (bridge-script.ts:384-386, :1420-1436): while armed, a click pins an element and a drag selects text at the same time, and the one-shot dragEndedClick guard stops a completed drag's trailing click from re-pinning (bridge-script.ts:1381-1390).
These surfaces are comment-only. redline (auto-DELETION) and quickLabel are clamped at the trust boundary, which is the parent's postMessage ingest rather than the server, covering the host mode and a page-supplied modeOverride alike so a hostile page cannot force a DELETION (packages/ui/components/html-viewer/useHtmlAnnotation.ts:535-547). Only CREATION is restricted: persisted DELETION annotations still restore and still render their deletion styling (useHtmlAnnotation.ts:903). The selection toolbar drops Delete behind a commentOnly seam and is passed no quick-label handler (packages/ui/components/AnnotationToolbar.tsx:217-224, HtmlViewer.tsx:880-883); markdown surfaces keep the full toolbar. HTML surfaces also pin the viewer input method to pinpoint (App.tsx:5480), so there is no floating input-method toolstrip on them at all (toolstripVisible is gated on !isHtmlSurface, App.tsx:2788-2793) and the Shift+1-4 annotation-mode shortcuts cannot fire there. A header eye button immediately left of the pen toggles Show/Hide tools: hiding REMOVES all floating chrome over the page from the DOM (the sidebar tongue tabs and the comment/attachments cluster) rather than merely hiding it (AppHeader.tsx:364-385, App.tsx:5193, HtmlViewer.tsx:810). The toggle lives in the header, so a hidden state always has a way back, which is what makes honoring the persisted toolsHidden cookie safe (packages/ui/utils/htmlChrome.ts:17-21).
HTML Refresh (#1232). A local rendered-HTML session can re-read its file from disk without reloading the tab, for the loop where an agent edits the page while the reviewer keeps annotating. The header Refresh button (left of the eye, data-html-refresh, titled "Refresh HTML from disk") fetches the active document through /api/doc, hands the bytes to the app, and remounts the viewer under a bumped reloadGeneration key (packages/editor/App.tsx, viewer key). The engine is the published useHtmlRefresh (packages/ui/hooks/useHtmlRefresh.ts: superseded and cross-document fetches are dropped, one restore acknowledgement per generation) and Plannotator's binding over fetchHtmlDocumentSnapshot is packages/editor/hooks/useHtmlRefresh.ts (toasts for refreshed, missing, and unavailable). Committed annotations survive on their durable anchors: the remounted viewer re-resolves every element selector and text snapshot against the new page, and the ones it cannot re-anchor are reported once (onUnanchoredChange to reportAnnotationRestore), toasted, and marked with an Unanchored chip in the annotations panel (htmlUnanchoredIds in App, cleared when the document changes); their comments stay in the panel and still export. A refresh keeps the version diff: for the root document /api/doc carries previousPlan/versionInfo/diffHtml recomputed against the bytes just read (see the annotate /api/plan row), applyRefreshedHtml sets them and resets isPlanDiffActive, so the view returns to normal mode with "Show changes" still available; a tab reload converges on the same state because /api/plan serves the current bytes and recomputes the same diff. /api/share-html shares the current bytes too. Only local files refresh: canRefresh is false for http(s) paths and live-app sessions, and the control is absent on read-only (archive) documents. The compact touch shell renders no header controls (HtmlSurfaceControls returns null when compact), so its Options menu offers "Refresh from disk" beside the Show/Hide tools and Interact/Annotate actions (compactDocumentActions in App, disabled while a refresh is in flight); a host that passes canRefresh and onRefresh to HtmlSurfaceControls gets the Refresh button with or without the eye.
Known limitations: printing a raw-HTML annotate session prints highlight stripes from a best-effort absolute-coordinate layer and is degraded inside the iframe (pre-existing); element-only targets (SVG anchors, multi-select additional element targets) have no print representation. Annotation undo/redo listeners live in the parent document, so they are unavailable while focus is inside a raw-HTML or live-app iframe; the framed page keeps its own Mod+Z. Focus the editor chrome or annotation panel first. Forwarding this safely would require synchronizing parent history availability without stealing native or live-app undo; only the reserved Mod+Shift+A annotate toggle is currently forwarded.
The design document lives outside the tree (it is not checked in); the user-facing reference is apps/marketing/src/content/docs/reference/webmcp-tools.md. Phase 1 makes plan review and every annotate surface a WebMCP provider: a browser-integrated agent (Chrome/Edge origin trial, chrome://flags/#enable-webmcp-testing or --enable-features=WebMCPTesting locally; agent-embedded browsers unflagged) calls in-page tools instead of scraping the DOM. Code review (phase 2) and consuming the annotated app's own tools (phase 3) are not built.
Shape (mirrors the shortcut system). Engine in packages/ui/webmcp/: modelContext.ts is the ONLY file that spells document.modelContext, registerTool, getTools, executeTool, toolchange and the annotation hints (local structural types, no webmcp-types dependency, no declare global); toolset.ts (tool specs, the { ok, data, nudges, error? } envelope, runTool, a per-document registry with reconcile-by-name and one AbortController per tool, since unregistration is only by abort); changes.ts (per-annotation seq, tombstones, per-tab watermark with since override, claimOwn so the agent's own writes are never "new" to it); nudges.ts (the twelve codes: annotations_new, annotations_removed, replies_new, composer_open, source_stale, document_edited, comment_only_surface, page_changed, other_document_active, pending_unsent, session_decided, truncated; messages are static strings, document and comment text never enter a message); useToolset.ts (React hook; handlers read through refs so a re-render never touches registerTool); policy.ts (the webmcp seam on configurePlannotatorUI, { enabled, namePrefix }, default enabled with prefix plannotator.). Catalog in packages/editor/webmcp/: documentTools.ts builds the tools over a narrow DocumentToolAdapter (never imports App), documentText.ts holds the pure outline / windowing / quote-resolution helpers, useDocumentWebMcp.ts builds the adapter over App state through one ref.
What is exposed. read_document (the whole situation in one zero-argument call: session, text windowed at 16k chars cut at a block boundary with textRange.nextOffset, outline with per-section counts, every annotation with quote, context, isNew and thread ids, otherDocuments, nudges; readOnlyHint + untrustedContentHint), add_comments (1..20 items; anchoring cascade inReplyTo inherits the parent anchor, then quote resolved by text search with section to disambiguate, ambiguous returns candidate contexts, then section alone anchors on the heading, else a document-level note; requestId idempotency), update_comment, remove_comments (destructiveHint, ignored by today's dictionary on purpose), reveal, nudge_user (one transient banner, 280 chars, not persisted, not exported, dismissible), and list_documents in folder sessions only. Write tools are absent on archive and after the human's decision. Every tool-created comment is stamped author and source: "browser-agent" and renders exactly like an external-annotation comment.
Rules. (1) Decisions are human: there is no tool that approves, denies, submits feedback, closes the session, stages files or marks viewed, and there is no confirmation seam; the catalog test pins the names. (2) Ownership: the agent may update or remove only comments the tracker claimOwned in THIS page load (AnnotationChangeTracker.isOwn), never merely comments whose source reads browser-agent: POST /api/external-annotations accepts any source, so a stamp alone would let another tool's findings become agent-editable. Consequence: after a reload, the agent's earlier comments (restored from the draft) are read-only to it, like the human's; it replies instead. A requestId replayed after the human removed the comment it created answers conflict and never re-creates it. (3) Zero footprint without WebMCP: resolveModelContext runs once per mount; when document.modelContext is absent nothing is built, registered, rendered, fetched, scheduled or written. The opt-out is deliberately NOT a settings-registry entry: configStore.ensureLoaded seeds every registry default into a cookie on first settings access, on every surface including the guides.show viewer, so the preference lives in packages/ui/webmcp/preference.ts, is read lazily, and writes plannotator-webmcp-tools=false only when the user opts out (turning the tools back on removes the cookie). The phase-1 PR's A/B proof compared DOM shell, requests, console, timer counts and the cookie jar between the main and branch builds in a browser without the API and found no difference, and the portable viewer bundle is byte-identical to main. (4) Indicator policy: nothing visible appears merely because the API exists; only after the first successful tool call does the header show the small "Agent" affordance (data-webmcp-indicator). (5) Iframes: Plannotator never registers tools inside the untrusted frames, the srcdoc viewer keeps sandbox="allow-scripts" with no allow attribute and the live-app iframe gets no allow="tools", so a framed page cannot register or impersonate tools (packages/ui/webmcp/iframeIsolation.test.ts pins this at source level; in Chrome both frames answer NotAllowedError for getTools and registerTool). (6) Opt-out: Settings > General > "Agent tools" (row shown only when the API exists) aborts every registration when off. (7) No server changes: the Pi and OpenCode builds get the feature through the built HTML.
Folder sessions. list_documents walks the file browser's loaded directories (fileBrowser.dirs, absolute path = ${dir.path}/${node.path}, vault dirs excluded) so every document is listed, not only the ones already visited. The agent learns that the human navigated from document.path on its next response, not from a sibling flag: siblings exclude the open path, and after a sidebar click fileBrowser.activeFile equals linkedDoc.filepath, so a sibling with open: true (and with it openedSinceLastRead and the "opened" branch of other_document_active) exists only transiently, during the load window between the click and the document commit. reveal { path } answers not_found at once for a path that is neither in the folder tree nor in the linked-doc cache; otherwise it navigates (folder sessions through App's file-browser selection handler, so the active file, the doc URL and the linked document stay in step) and WAITS for the commit that makes that path the open document (an effect settles the waiter, and the linked-doc error state settles it early when the load fails, so a bad path never runs out the 5s timeout) before looking the comment or section up; reading state right after the await would see the pre-navigation document. After a tool mutation the adapter overlays the pending write on the last committed annotation list (applyOverlay) so the response's nudges and the new comment's seq reflect the mutation even though setAnnotations has not committed yet; the agent's own removals are claimed (claimRemoved) so they are never reported back to it as annotations_removed. Still not reachable in phase 1: composerOpen for a sibling (the composer is detected from the DOM of the open document only, so other_document_active never fires for a composer in another document), and writes to a sibling that is not open (see below).
inReplyTo. One additive field on Annotation: a reply inherits its parent's anchor, renders indented under it in the annotations panel (threadReplies in AnnotationPanel.tsx), and exports nested under the parent's entry (**Replies:** block in exportAnnotations); an annotation without it renders and exports byte-identically to before. The threading rule is shared (resolveReplyParents in packages/core/annotation-threads.ts): an annotation is a reply only when its target is a different annotation in the same list and the parent chain never returns to it; orphans, self-references, and every member of a cycle render and export as roots in original order, so nothing is ever dropped and the export's header count equals what is emitted. PATCH /api/external-annotations refuses an inReplyTo that is self, missing, or would close a cycle (validateReplyTarget, both runtimes, 400). Drafts carry it (annotations are opaque JSON to the draft transport); share links deliberately do not (a reply shares as a plain comment on the same quote, the existing text-restore contract, pinned by sharing.inReplyTo.test.ts). Known limitation: comments on a sibling document that is not open answer not_available with a hint to reveal { path } first, because the linked-doc cache is a copy.
Docs: apps/marketing/src/content/docs/reference/webmcp-tools.md (the user-facing reference) and the manual five-flow checklist in tests/UI-TESTING.md.
Location: packages/ui/shortcuts/ (engine + scope data), packages/editor/shortcuts.ts and packages/review-editor/shortcuts.ts (per-app surfaces).
The shortcut system has three layers:
- Engine (
packages/ui/shortcuts/{core,runtime}.ts) — parser for declarative bindings (Mod+Enter,Alt Altdouble-tap,Alt hold), dispatcher, platform-aware formatter (mac glyphs vs.Ctrl), validator, and theuseShortcutScope/useDoubleTapShortcutsReact hooks. Truly shared — both apps use it as-is. - Scopes —
defineShortcutScope({ id, title, shortcuts: { actionId: { bindings, description, section, ... } } }). One scope per UI surface (annotation toolbar, comment popover, file tree, etc.). App-specific scopes live inpackages/ui/shortcuts/{plan-review,code-review}/— the subfolder names which app's UI the scope serves — while genuinely cross-app scopes such ashistory.shortcuts.tsanddecisionControl.shortcuts.ts(the header decision control's note-composer chords, mounted identically by both apps) live at the shortcuts root. Components/Apps wire handlers to a scope viauseShortcutScope({ scope, handlers: { actionId: () => ... } }). - Surfaces (
packages/editor/shortcuts.ts,packages/review-editor/shortcuts.ts) — each app composes its scopes into aShortcutSurface(planReviewSurface,annotateSurface,codeReviewSurface). Surfaces feed both the in-app help modal and the marketing site's auto-generated docs page.
Convention for adding new shortcuts: define the action in the relevant app-specific subfolder (plan-review/ or code-review/), or at the shortcuts root when both apps share the same action and semantics. Declare the binding(s) and description, then wire a handler at the call site with useShortcutScope. The marketing docs page picks it up automatically at next build. Unit tests in packages/ui/shortcuts.test.ts enforce normalized binding tokens (Mod, Shift, Alt, A-Z, 1-0, named keys, F1–F12) and unique scope ids.
Marketing docs auto-generation: apps/marketing/src/lib/shortcutReference.ts reads the three surfaces and apps/marketing/src/components/ShortcutReference.astro renders them as tables. The /docs/reference/keyboard-shortcuts page is special-cased in apps/marketing/src/pages/docs/[...slug].astro to render the component instead of the markdown body.
Location: packages/ui/utils/sharing.ts, packages/ui/hooks/useSharing.ts
Shares full plan + annotations via URL hash using deflate compression. For large plans, short URLs are created via the paste service (user must explicitly confirm).
Payload format:
// Image in shareable format: plain string (old) or [path, name] tuple (new)
type ShareableImage = string | [string, string];
interface SharePayload {
p: string; // Plan markdown
a: ShareableAnnotation[]; // Compact annotations
g?: ShareableImage[]; // Global attachments
d?: (string | null)[]; // diffContext per annotation, parallel to `a`
s?: (string | undefined)[]; // source per annotation (external tool identifier), parallel to `a`
h?: string; // Raw HTML content (direct HTML rendering mode)
r?: 'html'; // Render mode flag (omitted = markdown)
}
type ShareableAnnotation =
| ["D", string, string | null, ShareableImage[]?] // [type, original, author, images?]
| ["C", string, string, string | null, ShareableImage[]?] // [type, original, comment, author, images?]
| ["G", string, string | null, ShareableImage[]?]; // [type, comment, author, images?]Compression pipeline:
JSON.stringify(payload)CompressionStream('deflate-raw')- Base64 encode
- URL-safe: replace
+/=with-_
On load from shared URL:
- Parse hash, decompress, restore annotations
- Find text positions in rendered DOM via text search
- Apply
<mark>highlights - Clear hash from URL (prevents re-parse on refresh)
Known limitation: share links intentionally do not carry HTML element anchors or additional multi-select targets. Restore on the raw-HTML surface is text-search based; this is the contract asserted by sharing.multiTarget.test.ts.
Location: packages/ui/utils/storage.ts, planSave.ts, agentSwitch.ts
Uses cookies (not localStorage) because each hook invocation runs on a random port. Settings include identity, plan saving (enabled/custom path), and agent switching (OpenCode only).
There is one highlighter in the app: the Shiki instance @pierre/diffs already runs for the code-review diff pane, driven by Shiki's JavaScript regex engine (preferredHighlighter: 'shiki-js'). highlight.js is gone. The wrapper is packages/ui/utils/codeHighlight.ts:
applyHighlight(el, code, lang, theme)— imperative drop-in for the oldhljs.highlightElement(el). Writes plain text immediately (final size on first paint, no layout shift), then swaps in highlighted markup once the grammar is attached; already-attached grammars highlight synchronously, so there is no flicker on cached highlights. It also enforces that the rendered text is byte-identical to the source and falls back to plain text otherwise, because the annotation layer addresses code blocks by text offset.highlightToHtml(code, lang, theme)/ensureHighlight(lang, theme)— the sync/async pair behind it, for callers that need HTML strings (the code-file hover preview).codeBlockClassName(lang)— thepn-code font-mono language-{lang}class every fenced<code>carries.pn-codereplaced the oldhljsclass and is the structural hookblockTargeting, vim navigation andprint.cssuse (pre > code.pn-code);language-*is howblockTargetingreads a block's language back out of the DOM.onCodeHighlightSwap(listener)— observes every writeapplyHighlightmakes, SYNCHRONOUSLY, immediately after it. Each write replaces the element's children, so it also destroys whatever the annotation layer wrapped inside the fence.
Code-block annotation marks and highlight swaps. web-highlighter cannot select inside a <pre>, so a fenced block is annotated all-or-nothing: one <mark data-bind-id> that is the <code> element's only child, painted by paintCodeBlockMark (packages/ui/utils/codeBlockMark.ts) — which MOVES the token spans into the mark rather than flattening them to text, so annotating or re-theming a block never costs it its colours. Viewer subscribes to onCodeHighlightSwap and re-paints that mark right after any swap, which is what keeps a palette or dark/light change from wiping code-block annotations. Being driven by the swap is also what makes the share/draft restore race safe by ordering rather than by timing: a restore that painted before the swap is re-established in the same task the swap ran in, and one that runs after finds the mark already there. Do not "fix" a mark-eating swap by skipping the rewrite when a mark is present — that leaves annotated blocks in stale theme colours.
Language-less fences render as plain text and are never guessed at (#1212). There is no auto-detection anywhere. HighlightedCode (review suggestions) derives its language from the caller's file path via detectLanguage; an unrecognised extension renders plain.
Theming: fences resolve the SAME theme the diff pane resolves, via resolveFenceTheme / resolveSyntaxTheme in packages/ui/utils/syntaxTheme.ts (keyed on (colorTheme, resolvedMode); packages/review-editor/hooks/usePierreTheme.ts re-exports them). useFenceTheme() (packages/ui/hooks/useFenceTheme.ts) feeds the components and re-highlights on palette or mode change. Palettes with no Shiki counterpart fall back to @pierre/diffs' own pierre-dark / pierre-light. Consequence: code blocks follow the active palette in both light and dark instead of always rendering github-dark, so do not add per-theme .hljs-*-style token CSS — pick the right Shiki theme in SHIKI_THEME_MAP instead.
Bundle note: Pierre imports Shiki's full bundle, so every grammar and theme is already inlined in the single-file builds; reusing its shared highlighter costs no extra bytes and needs no CDN or runtime wasm fetch. The Oniguruma WASM engine is dead weight under shiki-js and is aliased to build/shiki-wasm-stub.ts in the review, hook and portal Vite configs (via resolve.alias, which — unlike plugins — is shared with Vite's worker build).
- Bun runtime
- Claude Code with plugin/hooks support, or OpenCode
- Cross-platform: macOS (
open), Linux (xdg-open), Windows (start)
bun install
# Run any app
bun run dev:hook # Hook server (plan review)
bun run dev:review # Review editor (code review)
bun run dev:portal # Portal editor
bun run dev:marketing # Marketing site
bun run dev:vscode # VS Code extension (watch mode)Local plannotator command: run bun link once in the checkout to make the global plannotator command use this repo's source (apps/hook/server/index.ts) instead of an installed release binary. Commands like plannotator review then reflect local changes immediately. Rebuild the bundled HTML when changing UI code (see Build below).
A test must guard a behavior that can actually regress. Before writing one, name the failure it catches; if you can't, don't write it.
- Pin copy only on purpose, never as a snapshot. Locking a short user-facing string is legitimate when it is a deliberate decision — an action label ("Approve"), a command name, a legally/UX-critical phrase the maintainer wants frozen so agents can't drift it. Mark it as such in a comment. What is banned is incidentally snapshotting explanatory prose (intro dialogs, setting descriptions, empty-state copy) with
toBejust because it was on screen when the test was written — that couples wording edits to test churn while guarding nothing. If such a string carries data that must stay truthful (a server-computed size, a language list, a version), assert those facts withtoContainon the data, not the sentence around them. - No round-trip prop tests. Asserting that a hardcoded string passed as a prop appears in the DOM verifies nothing — any string round-trips. If the only thing worth checking is "this prop renders somewhere," use a sentinel string and one assertion, and say so in a comment.
- Assert behavior, not implementation echo. A test that restates what the code obviously does (calls X with Y, sets state to Z) without exercising an observable outcome is noise; it breaks on refactors and catches nothing.
- Bun runs every test file in one process. Never mutate
process.env,~/.plannotator, or any global at module scope; mutate inside tests with restore infinally/afterEach, and sandbox all server/data-dir interaction under a tempPLANNOTATOR_DATA_DIR. Never read or write the real user config.
bun run build:hook # Single-file HTML for hook server
bun run build:review # Code review editor
bun run build:opencode # OpenCode plugin (copies HTML from hook + review)
bun run build:portal # Static build for share.plannotator.ai
bun run build:marketing # Static build for plannotator.ai
bun run build:vscode # VS Code extension bundle
bun run package:vscode # Package .vsix for marketplace
bun run build # Build hook + opencode (main targets)Important: Tailwind @source paths. When creating new directories that contain .tsx files with Tailwind classes, add a matching @source entry to the app's index.css. Tailwind only generates CSS for classes it finds in scanned files — missing paths means classes appear in the DOM but have no effect.
Important: Build order matters. The hook build (build:hook) copies pre-built HTML from apps/review/dist/. If you change UI code in packages/ui/, packages/editor/, or packages/review-editor/, you must rebuild the review app first, then the hook:
bun run --cwd apps/review build && bun run build:hook # For review UI changes
bun run build:hook # For plan UI changes only
bun run build:hook && bun run build:opencode # For OpenCode pluginRunning only build:hook after review-editor changes will copy stale HTML files. When testing locally with a compiled binary, the full sequence is:
bun run --cwd apps/review build && bun run build:hook && \
bun build apps/hook/server/index.ts --compile --outfile ~/.local/bin/plannotatorRunning only build:opencode will copy stale HTML files.
apps/marketing/ is the plannotator.ai website — landing page, documentation, and blog. Built with Astro 5 (static output, zero client JS except a theme toggle island). Docs are markdown files in src/content/docs/, blog posts in src/content/blog/, both using Astro content collections. Tailwind CSS v4 via @tailwindcss/vite. Deploys to S3/CloudFront via GitHub Actions on push to main.
The /docs/reference/keyboard-shortcuts page is auto-generated from the shortcut registry at build time — see the Keyboard Shortcuts section above. Editing the markdown body has no effect; update the scope files instead.
claude --plugin-dir ./apps/hook