Files
backnotprop__plannotator/CLAUDE.md
T
Michael Ramos 14e9aea0ec Fix/devcontainer & plan auto switching (opencode) (#31)
* Refactor: shared server package with PLANNOTATOR_REMOTE env var

- Create packages/server/ with shared server implementation
- Add PLANNOTATOR_REMOTE=1 env var for devcontainer/SSH mode
- Deprecate SSH_CONNECTION detection (still works with warning)
- Both Claude Code and OpenCode now use identical server logic
- OpenCode gains Obsidian/Bear integrations and remote detection
- Update documentation with environment variables section

Fixes #27

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove PLANNOTATOR_ORIGIN env var, fix onReady callback timing

- Remove PLANNOTATOR_ORIGIN hack from Claude Code hook (hardcode "claude-code")
- Fix onReady callback to pass port directly (was referencing undefined server)
- Create tests/manual/test-server.ts for testing either origin
- Update test-hook-2.sh to use new test server with opencode origin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add devcontainer test setup, reduce logging verbosity

- Add tests/devcontainer/ with devcontainer.json for testing remote mode
- Add tests/opencode-local/ for local OpenCode testing
- Update package.json build script for proper bundling
- Remove verbose multi-line error messages, keep useful errors
- Clean up unused deprecationWarned variable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add devcontainer support and documentation

- Add PLANNOTATOR_REMOTE env var for container/remote detection
- Remove console logging (silent operation)
- Add devcontainer.md with full setup instructions
- Add devcontainer section to OpenCode plugin README
- Add port-only test setup to reproduce common misconfiguration
- Update test devcontainers to forward port 4096 for opencode web
- Bump version to 0.4.1

Fixes #27

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update CLAUDE.md: legacy SSH detection, not deprecated

Removed incorrect mention of deprecation warning - SSH_TTY/SSH_CONNECTION
detection is silent by design.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix OpenCode agent switching after plan approval

After approving a plan, the conversation context stayed in "plan" mode
even though the TUI showed "build". This caused edits to fail because
the plan agent has edit permissions denied.

The fix uses session.prompt() to inject a message with agent: "build",
which triggers a new agentic loop with proper build agent permissions.

Fixes #29

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 14:25:19 -08:00

7.3 KiB

Plannotator

A plan review UI for Claude Code that intercepts ExitPlanMode via hooks, letting users approve or request changes with annotated feedback.

Project Structure

plannotator/
├── apps/
│   ├── hook/                     # Claude Code plugin
│   │   ├── .claude-plugin/plugin.json
│   │   ├── hooks/hooks.json      # PermissionRequest hook config
│   │   ├── server/index.ts       # Entry point (reads stdin, outputs decision)
│   │   └── dist/index.html       # Built single-file app
│   └── opencode-plugin/          # OpenCode plugin
│       ├── index.ts              # Plugin entry with submit_plan tool
│       └── plannotator.html      # Built single-file app (copied from hook)
├── packages/
│   ├── server/                   # Shared server implementation
│   │   ├── index.ts              # startPlannotatorServer(), handleServerReady()
│   │   ├── 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
│   │   ├── hooks/                # useSharing.ts
│   │   └── types.ts
│   └── editor/                   # Main App.tsx
├── .claude-plugin/marketplace.json  # For marketplace install
└── legacy/                       # Old pre-monorepo code (reference only)

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

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

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

Server API

Endpoint Method Purpose
/api/plan GET Returns plan markdown as JSON
/api/approve POST User approved the plan
/api/deny POST User denied with feedback in body

Location: packages/server/index.ts (shared), apps/hook/server/index.ts (Claude Code entry), apps/opencode-plugin/index.ts (OpenCode entry)

Both plugins use the shared startPlannotatorServer() from packages/server. The server handles remote detection, port configuration, and all API routes. Port is random locally (port: 0) or fixed (19432) in remote mode.

Data Types

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

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)

exportDiff(blocks, annotations) generates human-readable feedback for Claude.

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.

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 comment

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

Uses cookies instead of localStorage because each hook invocation runs on a random port, and localStorage is scoped by origin (including port). Cookies are scoped by domain 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
bun run dev:portal     # Portal editor
bun run dev:marketing  # Marketing site

Build

bun run build:hook       # Single-file HTML for hook server
bun run build:portal     # Static build for share.plannotator.ai
bun run build:marketing  # Static build for plannotator.ai

Test plugin locally

claude --plugin-dir ./apps/hook