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.
plannotator/
├── apps/
│ ├── hook/ # Claude Code plugin
│ │ ├── .claude-plugin/plugin.json
│ │ ├── commands/ # Slash commands (plannotator-review.md)
│ │ ├── hooks/hooks.json # PermissionRequest hook config
│ │ ├── server/index.ts # Entry point (plan + review subcommand)
│ │ └── dist/ # Built single-file apps (index.html, review.html)
│ ├── opencode-plugin/ # OpenCode plugin
│ │ ├── commands/ # Slash commands (plannotator-review.md)
│ │ ├── index.ts # Plugin entry with submit_plan tool + review event handler
│ │ ├── plannotator.html # Built plan review app
│ │ └── review-editor.html # Built code review app
│ └── review/ # Standalone review server (for development)
│ ├── index.html
│ ├── index.tsx
│ └── vite.config.ts
├── packages/
│ ├── server/ # Shared server implementation
│ │ ├── index.ts # startPlannotatorServer(), handleServerReady()
│ │ ├── review.ts # startReviewServer(), handleReviewServerReady()
│ │ ├── storage.ts # Plan saving to disk (getPlanDir, savePlan, etc.)
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
│ │ ├── browser.ts # openBrowser()
│ │ ├── integrations.ts # Obsidian, Bear integrations
│ │ └── project.ts # Project name detection for tags
│ ├── ui/ # Shared React components
│ │ ├── components/ # Viewer, Toolbar, Settings, etc.
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts
│ │ ├── hooks/ # useSharing.ts
│ │ └── types.ts
│ ├── editor/ # Plan review App.tsx
│ └── review-editor/ # Code review UI
│ ├── App.tsx # Main review app
│ ├── components/ # DiffViewer, FileTree, ReviewPanel
│ ├── 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)
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 or true for remote mode (devcontainer, SSH). Uses fixed port and skips browser open. |
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. |
Legacy: SSH_TTY and SSH_CONNECTION are still detected. Prefer PLANNOTATOR_REMOTE=1 for explicit control.
Devcontainer/SSH usage:
export PLANNOTATOR_REMOTE=1
export PLANNOTATOR_PORT=9999Claude 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
↓
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
| Endpoint | Method | Purpose |
|---|---|---|
/api/plan |
GET | Returns { plan, origin } |
/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 temp path |
/api/obsidian/vaults |
GET | Detect available Obsidian vaults |
| Endpoint | Method | Purpose |
|---|---|---|
/api/diff |
GET | Returns { rawPatch, gitRef, origin } |
/api/feedback |
POST | Submit review (body: feedback, annotations, agentSwitch) |
/api/image |
GET | Serve image by path query param |
/api/upload |
POST | Upload image, returns temp path |
Both servers use random ports locally or fixed port (19432) in remote mode.
Location: packages/ui/types.ts
enum AnnotationType {
DELETION = "DELETION",
INSERTION = "INSERTION",
REPLACEMENT = "REPLACEMENT",
COMMENT = "COMMENT",
GLOBAL_COMMENT = "GLOBAL_COMMENT",
}
interface Annotation {
id: string;
blockId: string;
startOffset: number;
endOffset: number;
type: AnnotationType;
text?: string; // For comment/replacement/insertion
originalText: string; // The selected text
createdA: number; // Timestamp
author?: string; // Tater identity
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;
}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)
exportDiff(blocks, annotations) generates human-readable feedback for Claude.
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>).
Location: packages/ui/utils/sharing.ts, packages/ui/hooks/useSharing.ts
Shares full plan + annotations via URL hash using deflate compression.
Payload format:
interface SharePayload {
p: string; // Plan markdown
a: ShareableAnnotation[]; // Compact annotations
}
type ShareableAnnotation =
| ["D", string, string | null] // [type, original, author]
| ["R", string, string, string | null] // [type, original, replacement, author]
| ["C", string, string, string | null] // [type, original, comment, author]
| ["I", string, string, string | null] // [type, context, newText, author]
| ["G", string, string | null]; // [type, comment, author] - global commentCompression 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)
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).
Code blocks use bundled highlight.js. Language is extracted from fence (```rust) and applied as language-{lang}class. Each block highlighted individually via`hljs.highlightElement()`.
- 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 sitebun 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 # Build hook + opencode (main targets)claude --plugin-dir ./apps/hook