diff --git a/.gitignore b/.gitignore
index a547bf3..9ae8ac1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,12 @@ dist
dist-ssr
*.local
+# npm pack output
+*.tgz
+
+# OpenCode plugin build artifact (generated from hook dist)
+apps/opencode-plugin/plannotator.html
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/CLAUDE.md b/CLAUDE.md
index e38dd7e..56698aa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -11,6 +11,11 @@ plannotator/
│ ├── hooks/hooks.json # PermissionRequest hook config
│ ├── server/index.ts # Bun server (reads stdin, serves UI)
│ └── dist/index.html # Built single-file app
+├── apps/opencode-plugin/ # OpenCode plugin
+│ └── index.ts # Plugin with submit_plan tool
+├── apps/mcp/ # General MCP Server
+│ ├── index.ts # MCP server with submit_plan tool
+│ └── README.md # Integration documentation
├── packages/
│ ├── ui/ # Shared React components
│ │ ├── components/ # Viewer, Toolbar, Settings, etc.
@@ -75,6 +80,7 @@ enum AnnotationType {
INSERTION = "INSERTION",
REPLACEMENT = "REPLACEMENT",
COMMENT = "COMMENT",
+ GLOBAL_COMMENT = "GLOBAL_COMMENT",
}
interface Annotation {
@@ -142,7 +148,8 @@ 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]
+ | ["I", string, string, string | null] // [type, context, newText, author]
+ | ["G", string, string | null]; // [type, comment, author] - global comment
```
**Compression pipeline:**
diff --git a/README.md b/README.md
index 341e850..ee4983f 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,38 @@
-
+
# Plannotator
-Interactive Plan Review: Mark up and refine your plans using a UI, easily share for team collaboration, automatically integrates with Claude Code plan mode.
+Interactive Plan Review for AI Coding Agents. Mark up and refine your plans using a visual UI, share for team collaboration, and seamlessly integrate with **Claude Code** and **OpenCode**.
-
- Watch video
-
-
-
-
-
-
+
-## Install
-**Install the `plannotator` command so Claude Code can use it:**
+**New:**
+
+ - We now support auto-saving approved plans to [Obsidian](https://obsidian.md/).
+
+## Install for Claude Code
+
+**Install the `plannotator` command:**
**macOS / Linux / WSL:**
@@ -42,14 +57,30 @@ irm https://plannotator.ai/install.ps1 | iex
See [apps/hook/README.md](apps/hook/README.md) for detailed installation instructions including a `manual hook` approach.
+---
+
+## Install for OpenCode
+
+Add to your `opencode.json`:
+
+```json
+{
+ "plugin": ["@plannotator/opencode"]
+}
+```
+
+That's it! Restart OpenCode and the `submit_plan` tool will be available.
+
+---
+
## How It Works
-When Claude Code calls `ExitPlanMode`, this hook intercepts and:
+When your AI agent finishes planning, Plannotator:
-1. Opens Plannotator UI in your browser
-2. Lets you annotate the plan visually
-3. Approve → Claude proceeds with implementation
-4. Request changes → Your annotations are sent back to Claude
+1. Opens the Plannotator UI in your browser
+2. Lets you annotate the plan visually (delete, insert, replace, comment)
+3. **Approve** → Agent proceeds with implementation
+4. **Request changes** → Your annotations are sent back as structured feedback
---
diff --git a/apps/hook/.claude-plugin/plugin.json b/apps/hook/.claude-plugin/plugin.json
index 69d3187..063263d 100644
--- a/apps/hook/.claude-plugin/plugin.json
+++ b/apps/hook/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "plannotator",
"description": "Interactive Plan Review: Mark up and refine your plans using a UI, easily share for team collaboration, automatically integrates with plan mode hooks.",
- "version": "0.1.0",
+ "version": "0.3.0",
"author": {
"name": "backnotprop"
},
diff --git a/apps/hook/README.md b/apps/hook/README.md
index 3059816..4ddcefa 100644
--- a/apps/hook/README.md
+++ b/apps/hook/README.md
@@ -23,7 +23,7 @@ curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && d
---
-[Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks)
+[Plugin Installation](#plugin-installation) · [Manual Installation (Hooks)](#manual-installation-hooks) · [Obsidian Integration](#obsidian-integration)
---
@@ -68,4 +68,37 @@ When Claude Code calls `ExitPlanMode`, this hook intercepts and:
1. Opens Plannotator UI in your browser
2. Lets you annotate the plan visually
3. Approve → Claude proceeds with implementation
-4. Request changes → Your annotations are sent back to Claude
\ No newline at end of file
+4. Request changes → Your annotations are sent back to Claude
+
+## Obsidian Integration
+
+Approved plans can be automatically saved to your Obsidian vault.
+
+**Setup:**
+1. Open Settings (gear icon) in Plannotator
+2. Enable "Obsidian Integration"
+3. Select your vault from the dropdown (auto-detected) or enter the path manually
+4. Set folder name (default: `plannotator`)
+
+**What gets saved:**
+- Plans saved with human-readable filenames: `Title - Jan 2, 2026 2-30pm.md`
+- YAML frontmatter with `created`, `source`, and `tags`
+- Tags extracted automatically from the plan title and code languages
+- Backlink to `[[Plannotator Plans]]` for graph connectivity
+
+**Example saved file:**
+```markdown
+---
+created: 2026-01-02T14:30:00.000Z
+source: plannotator
+tags: [plan, authentication, typescript, sql]
+---
+
+[[Plannotator Plans]]
+
+# Implementation Plan: User Authentication
+...
+```
+
+
+
diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts
index d3d4ca3..c15d5cb 100644
--- a/apps/hook/server/index.ts
+++ b/apps/hook/server/index.ts
@@ -2,16 +2,248 @@
* Plannotator Ephemeral Server
*
* Spawned by ExitPlanMode hook to serve Plannotator UI and handle approve/deny decisions.
- * Uses random port to support multiple concurrent Claude Code sessions.
+ * Supports both local and SSH remote sessions.
+ *
+ * Environment variables:
+ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 over SSH)
*
* Reads hook event from stdin, extracts plan content, serves UI, returns decision.
*/
import { $ } from "bun";
+import { join } from "path";
+import { mkdirSync, existsSync, statSync } from "fs";
+
+// --- Obsidian Integration ---
+
+interface ObsidianConfig {
+ vaultPath: string;
+ folder: string;
+ plan: string;
+}
+
+/**
+ * Extract tags from markdown content using simple heuristics
+ */
+function extractTags(markdown: string): string[] {
+ const tags = new Set(["plan"]);
+
+ const stopWords = new Set([
+ "the", "and", "for", "with", "this", "that", "from", "into",
+ "plan", "implementation", "overview", "phase", "step", "steps",
+ ]);
+
+ // Extract from first H1 title
+ const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im);
+ if (h1Match) {
+ const titleWords = h1Match[1]
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, " ")
+ .split(/\s+/)
+ .filter((word) => word.length > 2 && !stopWords.has(word));
+ titleWords.slice(0, 3).forEach((word) => tags.add(word));
+ }
+
+ // Extract code fence languages
+ const langMatches = markdown.matchAll(/```(\w+)/g);
+ const seenLangs = new Set();
+ for (const [, lang] of langMatches) {
+ const normalizedLang = lang.toLowerCase();
+ if (
+ !seenLangs.has(normalizedLang) &&
+ !["json", "yaml", "yml", "text", "txt", "markdown", "md"].includes(normalizedLang)
+ ) {
+ seenLangs.add(normalizedLang);
+ tags.add(normalizedLang);
+ }
+ }
+
+ return Array.from(tags).slice(0, 6);
+}
+
+/**
+ * Generate frontmatter for the note
+ */
+function generateFrontmatter(tags: string[]): string {
+ const now = new Date().toISOString();
+ const tagList = tags.map((t) => t.toLowerCase()).join(", ");
+ return `---
+created: ${now}
+source: plannotator
+tags: [${tagList}]
+---`;
+}
+
+/**
+ * Extract title from markdown (first H1 heading)
+ */
+function extractTitle(markdown: string): string {
+ const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im);
+ if (h1Match) {
+ // Clean up the title for use as filename
+ return h1Match[1]
+ .trim()
+ .replace(/[<>:"/\\|?*]/g, '') // Remove invalid filename chars
+ .replace(/\s+/g, ' ') // Normalize whitespace
+ .slice(0, 50); // Limit length
+ }
+ return 'Plan';
+}
+
+/**
+ * Generate human-readable filename: Title - Mon D, YYYY H-MMam.md
+ * Example: User Authentication - Jan 2, 2026 2-30pm.md
+ */
+function generateFilename(markdown: string): string {
+ const title = extractTitle(markdown);
+ const now = new Date();
+
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ const month = months[now.getMonth()];
+ const day = now.getDate();
+ const year = now.getFullYear();
+
+ let hours = now.getHours();
+ const minutes = now.getMinutes().toString().padStart(2, '0');
+ const ampm = hours >= 12 ? 'pm' : 'am';
+ hours = hours % 12 || 12;
+
+ return `${title} - ${month} ${day}, ${year} ${hours}-${minutes}${ampm}.md`;
+}
+
+/**
+ * Detect Obsidian vaults by reading Obsidian's config file
+ * Returns array of vault paths found on the system
+ */
+function detectObsidianVaults(): string[] {
+ try {
+ const home = process.env.HOME || process.env.USERPROFILE || "";
+ let configPath: string;
+
+ // Platform-specific config locations
+ if (process.platform === "darwin") {
+ configPath = join(home, "Library/Application Support/obsidian/obsidian.json");
+ } else if (process.platform === "win32") {
+ const appData = process.env.APPDATA || join(home, "AppData/Roaming");
+ configPath = join(appData, "obsidian/obsidian.json");
+ } else {
+ // Linux
+ configPath = join(home, ".config/obsidian/obsidian.json");
+ }
+
+ if (!existsSync(configPath)) {
+ return [];
+ }
+
+ const configContent = require("fs").readFileSync(configPath, "utf-8");
+ const config = JSON.parse(configContent);
+
+ if (!config.vaults || typeof config.vaults !== "object") {
+ return [];
+ }
+
+ // Extract vault paths, filter to ones that exist
+ const vaults: string[] = [];
+ for (const vaultId of Object.keys(config.vaults)) {
+ const vault = config.vaults[vaultId];
+ if (vault.path && existsSync(vault.path)) {
+ vaults.push(vault.path);
+ }
+ }
+
+ return vaults;
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Save plan to Obsidian vault with cross-platform path handling
+ * Returns { success: boolean, error?: string, path?: string }
+ */
+async function saveToObsidian(
+ config: ObsidianConfig
+): Promise<{ success: boolean; error?: string; path?: string }> {
+ try {
+ const { vaultPath, folder, plan } = config;
+
+ // Normalize path (handle ~ on Unix, forward/back slashes)
+ let normalizedVault = vaultPath.trim();
+
+ // Expand ~ to home directory (Unix/macOS)
+ if (normalizedVault.startsWith("~")) {
+ const home = process.env.HOME || process.env.USERPROFILE || "";
+ normalizedVault = join(home, normalizedVault.slice(1));
+ }
+
+ // Validate vault path exists and is a directory
+ if (!existsSync(normalizedVault)) {
+ return { success: false, error: `Vault path does not exist: ${normalizedVault}` };
+ }
+
+ const vaultStat = statSync(normalizedVault);
+ if (!vaultStat.isDirectory()) {
+ return { success: false, error: `Vault path is not a directory: ${normalizedVault}` };
+ }
+
+ // Build target folder path
+ const folderName = folder.trim() || "plannotator";
+ const targetFolder = join(normalizedVault, folderName);
+
+ // Create folder if it doesn't exist
+ mkdirSync(targetFolder, { recursive: true });
+
+ // Generate filename and full path
+ const filename = generateFilename(plan);
+ const filePath = join(targetFolder, filename);
+
+ // Generate content with frontmatter and backlink
+ const tags = extractTags(plan);
+ const frontmatter = generateFrontmatter(tags);
+ const content = `${frontmatter}\n\n[[Plannotator Plans]]\n\n${plan}`;
+
+ // Write file
+ await Bun.write(filePath, content);
+
+ return { success: true, path: filePath };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ return { success: false, error: message };
+ }
+}
// Embed the built HTML at compile time
import indexHtml from "../dist/index.html" with { type: "text" };
+// --- SSH Detection and Port Configuration ---
+
+const DEFAULT_SSH_PORT = 19432;
+
+function isSSHSession(): boolean {
+ // SSH_TTY is set when SSH allocates a pseudo-terminal
+ // SSH_CONNECTION contains "client_ip client_port server_ip server_port"
+ return !!(process.env.SSH_TTY || process.env.SSH_CONNECTION);
+}
+
+function getServerPort(): number {
+ // Explicit port from environment takes precedence
+ const envPort = process.env.PLANNOTATOR_PORT;
+ if (envPort) {
+ const parsed = parseInt(envPort, 10);
+ if (!isNaN(parsed) && parsed > 0 && parsed < 65536) {
+ return parsed;
+ }
+ console.error(`Warning: Invalid PLANNOTATOR_PORT "${envPort}", using default`);
+ }
+
+ // Over SSH, use fixed port for port forwarding; locally use random
+ return isSSHSession() ? DEFAULT_SSH_PORT : 0;
+}
+
+const isRemote = isSSHSession();
+const configuredPort = getServerPort();
+
// Read hook event from stdin
const eventJson = await Bun.stdin.text();
@@ -35,50 +267,122 @@ const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
(resolve) => { resolveDecision = resolve; }
);
-const server = Bun.serve({
- port: 0, // Random available port - critical for multi-instance support
+// --- Server with port conflict handling ---
- async fetch(req) {
- const url = new URL(req.url);
+const MAX_RETRIES = 5;
+const RETRY_DELAY_MS = 500;
- // API: Get plan content
- if (url.pathname === "/api/plan") {
- return Response.json({ plan: planContent });
- }
+async function startServer(): Promise> {
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ return Bun.serve({
+ port: configuredPort,
- // API: Approve plan
- if (url.pathname === "/api/approve" && req.method === "POST") {
- resolveDecision({ approved: true });
- return Response.json({ ok: true });
- }
+ async fetch(req) {
+ const url = new URL(req.url);
+
+ // API: Get plan content
+ if (url.pathname === "/api/plan") {
+ return Response.json({ plan: planContent });
+ }
+
+ // API: Detect Obsidian vaults
+ if (url.pathname === "/api/obsidian/vaults") {
+ const vaults = detectObsidianVaults();
+ return Response.json({ vaults });
+ }
+
+ // API: Approve plan
+ if (url.pathname === "/api/approve" && req.method === "POST") {
+ // Check for Obsidian integration
+ try {
+ const body = (await req.json().catch(() => ({}))) as {
+ obsidian?: ObsidianConfig;
+ };
- // API: Deny with feedback
- if (url.pathname === "/api/deny" && req.method === "POST") {
- try {
- const body = await req.json() as { feedback?: string };
- resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
- } catch {
- resolveDecision({ approved: false, feedback: "Plan rejected by user" });
+ if (body.obsidian?.vaultPath && body.obsidian?.plan) {
+ const result = await saveToObsidian(body.obsidian);
+ if (result.success) {
+ console.error(`[Obsidian] Saved plan to: ${result.path}`);
+ } else {
+ console.error(`[Obsidian] Save failed: ${result.error}`);
+ }
+ }
+ } catch (err) {
+ // Don't block approval on Obsidian errors
+ console.error(`[Obsidian] Error:`, err);
+ }
+
+ resolveDecision({ approved: true });
+ return Response.json({ ok: true });
+ }
+
+ // API: Deny with feedback
+ if (url.pathname === "/api/deny" && req.method === "POST") {
+ try {
+ const body = await req.json() as { feedback?: string };
+ resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
+ } catch {
+ resolveDecision({ approved: false, feedback: "Plan rejected by user" });
+ }
+ return Response.json({ ok: true });
+ }
+
+ // Serve embedded HTML for all other routes (SPA)
+ return new Response(indexHtml, {
+ headers: { "Content-Type": "text/html" }
+ });
+ },
+ });
+ } catch (err: unknown) {
+ const isAddressInUse = err instanceof Error && err.message.includes("EADDRINUSE");
+ if (isAddressInUse && attempt < MAX_RETRIES) {
+ console.error(`Port ${configuredPort} in use, retrying in ${RETRY_DELAY_MS}ms... (${attempt}/${MAX_RETRIES})`);
+ await Bun.sleep(RETRY_DELAY_MS);
+ continue;
}
- return Response.json({ ok: true });
+ if (isAddressInUse) {
+ console.error(`\nError: Port ${configuredPort} is already in use after ${MAX_RETRIES} retries.`);
+ if (isRemote) {
+ console.error(`Another Plannotator session may be running.`);
+ console.error(`To use a different port, set PLANNOTATOR_PORT environment variable.\n`);
+ }
+ process.exit(1);
+ }
+ throw err;
}
+ }
+ throw new Error("Unreachable");
+}
- // Serve embedded HTML for all other routes (SPA)
- return new Response(indexHtml, {
- headers: { "Content-Type": "text/html" }
- });
- },
-});
+const server = await startServer();
-// Log to stderr so it doesn't interfere with hook stdout
-console.error(`Plannotator server running on http://localhost:${server.port}`);
+// --- Conditional browser opening and messaging ---
-// Open browser
-try {
- await $`open http://localhost:${server.port}`.quiet();
-} catch {
- // Fallback for non-macOS
- console.error(`Open browser manually: http://localhost:${server.port}`);
+const serverUrl = `http://localhost:${server.port}`;
+console.error(`\nPlannotator server running on ${serverUrl}`);
+
+if (isRemote) {
+ // SSH session: print helpful setup instructions
+ console.error(`\n[SSH Remote Session Detected]`);
+ console.error(`Add this to your local ~/.ssh/config to access Plannotator:\n`);
+ console.error(` Host your-server-alias`);
+ console.error(` LocalForward ${server.port} localhost:${server.port}\n`);
+ console.error(`Then open ${serverUrl} in your local browser.\n`);
+} else {
+ // Local session: try to open browser (cross-platform)
+ try {
+ const platform = process.platform;
+ if (platform === "win32") {
+ await $`cmd /c start ${serverUrl}`.quiet();
+ } else if (platform === "darwin") {
+ await $`open ${serverUrl}`.quiet();
+ } else {
+ await $`xdg-open ${serverUrl}`.quiet();
+ }
+ } catch {
+ console.error(`Open browser manually: ${serverUrl}`);
+ }
}
// Wait for user decision (blocks until approve/deny)
diff --git a/apps/marketing/public/youtube-opencode.png b/apps/marketing/public/youtube-opencode.png
new file mode 100644
index 0000000..50fa004
Binary files /dev/null and b/apps/marketing/public/youtube-opencode.png differ
diff --git a/apps/mcp/README.md b/apps/mcp/README.md
new file mode 100644
index 0000000..d40a40f
--- /dev/null
+++ b/apps/mcp/README.md
@@ -0,0 +1,308 @@
+# Plannotator MCP Server
+
+Interactive plan review for AI coding agents via the **Model Context Protocol (MCP)**.
+
+## Compatible Tools
+
+Works with any MCP-compatible AI coding agent:
+
+| Tool | Status |
+|------|--------|
+| **Cursor** | ✅ Supported |
+| **GitHub Copilot** | ✅ Supported |
+| **Windsurf** | ✅ Supported |
+| **Cline** | ✅ Supported |
+| **RooCode** | ✅ Supported |
+| **KiloCode** | ✅ Supported |
+| **Google Antigravity** | ✅ Supported |
+| **Any MCP-compatible tool** | ✅ Supported |
+
+## Features
+
+- 🎯 **Visual Plan Review** - View implementation plans in a clean, readable UI
+- ✏️ **Rich Annotations** - Delete, insert, replace text, or add comments to specific sections
+- ✅ **One-Click Approval** - Approve plans to proceed with implementation
+- 🔄 **Structured Feedback** - Request changes with detailed, annotated feedback
+- 🔗 **Share Plans** - Share plans with team members via URL
+
+## Installation
+
+Add Plannotator to your MCP configuration file. The location and format varies by tool:
+
+### Cursor
+
+Add to `.cursor/mcp.json` in your project or `~/.cursor/mcp.json` globally:
+
+```json
+{
+ "mcpServers": {
+ "plannotator": {
+ "command": "bunx",
+ "args": ["@plannotator/mcp", "--mcp"]
+ }
+ }
+}
+```
+
+### GitHub Copilot (VS Code)
+
+Add to your VS Code settings or `.vscode/mcp.json`:
+
+```json
+{
+ "mcpServers": {
+ "plannotator": {
+ "command": "bunx",
+ "args": ["@plannotator/mcp", "--mcp"]
+ }
+ }
+}
+```
+
+### Windsurf
+
+Add to `~/.windsurf/mcp.json`:
+
+```json
+{
+ "mcpServers": {
+ "plannotator": {
+ "command": "bunx",
+ "args": ["@plannotator/mcp", "--mcp"]
+ }
+ }
+}
+```
+
+### Cline
+
+Add to Cline's MCP settings (accessible via Cline settings panel):
+
+```json
+{
+ "mcpServers": {
+ "plannotator": {
+ "command": "bunx",
+ "args": ["@plannotator/mcp", "--mcp"]
+ }
+ }
+}
+```
+
+### RooCode / KiloCode
+
+Add to your tool's MCP configuration:
+
+```json
+{
+ "mcpServers": {
+ "plannotator": {
+ "command": "bunx",
+ "args": ["@plannotator/mcp", "--mcp"]
+ }
+ }
+}
+```
+
+### Google Antigravity
+
+Add to `%USERPROFILE%\.gemini\antigravity\mcp_config.json` (Windows) or `~/.gemini/antigravity/mcp_config.json` (macOS/Linux):
+
+```json
+{
+ "mcpServers": {
+ "plannotator": {
+ "command": "bunx",
+ "args": ["@plannotator/mcp", "--mcp"]
+ }
+ }
+}
+```
+
+### Using npx instead of bunx
+
+If you prefer Node.js over Bun:
+
+```json
+{
+ "mcpServers": {
+ "plannotator": {
+ "command": "npx",
+ "args": ["-y", "@plannotator/mcp", "--mcp"]
+ }
+ }
+}
+```
+
+After adding the config, restart your tool. The `submit_plan` tool will be available to the agent.
+
+## How It Works
+
+1. **Agent Creates Plan** - When in planning mode, the AI agent generates an implementation plan
+2. **Submit for Review** - The agent calls the `submit_plan` tool
+3. **Plannotator Opens** - A browser window opens with the visual plan review UI
+4. **You Review & Annotate** - You can:
+ - Read through the plan
+ - Mark sections for deletion (strikethrough)
+ - Insert new content
+ - Replace text with alternatives
+ - Add comments to specific sections
+5. **Approve or Request Changes**
+ - **Approve** → Agent receives approval and proceeds with implementation
+ - **Request Changes** → Annotated feedback is sent back to the agent
+6. **Iterate** - If changes are requested, the agent revises and resubmits
+
+## The `submit_plan` Tool
+
+When the MCP server is configured, your AI agent gains access to this tool:
+
+```
+Tool: submit_plan
+
+Description: Submit your completed implementation plan for interactive user review.
+The user can annotate, approve, or request changes.
+
+Parameters:
+ - plan (required): The complete implementation plan in markdown format
+ - summary (optional): A brief 1-2 sentence summary of what the plan accomplishes
+```
+
+### Example Agent Usage
+
+The agent would use it like this:
+
+```
+I've completed my implementation plan. Let me submit it for your review.
+
+[calls submit_plan with plan content]
+```
+
+## Environment Variables
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `PLANNOTATOR_PORT` | Fixed port for the review UI | Random |
+
+## Requirements
+
+- **Bun** (recommended) or **Node.js** (for running the MCP server)
+- An MCP-compatible AI coding agent
+
+## Running as a Standalone Server
+
+For testing or debugging, you can run the MCP server directly:
+
+```bash
+# With Bun
+bunx @plannotator/mcp --mcp
+
+# Or install globally and run
+npm install -g @plannotator/mcp
+plannotator-mcp --mcp
+```
+
+## Troubleshooting
+
+### "Tool not found" in your AI tool
+
+1. Make sure the MCP config file is in the correct location for your tool
+2. Restart the AI tool after editing the config
+3. Check that Bun or Node.js is installed and in your PATH
+
+### Browser doesn't open automatically
+
+The server URL will be printed to stderr. Open it manually:
+```
+[Plannotator] Review your plan at: http://localhost:12345
+```
+
+### Port already in use
+
+Set a different port via environment variable:
+```json
+{
+ "mcpServers": {
+ "plannotator": {
+ "command": "bunx",
+ "args": ["@plannotator/mcp", "--mcp"],
+ "env": {
+ "PLANNOTATOR_PORT": "19433"
+ }
+ }
+ }
+}
+```
+
+### MCP connection issues
+
+Ensure Bun is installed:
+```bash
+# Install Bun
+curl -fsSL https://bun.sh/install | bash
+
+# Or use npm with Node.js
+npm install -g @plannotator/mcp
+```
+
+## Best Practices
+
+### Plan Format
+
+For best results, structure your plans with:
+
+```markdown
+# Implementation Plan: Feature Name
+
+## Overview
+High-level description of the approach.
+
+## Phase 1: Setup
+- [ ] Task 1
+- [ ] Task 2
+
+## Phase 2: Implementation
+### Step 2.1: Create Component
+- Details...
+
+### Step 2.2: Add Styling
+- Details...
+
+## Phase 3: Testing
+- Unit test approach
+- Integration test plan
+
+## Potential Challenges
+1. Challenge 1 and mitigation
+2. Challenge 2 and mitigation
+```
+
+## Development
+
+```bash
+# Clone the repository
+git clone https://github.com/backnotprop/plannotator.git
+cd plannotator
+
+# Install dependencies
+bun install
+
+# Build the UI first
+bun run build:hook
+
+# Build the MCP plugin
+bun run build:mcp
+
+# Test the MCP server
+bun run apps/mcp/index.ts --mcp
+```
+
+## License
+
+Copyright (c) 2025 backnotprop.
+This project is licensed under the Business Source License 1.1 (BSL).
+
+## Links
+
+- [Plannotator Website](https://plannotator.ai)
+- [GitHub Repository](https://github.com/backnotprop/plannotator)
+- [MCP Documentation](https://modelcontextprotocol.io)
diff --git a/apps/mcp/index.ts b/apps/mcp/index.ts
new file mode 100644
index 0000000..ff1e17d
--- /dev/null
+++ b/apps/mcp/index.ts
@@ -0,0 +1,375 @@
+/**
+ * Plannotator MCP Server
+ *
+ * A Model Context Protocol (MCP) server for interactive plan review.
+ * Works with any MCP-compatible AI coding agent including:
+ * - Cursor
+ * - GitHub Copilot
+ * - Windsurf
+ * - Cline
+ * - RooCode
+ * - KiloCode
+ * - Google Antigravity
+ * - And more...
+ *
+ * When the agent calls submit_plan, the Plannotator UI opens for the user to
+ * annotate, approve, or request changes to the plan.
+ *
+ * Usage:
+ * Add to your MCP configuration and run as MCP server:
+ * bunx @plannotator/mcp --mcp
+ *
+ * @packageDocumentation
+ */
+
+import { $ } from "bun";
+
+// @ts-ignore - Bun import attribute for text
+import indexHtml from "./plannotator.html" with { type: "text" };
+const htmlContent = indexHtml as unknown as string;
+
+// --- Types ---
+
+interface ServerResult {
+ port: number;
+ url: string;
+ waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>;
+ stop: () => void;
+}
+
+// --- Plannotator Server ---
+
+/**
+ * Start a Plannotator server instance to review a plan
+ */
+async function startPlannotatorServer(planContent: string): Promise {
+ let resolveDecision: (result: { approved: boolean; feedback?: string }) => void;
+ const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
+ (resolve) => { resolveDecision = resolve; }
+ );
+
+ // Use configured port or random
+ const port = process.env.PLANNOTATOR_PORT ? parseInt(process.env.PLANNOTATOR_PORT, 10) : 0;
+
+ const server = Bun.serve({
+ port,
+ async fetch(req: Request) {
+ const url = new URL(req.url);
+
+ // API: Get plan content
+ if (url.pathname === "/api/plan") {
+ return Response.json({ plan: planContent });
+ }
+
+ // API: Approve plan
+ if (url.pathname === "/api/approve" && req.method === "POST") {
+ resolveDecision({ approved: true });
+ return Response.json({ ok: true });
+ }
+
+ // API: Deny with feedback
+ if (url.pathname === "/api/deny" && req.method === "POST") {
+ try {
+ const body = await req.json() as { feedback?: string };
+ resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
+ } catch {
+ resolveDecision({ approved: false, feedback: "Plan rejected by user" });
+ }
+ return Response.json({ ok: true });
+ }
+
+ // Serve embedded HTML for all other routes (SPA)
+ return new Response(htmlContent, {
+ headers: { "Content-Type": "text/html" }
+ });
+ },
+ });
+
+ return {
+ port: server.port!,
+ url: `http://localhost:${server.port}`,
+ waitForDecision: () => decisionPromise,
+ stop: () => server.stop(),
+ };
+}
+
+/**
+ * Open a URL in the default browser (cross-platform)
+ */
+async function openBrowser(url: string): Promise {
+ try {
+ if (process.platform === "win32") {
+ await $`cmd /c start ${url}`.quiet();
+ } else if (process.platform === "darwin") {
+ await $`open ${url}`.quiet();
+ } else {
+ await $`xdg-open ${url}`.quiet();
+ }
+ } catch {
+ // Silently fail - user can open manually if needed
+ console.error(`[Plannotator] Open browser manually: ${url}`);
+ }
+}
+
+// --- MCP Tool Definition ---
+
+/**
+ * MCP Tool Definition for the Model Context Protocol
+ */
+const mcpToolDefinition = {
+ name: "submit_plan",
+ description: `Submit your completed implementation plan for interactive user review.
+
+The user will be able to:
+- Review your plan visually in a dedicated UI
+- Annotate specific sections with feedback (delete, insert, replace, comment)
+- Approve the plan to proceed with implementation
+- Request changes with detailed feedback
+
+Call this when you have finished creating your implementation plan.
+Do NOT proceed with implementation until your plan is approved.`,
+ inputSchema: {
+ type: "object" as const,
+ properties: {
+ plan: {
+ type: "string",
+ description: "The complete implementation plan in markdown format",
+ },
+ summary: {
+ type: "string",
+ description: "A brief 1-2 sentence summary of what the plan accomplishes",
+ },
+ },
+ required: ["plan"],
+ },
+};
+
+/**
+ * Handle the submit_plan tool call
+ */
+async function handleSubmitPlan(args: { plan: string; summary?: string }): Promise<{
+ content: Array<{ type: "text"; text: string }>;
+}> {
+ // Add summary header if provided
+ let fullPlan = args.plan;
+ if (args.summary) {
+ fullPlan = `> **Summary:** ${args.summary}\n\n${args.plan}`;
+ }
+
+ // Start the Plannotator server
+ const server = await startPlannotatorServer(fullPlan);
+ console.error(`\n[Plannotator] Review your plan at: ${server.url}\n`);
+
+ // Open browser automatically
+ await openBrowser(server.url);
+
+ // Wait for user decision
+ const result = await server.waitForDecision();
+
+ // Give browser time to receive response
+ await Bun.sleep(1500);
+
+ // Cleanup
+ server.stop();
+
+ if (result.approved) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `✅ Plan approved!
+
+Your plan has been approved by the user. You may now proceed with implementation.
+
+${args.summary ? `**Plan Summary:** ${args.summary}` : ""}`,
+ },
+ ],
+ };
+ } else {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `⚠️ Plan needs revision.
+
+The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly.
+
+## User Feedback
+
+${result.feedback}
+
+---
+
+Please revise your plan based on this feedback and call \`submit_plan\` again when ready.`,
+ },
+ ],
+ };
+ }
+}
+
+// --- MCP Server Implementation ---
+
+/**
+ * Run as MCP server for any MCP-compatible AI coding agent
+ *
+ * Usage: bunx @plannotator/mcp --mcp
+ */
+async function runMcpServer() {
+ console.error("[Plannotator] Starting MCP server...");
+
+ // Simple stdio-based MCP server
+ const stdin = Bun.stdin.stream();
+ const reader = stdin.getReader();
+ const decoder = new TextDecoder();
+
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete JSON-RPC messages (newline-delimited)
+ while (buffer.includes("\n")) {
+ const newlineIndex = buffer.indexOf("\n");
+ const line = buffer.slice(0, newlineIndex).trim();
+ buffer = buffer.slice(newlineIndex + 1);
+
+ if (!line) continue;
+
+ try {
+ const message = JSON.parse(line);
+ await handleMcpMessage(message);
+ } catch (e) {
+ console.error("[Plannotator] Failed to parse message:", e);
+ }
+ }
+ }
+}
+
+async function handleMcpMessage(message: {
+ jsonrpc: string;
+ id?: number | string;
+ method: string;
+ params?: Record;
+}) {
+ const { id, method, params } = message;
+
+ // Send JSON-RPC response to stdout
+ function respond(result: unknown) {
+ const response = JSON.stringify({ jsonrpc: "2.0", id, result });
+ console.log(response);
+ }
+
+ function respondError(code: number, errorMessage: string) {
+ const response = JSON.stringify({
+ jsonrpc: "2.0",
+ id,
+ error: { code, message: errorMessage },
+ });
+ console.log(response);
+ }
+
+ switch (method) {
+ case "initialize":
+ respond({
+ protocolVersion: "2024-11-05",
+ capabilities: {
+ tools: {},
+ },
+ serverInfo: {
+ name: "plannotator",
+ version: "0.3.0",
+ },
+ });
+ break;
+
+ case "tools/list":
+ respond({
+ tools: [mcpToolDefinition],
+ });
+ break;
+
+ case "tools/call":
+ if ((params as { name?: string })?.name === "submit_plan") {
+ const args = (params as { arguments?: { plan: string; summary?: string } })?.arguments;
+ if (!args?.plan) {
+ respondError(-32602, "Missing required parameter: plan");
+ return;
+ }
+ const result = await handleSubmitPlan(args);
+ respond(result);
+ } else {
+ respondError(-32601, `Unknown tool: ${(params as { name?: string })?.name}`);
+ }
+ break;
+
+ case "notifications/initialized":
+ // Acknowledgement, no response needed
+ break;
+
+ default:
+ if (id !== undefined) {
+ respondError(-32601, `Method not found: ${method}`);
+ }
+ }
+}
+
+// --- Main Entry Point ---
+
+if (process.argv.includes("--mcp")) {
+ runMcpServer().catch((err) => {
+ console.error("[Plannotator] Fatal error:", err);
+ process.exit(1);
+ });
+} else {
+ // Show usage if run without --mcp flag
+ console.log(`
+Plannotator MCP Server
+
+Interactive plan review for AI coding agents via the Model Context Protocol (MCP).
+
+Compatible with:
+ • Cursor
+ • GitHub Copilot
+ • Windsurf
+ • Cline
+ • RooCode
+ • KiloCode
+ • Google Antigravity
+ • Any MCP-compatible tool
+
+Usage:
+ bunx @plannotator/mcp --mcp
+
+Configuration:
+ Add to your MCP configuration file (mcp.json, mcp_config.json, etc.):
+
+ {
+ "mcpServers": {
+ "plannotator": {
+ "command": "bunx",
+ "args": ["@plannotator/mcp", "--mcp"]
+ }
+ }
+ }
+
+ Or with npx:
+
+ {
+ "mcpServers": {
+ "plannotator": {
+ "command": "npx",
+ "args": ["-y", "@plannotator/mcp", "--mcp"]
+ }
+ }
+ }
+
+Environment Variables:
+ PLANNOTATOR_PORT - Fixed port for the review UI (default: random)
+
+For more information, see:
+ https://github.com/backnotprop/plannotator
+`);
+}
diff --git a/apps/mcp/package.json b/apps/mcp/package.json
new file mode 100644
index 0000000..2f3ee71
--- /dev/null
+++ b/apps/mcp/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@plannotator/mcp",
+ "version": "0.3.0",
+ "description": "Plannotator MCP Server - interactive plan review with visual annotation for AI coding agents",
+ "author": "backnotprop",
+ "license": "BSL-1.1",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/backnotprop/plannotator.git",
+ "directory": "apps/mcp"
+ },
+ "homepage": "https://github.com/backnotprop/plannotator",
+ "bugs": {
+ "url": "https://github.com/backnotprop/plannotator/issues"
+ },
+ "keywords": [
+ "mcp",
+ "model-context-protocol",
+ "plannotator",
+ "plan-review",
+ "ai-agent",
+ "coding-agent",
+ "cursor",
+ "copilot",
+ "roocode",
+ "kilocode",
+ "windsurf",
+ "cline",
+ "antigravity"
+ ],
+ "type": "module",
+ "main": "index.ts",
+ "bin": {
+ "plannotator-mcp": "./index.ts"
+ },
+ "files": [
+ "index.ts",
+ "plannotator.html",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "cp ../hook/dist/index.html ./plannotator.html",
+ "prepublishOnly": "bun run build"
+ },
+ "peerDependencies": {
+ "bun": ">=1.0.0"
+ },
+ "devDependencies": {
+ "bun-types": "^1.0.0"
+ }
+}
\ No newline at end of file
diff --git a/apps/mcp/plannotator.html b/apps/mcp/plannotator.html
new file mode 100644
index 0000000..56529f7
--- /dev/null
+++ b/apps/mcp/plannotator.html
@@ -0,0 +1,452 @@
+
+
+
+
+
+ Plannotator
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json
new file mode 100644
index 0000000..b0c18a8
--- /dev/null
+++ b/apps/mcp/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "types": [
+ "bun-types"
+ ],
+ "lib": [
+ "ESNext"
+ ],
+ "declaration": true,
+ "outDir": "./dist"
+ },
+ "include": [
+ "*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ]
+}
\ No newline at end of file
diff --git a/apps/opencode-plugin/README.md b/apps/opencode-plugin/README.md
new file mode 100644
index 0000000..39c770d
--- /dev/null
+++ b/apps/opencode-plugin/README.md
@@ -0,0 +1,70 @@
+# @plannotator/opencode
+
+**Annotate plans. Not in the terminal.**
+
+Interactive Plan Review for OpenCode. Select the exact parts of the plan you want to change—mark for deletion, add a comment, or suggest a replacement. Feedback flows back to your agent automatically.
+
+Obsidian users can auto-save approved plans to Obsidian as well. [See details](#obsidian-integration)
+
+
+
+
+Watch Demo
+
+
+
+
+
+
+
+## Install
+
+Add to your `opencode.json`:
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "plugin": ["@plannotator/opencode"]
+}
+```
+
+Restart OpenCode. The `submit_plan` tool is now available.
+
+## How It Works
+
+1. Agent calls `submit_plan` → Plannotator opens in your browser
+2. Select text → annotate (delete, replace, comment)
+3. **Approve** → Agent proceeds with implementation
+4. **Request changes** → Annotations sent back as structured feedback
+
+## Features
+
+- **Visual annotations**: Select text, choose an action, see feedback in the sidebar
+- **Runs locally**: No network requests. Plans never leave your machine.
+- **Private sharing**: Plans and annotations compress into the URL itself—share a link, no accounts or backend required
+- **Obsidian integration**: Auto-save approved plans to your vault with frontmatter and tags
+
+## Obsidian Integration
+
+Save approved plans directly to your Obsidian vault.
+
+1. Open Settings in Plannotator UI
+2. Enable "Obsidian Integration" and select your vault
+3. Approved plans save automatically with:
+ - Human-readable filenames: `Title - Jan 2, 2026 2-30pm.md`
+ - YAML frontmatter (`created`, `source`, `tags`)
+ - Auto-extracted tags from plan title and code languages
+ - Backlink to `[[Plannotator Plans]]` for graph view
+
+
+
+
+## Links
+
+- [Website](https://plannotator.ai)
+- [GitHub](https://github.com/backnotprop/plannotator)
+- [Claude Code Plugin](https://github.com/backnotprop/plannotator/tree/main/apps/hook)
+
+## License
+
+Copyright (c) 2025 backnotprop. Licensed under [BSL-1.1](https://github.com/backnotprop/plannotator/blob/main/LICENSE).
diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts
new file mode 100644
index 0000000..82a27ae
--- /dev/null
+++ b/apps/opencode-plugin/index.ts
@@ -0,0 +1,157 @@
+/**
+ * Plannotator Plugin for OpenCode
+ *
+ * Provides a Claude Code-style planning experience with interactive plan review.
+ * When the agent calls submit_plan, the Plannotator UI opens for the user to
+ * annotate, approve, or request changes to the plan.
+ *
+ * @packageDocumentation
+ */
+
+import { type Plugin, tool } from "@opencode-ai/plugin";
+import { $ } from "bun";
+
+// @ts-ignore - Bun import attribute for text
+import indexHtml from "./plannotator.html" with { type: "text" };
+const htmlContent = indexHtml as unknown as string;
+
+interface ServerResult {
+ port: number;
+ url: string;
+ waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>;
+ stop: () => void;
+}
+
+async function startPlannotatorServer(planContent: string): Promise {
+ let resolveDecision: (result: { approved: boolean; feedback?: string }) => void;
+ const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
+ (resolve) => { resolveDecision = resolve; }
+ );
+
+ const server = Bun.serve({
+ port: 0,
+ async fetch(req) {
+ const url = new URL(req.url);
+
+ if (url.pathname === "/api/plan") {
+ return Response.json({ plan: planContent });
+ }
+
+ if (url.pathname === "/api/approve" && req.method === "POST") {
+ resolveDecision({ approved: true });
+ return Response.json({ ok: true });
+ }
+
+ if (url.pathname === "/api/deny" && req.method === "POST") {
+ try {
+ const body = await req.json() as { feedback?: string };
+ resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
+ } catch {
+ resolveDecision({ approved: false, feedback: "Plan rejected by user" });
+ }
+ return Response.json({ ok: true });
+ }
+
+ return new Response(htmlContent, {
+ headers: { "Content-Type": "text/html" }
+ });
+ },
+ });
+
+ return {
+ port: server.port!,
+ url: `http://localhost:${server.port}`,
+ waitForDecision: () => decisionPromise,
+ stop: () => server.stop(),
+ };
+}
+
+async function openBrowser(url: string): Promise {
+ try {
+ if (process.platform === "win32") {
+ await $`cmd /c start ${url}`.quiet();
+ } else if (process.platform === "darwin") {
+ await $`open ${url}`.quiet();
+ } else {
+ await $`xdg-open ${url}`.quiet();
+ }
+ } catch {
+ // Silently fail - user can open manually if needed
+ }
+}
+
+export const PlannotatorPlugin: Plugin = async (ctx) => {
+ return {
+ "experimental.chat.system.transform": async (_input, output) => {
+ output.system.push(`
+## Plan Submission
+
+When you have completed your plan, you MUST call the \`submit_plan\` tool to submit it for user review.
+The user will be able to:
+- Review your plan visually in a dedicated UI
+- Annotate specific sections with feedback
+- Approve the plan to proceed with implementation
+- Request changes with detailed feedback
+
+If your plan is rejected, you will receive the user's annotated feedback. Revise your plan
+based on their feedback and call submit_plan again.
+
+Do NOT proceed with implementation until your plan is approved.
+`);
+ },
+
+ tool: {
+ submit_plan: tool({
+ description:
+ "Submit your completed plan for interactive user review. The user can annotate, approve, or request changes. Call this when you have finished creating your implementation plan.",
+ args: {
+ plan: tool.schema
+ .string()
+ .describe("The complete implementation plan in markdown format"),
+ summary: tool.schema
+ .string()
+ .describe("A brief 1-2 sentence summary of what the plan accomplishes"),
+ },
+
+ async execute(args, _context) {
+ const server = await startPlannotatorServer(args.plan);
+ await openBrowser(server.url);
+
+ const result = await server.waitForDecision();
+ await Bun.sleep(1500);
+ server.stop();
+
+ if (result.approved) {
+ try {
+ await ctx.client.tui.executeCommand({
+ body: { command: "agent_cycle" },
+ });
+ } catch {
+ // Silently fail - agent switching is optional
+ }
+
+ return `Plan approved! Switching to build mode.
+
+Your plan has been approved by the user. You may now proceed with implementation.
+
+Plan Summary: ${args.summary}`;
+ } else {
+ return `Plan needs revision.
+
+The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly.
+
+## User Feedback
+
+${result.feedback}
+
+---
+
+Please revise your plan based on this feedback and call \`submit_plan\` again when ready.`;
+ }
+ },
+ }),
+ },
+ };
+};
+
+export default PlannotatorPlugin;
diff --git a/apps/opencode-plugin/package.json b/apps/opencode-plugin/package.json
new file mode 100644
index 0000000..bca55db
--- /dev/null
+++ b/apps/opencode-plugin/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@plannotator/opencode",
+ "version": "0.3.0",
+ "description": "Plannotator plugin for OpenCode - interactive plan review with visual annotation",
+ "author": "backnotprop",
+ "license": "BSL-1.1",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/backnotprop/plannotator.git",
+ "directory": "apps/opencode-plugin"
+ },
+ "homepage": "https://github.com/backnotprop/plannotator",
+ "bugs": {
+ "url": "https://github.com/backnotprop/plannotator/issues"
+ },
+ "keywords": [
+ "opencode",
+ "opencode-plugin",
+ "plannotator",
+ "plan-review",
+ "ai-agent",
+ "coding-agent"
+ ],
+ "main": "index.ts",
+ "files": [
+ "index.ts",
+ "plannotator.html",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "cp ../hook/dist/index.html ./plannotator.html",
+ "prepublishOnly": "bun run build"
+ },
+ "dependencies": {
+ "@opencode-ai/plugin": "^1.0.218"
+ },
+ "peerDependencies": {
+ "bun": ">=1.0.0"
+ }
+}
diff --git a/bun.lock b/bun.lock
index ab4d932..a9aa070 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,7 +4,7 @@
"": {
"name": "plannotator",
},
- "apps/hooks": {
+ "apps/hook": {
"name": "@plannotator/hooks",
"version": "0.0.1",
"dependencies": {
@@ -41,6 +41,18 @@
"vite": "^6.2.0",
},
},
+ "apps/opencode-plugin": {
+ "name": "@plannotator/opencode",
+ "version": "0.0.1",
+ "dependencies": {
+ "@opencode-ai/plugin": "latest",
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/node": "^22.14.0",
+ "typescript": "~5.8.2",
+ },
+ },
"apps/portal": {
"name": "@plannotator/portal",
"version": "0.0.1",
@@ -66,6 +78,7 @@
"@plannotator/ui": "workspace:*",
"react": "^19.2.3",
"react-dom": "^19.2.3",
+ "tailwindcss": "^4.1.18",
},
},
"packages/ui": {
@@ -181,12 +194,18 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.218", "", { "dependencies": { "@opencode-ai/sdk": "1.0.218", "zod": "4.1.8" } }, "sha512-7fsCx2SIu7TRNn3cdHiaIU7r1/XgnVTBufGnlezH+OZwHP+gILBjqtuMn+1L0e1npnl12CvS3FRNvg/3jr6yhA=="],
+
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.218", "", {}, "sha512-c6ss6UPAMskSVQUecuhNvPLFngyVh2Os9o0kpVjoqJJ16HXhzjVSk5axgh3ueQrfP5aZfg5o6l6srmjuCTPNnQ=="],
+
"@plannotator/editor": ["@plannotator/editor@workspace:packages/editor"],
- "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hooks"],
+ "@plannotator/hooks": ["@plannotator/hooks@workspace:apps/hook"],
"@plannotator/marketing": ["@plannotator/marketing@workspace:apps/marketing"],
+ "@plannotator/opencode": ["@plannotator/opencode@workspace:apps/opencode-plugin"],
+
"@plannotator/portal": ["@plannotator/portal@workspace:apps/portal"],
"@plannotator/ui": ["@plannotator/ui@workspace:packages/ui"],
@@ -275,6 +294,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+ "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
@@ -287,6 +308,8 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
+ "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
+
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -405,6 +428,8 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
+
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
diff --git a/package.json b/package.json
index 356fa25..7553a9e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "plannotator",
- "version": "0.1.0",
+ "version": "0.3.0",
"private": true,
"description": "Interactive Plan Review for Claude Code - annotate plans visually, share with team, automatically send feedback",
"author": "backnotprop",
@@ -13,13 +13,20 @@
"bugs": {
"url": "https://github.com/backnotprop/plannotator/issues"
},
- "workspaces": ["apps/*", "packages/*"],
+ "workspaces": [
+ "apps/*",
+ "packages/*"
+ ],
"scripts": {
"dev:hook": "bun run --cwd apps/hook dev",
"dev:portal": "bun run --cwd apps/portal dev",
"dev:marketing": "bun run --cwd apps/marketing dev",
+ "dev:mcp": "bun run --cwd apps/mcp dev",
"build:hook": "bun run --cwd apps/hook build",
"build:portal": "bun run --cwd apps/portal build",
- "build:marketing": "bun run --cwd apps/marketing build"
+ "build:marketing": "bun run --cwd apps/marketing build",
+ "build:opencode": "bun run --cwd apps/opencode-plugin build",
+ "build:mcp": "bun run --cwd apps/mcp build",
+ "build": "bun run build:hook && bun run build:opencode && bun run build:mcp"
}
-}
+}
\ No newline at end of file
diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx
index 455a002..89b8724 100644
--- a/packages/editor/App.tsx
+++ b/packages/editor/App.tsx
@@ -13,6 +13,7 @@ import { Settings } from '@plannotator/ui/components/Settings';
import { useSharing } from '@plannotator/ui/hooks/useSharing';
import { storage } from '@plannotator/ui/utils/storage';
import { UpdateBanner } from '@plannotator/ui/components/UpdateBanner';
+import { getObsidianSettings } from '@plannotator/ui/utils/obsidian';
const PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration
@@ -39,8 +40,17 @@ server.on('connection', (socket, request) => {
### Client Connection
- Establish persistent connection on document load
+ - Initialize WebSocket with authentication token
+ - Set up heartbeat ping/pong every 30 seconds
+ - Handle connection state changes (connecting, open, closing, closed)
- Implement reconnection logic with exponential backoff
+ - Start with 1 second delay
+ - Double delay on each retry (max 30 seconds)
+ - Reset delay on successful connection
- Handle offline state gracefully
+ - Queue local changes in IndexedDB
+ - Show offline indicator in UI
+ - Sync queued changes on reconnect
### Database Schema
@@ -71,9 +81,20 @@ CREATE INDEX idx_collaborators_document ON collaborators(document_id);
Key requirements:
- Transform insert against insert
+ - Same position: use user ID for deterministic ordering
+ - Different positions: adjust offset of later operation
- Transform insert against delete
+ - Insert before delete: no change needed
+ - Insert inside deleted range: special handling required
+ - Option A: Move insert to delete start position
+ - Option B: Discard the insert entirely
+ - Insert after delete: adjust insert position
- Transform delete against delete
+ - Non-overlapping: adjust positions
+ - Overlapping: merge or split operations
- Maintain cursor positions across transforms
+ - Track cursor as a zero-width insert operation
+ - Update cursor position after each transform
### Transform Implementation
@@ -127,9 +148,26 @@ class OperationalTransform {
## Phase 3: UI Updates
1. Show collaborator cursors in real-time
+ - Render cursor as colored vertical line
+ - Add name label above cursor
+ - Animate cursor movement smoothly
2. Display presence indicators
+ - Avatar stack in header
+ - Dropdown with full collaborator list
+ - Show online/away status
+ - Display last activity time
+ - Allow @mentioning collaborators
3. Add conflict resolution UI
+ - Highlight conflicting regions
+ - Show diff comparison panel
+ - Provide merge options:
+ - Accept mine
+ - Accept theirs
+ - Manual merge
4. Implement undo/redo stack per user
+ - Track operations by user ID
+ - Allow undoing only own changes
+ - Show undo history in sidebar
### React Component for Cursors
@@ -209,6 +247,47 @@ export const CursorOverlay: React.FC = ({
---
+## Pre-launch Checklist
+
+- [ ] Infrastructure ready
+ - [x] WebSocket server deployed
+ - [x] Database migrations applied
+ - [ ] Load balancer configured
+ - [ ] SSL certificates installed
+ - [ ] Health checks enabled
+ - [ ] /health endpoint returns 200
+ - [ ] /ready endpoint checks DB connection
+ - [ ] Primary database
+ - [ ] Read replicas
+ - [ ] us-east-1 replica
+ - [ ] eu-west-1 replica
+- [ ] Security audit complete
+ - [x] Authentication flow reviewed
+ - [ ] Rate limiting implemented
+ - [x] 100 req/min for anonymous users
+ - [ ] 1000 req/min for authenticated users
+ - [ ] Input sanitization verified
+- [x] Documentation updated
+ - [x] API reference generated
+ - [x] Integration guide written
+ - [ ] Video tutorials recorded
+
+### Mixed List Styles
+
+* Asterisk item at level 0
+ - Dash item at level 1
+ * Asterisk at level 2
+ - Dash at level 3
+ * Asterisk at level 4
+ - Maximum reasonable depth
+1. Numbered item
+ - Sub-bullet under numbered
+ - Another sub-bullet
+ 1. Nested numbered list
+ 2. Second nested number
+
+---
+
**Target:** Ship MVP in next sprint
`;
@@ -299,7 +378,24 @@ const App: React.FC = () => {
const handleApprove = async () => {
setIsSubmitting(true);
try {
- await fetch('/api/approve', { method: 'POST' });
+ const obsidianSettings = getObsidianSettings();
+
+ // Build request body - include obsidian config if enabled
+ const body = obsidianSettings.enabled && obsidianSettings.vaultPath
+ ? {
+ obsidian: {
+ vaultPath: obsidianSettings.vaultPath,
+ folder: obsidianSettings.folder || 'plannotator',
+ plan: markdown,
+ },
+ }
+ : {};
+
+ await fetch('/api/approve', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
setSubmitted('approved');
} catch {
setIsSubmitting(false);
@@ -356,7 +452,7 @@ const App: React.FC = () => {
>
Plannotator
- v0.1
+ v{typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'}
diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx
index 67dbcce..13db194 100644
--- a/packages/ui/components/AnnotationPanel.tsx
+++ b/packages/ui/components/AnnotationPanel.tsx
@@ -168,10 +168,30 @@ const AnnotationCard: React.FC<{
)
+ },
+ [AnnotationType.GLOBAL_COMMENT]: {
+ label: 'Global',
+ color: 'text-purple-500',
+ bg: 'bg-purple-500/10',
+ icon: (
+
+
+
+ )
}
};
- const config = typeConfig[annotation.type];
+ // Fallback for unknown types (forward compatibility)
+ const config = typeConfig[annotation.type] || {
+ label: 'Note',
+ color: 'text-muted-foreground',
+ bg: 'bg-muted/50',
+ icon: (
+
+
+
+ )
+ };
return (
- {/* Original Text */}
-
- "{annotation.originalText}"
-
-
- {/* Comment/Replacement Text */}
- {annotation.text && annotation.type !== AnnotationType.DELETION && (
-
+ {/* Global Comment - show text directly */}
+ {annotation.type === AnnotationType.GLOBAL_COMMENT ? (
+
{annotation.text}
+ ) : (
+ <>
+ {/* Original Text */}
+
+ "{annotation.originalText}"
+
+
+ {/* Comment/Replacement Text */}
+ {annotation.text && annotation.type !== AnnotationType.DELETION && (
+
+ {annotation.text}
+
+ )}
+ >
)}
);
diff --git a/packages/ui/components/Landing.tsx b/packages/ui/components/Landing.tsx
index 761e4b3..ca2b38e 100644
--- a/packages/ui/components/Landing.tsx
+++ b/packages/ui/components/Landing.tsx
@@ -47,7 +47,7 @@ export const Landing: React.FC
= ({ onEnter }) => {
- For Claude Code
+ For Claude Code & OpenCode
@@ -66,71 +66,82 @@ export const Landing: React.FC = ({ onEnter }) => {
- Interactive Plan Review: Mark up and refine your plans using a UI,
- easily share for team collaboration, automatically integrates with
- agent plan mode.
+ Interactive Plan Review for coding agents. Mark up and refine plans visually,
+ share for team collaboration. Works with Claude Code and OpenCode.
-
@@ -143,10 +154,9 @@ export const Landing: React.FC
= ({ onEnter }) => {
The Problem
- Claude Code shows plans in the terminal. You read them, approve
- or deny, but giving specific feedback means typing everything
- out. Hard to reference exact sections. Zero team collaboration
- features.
+ Coding agents show plans in the terminal. You approve or deny, but
+ giving specific feedback means typing everything out. Hard to
+ reference exact sections. Zero collaboration features.
@@ -154,10 +164,9 @@ export const Landing: React.FC
= ({ onEnter }) => {
The Solution
- Select the exact parts of the plan you want to change. Mark it
- for deletion, add a comment, or suggest a replacement. Share
- plans and collect team member feedback. Automatically send
- feedback for Claude Code to act on.
+ Select the exact parts of the plan you want to change. Mark for
+ deletion, add a comment, or suggest a replacement. Share plans
+ with your team. Feedback flows back to your agent automatically.
@@ -224,6 +233,31 @@ export const Landing: React.FC = ({ onEnter }) => {
+
+
+
+
Save to Obsidian.
+
+ Approved plans auto-save to your vault with frontmatter and
+ auto-extracted tags. Build a searchable archive of every plan
+ your agents create.
+
+
+
@@ -344,20 +378,21 @@ export const Landing: React.FC = ({ onEnter }) => {
How it works
-
-
- ExitPlanMode → opens Plannotator with plan content
-
+
+
+ Claude Code: ExitPlanMode hook opens UI
+ OpenCode: Agent calls submit_plan tool
+
- Select text → choose action (delete, comment) → annotations appear
+ Select text → choose action (delete, comment, replace) → annotations appear
in the sidebar
- Click approve to proceed, or deny with annotations. Feedback flows
- back to Claude automatically via hooks.
+ Click approve to proceed, or provide feedback with annotations.
+ Feedback flows back to your agent automatically.
@@ -372,7 +407,11 @@ export const Landing: React.FC = ({ onEnter }) => {
•
- Claude Code plugin with PermissionRequest hook on ExitPlanMode
+ Claude Code: Binary + plugin with PermissionRequest hook
+
+
+ •
+ OpenCode: npm package with submit_plan tool
•
diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx
index 0fe2692..c3185fd 100644
--- a/packages/ui/components/Settings.tsx
+++ b/packages/ui/components/Settings.tsx
@@ -2,6 +2,11 @@ import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { TaterSpritePullup } from './TaterSpritePullup';
import { getIdentity, regenerateIdentity } from '../utils/identity';
+import {
+ getObsidianSettings,
+ saveObsidianSettings,
+ type ObsidianSettings,
+} from '../utils/obsidian';
interface SettingsProps {
taterMode: boolean;
@@ -12,13 +17,45 @@ interface SettingsProps {
export const Settings: React.FC = ({ taterMode, onTaterModeChange, onIdentityChange }) => {
const [showDialog, setShowDialog] = useState(false);
const [identity, setIdentity] = useState('');
+ const [obsidian, setObsidian] = useState({
+ enabled: false,
+ vaultPath: '',
+ folder: 'plannotator',
+ });
+ const [detectedVaults, setDetectedVaults] = useState([]);
+ const [vaultsLoading, setVaultsLoading] = useState(false);
useEffect(() => {
if (showDialog) {
setIdentity(getIdentity());
+ setObsidian(getObsidianSettings());
}
}, [showDialog]);
+ // Fetch detected vaults when Obsidian is enabled
+ useEffect(() => {
+ if (obsidian.enabled && detectedVaults.length === 0 && !vaultsLoading) {
+ setVaultsLoading(true);
+ fetch('/api/obsidian/vaults')
+ .then(res => res.json())
+ .then((data: { vaults: string[] }) => {
+ setDetectedVaults(data.vaults || []);
+ // Auto-select first vault if none set
+ if (data.vaults?.length > 0 && !obsidian.vaultPath) {
+ handleObsidianChange({ vaultPath: data.vaults[0] });
+ }
+ })
+ .catch(() => setDetectedVaults([]))
+ .finally(() => setVaultsLoading(false));
+ }
+ }, [obsidian.enabled]);
+
+ const handleObsidianChange = (updates: Partial) => {
+ const newSettings = { ...obsidian, ...updates };
+ setObsidian(newSettings);
+ saveObsidianSettings(newSettings);
+ };
+
const handleRegenerateIdentity = () => {
const oldIdentity = identity;
const newIdentity = regenerateIdentity();
@@ -104,6 +141,100 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange
/>
+
+
+
+ {/* Obsidian Integration */}
+
+
+
+
Obsidian Integration
+
+ Auto-save approved plans to your vault
+
+
+
handleObsidianChange({ enabled: !obsidian.enabled })}
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+ obsidian.enabled ? 'bg-primary' : 'bg-muted'
+ }`}
+ >
+
+
+
+
+ {obsidian.enabled && (
+
+ {/* Vault Path */}
+
+
Vault
+ {vaultsLoading ? (
+
+ Detecting vaults...
+
+ ) : detectedVaults.length > 0 ? (
+
handleObsidianChange({ vaultPath: e.target.value })}
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
+ >
+ {detectedVaults.map((vault) => (
+
+ {vault.split('/').pop() || vault}
+
+ ))}
+
+ ) : (
+
handleObsidianChange({ vaultPath: e.target.value })}
+ placeholder="/Users/you/Documents/MyVault"
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
+ />
+ )}
+ {detectedVaults.length === 0 && !vaultsLoading && (
+
+ No vaults detected. Enter path manually.
+
+ )}
+
+
+ {/* Folder */}
+
+
Folder
+
handleObsidianChange({ folder: e.target.value })}
+ placeholder="plannotator"
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
+ />
+
+ Plans saved to: {obsidian.vaultPath || '...'}/{obsidian.folder || 'plannotator'}/
+
+
+
+ {/* Frontmatter Preview */}
+
+
Frontmatter (auto-generated)
+
+{`---
+created: ${new Date().toISOString().slice(0, 19)}Z
+source: plannotator
+tags: [plan, ...]
+---`}
+
+
+
+ )}
+
,
diff --git a/packages/ui/components/Toolbar.tsx b/packages/ui/components/Toolbar.tsx
index 79fbe57..d2010ae 100644
--- a/packages/ui/components/Toolbar.tsx
+++ b/packages/ui/components/Toolbar.tsx
@@ -1,6 +1,6 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { AnnotationType } from '../types';
-import { createPortal } from 'react-dom';
+import React, { useState, useEffect, useRef } from "react";
+import { AnnotationType } from "../types";
+import { createPortal } from "react-dom";
interface ToolbarProps {
highlightElement: HTMLElement | null;
@@ -8,21 +8,28 @@ interface ToolbarProps {
onClose: () => void;
}
-export const Toolbar: React.FC = ({ highlightElement, onAnnotate, onClose }) => {
- const [step, setStep] = useState<'menu' | 'input'>('menu');
+export const Toolbar: React.FC = ({
+ highlightElement,
+ onAnnotate,
+ onClose,
+}) => {
+ const [step, setStep] = useState<"menu" | "input">("menu");
const [activeType, setActiveType] = useState(null);
- const [inputValue, setInputValue] = useState('');
- const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
- const inputRef = useRef(null);
+ const [inputValue, setInputValue] = useState("");
+ const [position, setPosition] = useState<{
+ top: number;
+ left: number;
+ } | null>(null);
+ const inputRef = useRef(null);
useEffect(() => {
- if (step === 'input') inputRef.current?.focus();
+ if (step === "input") inputRef.current?.focus();
}, [step]);
useEffect(() => {
- setStep('menu');
+ setStep("menu");
setActiveType(null);
- setInputValue('');
+ setInputValue("");
}, [highlightElement]);
// Update position on scroll/resize
@@ -36,8 +43,12 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate,
const rect = highlightElement.getBoundingClientRect();
const toolbarTop = rect.top - 48;
- // If selection scrolled out of viewport, close the toolbar
- if (rect.bottom < 0 || rect.top > window.innerHeight) {
+ // If selection scrolled out of viewport, only close if still in menu step
+ // Don't close when user is typing - they can scroll back to continue
+ if (
+ step === "menu" &&
+ (rect.bottom < 0 || rect.top > window.innerHeight)
+ ) {
onClose();
return;
}
@@ -49,14 +60,14 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate,
};
updatePosition();
- window.addEventListener('scroll', updatePosition, true);
- window.addEventListener('resize', updatePosition);
+ window.addEventListener("scroll", updatePosition, true);
+ window.addEventListener("resize", updatePosition);
return () => {
- window.removeEventListener('scroll', updatePosition, true);
- window.removeEventListener('resize', updatePosition);
+ window.removeEventListener("scroll", updatePosition, true);
+ window.removeEventListener("resize", updatePosition);
};
- }, [highlightElement, onClose]);
+ }, [highlightElement, onClose, step]);
if (!highlightElement || !position) return null;
@@ -67,7 +78,7 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate,
onAnnotate(type);
} else {
setActiveType(type);
- setStep('input');
+ setStep("input");
}
};
@@ -82,15 +93,25 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate,
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
>
- {step === 'menu' ? (
+ {step === "menu" ? (
handleTypeSelect(AnnotationType.DELETION)}
icon={
-
-
+
+
}
label="Delete"
@@ -99,8 +120,18 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate,
handleTypeSelect(AnnotationType.COMMENT)}
icon={
-
-
+
+
}
label="Comment"
@@ -110,8 +141,18 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate,
-
+
+
}
label="Cancel"
@@ -119,30 +160,55 @@ export const Toolbar: React.FC = ({ highlightElement, onAnnotate,
/>
) : (
-
diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx
index 837716b..1fa8a57 100644
--- a/packages/ui/components/Viewer.tsx
+++ b/packages/ui/components/Viewer.tsx
@@ -36,6 +36,9 @@ export const Viewer = forwardRef
(({
taterMode
}, ref) => {
const [copied, setCopied] = useState(false);
+ const [showGlobalCommentInput, setShowGlobalCommentInput] = useState(false);
+ const [globalCommentValue, setGlobalCommentValue] = useState('');
+ const globalCommentInputRef = useRef(null);
const handleCopyPlan = async () => {
try {
@@ -46,6 +49,32 @@ export const Viewer = forwardRef(({
console.error('Failed to copy:', e);
}
};
+
+ const handleAddGlobalComment = () => {
+ if (!globalCommentValue.trim()) return;
+
+ const newAnnotation: Annotation = {
+ id: `global-${Date.now()}`,
+ blockId: '',
+ startOffset: 0,
+ endOffset: 0,
+ type: AnnotationType.GLOBAL_COMMENT,
+ text: globalCommentValue.trim(),
+ originalText: '',
+ createdA: Date.now(),
+ author: getIdentity(),
+ };
+
+ onAddAnnotation(newAnnotation);
+ setGlobalCommentValue('');
+ setShowGlobalCommentInput(false);
+ };
+
+ useEffect(() => {
+ if (showGlobalCommentInput) {
+ globalCommentInputRef.current?.focus();
+ }
+ }, [showGlobalCommentInput]);
const containerRef = useRef(null);
const highlighterRef = useRef(null);
const modeRef = useRef(mode);
@@ -481,28 +510,87 @@ export const Viewer = forwardRef(({
ref={containerRef}
className="w-full max-w-3xl bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-10 lg:p-14 relative"
>
- {/* Copy plan button */}
-
- {copied ? (
- <>
-
-
-
- Copied!
- >
+ {/* Header buttons */}
+
+ {/* Global comment button/input */}
+ {showGlobalCommentInput ? (
+
{
+ e.preventDefault();
+ handleAddGlobalComment();
+ }}
+ className="flex items-center gap-1.5 bg-muted/80 rounded-md p-1"
+ >
+ setGlobalCommentValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') {
+ setShowGlobalCommentInput(false);
+ setGlobalCommentValue('');
+ }
+ }}
+ />
+
+ Add
+
+ {
+ setShowGlobalCommentInput(false);
+ setGlobalCommentValue('');
+ }}
+ className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
+ >
+
+
+
+
+
) : (
- <>
+
setShowGlobalCommentInput(true)}
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
+ title="Add global comment"
+ >
-
+
- Copy plan
- >
+ Global comment
+
)}
-
+
+ {/* Copy plan button */}
+
+ {copied ? (
+ <>
+
+
+
+ Copied!
+ >
+ ) : (
+ <>
+
+
+
+ Copy plan
+ >
+ )}
+
+
{blocks.map(block => (
block.type === 'code' ? (
= ({ block }) => {
);
- case 'list-item':
+ case 'list-item': {
+ const indent = (block.level || 0) * 1.25; // 1.25rem per level
+ const isCheckbox = block.checked !== undefined;
return (
-
-
•
-
+
+
+ {isCheckbox ? (
+ block.checked ? (
+
+
+
+ ) : (
+
+
+
+ )
+ ) : (
+
+ {(block.level || 0) === 0 ? '•' : (block.level || 0) === 1 ? '◦' : '▪'}
+
+ )}
+
+
+
+
);
+ }
case 'code':
return
;
@@ -834,7 +947,7 @@ const CodeBlockToolbar: React.FC<{
const [step, setStep] = useState<'menu' | 'input'>('menu');
const [inputValue, setInputValue] = useState('');
const [position, setPosition] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
- const inputRef = useRef
(null);
+ const inputRef = useRef(null);
useEffect(() => {
if (step === 'input') inputRef.current?.focus();
@@ -935,20 +1048,27 @@ const CodeBlockToolbar: React.FC<{
) : (
-
-
+ setInputValue(e.target.value)}
- onKeyDown={e => e.key === 'Escape' && setStep('menu')}
+ onKeyDown={e => {
+ if (e.key === 'Escape') setStep('menu');
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && inputValue.trim()) {
+ e.preventDefault();
+ onAnnotate(AnnotationType.COMMENT, inputValue);
+ }
+ }}
/>
Save
diff --git a/packages/ui/types.ts b/packages/ui/types.ts
index 0a95486..5492f3a 100644
--- a/packages/ui/types.ts
+++ b/packages/ui/types.ts
@@ -3,6 +3,7 @@ export enum AnnotationType {
INSERTION = 'INSERTION',
REPLACEMENT = 'REPLACEMENT',
COMMENT = 'COMMENT',
+ GLOBAL_COMMENT = 'GLOBAL_COMMENT',
}
export type EditorMode = 'selection' | 'redline';
@@ -34,8 +35,9 @@ export interface Block {
id: string;
type: 'paragraph' | 'heading' | 'blockquote' | 'list-item' | 'code' | 'hr' | 'table';
content: string; // Plain text content
- level?: number; // For headings (1-6)
+ level?: number; // For headings (1-6) or list indentation
language?: string; // For code blocks (e.g., 'rust', 'typescript')
+ checked?: boolean; // For checkbox list items (true = checked, false = unchecked, undefined = not a checkbox)
order: number; // Sorting order
startLine: number; // 1-based line number in source
}
diff --git a/packages/ui/utils/obsidian.ts b/packages/ui/utils/obsidian.ts
new file mode 100644
index 0000000..1a10d6f
--- /dev/null
+++ b/packages/ui/utils/obsidian.ts
@@ -0,0 +1,153 @@
+/**
+ * Obsidian Integration Utility
+ *
+ * Manages settings for auto-saving plans to Obsidian vaults.
+ * Settings are stored in cookies (like other settings) so they persist
+ * across different ports used by the hook server.
+ */
+
+import { storage } from './storage';
+
+// Storage keys
+const STORAGE_KEY_ENABLED = 'plannotator-obsidian-enabled';
+const STORAGE_KEY_VAULT = 'plannotator-obsidian-vault';
+const STORAGE_KEY_FOLDER = 'plannotator-obsidian-folder';
+
+// Default folder name in the vault
+const DEFAULT_FOLDER = 'plannotator';
+
+/**
+ * Obsidian integration settings
+ */
+export interface ObsidianSettings {
+ enabled: boolean;
+ vaultPath: string;
+ folder: string;
+}
+
+/**
+ * Get current Obsidian settings from storage
+ */
+export function getObsidianSettings(): ObsidianSettings {
+ return {
+ enabled: storage.getItem(STORAGE_KEY_ENABLED) === 'true',
+ vaultPath: storage.getItem(STORAGE_KEY_VAULT) || '',
+ folder: storage.getItem(STORAGE_KEY_FOLDER) || DEFAULT_FOLDER,
+ };
+}
+
+/**
+ * Save Obsidian settings to storage
+ */
+export function saveObsidianSettings(settings: ObsidianSettings): void {
+ storage.setItem(STORAGE_KEY_ENABLED, String(settings.enabled));
+ storage.setItem(STORAGE_KEY_VAULT, settings.vaultPath);
+ storage.setItem(STORAGE_KEY_FOLDER, settings.folder);
+}
+
+/**
+ * Check if Obsidian integration is properly configured
+ */
+export function isObsidianConfigured(): boolean {
+ const settings = getObsidianSettings();
+ return settings.enabled && settings.vaultPath.trim().length > 0;
+}
+
+/**
+ * Extract tags from markdown content using simple heuristics
+ *
+ * Extracts:
+ * - Words from the first H1 title (excluding common words)
+ * - Code fence languages (```typescript, ```sql, etc.)
+ * - Always includes "plan" as base tag
+ *
+ * @param markdown - The markdown content to extract tags from
+ * @returns Array of lowercase tag strings (max 6)
+ */
+export function extractTags(markdown: string): string[] {
+ const tags = new Set(['plan']);
+
+ // Common words to exclude from title extraction
+ const stopWords = new Set([
+ 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'into',
+ 'plan', 'implementation', 'overview', 'phase', 'step', 'steps',
+ ]);
+
+ // 1. Extract from first H1 title
+ // Matches: "# Title" or "# Implementation Plan: Title" or "# Plan: Title"
+ const h1Match = markdown.match(/^#\s+(?:Implementation\s+Plan:|Plan:)?\s*(.+)$/im);
+ if (h1Match) {
+ const titleWords = h1Match[1]
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, ' ') // Remove special chars except hyphens
+ .split(/\s+/)
+ .filter(word => word.length > 2 && !stopWords.has(word));
+
+ // Add first 3 meaningful words from title
+ titleWords.slice(0, 3).forEach(word => tags.add(word));
+ }
+
+ // 2. Extract code fence languages
+ // Matches: ```typescript, ```sql, ```rust, etc.
+ const langMatches = markdown.matchAll(/```(\w+)/g);
+ const seenLangs = new Set();
+
+ for (const [, lang] of langMatches) {
+ const normalizedLang = lang.toLowerCase();
+ // Skip generic/config languages and duplicates
+ if (!seenLangs.has(normalizedLang) &&
+ !['json', 'yaml', 'yml', 'text', 'txt', 'markdown', 'md'].includes(normalizedLang)) {
+ seenLangs.add(normalizedLang);
+ tags.add(normalizedLang);
+ }
+ }
+
+ // Return max 6 tags
+ return Array.from(tags).slice(0, 6);
+}
+
+/**
+ * Generate YAML frontmatter for an Obsidian note
+ *
+ * @param tags - Array of tags to include
+ * @returns Frontmatter string including opening and closing ---
+ */
+export function generateFrontmatter(tags: string[]): string {
+ const now = new Date().toISOString();
+ const tagList = tags.map(t => t.toLowerCase()).join(', ');
+
+ return `---
+created: ${now}
+source: plannotator
+tags: [${tagList}]
+---`;
+}
+
+/**
+ * Generate a filename for the plan note
+ * Format: YYYY-MM-DD-HHmm.md (e.g., 2026-01-02-1430.md)
+ *
+ * @returns Filename string
+ */
+export function generateFilename(): string {
+ const now = new Date();
+ const timestamp = now.toISOString()
+ .slice(0, 16) // "2026-01-02T14:30"
+ .replace('T', '-') // "2026-01-02-14:30"
+ .replace(/:/g, ''); // "2026-01-02-1430"
+
+ return `${timestamp}.md`;
+}
+
+/**
+ * Prepare the full note content with frontmatter
+ *
+ * @param markdown - The plan markdown content
+ * @returns Full note content with frontmatter prepended
+ */
+export function prepareNoteContent(markdown: string): string {
+ const tags = extractTags(markdown);
+ const frontmatter = generateFrontmatter(tags);
+
+ return `${frontmatter}\n\n${markdown}`;
+}
diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts
index f1dc0a1..d199c0a 100644
--- a/packages/ui/utils/parser.ts
+++ b/packages/ui/utils/parser.ts
@@ -66,10 +66,29 @@ export const parseMarkdownToBlocks = (markdown: string): Block[] => {
// List Items (Simple detection)
if (trimmed.match(/^(\*|-|\d+\.)\s/)) {
flush(); // Treat each list item as a separate block for easier annotation
+ // Calculate indentation level from leading whitespace
+ const leadingWhitespace = line.match(/^(\s*)/)?.[1] || '';
+ // Count spaces (2 spaces = 1 level) or tabs (1 tab = 1 level)
+ const spaceCount = leadingWhitespace.replace(/\t/g, ' ').length;
+ const listLevel = Math.floor(spaceCount / 2);
+
+ // Remove list marker
+ let content = trimmed.replace(/^(\*|-|\d+\.)\s/, '');
+
+ // Check for checkbox syntax: [ ] or [x] or [X]
+ let checked: boolean | undefined = undefined;
+ const checkboxMatch = content.match(/^\[([ xX])\]\s*/);
+ if (checkboxMatch) {
+ checked = checkboxMatch[1].toLowerCase() === 'x';
+ content = content.replace(/^\[([ xX])\]\s*/, '');
+ }
+
blocks.push({
id: `block-${currentId++}`,
type: 'list-item',
- content: trimmed.replace(/^(\*|-|\d+\.)\s/, ''),
+ content,
+ level: listLevel,
+ checked,
order: currentId,
startLine: currentLineNum
});
@@ -204,6 +223,11 @@ export const exportDiff = (blocks: Block[], annotations: any[]): string => {
output += `Feedback on: "${ann.originalText}"\n`;
output += `> ${ann.text}\n`;
break;
+
+ case 'GLOBAL_COMMENT':
+ output += `General feedback about the plan\n`;
+ output += `> ${ann.text}\n`;
+ break;
}
output += '\n';
diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts
index 00625dd..d90ad0e 100644
--- a/packages/ui/utils/sharing.ts
+++ b/packages/ui/utils/sharing.ts
@@ -15,7 +15,8 @@ export type ShareableAnnotation =
| ['D', string, string | null] // Deletion: type, original, author
| ['R', string, string, string | null] // Replacement: type, original, replacement, author
| ['C', string, string, string | null] // Comment: type, original, comment, author
- | ['I', string, string, string | null]; // Insertion: type, context, new text, author
+ | ['I', string, string, string | null] // Insertion: type, context, new text, author
+ | ['G', string, string | null]; // Global Comment: type, comment, author
export interface SharePayload {
p: string; // plan markdown
@@ -73,9 +74,15 @@ export async function decompress(b64: string): Promise {
*/
export function toShareable(annotations: Annotation[]): ShareableAnnotation[] {
return annotations.map(ann => {
- const type = ann.type[0] as 'D' | 'R' | 'C' | 'I';
const author = ann.author || null;
+ // Handle GLOBAL_COMMENT specially - it starts with 'G' (from GLOBAL_COMMENT)
+ if (ann.type === AnnotationType.GLOBAL_COMMENT) {
+ return ['G', ann.text || '', author] as ShareableAnnotation;
+ }
+
+ const type = ann.type[0] as 'D' | 'R' | 'C' | 'I';
+
if (type === 'D') {
return ['D', ann.originalText, author] as ShareableAnnotation;
}
@@ -96,10 +103,30 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] {
'R': AnnotationType.REPLACEMENT,
'C': AnnotationType.COMMENT,
'I': AnnotationType.INSERTION,
+ 'G': AnnotationType.GLOBAL_COMMENT,
};
return data.map((item, index) => {
const type = item[0];
+
+ // Handle global comments specially: ['G', text, author]
+ if (type === 'G') {
+ const text = item[1] as string;
+ const author = item[2] as string | null;
+
+ return {
+ id: `shared-${index}-${Date.now()}`,
+ blockId: '',
+ startOffset: 0,
+ endOffset: 0,
+ type: AnnotationType.GLOBAL_COMMENT,
+ text: text || undefined,
+ originalText: '',
+ createdA: Date.now() + index,
+ author: author || undefined,
+ };
+ }
+
const originalText = item[1];
// For deletion: [type, original, author]
// For others: [type, original, text, author]
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..a4d03a1
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,19 @@
+# Tests
+
+## Manual Tests
+
+### Local Hook Simulation (`manual/local/`)
+
+Simulates the Claude Code hook locally for testing the UI.
+
+```bash
+./tests/manual/local/test-hook.sh
+```
+
+Builds the hook, pipes a sample plan to the server, and opens the browser. Test approving/denying and check the hook output.
+
+### SSH Remote Support (`manual/ssh/`)
+
+Tests SSH session detection and port forwarding for remote development scenarios.
+
+See [manual/ssh/DOCKER_SSH_TEST.md](manual/ssh/DOCKER_SSH_TEST.md) for setup instructions.
diff --git a/tests/manual/local/fix-vault-links.sh b/tests/manual/local/fix-vault-links.sh
new file mode 100755
index 0000000..d359b15
--- /dev/null
+++ b/tests/manual/local/fix-vault-links.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+# Adds [[Plannotator Plans]] backlink to existing plan files
+#
+# Usage: ./fix-vault-links.sh /path/to/vault/plannotator
+
+FOLDER="${1:-$HOME/Documents/*/plannotator}"
+
+# Expand glob
+FOLDER=$(echo $FOLDER)
+
+if [[ ! -d "$FOLDER" ]]; then
+ echo "Folder not found: $FOLDER"
+ exit 1
+fi
+
+echo "Fixing files in: $FOLDER"
+
+COUNT=0
+for FILE in "$FOLDER"/*.md; do
+ # Skip if already has the link
+ if grep -q '\[\[Plannotator Plans\]\]' "$FILE" 2>/dev/null; then
+ continue
+ fi
+
+ # Insert [[Plannotator Plans]] after frontmatter (after second ---)
+ # Using awk to find the end of frontmatter and insert
+ awk '
+ /^---$/ { count++ }
+ { print }
+ count == 2 && !inserted { print "\n[[Plannotator Plans]]"; inserted=1 }
+ ' "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
+
+ COUNT=$((COUNT + 1))
+ echo "Fixed: $(basename "$FILE")"
+done
+
+echo ""
+echo "Done. Fixed $COUNT files."
diff --git a/tests/manual/local/test-bulk-plans.sh b/tests/manual/local/test-bulk-plans.sh
new file mode 100755
index 0000000..b51d132
--- /dev/null
+++ b/tests/manual/local/test-bulk-plans.sh
@@ -0,0 +1,67 @@
+#!/bin/bash
+# Test script to run Plannotator against all plans in ~/.claude/plans
+#
+# Usage:
+# ./test-bulk-plans.sh
+#
+# For each plan file:
+# 1. Launches Plannotator server with that plan
+# 2. Opens browser for you to review
+# 3. After approve/deny, moves to next plan
+#
+# Great for bulk-testing Obsidian integration.
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+PLANS_DIR="$HOME/.claude/plans"
+
+echo "=== Plannotator Bulk Plan Test ==="
+echo ""
+
+# Check plans directory exists
+if [[ ! -d "$PLANS_DIR" ]]; then
+ echo "Error: Plans directory not found: $PLANS_DIR"
+ exit 1
+fi
+
+# Count plans
+PLAN_COUNT=$(ls -1 "$PLANS_DIR"/*.md 2>/dev/null | wc -l | tr -d ' ')
+echo "Found $PLAN_COUNT plans in $PLANS_DIR"
+echo ""
+
+# Build first
+echo "Building hook..."
+cd "$PROJECT_ROOT"
+bun run build:hook
+echo ""
+
+# Iterate through plans
+CURRENT=0
+for PLAN_FILE in "$PLANS_DIR"/*.md; do
+ CURRENT=$((CURRENT + 1))
+ PLAN_NAME=$(basename "$PLAN_FILE")
+
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "Plan $CURRENT of $PLAN_COUNT: $PLAN_NAME"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+
+ # Read plan content and escape for JSON
+ PLAN_CONTENT=$(cat "$PLAN_FILE" | jq -Rs .)
+
+ # Create hook event JSON
+ PLAN_JSON="{\"tool_input\":{\"plan\":$PLAN_CONTENT}}"
+
+ # Run the hook server
+ echo "$PLAN_JSON" | bun run "$PROJECT_ROOT/apps/hook/server/index.ts"
+
+ echo ""
+ echo "Completed: $PLAN_NAME"
+ echo ""
+
+ # Small pause between plans
+ sleep 1
+done
+
+echo "=== All $PLAN_COUNT plans processed ==="
diff --git a/tests/manual/local/test-hook.sh b/tests/manual/local/test-hook.sh
new file mode 100755
index 0000000..f14d746
--- /dev/null
+++ b/tests/manual/local/test-hook.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Test script to simulate Claude Code hook locally
+#
+# Usage:
+# ./test-hook.sh
+#
+# What it does:
+# 1. Builds the hook (ensures latest code)
+# 2. Pipes sample plan JSON to the server (simulating Claude Code)
+# 3. Opens browser for you to test the UI
+# 4. Prints the hook output (allow/deny decision)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+
+echo "=== Plannotator Hook Test ==="
+echo ""
+
+# Build first to ensure latest code
+echo "Building hook..."
+cd "$PROJECT_ROOT"
+bun run build:hook
+
+echo ""
+echo "Starting hook server..."
+echo "Browser should open automatically. Approve or deny the plan."
+echo ""
+
+# Sample plan with code blocks (for tag extraction testing)
+PLAN_JSON=$(cat << 'EOF'
+{
+ "tool_input": {
+ "plan": "# Implementation Plan: User Authentication\n\n## Overview\nAdd secure user authentication using JWT tokens and bcrypt password hashing.\n\n## Phase 1: Database Schema\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY,\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(255) NOT NULL,\n created_at TIMESTAMP DEFAULT NOW()\n);\n```\n\n## Phase 2: API Endpoints\n\n```typescript\n// POST /auth/register\napp.post('/auth/register', async (req, res) => {\n const { email, password } = req.body;\n const hash = await bcrypt.hash(password, 10);\n // ... create user\n});\n\n// POST /auth/login\napp.post('/auth/login', async (req, res) => {\n // ... verify credentials\n const token = jwt.sign({ userId }, SECRET);\n res.json({ token });\n});\n```\n\n## Checklist\n\n- [ ] Set up database migrations\n- [ ] Implement password hashing\n- [ ] Add JWT token generation\n- [ ] Create login/register endpoints\n- [x] Design database schema\n\n---\n\n**Target:** Complete by end of sprint"
+ }
+}
+EOF
+)
+
+# Run the hook server
+echo "$PLAN_JSON" | bun run "$PROJECT_ROOT/apps/hook/server/index.ts"
+
+echo ""
+echo "=== Test Complete ==="
diff --git a/tests/manual/ssh/DOCKER_SSH_TEST.md b/tests/manual/ssh/DOCKER_SSH_TEST.md
new file mode 100644
index 0000000..0166978
--- /dev/null
+++ b/tests/manual/ssh/DOCKER_SSH_TEST.md
@@ -0,0 +1,92 @@
+# Testing SSH Remote Support with Docker
+
+This setup creates a Docker container with SSH server to test Plannotator's SSH remote session detection.
+
+## Build and Run
+
+```bash
+# From repo root, cd into this directory
+cd tests/manual/ssh
+
+# Build the Docker image
+docker-compose build
+
+# Start the SSH server
+docker-compose up -d
+
+# Check it's running
+docker-compose ps
+```
+
+## Test SSH Detection
+
+### Option 1: SSH into the container and run test script
+
+```bash
+# SSH into the container (password: testpass)
+ssh -p 2222 root@localhost
+
+# Once inside, run the test script
+cd /app
+chmod +x test-ssh.sh
+./test-ssh.sh
+```
+
+You should see:
+- `[SSH Remote Session Detected]` message
+- Instructions for SSH port forwarding
+- Server running on port 19432
+
+### Option 2: Test via SSH with port forwarding
+
+```bash
+# In one terminal, SSH with port forwarding
+ssh -p 2222 -L 19432:localhost:19432 root@localhost
+
+# Inside the SSH session, run:
+cd /app
+echo '{"tool_input":{"plan":"# Test Plan\n\nTest content"}}' | bun run apps/hook/server/index.ts
+
+# In another terminal on your local machine, open browser
+open http://localhost:19432
+```
+
+### Option 3: Test local (non-SSH) mode
+
+```bash
+# Execute directly in container without SSH
+docker-compose exec plannotator-ssh bash -c 'cd /app && echo "{\"tool_input\":{\"plan\":\"# Test Plan\n\nTest content\"}}" | bun run apps/hook/server/index.ts'
+```
+
+You should see:
+- NO `[SSH Remote Session Detected]` message
+- Random port assignment (since SSH_TTY and SSH_CONNECTION are not set)
+
+## Verify SSH Environment Variables
+
+```bash
+# SSH into container
+ssh -p 2222 root@localhost
+
+# Check SSH env vars are set
+echo "SSH_TTY: $SSH_TTY"
+echo "SSH_CONNECTION: $SSH_CONNECTION"
+```
+
+## Clean Up
+
+```bash
+docker-compose down
+```
+
+## Environment Variable Override
+
+To test custom port:
+
+```bash
+ssh -p 2222 root@localhost
+cd /app
+PLANNOTATOR_PORT=9999 ./test-ssh.sh
+```
+
+Server should use port 9999 instead of 19432.
diff --git a/tests/manual/ssh/Dockerfile b/tests/manual/ssh/Dockerfile
new file mode 100644
index 0000000..b95f7f4
--- /dev/null
+++ b/tests/manual/ssh/Dockerfile
@@ -0,0 +1,37 @@
+# Test Dockerfile for SSH remote support
+FROM ubuntu:24.04
+
+# Install dependencies
+RUN apt-get update && apt-get install -y \
+ curl \
+ unzip \
+ openssh-server \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Bun
+RUN curl -fsSL https://bun.sh/install | bash
+ENV PATH="/root/.bun/bin:$PATH"
+
+# Set up SSH
+RUN mkdir -p /var/run/sshd && \
+ echo 'root:testpass' | chpasswd && \
+ sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config
+
+# Copy project
+WORKDIR /app
+COPY package.json bun.lock ./
+COPY apps ./apps
+COPY packages ./packages
+COPY tests/manual/ssh/test-ssh.sh ./
+
+# Install dependencies
+RUN bun install
+
+# Build the hook
+RUN bun run build:hook
+
+# Expose SSH port
+EXPOSE 22
+
+# Start SSH server
+CMD ["/usr/sbin/sshd", "-D"]
diff --git a/tests/manual/ssh/docker-compose.yml b/tests/manual/ssh/docker-compose.yml
new file mode 100644
index 0000000..8f68439
--- /dev/null
+++ b/tests/manual/ssh/docker-compose.yml
@@ -0,0 +1,12 @@
+version: '3.8'
+
+services:
+ plannotator-ssh:
+ build:
+ context: ../../..
+ dockerfile: tests/manual/ssh/Dockerfile
+ ports:
+ - "2222:22" # SSH
+ - "19432:19432" # Plannotator default SSH port
+ stdin_open: true
+ tty: true
diff --git a/tests/manual/ssh/test-ssh.sh b/tests/manual/ssh/test-ssh.sh
new file mode 100644
index 0000000..cc12704
--- /dev/null
+++ b/tests/manual/ssh/test-ssh.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+# Test script to simulate Plannotator running over SSH
+
+# Sample plan JSON input
+PLAN_JSON='{"tool_input":{"plan":"# Test Plan\n\n## Overview\nThis is a test plan for SSH remote support.\n\n## Steps\n1. Do something\n2. Do something else\n3. Profit"}}'
+
+# Run plannotator with the test plan
+echo "$PLAN_JSON" | bun run /app/apps/hook/server/index.ts