A fully local Claude Code plugin that intercepts ExitPlanMode hook events and opens a browser UI for reviewing and annotating plans before code is written. Ships as a package-managed platform runtime binary (compiled via bun build --compile) that embeds the React UI. Also works as an OpenCode plugin via the @opencode-ai/plugin SDK.
Claude Code path:
Hook fires (ExitPlanMode)
→ bin/open-plan-annotator.mjs (Node wrapper: buffers stdin, resolves runtime package, delegates)
→ @open-plan-annotator/runtime-*/bin/open-plan-annotator (compiled Bun binary)
→ Reads hook JSON from stdin
→ Starts HTTP server on ephemeral port
→ Opens browser to the UI
→ Blocks until user approves/denies
→ Writes hook response JSON to stdout
OpenCode path:
Agent calls submit_plan tool
→ opencode/index.js (OpenCode plugin entry, loaded via package.json "main")
→ opencode/bridge.js (constructs fake HookEvent, spawns binary)
→ bin/open-plan-annotator.mjs → binary (same as above)
→ Parses HookOutput, returns approval/feedback to agent
The OpenCode plugin bridges to the same binary by constructing a Claude-format HookEvent payload and parsing the HookOutput response. This means there is only one server/UI codepath.
bin/open-plan-annotator.mjs— npm bin wrapper. Buffers stdin, resolves the installed platform runtime package, delegates to the binary, and exposesdoctor.shared/runtimeResolver.mjs— Maps platform/arch to the correct runtime package and resolves its binary path.server/index.ts— Main entry. Parses hook event, manages plan history, starts Bun HTTP server, outputs hook response.server/api.ts— Routes:GET /api/plan,POST /api/approve,POST /api/deny,POST /api/settings, and catch-all serving the embedded HTML.server/launch.ts— Cross-platformopen/xdg-openbrowser launcher.server/types.ts— Shared types (HookEvent,HookOutput,Annotation,ServerState,ServerDecision,UserPreferences).opencode/index.js— OpenCode plugin entry point. Registerssubmit_plantool, injects system prompt instructions, handles implementation agent handoff.opencode/bridge.js— Spawns the binary with a fake HookEvent, parses the HookOutput response.opencode/config.js— Readsopen-plan-annotator.jsonconfig for implementation handoff settings.ui/— React + Vite frontend, built to a singlebuild/index.htmlembedded at compile time.hooks/hooks.json— Claude Code hook registration.CLAUDE.plugin.md— Instructions shipped to end users (copied toCLAUDE.mdduringnpm packvia prepack/postpack).
- stdout is reserved for Claude Code. The JSON hook response is the ONLY thing that may be written to stdout. All logs, progress, and diagnostics MUST go to stderr (
console.error,process.stderr.write). CLAUDE.plugin.mdis user-facing. It tells Claude to use plan mode. Keep developer-only content in this file (CLAUDE.md), not inCLAUDE.plugin.md.- The binaries are not committed. Runtime package binaries under
packages/runtime-*/bin/are generated during build and ignored by git. - The OpenCode plugin uses plain JS (not TypeScript). The
opencode/directory ships as-is in the npm package — no build step. Keep it as.jsfiles with JSDoc types. - Never use 'as any' Always use the correct type.
- We use bun not pnpm, npm or yarn in this project.
- Always double check your work before telling me you're done by running the typecheck, lint, and test scripts.
bun run dev # Server (port 3847) + Vite UI (port 5173)
bun run dev:server # Server only (NODE_ENV=development, uses DEV_PLAN)
bun run dev:ui # Vite dev server onlyIn dev mode, server/index.ts uses a hardcoded DEV_PLAN and skips stdin parsing. The UI proxies API calls to port 3847.
bun run build # Build UI + cross-compile binaries into packages/runtime-*/bin/
bun run release # Alias for build
./scripts/release.sh # Full release: bump versions, build, git tag, and publish runtime + main npm packagesCross-compilation targets: darwin-arm64, darwin-x64, linux-x64, linux-arm64.
Uses Biome for linting and formatting.
bun run lint # Check
bun run lint:fix # Auto-fix
bun run format # FormatClaude Code sends a HookEvent JSON on stdin with tool_input.plan containing the plan markdown. The binary responds on stdout with a HookOutput JSON:
- Approve:
{ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } } - Deny:
{ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", message: "..." } } }
The deny message contains serialized annotations (deletions, replacements, insertions, comments) as markdown so Claude can revise the plan.
The OpenCode bridge (opencode/bridge.js) constructs the same HookEvent format and parses the same HookOutput response, so the binary always goes through the same code path.