Files
backnotprop__plannotator/AGENTS.md
T
Michael Ramos b780739291 feat(annotate): support HTML files and URL annotation (#545)
* fix(annotate): sanitize dangerous link protocols in markdown renderer

Block javascript:, data:, and vbscript: URLs in InlineMarkdown link
rendering. Links with dangerous protocols render as plain text instead
of clickable anchors. Uses a blocklist approach so existing links with
custom protocols (obsidian://, vscode://, Windows C:\ paths) continue
to work.

For provenance purposes, this commit was AI assisted.

* feat(annotate): add HTML-to-markdown and URL-to-markdown utilities

- html-to-markdown.ts: Turndown wrapper with GFM table rule, strips
  script/style/noscript tags
- url-to-markdown.ts: Jina Reader (free, returns markdown) with
  fetch+Turndown fallback. Warns on Jina failure, auto-skips Jina for
  local/private URLs (localhost, 192.168.*, 10.*, etc.)
- config.ts: add jina setting and resolveUseJina() with priority chain
  --no-jina flag > PLANNOTATOR_JINA env > config.json > default true

For provenance purposes, this commit was AI assisted.

* feat(annotate): support HTML files and URLs in annotate command

Extend the annotate subcommand to accept .html/.htm local files
(converted via Turndown) and https:// URLs (fetched via Jina Reader
with fetch+Turndown fallback). URL content is fetched terminal-side
before opening the browser.

Add --no-jina global flag to disable Jina Reader per-invocation.
Add 10MB file size guard for local HTML files.

For provenance purposes, this commit was AI assisted.

* feat(annotate): HTML files in folder browser and on-demand conversion

- Widen file browser glob to include .html/.htm alongside markdown
- handleDoc converts HTML files via Turndown on demand when selected
- hasMarkdownFiles accepts optional extensions param for folder validation
- Add sourceInfo field to annotate server API response
- Add _site/, public/, out/, .docusaurus/, .jekyll-cache/,
  storybook-static/ to FILE_BROWSER_EXCLUDED

For provenance purposes, this commit was AI assisted.

* feat(annotate): source attribution badge for HTML/URL annotations

Show a subtle badge in DocBadges displaying the URL hostname or HTML
filename for converted content. Thread sourceInfo from API response
through App → Viewer → DocBadges.

Also update Pi extension to accept HTML-only folders in annotate mode.

For provenance purposes, this commit was AI assisted.

* test: update CLI help text assertion for HTML/URL annotate support

For provenance purposes, this commit was AI assisted.

* fix(annotate): address PR review findings

Security:
- Add project-root containment check for HTML files in /api/doc handler
  using exported isWithinProjectRoot() from resolve-file.ts
- Blocks path traversal via absolute paths or ../ escapes

isLocalUrl fixes:
- Add bracketed IPv6 loopback [::1] detection
- Replace hostname.startsWith('10.') with proper IPv4 regex to avoid
  matching public hostnames like 10.example.com

Revert Pi extension change:
- Pi server doesn't implement HTML file browsing or conversion yet
- Keep Pi folder validation markdown-only until both implementations
  are updated per CLAUDE.md guidelines

Cleanup:
- Remove dead el.children || el.childNodes fallback in table rule
- Extract hostnameOrFallback() helper to @plannotator/shared/project
  replacing duplicated try/catch IIFEs in DocBadges and index.ts

For provenance purposes, this commit was AI assisted.

* feat(annotate): Pi extension HTML annotation parity

Bring the Pi extension to full parity with the Bun server for HTML
annotation support:

- Vendor html-to-markdown and url-to-markdown via vendor.sh
- walkMarkdownFiles now scans .html/.htm alongside markdown
- handleDocRequest converts HTML files on-demand via Turndown with
  isWithinProjectRoot containment check
- serverAnnotate includes sourceInfo in /api/plan response
- index.ts supports URL detection (Jina Reader + fallback), HTML file
  detection with Turndown conversion, folder HTML validation, and 10MB
  file size guard
- openMarkdownAnnotation accepts and threads sourceInfo
- Add turndown as a Pi extension dependency

For provenance purposes, this commit was AI assisted.

* fix(pi): Obsidian vault walks stay markdown-only, add try/catch for HTML

- Add extensions param to walkMarkdownFiles (default: HTML-inclusive)
- Obsidian callers pass /\.mdx?$/i to match Bun server behavior
- Add try/catch around HTML file reads in handleDocRequest

For provenance purposes, this commit was AI assisted.

* fix(annotate): address second review — base-block traversal, metadata IP, dead code

Security:
- Add isWithinProjectRoot check to the base-relative block for HTML
  files in both Bun and Pi /api/doc handlers. Previously HTML files
  served via the base query param bypassed the containment guard.
- Add 169.254.0.0/16 (link-local / cloud metadata) to isLocalUrl
  private IP ranges

Cleanup:
- Remove dead hostname === "[::1]" check (WHATWG URL parser strips
  brackets; hostname === "::1" already handles it)
- Remove dead parent?.childNodes fallback in table cell() function

For provenance purposes, this commit was AI assisted.

* refactor(annotate): replace custom table rules with turndown-plugin-gfm

Drop ~60 lines of hand-rolled GFM table conversion that had a bug
(tables without explicit <thead> produced invalid GFM). Use the
official turndown-plugin-gfm plugin (24KB) which correctly handles
all table patterns plus adds strikethrough and task list support.

For provenance purposes, this commit was AI assisted.

* fix(annotate): handle all CommonMark backslash escapes in InlineMarkdown

Expand the backslash escape regex to cover all CommonMark-defined
escapable characters (. ) - # > + | { } &), not just the subset
the parser uses for formatting. Fixes literal backslashes appearing
in rendered output for Turndown-escaped content like "1\." → "1.".

For provenance purposes, this commit was AI assisted.

* fix(annotate): prevent SSRF via redirect to private/local URLs

Replace redirect: "follow" with redirect: "manual" in fetchViaTurndown
and validate each redirect hop against isLocalUrl. Blocks attacks where
an external URL redirects to cloud metadata endpoints (169.254.169.254)
or other private IPs. Limits redirect chain to 10 hops.

For provenance purposes, this commit was AI assisted.

* chore: update lockfile for turndown-plugin-gfm in Pi extension

bun install needed to resolve turndown-plugin-gfm in the Pi extension
workspace after adding it to apps/pi-extension/package.json.

For provenance purposes, this commit was AI assisted.

* fix(annotate): switch to @joplin/turndown-plugin-gfm, fix TS errors

Replace unmaintained turndown-plugin-gfm (2017, v1.0.2) with the
actively maintained Joplin fork (2025, v1.0.64, 16KB).

Fix TypeScript errors that broke CI:
- Add @ts-expect-error for untyped @joplin/turndown-plugin-gfm import
- Restructure fetchViaTurndown redirect loop to avoid uninitialized
  variable — first fetch before loop, loop only for redirects

For provenance purposes, this commit was AI assisted.

* fix(annotate): use proper declarations.d.ts instead of ts-expect-error

Add declarations.d.ts for @joplin/turndown-plugin-gfm with typed
function signatures, remove the ts-expect-error suppression.

For provenance purposes, this commit was AI assisted.

* fix: explicitly include declarations.d.ts in shared tsconfig

CI's tsc wasn't finding the ambient module declaration with implicit
include. Add explicit include to ensure declarations.d.ts is always
picked up regardless of environment.

For provenance purposes, this commit was AI assisted.

* fix: use ts-expect-error for @joplin/turndown-plugin-gfm types

CI's tsc does not pick up ambient declarations.d.ts files despite
local tsc finding them — likely a module resolution discrepancy
between environments. Revert to @ts-expect-error which passes in
both CI and local typecheck.

For provenance purposes, this commit was AI assisted.

* fix(annotate): body size limit for URL fetches, redirect error, file: protocol

- Add 10MB body size limit to both Jina and fetch+Turndown URL paths,
  matching the local HTML file guard. Streams response body and aborts
  if limit exceeded.
- Distinguish "Too many redirects" from a genuine 3xx response after
  redirect loop exhaustion.
- Add file: to the dangerous protocol blocklist in sanitizeLinkUrl.

For provenance purposes, this commit was AI assisted.

* fix(annotate): HTML folder outside cwd, HTML linked doc navigation

- Remove containment check from base-relative block for HTML files in
  both Bun and Pi /api/doc handlers. Matches markdown behavior so HTML
  files in annotated folders outside cwd are served correctly.
  Standalone block (no base) retains its cwd check as fallback.
- Widen isLocalMd → isLocalDoc to treat .html/.htm links as linked
  documents. Clicking [Next](next.html) in a converted page now opens
  it via /api/doc with Turndown conversion instead of a new browser tab.

For provenance purposes, this commit was AI assisted.

* fix(annotate): full loopback range, drain redirect bodies, document env vars

- Expand loopback check from just 127.0.0.1 to the full 127.0.0.0/8
  range so all loopback addresses skip Jina Reader
- Cancel redirect response body before re-fetching to avoid leaking
  TCP connections back to the pool
- Document PLANNOTATOR_JINA and JINA_API_KEY in CLAUDE.md env var table

For provenance purposes, this commit was AI assisted.

* fix(annotate): IPv6 loopback, readBodyWithLimit fallback, env var docs, comments

- Add [::1] back to isLocalUrl — WHATWG URL hostname getter preserves
  brackets for IPv6 (verified: Bun and Node both return "[::1]").
  Add comment explaining the empirical verification so future reviewers
  don't re-flag.
- Fix readBodyWithLimit null-body fallback to still enforce the 10MB
  limit via text length check instead of silently falling through.
- Document PLANNOTATOR_JINA and JINA_API_KEY in AGENTS.md env var table
  (CLAUDE.md is a symlink to AGENTS.md).
- Add comments to base-relative blocks in both Bun and Pi handleDoc
  explaining the intentional lack of containment check (matches
  pre-existing markdown behavior, base is set server-side).

For provenance purposes, this commit was AI assisted.

* fix(annotate): block IPv4-mapped IPv6 and private IPv6 ranges in isLocalUrl

Add PRIVATE_IPV6 regex matching bracketed IPv6 private/reserved ranges:
- ::ffff: (IPv4-mapped — embeds private IPv4 as hex, e.g. [::ffff:c0a8:1])
- fe80: (link-local)
- fc00::/7 (unique-local, covers fc00:: through fdff::)

Closes the redirect-SSRF bypass where a public URL redirects to a
private address expressed as IPv4-mapped IPv6, e.g.
http://[::ffff:169.254.169.254]/latest/meta-data/

For provenance purposes, this commit was AI assisted.

* fix(annotate): document IPv6 hostname verification, sourceInfo type, annotate flow

- Expand isLocalUrl comment with full empirical verification table
  showing actual hostname getter output for every IPv6 format in both
  Bun and Node — prevents false-positive review findings about brackets
- Add sourceInfo to /api/plan response type in App.tsx for type safety
- Update CLAUDE.md annotate flow diagram to reflect HTML/URL/folder
  input types

For provenance purposes, this commit was AI assisted.

* fix(annotate): escape \(, cancel response bodies on error, doc sourceInfo

- Add ( to backslash escape regex alongside existing ) — Turndown
  emits \( in link-adjacent contexts
- Cancel response body before throwing on !res.ok in both fetchViaJina
  and fetchViaTurndown error paths (redirect loop already did this)
- Document sourceInfo field in AGENTS.md annotate server API table

For provenance purposes, this commit was AI assisted.

* fix(annotate): skip base injection for URL annotations, body cleanup

- Skip dirname(filePath) base injection when filePath is a URL in both
  Bun and Pi annotate servers. dirname on a URL string produces a
  nonsensical filesystem path, causing linked doc clicks to 404.
  URL annotations now let links open normally instead.
- Cancel response body before throwing on content-type mismatch and
  content-length overflow in fetchViaTurndown/readBodyWithLimit.
- Fix double parseInt in readBodyWithLimit content-length check.
- Correct AGENTS.md flow diagram: OpenCode not yet implemented for
  HTML/URL annotation.

For provenance purposes, this commit was AI assisted.

* feat(annotate): OpenCode HTML file and URL annotation support

Add URL detection (Jina Reader + fallback), HTML file detection with
Turndown conversion, 10MB file size guard, and sourceInfo threading
to OpenCode's handleAnnotateCommand. Uses the same shared utilities
as the Bun CLI and Pi extension.

OpenCode uses the Bun server directly (startAnnotateServer from
@plannotator/server/annotate), so no server-side changes needed —
only the command handler routing was missing.

Note: folder annotation mode is not added (OpenCode didn't have it
before this PR for markdown either — separate scope).

For provenance purposes, this commit was AI assisted.

* chore(annotate): update slash command description, align fetch log messages

- OpenCode plannotator-annotate.md description now mentions HTML/URL
- Align fetch progress messages across all three clients: all now show
  "(via Jina Reader)" or "(via fetch+Turndown)" consistently

For provenance purposes, this commit was AI assisted.

* fix(annotate): skip conversion for .md URLs, wikilink HTML targets, cleanup

- URLs ending in .md/.mdx are fetched raw — no Jina, no Turndown.
  Content is already markdown. Removes text/plain from fetchViaTurndown
  content-type whitelist since .md URLs are now short-circuited.
- Wikilink regex widened to preserve .html/.htm targets instead of
  appending .md (e.g. [[page.html]] no longer becomes page.html.md)
- Remove redundant existsSync before statSync in OpenCode handler

For provenance purposes, this commit was AI assisted.

* test(annotate): add htmlToMarkdown conversion tests

Tests cover the core conversion utility that all three clients depend on:
- Basic HTML → markdown (headings, paragraphs, links, code blocks)
- Tables with and without <thead> (the GFM plugin bug that was caught)
- Script/style/noscript stripping
- Strikethrough (GFM)
- Empty HTML handling
- Dangerous links preserved (sanitization is in the renderer, not here)

For provenance purposes, this commit was AI assisted.

* fix(annotate): check content-type before treating .md URLs as raw markdown

URLs ending in .md/.mdx (e.g. GitHub's viewer page for README.md)
may return HTML instead of raw markdown. fetchRawText now checks the
response content-type — if the server returns HTML, returns null so
the caller falls through to Jina/Turndown for proper conversion.

For provenance purposes, this commit was AI assisted.

* fix(annotate): add SSRF redirect protection to fetchRawText

fetchRawText (for .md/.mdx URLs) was using default redirect: "follow"
with no isLocalUrl validation on redirect hops — a .md URL redirecting
to 169.254.169.254 would be followed and credentials returned as
"markdown". Now uses redirect: "manual" with per-hop isLocalUrl checks,
matching fetchViaTurndown's SSRF protection.

For provenance purposes, this commit was AI assisted.
2026-04-12 18:56:28 -07:00

26 KiB

Plannotator

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.

Project Structure

plannotator/
├── apps/
│   ├── hook/                     # Claude Code plugin
│   │   ├── .claude-plugin/plugin.json
│   │   ├── commands/             # Slash commands (plannotator-review.md, plannotator-annotate.md)
│   │   ├── 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 commands (plannotator-review.md, plannotator-annotate.md)
│   │   ├── index.ts              # Plugin entry with submit_plan tool + review/annotate event handlers
│   │   ├── plannotator.html      # Built plan review app
│   │   └── review-editor.html    # Built code review app
│   ├── marketing/                # Marketing site, docs, and blog (plannotator.ai)
│   │   └── astro.config.mjs      # Astro 5 static site with content collections
│   ├── 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
│   └── 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)
├── 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
│   │   ├── 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)
│   ├── shared/                   # Shared types, utilities, and cross-runtime logic
│   │   ├── storage.ts            # Plan saving, version history, archive listing (node:fs only)
│   │   ├── draft.ts              # Annotation draft persistence (node:fs only)
│   │   └── project.ts            # Pure string helpers (sanitizeTag, extractRepoName, extractDirName)
│   ├── editor/                   # Plan review App.tsx
│   └── review-editor/            # Code review UI
│       ├── App.tsx               # Main review app
│       ├── 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)

Server Runtimes

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 uses node:http primitives instead of Bun's Request/Response APIs.

When adding or modifying server endpoints, both implementations must be updated. Runtime-agnostic logic (store, validation, types) lives in packages/shared/ and is imported by both.

Installation

Via plugin marketplace (when repo is public):

/plugin marketplace add backnotprop/plannotator

Local testing:

claude --plugin-dir ./apps/hook

Environment Variables

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.
PLANNOTATOR_PORT Fixed port to use. Default: random locally, 19432 for remote sessions.
PLANNOTATOR_BROWSER Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path.
PLANNOTATOR_SHARE Set to disabled to turn off URL sharing entirely. Default: enabled.
PLANNOTATOR_SHARE_URL Custom base URL for share links (self-hosted portal). Default: https://share.plannotator.ai.
PLANNOTATOR_PASTE_URL Base URL of the paste service API for short URL sharing. Default: https://plannotator-paste.plannotator.workers.dev.
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.
JINA_API_KEY Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens.
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 gh installed and authenticated.

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=9999

Plan Review Flow

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":"..."}}}

Code Review Flow

User runs /plannotator-review command
        ↓
Claude Code: plannotator review subcommand runs
OpenCode: event handler intercepts command
        ↓
git diff captures unstaged changes
        ↓
Review server starts, opens browser with diff viewer
        ↓
User annotates code, provides feedback
        ↓
Send Feedback → feedback sent to agent session
Approve → "LGTM" sent to agent session

Annotate Flow

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   → file read from disk
  .html/.htm → file read, converted to markdown via Turndown
  https://   → fetched via Jina Reader (default) or fetch+Turndown (--no-jina)
  folder/    → file browser opened, files converted on demand
        ↓
Annotate server starts (reuses plan editor HTML with mode:"annotate")
        ↓
User annotates content, provides feedback
        ↓
Send Annotations → feedback sent to agent session

Archive Flow

User runs plannotator archive (CLI) or /plannotator-archive (Pi)
        ↓
Server starts in mode:"archive", reads ~/.plannotator/plans/
        ↓
Browser opens read-only archive viewer (sharing disabled)
        ↓
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.

Server API

Plan Server (packages/server/index.ts)

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/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/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/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/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

Review Server (packages/server/review.ts)

Endpoint Method Purpose
/api/diff GET Returns { rawPatch, gitRef, origin, diffType, gitContext }
/api/file-content GET Returns { oldContent, newContent } for expandable diff context
/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)
/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 })
/api/agents/jobs DELETE Kill all running agent jobs
/api/agents/jobs/:id DELETE Kill a specific agent job

Annotate Server (packages/server/annotate.ts)

Endpoint Method Purpose
/api/plan GET Returns { plan, origin, mode: "annotate", filePath, sourceInfo? }
/api/feedback POST Submit annotations (body: feedback, annotations)
/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/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.

Paste Service (apps/paste-service/)

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).

Plan Version History

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.

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).

Plan Diff

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.

Data Types

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
  startMeta?: { parentTagName; parentIndex; textOffset };
  endMeta?: { parentTagName; parentIndex; textOffset };
}

interface Block {
  id: string;
  type: "paragraph" | "heading" | "blockquote" | "list-item" | "code" | "hr";
  content: string;
  level?: number; // For headings (1-6)
  language?: string; // For code blocks
  order: number;
  startLine: number;
}

Markdown Parser

Location: packages/ui/utils/parser.ts

parseMarkdownToBlocks(markdown) splits markdown into Block objects. Handles:

  • Headings (#, ##, etc.)
  • Code blocks (``` with language extraction)
  • List items (-, *, 1.)
  • Blockquotes (>)
  • Horizontal rules (---)
  • Paragraphs (default)

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.

Annotation System

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>).

URL Sharing

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`
}

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:

  1. JSON.stringify(payload)
  2. CompressionStream('deflate-raw')
  3. Base64 encode
  4. URL-safe: replace +/= with -_

On load from shared URL:

  1. Parse hash, decompress, restore annotations
  2. Find text positions in rendered DOM via text search
  3. Apply <mark> highlights
  4. Clear hash from URL (prevents re-parse on refresh)

Settings Persistence

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).

Syntax Highlighting

Code blocks use bundled highlight.js. Language is extracted from fence (```rust) and applied as language-{lang}class. Each block highlighted individually viahljs.highlightElement().

Requirements

  • Bun runtime
  • Claude Code with plugin/hooks support, or OpenCode
  • Cross-platform: macOS (open), Linux (xdg-open), Windows (start)

Development

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)

Build

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: 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 plugin

Running 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/plannotator

Running only build:opencode will copy stale HTML files.

Marketing Site

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.

Test plugin locally

claude --plugin-dir ./apps/hook