# Plannotator A plan review UI for Claude Code that intercepts `ExitPlanMode` via hooks, letting users approve or request changes with annotated feedback. Also provides code review for git diffs and annotation of arbitrary markdown files. > **Reusing the document UI (theme / markdown / editor / settings / comments / layout) in the commercial Workspaces app? Read `packages/ui/README.md` FIRST.** It explains the published `@plannotator/ui` + `@plannotator/core` packages and the host-override seams a host plugs its own backend into via `configurePlannotatorUI()`. A prior from-scratch reimplementation of this UI broke the app and was reverted — do **not** rebuild it or recreate `packages/document-ui`. Add a seam to `@plannotator/ui` instead, keep Plannotator's app unchanged, and never delete working code until a human confirms parity in the browser. ## Project Structure ``` plannotator/ ├── apps/ │ ├── hook/ # Claude Code plugin (no commands/ — core skills installed to ~/.claude/skills act as slash commands) │ │ ├── .claude-plugin/plugin.json │ │ ├── hooks/hooks.json # PermissionRequest hook config + "modules" (the Claude Code mod) │ │ ├── hooks/mod/ # The Claude Code mod: non-blocking plan review/annotate/review/last + Ask this session (see "Claude Code mod") │ │ ├── tests/ # `claude plugin test` harness tests (scripts/test-claude-code-mod.sh; bun skips them) │ │ ├── server/index.ts # Entry point (plan + review + annotate + archive subcommands) │ │ └── dist/ # Built single-file apps (index.html, review.html) │ ├── opencode-plugin/ # OpenCode plugin │ │ ├── commands/ # Slash command stubs (review, annotate, last — plugin intercepts execution) │ │ ├── index.ts # OpenCode 1 entry with submit_plan tool + review/annotate event handlers │ │ ├── server.ts # OpenCode 2 adapter (stable @opencode/plugin types; older-host capability fallbacks) │ │ ├── plannotator-tool.ts # OpenCode 2 `plannotator` tool + per-session launch record (list/close); see "OpenCode 2: the plannotator tool" │ │ ├── plannotator.html # Built plan review app │ │ └── review-editor.html # Built code review app │ ├── amp-plugin/ # Amp plugin │ │ ├── plannotator.ts # Native Amp command-palette integration │ │ └── README.md # Install and local development notes │ ├── droid-plugin/ # Droid plugin │ │ ├── .factory-plugin/plugin.json │ │ ├── commands/ # Slash command entrypoints │ │ └── lib/ # Shared command wrapper helpers │ ├── marketing/ # Marketing site, docs, and blog (plannotator.ai) │ │ └── astro.config.mjs # Astro 5 static site with content collections │ ├── vibe/ # Mistral Vibe integration source (consumed by scripts/install.sh; auto-detected via $VIBE_HOME, default ~/.vibe) │ │ ├── hooks/hooks.toml # Reference copy of the managed pre_tool hook block install.sh writes into $VIBE_HOME/hooks.toml (macOS/Linux only) │ │ └── skills/ # Vibe-specific skills (review, annotate, last) with PLANNOTATOR_ORIGIN=mistral-vibe baked in; not installed on Windows │ ├── kiro-cli/ # Kiro CLI integration source (consumed by scripts/install.sh; auto-detected via ~/.kiro) │ │ ├── agents/plannotator.json # Example Kiro custom agent │ │ └── skills/ # Kiro-specific skill packages (review, annotate); setup-goal + visual-explainer install from apps/skills/extra │ ├── paste-service/ # Paste service for short URL sharing │ │ ├── core/ # Platform-agnostic logic (handler, storage interface, cors) │ │ ├── stores/ # Storage backends (fs, kv, s3) │ │ └── targets/ # Deployment entries (bun.ts, cloudflare.ts) │ ├── inbox/ # The Plannotator Inbox window's Vite single-file build (packages/inbox); build:hook copies it to apps/hook/dist/inbox.html, and the phone's surface (build:surface) to apps/hook/dist/surface.html │ ├── review/ # Standalone review server (for development) │ │ ├── index.html │ │ ├── index.tsx │ │ └── vite.config.ts │ ├── guides-show/ # guides.show — portable Guided Review viewer (multi-file CDN build) + Cloudflare Worker (viewer/, worker/, share/, build/); the Worker is the only host target, self-hosting = deploying it under your own account │ ├── relay/ # The Inbox relay (mobile steps R1, R2, R3): a Cloudflare Worker + one Durable Object per mailbox (src/index.ts, src/mailbox.ts), holding only ciphertext and hashes: pushes, and the phone's reads and answers while it is away; src/apns-h2.ts is a standalone HTTP/2 client for APNs over connect(); src/fcm.ts sends Android pushes through FCM HTTP v1 with plain fetch. Private, own version line, owner-deployed (never from CI; README.md has the deploy); its proof (test/) runs `wrangler dev` in the relay workflow and is skipped by the root `bun test` │ ├── vscode-extension/ # VS Code extension — opens plans in editor tabs │ │ ├── bin/ # Router scripts (open-in-vscode, xdg-open) │ │ ├── src/ # extension.ts, cookie-proxy.ts, ipc-server.ts, panel-manager.ts, editor-annotations.ts, vscode-theme.ts │ │ └── package.json # Extension manifest (publisher: backnotprop) │ └── skills/ # Agent skills (agentskills.io format) │ ├── core/ # CORE skills (single-sourced) — installed to ~/.claude/skills and ~/.agents/skills (Codex) │ │ ├── plannotator/ # Knowledge layer: model-invocable CLI reference (subcommands, flags, exit codes) an agent loads on generic "use Plannotator" intent; freshness-guarded against apps/hook/server/cli.ts by plannotator-skill-reference.test.ts. Unreleased subcommands (`HIDDEN_SUBCOMMANDS` in cli.ts: today `t3` and `t3-hook`, the opt-in T3 Code connection, and `card`, the Board's card call from a shell; `inbox` was the last to launch) still run and keep ` --help`, but stay out of `plannotator --help`, "Did you mean" suggestions and this skill (the test requires their absence); un-hide by deleting from that set, adding the top-level usage lines and documenting it here │ │ ├── plannotator-review/ # Lightweight: opens review UI │ │ ├── plannotator-annotate/ # Lightweight: opens annotate UI │ │ └── plannotator-last/ # Lightweight: annotates last message │ └── extra/ # EXTRA skills — NOT default-installed (except Kiro); add via `npx skills add backnotprop/plannotator/apps/skills/extra --global` │ ├── plannotator-compound/ # Research analysis agent (map-reduce over denied plans) │ ├── plannotator-setup-goal/ # Goal package scaffolder for /goal workflows │ └── plannotator-visual-explainer/ # Visual HTML generator (plans, diagrams, PR explainers) with Plannotator theming ├── packages/ │ ├── server/ # Shared server implementation │ │ ├── index.ts # startPlannotatorServer(), handleServerReady() │ │ ├── review.ts # startReviewServer(), handleReviewServerReady() │ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady() │ │ ├── inbox.ts # startInboxServer(), handleInboxServerReady(): the Plannotator Inbox (Bun-only, loopback-only; see "Plannotator Inbox") │ │ ├── inbox-mcp.ts # The Inbox's MCP tools (send_message, read_thread, resolve_message, wait_for_reply, get_guide_brief, submit_guide) │ │ ├── inbox-tailscale.ts # "Over your tailnet": `plannotator inbox --tailscale`, the owner-only tailnet listener and its serve mapping (see "Plannotator Inbox") │ │ ├── storage.ts # Re-exports from @plannotator/shared/storage │ │ ├── share-url.ts # Server-side share URL generation for remote sessions │ │ ├── remote.ts # isRemoteSession(), getServerPort() │ │ ├── browser.ts # openBrowser() │ │ ├── draft.ts # Re-exports from @plannotator/shared/draft │ │ ├── integrations.ts # Obsidian, Bear integrations │ │ ├── ide.ts # VS Code diff integration (openEditorDiff) │ │ ├── editor-annotations.ts # VS Code editor annotation endpoints │ │ └── project.ts # Project name detection for tags │ ├── ui/ # Shared React components + theme │ │ ├── theme.css # Single source of truth for color tokens + Tailwind bridge │ │ ├── components/ # Viewer, Toolbar, Settings, etc. │ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.) │ │ │ ├── diagram/ # The diagram engine's viewer (DiagramViewer, DiagramCanvas, DiagramOverlay, DiagramComposer, DiagramSourcePane, DiagramPopout + hooks); DiagramBlock.tsx is the fence side │ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views │ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser │ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below) │ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator │ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks │ │ │ ├── index.ts # Barrel — re-exports engine + scopes from both subfolders │ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationMode, annotationPanel, annotationToolbar, commentPopover, documentView, goalSetup, htmlAnnotate, imageAnnotator, inputMethod, sidebar, viewer, vimSelection) │ │ │ └── code-review/ # Scopes for review-editor surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog) │ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator) │ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts, annotateAgentInstructions.ts │ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts │ │ └── types.ts │ ├── ai/ # Provider-agnostic AI backbone (providers, sessions, endpoints) │ ├── core/ # @plannotator/core — browser-safe, zero-dep universal slice (pure utils + types) shared by ui + shared; published so @plannotator/ui can be installed standalone. `shared` re-exports the moved modules via one-line shims so Plannotator is unchanged. │ ├── shared/ # Node/git/server logic + cross-runtime types (re-exports browser-safe modules from @plannotator/core) │ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) │ │ ├── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) │ │ └── inbox/ # The Inbox store (store.ts, append-only JSONL; thread routing), the per-thread list sections (list.ts), layout + line reader (schema.ts), registry (registry.ts: inbox/inbox.json) │ ├── inbox/ # The Plannotator Inbox window (App, sidebar, list, thread, empty state, Settings), composed from ui, and surface/ (what a phone hosts); built by apps/inbox │ ├── t3-code/ # @plannotator/t3-code — the opt-in T3 Code connection (Ask this session through a Claude Bash hook); loaded by the CLI only for `t3` / `t3-hook` (see "T3 Code") │ ├── guide-viewer/ # @plannotator/guide-viewer — the Guided Review chain (GuideView → GuideSectionCard → GuideFileCard → GuideViewportManager) behind a narrow GuideHost context; used by review-editor (ReviewGuideHost + AllFilesCodeView) and by the guides.show viewer (readOnly). Also home of diffParser, DiffFile, and the two markdown renderers. │ ├── editor/ # Plan review app │ │ ├── App.tsx # Main plan review app │ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries │ └── review-editor/ # Code review UI │ ├── App.tsx # Main review app │ ├── shortcuts.ts # codeReviewSurface — composes code-review scopes into the review registry │ ├── components/ # DiffViewer, FileTree, ReviewSidebar │ ├── dock/ # Dockview center panel infrastructure │ ├── demoData.ts # Demo diff for standalone mode │ └── index.css # Review-specific styles ├── .claude-plugin/marketplace.json # For marketplace install └── legacy/ # Old pre-monorepo code (reference only) ``` ## Server Runtimes There are two separate server implementations with the same API surface: - **Bun server** (`packages/server/`) — used by both Claude Code (`apps/hook/`) and OpenCode (`apps/opencode-plugin/`). These plugins import directly from `@plannotator/server`. - **Pi server** (`apps/pi-extension/server/`) — a standalone Node.js server for the Pi extension. It mirrors the Bun server's API but uses `node:http` primitives instead of Bun's `Request`/`Response` APIs. When adding or modifying server endpoints, both implementations must be updated. Runtime-agnostic logic (store, validation, types) lives in `packages/shared/` and is imported by both. **Exception: the Plannotator Inbox.** The Inbox server (`packages/server/inbox.ts`, `plannotator inbox`) is Bun-only and lives in the compiled binary. Pi and OpenCode are its clients, never its hosts, so it has no `node:http` mirror; its routes change in one place. See "Plannotator Inbox". ## Installation **Via plugin marketplace** (when repo is public): ``` /plugin marketplace add backnotprop/plannotator ``` **Local testing:** ```bash claude --plugin-dir ./apps/hook ``` ## Environment Variables | Variable | Description | |----------|-------------| | `PLANNOTATOR_REMOTE` | Set to `1` / `true` for remote mode, `0` / `false` for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. Remote mode binds `0.0.0.0` and turns off these features: Ask this session (in-process and pull bridge), host control, the agent terminal, open-in, Update now, live app annotation, the strict-gate client lease. Everything else (approve/deny, feedback, agent jobs, `/api/git-add`, `/api/pr-action`, `/api/source/save`, `/api/save-notes`, SDK Ask AI) stays reachable by anyone who can reach the URL. WSL2 does not need it (#1983): local mode binds `127.0.0.1`, WSL2's `localhostForwarding` (default on; mirrored networking too) makes that reachable from Windows at `http://localhost:`, and local mode already opens the Windows browser through `cmd.exe` when neither `BROWSER` nor `PLANNOTATOR_BROWSER` is set and WSL interop is on (`isWSL` in `packages/server/browser.ts`; Pi's `network.ts` likewise), so the guide tells WSL2 users to leave it unset. A "remote but bind loopback" knob was considered and not added: its reachability is exactly local mode's. Remote ready messages also render a terminal QR code of the advertised URL when stderr is a TTY and the advertised host is overridden away from localhost (a QR of a localhost URL scans to nowhere), so another device can join without retyping the URL. | | `PLANNOTATOR_AGENT_TERMINAL_REMOTE` | Set to `1` / `true` to enable the annotate-mode agent terminal while `PLANNOTATOR_REMOTE` is active or the session is published with `--tailscale`. Off by default in both cases because the session is reachable by network peers and the PTY token is not an auth boundary. | | `PLANNOTATOR_INBOX_PHONES` | Switch for the Plannotator Inbox's whole phone surface (see "Phones" under "Plannotator Inbox"): pairing, the device door `/api/inbox/device/*`, the tailnet and Wi-Fi listeners, the relay connection, and Phones and Pair a phone in Settings → Devices. **Default: off**, until the iPhone app and the relay ship. Off, the Inbox does nothing phone-related: every pairing, device and reach-switch route answers the unknown-route `404 { code: "not_found" }`, no LAN or door listener opens, nothing connects to the relay, no certificate is made and no `openssl`, `tailscale` or Bonjour process runs, even when `inbox.json`, `relay.json` or `devices.jsonl` hold state from a run that had it on (that state is kept for the next run with it on). `1` / `true` / `on` turn it on, `0` / `false` / `off` / `disabled` off; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `{ "inboxPhones": true }`; the env var wins (`resolveInboxPhones` in `packages/shared/config.ts`). Read once when the Inbox starts (`startInboxServer`'s `phones` option overrides it); `GET /api/inbox/settings` carries it as `phones`, which is how the window decides to draw the block. The iPhone app's proofs, the relay proof, `tests/e2e/inbox-phones.spec.ts`, the phone server tests (`createInboxWorld(..., { phones: true })`, `phones: true`) and the DAST target set it; `packages/server/inbox-phones-gate.test.ts` proves both states. Not in the public docs. | | `PLANNOTATOR_RELAY_URL` | The Inbox relay a new mailbox is made at (`packages/server/inbox-relay.ts`). Default `https://relay.plannotator.ai`. Proofs point it at a `wrangler dev` relay; tests at a closed loopback port. A mailbox, once made, stays at the URL `inbox/relay.json` names. | | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. The Plannotator Inbox ignores it (and `PLANNOTATOR_REMOTE`): it always binds `127.0.0.1` on its last port or a random one; to reach it from your other devices use `plannotator inbox --tailscale` / `PLANNOTATOR_INBOX_TAILSCALE`; see "Plannotator Inbox". | | `PLANNOTATOR_URL_HOST` | Display-only hostname for advertised session URLs (issue #657), e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:`. Host only — bare hostname, IPv4, or bracketed IPv6 (`[fd7a::1]`); the runtime-chosen port is always appended, and anything carrying a scheme, port, path, credentials, or whitespace warns once on stderr and falls back to `localhost`. Strictly display-only and remote-only: binding stays governed by `PLANNOTATOR_REMOTE`; a local session ignores the override (localhost is advertised and opened, since only loopback is bound) with a once-per-process stderr warning to set `PLANNOTATOR_REMOTE=1`, and spawned agent-review jobs keep a pinned `http://127.0.0.1:` API URL so a tailnet-only hostname cannot break local jobs. The sentinel `auto` resolves the host from Tailscale once per process, at first use in a remote session: `tailscale status --json` → `Self.DNSName` (trailing dot stripped), falling back to the single `tailscale ip -4` CGNAT (100.64.0.0/10) address; detection failure warns once on stderr and falls back to `localhost`, and detection never changes binding — `auto` is as display-only as any explicit host. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }` or `{ "urlHost": "auto" }`); the env var takes precedence, and an empty-but-set env var (`PLANNOTATOR_URL_HOST=`) suppresses a config-file `urlHost`. Default: unset (`localhost`). | | `PLANNOTATOR_ALLOWED_HOSTS` | Extra hostnames the servers answer on, for the Host allowlist every plan, review, annotate, archive and goal-setup server (and the Plannotator Inbox) applies to every request (see "Host allowlist" under Server API). Comma list of hostnames; a leading dot (`.example.com`) also matches subdomains; `*` turns the check off. For proxies and tunnels that forward their own hostname (a reverse proxy, a Docker service name, a LAN DNS name in remote mode). Not needed for GitHub Codespaces or VS Code dev tunnels (their forwarders present `Host: localhost:`) or for a code-server / Coder port proxy (recognized from `VSCODE_PROXY_URI`). Read once per server start. An entry named explicitly (exact or `.suffix`, never `*`) is also accepted as a page Origin by the cross-origin write guard, for a plain-HTTP reverse proxy that rewrites Host: name the proxy's public hostname here (read on each request; see "Cross-origin write guard" under Server API). Default: unset. | | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. `--browser ` sets it for one run. A "none" value, `none` `false` `0` `off` `no` `true` `1` `:` (trimmed, any case), opens NOTHING, local or remote: no VS Code IPC, no Glimpse, no `BROWSER`, no system opener (`resolveBrowserIntent` in `packages/shared/browser-intent.ts`, vendored to Pi; `none` / `default` / `explicit`), for every caller (sessions, the Inbox, `sessions --open`, `t3 login`). The Bun CLI prints ` Not opening a browser (PLANNOTATOR_BROWSER=).` under the ready line (`openBrowser` prints it itself for direct callers; `handleServerReady` never calls the opener then, and says nothing when the host opens the browser itself, `skipBrowserOpen`); Pi says it in its "opened" message and returns `notOpening` from `openBrowser`. `BROWSER` is different: hosts set it (Claude Code's agent view sets `BROWSER=true`), so its #756 values (`true` `false` `none` `0` `1` `:`) are still read as unset and the default chain runs. Every session prints ` Plannotator session ready: ` on stderr before any launch (`SESSION_READY_LINE_PREFIX`, `handleServerReady`), so nothing is lost by opening nothing. | | `PLANNOTATOR_AI` | Set to `disabled` to disable Ask AI and the Review Agents / Guided Review execution surfaces, including provider and agent-job endpoints. Persisted guide data is retained and its server APIs remain available, but the in-app history browser is hidden while AI is disabled. External agents can still open reviews and submit annotations. The explicit annotate-mode agent terminal is separate and remains controlled by its own settings. Default: enabled. | | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely, including Guided Review share links (the review UI hides "Create share link", `POST /api/guide/:jobId/share` answers `403 { error: "sharing disabled" }`, and `plannotator guide share` refuses with exit 1). Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "share": "disabled" }`); the env var takes precedence. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | | `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | | `PLANNOTATOR_CLAUDE_MOD` | Switch for the Claude Code mod (non-blocking plan review, annotate, code review and annotate-last, the `plannotator` tool, the take-over of agent-run Bash `plannotator` commands, plus Ask this session; see "Claude Code mod"). **Default: on** wherever Claude Code runs hooks modules (2.1.287+, interactive CLI sessions; the mod still stands down by itself in `-p` / SDK sessions and where `/bin/sh` is missing, i.e. Windows). Set to `0` / `false` / `off` / `disabled` to turn it off, which leaves the module inert (every hook passes through, no command or tool is registered, no Bash command is taken over, no environment is set) so the classic PermissionRequest hook and `/plannotator-*` skills run exactly as before; `1` / `true` / `on` force it on, and empty or unrecognized counts as unset (on). Can also be turned off via `~/.plannotator/config.json` (`{ "claudeCodeMod": false }`, read from the data dir); the env var takes precedence (`resolveClaudeCodeMod` in `packages/shared/config.ts`, mirrored by `apps/hook/hooks/mod/enabled.ts` because a hooks module can import only its own files; `enabled.test.ts` keeps them in step). Read once when Claude Code starts, so a change applies to the next session start. Set it in the shell that starts Claude Code or in Claude Code's `settings.json` `env`. The plugin installs from the repo and the `plannotator` binary updates separately, so the mod also runs against older binaries; see "Version skew" in the mod section for what degrades. | | `PLANNOTATOR_AGENT_TOOL` | Switch for the `plannotator` agent tool (`packages/shared/plannotator-tool.ts`) on the hosts that register it. **Default per host** (owner's call, one table: `AGENT_TOOL_DEFAULTS` in `packages/shared/config.ts`): **on for the Claude Code mod** (the tool is deferred behind tool search there, so it costs about its name until used), **on in Oh My Pi** (host key `oh-my-pi`: the Pi extension running inside OMP, which lists an extension tool as one `xd://plannotator` line of its system prompt, measured on OMP 18.10.2 at 212 characters, about 50 tokens, with the tools array unchanged, and loads the schema only when the model reads it; owner's call 2026-10-12, #1814), **off on Pi and OpenCode 2** (a full tool definition in every request: 2,993 bytes, about 800 tokens; 2,901 bytes, about 780 tokens, measured on Pi 0.84.4 before #1907 added review's `gate`, which grew the entry Pi sends by 92 bytes). The Pi extension tells OMP from Pi by its ExtensionAPI object (`detectPiHost` in `apps/pi-extension/pi-host.ts`: OMP's carries `arktype`, its omptype schema builder, and `pi`, its SDK module; Pi's has neither) and resolves with that host; the Inbox tool is not affected (it registers as `pi` in OMP too, `InboxToolHost`). `1` / `true` / `on` turn it on and `0` / `false` / `off` / `disabled` turn it off on every host, overriding the host default in both directions; empty or unrecognized counts as unset. Off removes ONLY the tool: the `/plannotator-*` slash commands, plan review, Ask this session and (Claude Code) the take-over of agent-run Bash `plannotator` commands, which adds nothing to the model's context, are unchanged, and an agent that wants Plannotator runs the CLI. Can also be set via `~/.plannotator/config.json` (`{ "agentTool": true }` / `false`, read from the data dir; allowlisted, boolean only, on `POST /api/config` in both runtimes, which refuses a cross-origin write with `403` on every plan, review and annotate server: `isSameOriginOrNoOrigin` in `packages/shared/request-origin.ts`, the guard every plan, review, annotate and goal-setup server now runs on EVERY `POST` / `PUT` / `PATCH` / `DELETE` before routing (see "Cross-origin write guard" under Server API), and that these routes also run themselves: review progress, guide share links, the CallDiff install, annotate's Edit Mode `/api/source/save`, `/api/open-in` on the annotate and review servers, `/api/save-notes`, `/api/plan/vscode-diff` and the review server's `/api/agents/jobs` launch and kill, since a page on another site can send a preflight-free `text/plain` POST. It passes a request with no Origin, an Origin naming the request host, or the browser's own `Sec-Fetch-Site: same-origin`, and refuses `Origin: null`, and `same-site` or `cross-site` fetch metadata whatever Origin says (a request with no Origin included). The `Sec-Fetch-Site` leg is what lets the VS Code panel write: its cookie proxy serves the page from `http://127.0.0.1:` and rewrites Host to the server, so Origin and Host differ. Before it, `/api/review-progress` and `/api/call-flow/install` were already refused in the VS Code panel (silently: the client ignores the failure); they work there now too. `cookie-proxy.ts` additionally re-anchors an Origin equal to its own origin onto the upstream, for browsers without Fetch Metadata; `vscode-proxy-writes.test.ts` (Bun and Pi) covers both shapes); the env var takes precedence (`resolveAgentTool(config, env, host)` in `packages/shared/config.ts`; the mod's mirror `resolveAgentToolEnabled` lives in `apps/hook/hooks/mod/enabled.ts`, kept equal to the claude-code row by `enabled.test.ts`). `getServerConfig(gitUser, agentToolIntegration)` advertises it for a later Settings toggle / one-time "turn it on" offer, but only where the integration that started the server registers the tool: `agentToolHost` is the session-bridge host, not the agent origin (Bun: `agentToolHostForServer` in `packages/server/ai-runtime.ts`, an in-process bridge's host, else `launchingSessionHost()`, the `PLANNOTATOR_SESSION_BRIDGE_HOST` the Claude Code mod / OpenCode 2 plugin hand the CLI, read once at startup and kept in remote mode and after the `--tailscale` discard, where Ask this session is off but the integration still started the session; Pi: the extension's own host, `pi` or `oh-my-pi` (`currentPiHost()`, recorded by the extension at load), since its servers run inside the extension). With no such integration (the classic Claude Code hook, the mod off, `-p`, Windows, OpenCode 1, a person at a shell) every `agentTool*` field is absent except an `agentTool` config.json sets. Known gap: OpenCode 2's embedded plan review passes its bridge only when the host has `session.generate`, so on an older V2 host that one review advertises nothing. Fields: `agentTool` (the config value, or the host default while unset), `agentToolConfigured` (config.json holds an explicit choice), `agentToolEnv` (present when the env var overrides the file) and `agentToolEnabled` (the effective value, for the NEXT session). **Read once per session start and never mid-session**, because the tool list is part of every request's prompt prefix and changing it would miss the prompt cache: a change applies to the next session (Pi: the next session or `/reload`; Oh My Pi: the next OMP start, the conservative promise the Settings row makes; OpenCode 2: the next OpenCode start; Claude Code: the next Claude Code start). | | `PLANNOTATOR_INBOX_TOOL` | Switch for the Plannotator Inbox's `plannotator_inbox` agent tool and its reply wake on the hosts that register it (the Claude Code mod, see "The Plannotator Inbox" in the mod section; Pi and OpenCode, see "Agent connections" in "Plannotator Inbox"; OpenCode 1 gets the tool without the wake). On Pi and OpenCode only where `inbox/inbox.json` exists in the data dir (the person ran the Inbox once); otherwise nothing is registered and nothing polls there. The Claude Code mod registers the tool even before the Inbox ever ran (from the list bundled with the mod; the first call starts the Inbox), and stays silent (no HTTP, no files) until an Inbox exists. The same switch also gates the session-start reminder (config-only `inboxReminder`, below): never without the tool. Default per host `INBOX_TOOL_DEFAULTS` (= `AGENT_TOOL_DEFAULTS`: on for the Claude Code mod, off on Pi and OpenCode 2). `1` / `true` / `on` and `0` / `false` / `off` / `disabled` decide every host; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `inboxTool`: a boolean for every host, or one per host (`{ "inboxTool": { "pi": true } }`), which the Inbox's own Settings writes (`POST /api/inbox/settings`). The env var wins (`resolveInboxTool(config, env, host)` in `packages/shared/config.ts`; the mod's mirror `resolveInboxToolEnabled` in `apps/hook/hooks/mod/enabled.ts`, kept equal to the claude-code entry by `enabled.test.ts`). Independent of `PLANNOTATOR_AGENT_TOOL`. Read once per session start and never mid-session (the tool list is part of the prompt prefix), so a change applies to the next session: Pi, the next session or `/reload`; OpenCode, the next OpenCode start; Claude Code, the next Claude Code start (the mod decides at the process's first `session.start`). Documented for users in `apps/marketing/src/content/docs/reference/inbox.md`. | | `PLANNOTATOR_INBOX_TAILSCALE` | Publish the Plannotator Inbox over your tailnet at every start ("Over your tailnet", `packages/server/inbox-tailscale.ts`): `tailscale serve --bg --https= http://127.0.0.1:`, HTTPS, serve and never funnel, for the Tailscale login that owns this machine only. `1` / `true` / `on` and `0` / `false` / `off` / `disabled` decide; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `inboxTailscale` (boolean), which the Inbox's Settings switch writes (`POST /api/inbox/settings` `{ tailscale: { on } }`; the switch is locked while the env var is set). Precedence: env (a hard switch both ways: `0` keeps it off even against `--tailscale` and the control route) > `plannotator inbox --tailscale` (this run only; the flag only turns it on) > config > off (`resolveInboxTailscale` in `packages/shared/config.ts`). Read at each start and at each switch change. Tailscale missing, stopped or signed out never stops the Inbox: Settings, `inbox.json` `tailscale.error` and stderr say why. Other logins: config-only `inboxTailscaleAllow`. See "Over your tailnet" under "Plannotator Inbox". Default: off. | | `PLANNOTATOR_INBOX_NOTES` | Set to `0` / `false` / `off` / `disabled` to hide the Inbox window's note dialog (the offer to add a note to each agent's instructions), show a note row in Settings → Agents only for a note already written (so it can still be switched off), and refuse new notes (`plannotator inbox --notes` too). Notes already written stay until switched off or uninstalled. Default: on. See "The note in each agent's instructions" under "Plannotator Inbox". | | `PLANNOTATOR_INBOX_CONNECT` | Set to `0` / `false` / `off` / `disabled` to skip the step in which a person's `plannotator inbox` connects the agents on this computer to the Inbox (see "Connecting the agents on this computer" under "Plannotator Inbox"). Default: on. Read from the shell that runs `plannotator inbox` and from the Inbox's own environment; either one off skips it. Dotfiles and managed setups use it; the test preload, `worldEnv` and every test sandbox that spawns the CLI set it to `0` so no test ever runs a real agent's CLI. No config.json key. | | `PLANNOTATOR_HOST_RESULT_FILE` | Set by a host that starts the CLI detached (the Claude Code mod): when a review, annotate, annotate-last or `claude-mod-plan` session settles, the CLI writes one JSON decision record there atomically (mode 0600), with the agent message composed from the configured prompts. Only a path named `result.json` inside `/claude-code-mod/` is accepted (`isAllowedHostResultPath`); anything else is ignored with a stderr warning, so the variable cannot make the CLI create or replace an arbitrary file. Taken at startup and removed from the environment. Stdout is unchanged. See "Claude Code mod". | | `PLANNOTATOR_HOST_MESSAGES_FILE` | Set by the Claude Code mod for `annotate-last --stdin` (ignored without `--stdin`): a `messages.json` inside `/claude-code-mod/` (`isAllowedHostMessagesPath`; anything else is ignored with a stderr warning) holding `{ v: 1, messages: [{ messageId, text, timestamp? }] }`, newest first, which the CLI shows as the message picker instead of the single stdin message. Validated fail-closed (1..25 entries, string fields, unique ids, 2 MiB per message, 8 MiB file); a malformed file exits 1 with the reason. Taken at startup and removed from the environment. A CLI that predates it ignores it and opens the stdin text. See "Claude Code mod". | | `PLANNOTATOR_SESSION_TAG` | Set by the Claude Code mod in its session's environment (`claude-code:`), so processes the session starts can be matched to it; recorded as `hostSession` in the `sessions/` registry, and read by `plannotator inbox mcp`, which sends as that session when the agent leaves `agent_session` out (see "Plannotator Inbox"). Only `:` with host `claude-code`, `pi` or `opencode` counts; anything else is ignored. Not meant to be set by hand. | | `PLANNOTATOR_HOST_REVIEW_ID` | Set per launch by the Claude Code mod and the OpenCode plugin to the review's `pn-` id; the CLI takes it at startup (validated `pn-` + 6 hex, scrubbed from the environment) and records it as `reviewId` in the `sessions/` registry, which `plannotator sessions` (and `--json`) prints beside each session's full `target`. Not meant to be set by hand. | | `PLANNOTATOR_MOD_DEBUG` | Set to `1` before starting Claude Code to have the Claude Code mod write `claude-code-mod/debug.log` in the data dir (launches, results, turns, bridge commands). Default: off. | | `PLANNOTATOR_INBOX_AUTO_SEND_MS` | Testing knob for the Plannotator Inbox (see "Undelivered replies go in by themselves" under "Questions and Send"): how long a reply, message or Re-ask note waits on a busy agent before the Inbox puts it into the running turn by itself (a 👍 note never does; it waits for the turn to end). Whole milliseconds from 1000 up to the default: it can only shorten the wait, and only in a build that is not a release (`__CLI_VERSION__` not X.Y.Z, e.g. `0.0.0-dev` or a source run) or under the test sandbox (`PLANNOTATOR_TEST_SANDBOX`); a release ignores it, so an agent that starts the Inbox cannot make replies jump into turns. Anything else is ignored. Default `INBOX_AUTO_SEND_AFTER_MS`, 300000 (5 min). Read once when `plannotator inbox` starts (`autoSendAfterMsFromEnv`, `apps/hook/server/inbox-command.ts`, `inbox-auto-send-knob.test.ts`); used by `tests/e2e/inbox-undelivered.spec.ts`. Not in the public docs. | | `PLANNOTATOR_T3_WORKER_IDLE_MS` | Testing knob for the opt-in T3 Code connection (see "T3 Code"): how long a per-thread worker with no open review and no pending delivery waits before it exits. Default 600000 (10 min). | | `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`, `kiro-cli`, `mistral-vibe`, `pi`, `oh-my-pi`. Invalid values silently fall through to env-based detection. Unset by default. | | `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "jina": false }`) or per-invocation via `--no-jina`. | | `PLANNOTATOR_ANNOTATE_HISTORY` | Set to `0` / `false` to disable ALL annotate-session writes to the data dir: per-file version history (no copies of annotated files are written; the annotate version diff is unavailable) AND the durable submitted-feedback records (#678) that single-local-file annotate sessions otherwise write to `history/{project}/{slug}/submissions/` before deleting the draft on submit. Disabling it keeps annotate sessions fully stateless but also gives up that submit crash-recovery record. URL and annotate-last sessions never write either kind of data regardless of this flag. Folder sessions write no submitted-feedback records, but they do participate in per-file version history: the first time a session serves a file through /api/doc it snapshots that file (lazily, memoized per resolved path for the life of the server), which is what powers the per-file version diff when a folder file is reopened later; setting this flag to 0 disables those folder snapshots too. A review of several files (bundle) snapshots each of its text files when it opens, through the same pipeline, governed the same way. Setting it to 0 additionally suppresses **feedback archive** records for every annotate surface (single file, folder, URL, live app, annotate-last), so "fully stateless annotate session" stays literally true regardless of `PLANNOTATOR_FEEDBACK_HISTORY`, with one exception: annotation drafts (`drafts/`, including the path-keyed copies described under "Annotate drafts follow the file") are crash recovery and are still written; they are deleted when the review is decided. Raw-HTML and live-app pinpoints write their element context (selector, ancestor path, allowlisted attributes, visible text, a collapsed HTML skeleton, and the live route) into the submission records and drafts too; form values and inline handlers are never captured. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "annotateHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_FEEDBACK_HISTORY` | Set to `0` / `false` to stop archiving submitted feedback under `~/.plannotator/feedback/` (or `PLANNOTATOR_DATA_DIR`). Default: enabled, which appends one record per submission at decision-settlement time on all three surfaces and in both runtimes: plan approve/deny, code review Send Feedback / Approve (LGTM) / Close, and every annotate submit / approve / close. A review posted straight to GitHub, GitLab or Bitbucket with `POST /api/pr-action` is delivered to the platform and is not archived locally yet. **Note that this writes the user's own feedback text, the document and code excerpts it quotes, and per-annotation metadata to disk, and nothing prunes the directory** (same policy as `plans/`, `history/`, and `guides/`); delete `~/.plannotator/feedback/` or a project subdirectory to forget, or set this to 0 to never write. Code-review records carry diff IDENTITY only (vcsType, diffType, base, gitRef, snapshotId, cwd, PR metadata, changed-file count, patch byte count), never the patch bytes; plan records carry the decision text plus a reference to the `history/{project}/{slug}/NNN.md` version the decision was made on, never a second copy of the plan. Externally sourced annotations (linters, review agents, WebMCP browser agents) are included but keep their `source` / `author` tags, so `source == null` selects the reviewer's own comments; agent job outputs (guides, tours) are not archived. This knob governs only the new archive: the `planSave` decision snapshots in `plans/` and the #678 annotate submission records under `history/` are unaffected. Annotate surfaces honor `PLANNOTATOR_ANNOTATE_HISTORY` as well. Can also be set via `~/.plannotator/config.json` (`{ "feedbackHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_GUIDE_VIEWER_URL` | Base URL of the portable Guided Review viewer that exported guides pin (default `https://guides.show/v1/`). Must be `https:` (or `http:` on localhost for local viewer builds — `bun run --cwd apps/guides-show serve:local`); anything else is ignored. Read by the export endpoints of both servers and by `plannotator guide export` (which also accepts `--viewer-url`). | | `PLANNOTATOR_GUIDE_SHARE_URL` | Base URL of the guide host that Guided Review share links are created on: the review UI's "Create share link", `plannotator guide share`, and `plannotator guide unshare` upload to and delete from it (default `https://guides.show`; the origin of your own deployment of its Cloudflare Worker otherwise, see the `apps/guides-show` README). Must be `http(s)`; credentials, query and fragment are dropped and a trailing slash is trimmed; an invalid value warns once on stderr and falls back to the default so a share setting can never break a server launch or CLI run. An empty-but-set env var counts as unset. Can also be set via `~/.plannotator/config.json` (`{ "guideShareUrl": "https://guides.example.com" }`); the env var takes precedence; there is no per-invocation flag. Resolved by `resolveGuideShareUrl` in `packages/shared/config.ts`. Whether sharing is allowed at all is `PLANNOTATOR_SHARE` (`disabled` turns guide share links off entirely). Removal always goes to the host a saved guide's record names, never merely the currently configured URL, so changing this after sharing does not strand a link. | | `PLANNOTATOR_BITBUCKET_TOKEN` | Atlassian API token for Bitbucket Cloud PR review (`plannotator review https://bitbucket.org///pull-requests/`). Bitbucket has no gh-like CLI, so the provider (`packages/shared/pr-bitbucket.ts`, vendored to Pi) calls the REST API 2.0 with `fetch`. Scopes needed: `read:user:bitbucket`, `read:repository:bitbucket`, `read:pullrequest:bitbucket`, `write:pullrequest:bitbucket` (app passwords are deprecated by Atlassian and not supported). A missing, rejected or under-scoped token fails `checkAuth` with a message naming these env vars and scopes; the token is never logged, echoed in an error, or sent to the browser (`getServerConfig` allowlists its keys). Can also be set via `~/.plannotator/config.json` (`{ "bitbucketToken": "..." }`, plain text, so prefer the env var); the env var takes precedence, and the token and email always come from the same source (`resolveBitbucketCredentials` in `packages/shared/config.ts`). | | `PLANNOTATOR_BITBUCKET_EMAIL` | The Atlassian account email that owns `PLANNOTATOR_BITBUCKET_TOKEN`; with it the token goes out as HTTP basic auth, without it as a Bearer token. Config key `bitbucketEmail`. | | `PLANNOTATOR_BITBUCKET_API_URL` | Testing / proxy knob: base URL of the Bitbucket Cloud REST API (default `https://api.bitbucket.org/2.0`). The fake API in `tests/test-fixtures/bitbucket/fake-bitbucket.ts` is reached through it. Only `https:` or loopback `http:` is accepted (credentials are sent there); anything else warns once and uses the default. Redirects (the PR `/diff` and `/diffstat` endpoints answer with one) and pagination `next` links are followed only within this origin. | | `PLANNOTATOR_GUIDE_HISTORY` | Set to `0` / `false` to disable persisting successful Guided Reviews (no guide copies are written to the data dir; the "Previous guides" list is then never populated, though already-saved guides remain readable and listed). **Note that a persisted guide includes a full copy of the diff it was generated against** — `history/.../guides/{id}.patch` beside the `{id}.json` envelope, uncapped, as large as the diff — because that patch is what a later portable export or share link renders (the diff is captured when the guide job launches, never re-read from the working tree). Deleting a guide removes both files; nothing prunes the directory otherwise. Turning this flag off skips the patch copy too, at the cost of exports and share links for guides from that session once the server exits. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "guideHistory": false }`); the env var takes precedence. | | `PLANNOTATOR_REVIEW_PROGRESS` | Set to `0` / `false` (also `off` / `disabled`) to stop code review remembering which files you marked viewed across review sessions. Default: enabled, which writes one small JSON record per file you mark viewed or unviewed under `review-progress/{scopeHash}/{pathHash}.json` in the data dir (`~/.plannotator` or `PLANNOTATOR_DATA_DIR`), for local Git and PR reviews. **Each record carries the repo-relative file path in plain text** (plus a content fingerprint and the viewed flag; the scope directory name is a hash of the worktree root, branch and comparison, or of the PR URL). Nothing prunes the directory; delete `review-progress/` to forget, and `plannotator uninstall --purge` removes it (a plain uninstall keeps it). With the knob off the review server neither reads nor writes these records: `/api/review-progress` answers `{ available: false }` and viewed marks ride the annotation draft instead, exactly as in workspace, non-Git and piped-patch reviews. Records already on disk are left alone. Can also be set via `~/.plannotator/config.json` (`{ "reviewProgress": false }`); the env var takes precedence, and an empty or unrecognized env value counts as unset. Resolved by `resolveReviewProgress` in `packages/shared/config.ts`, read on every review snapshot in both runtimes. | | `PLANNOTATOR_AUTO_UPDATE` | Opt-in background auto-update (#1634). Set to `1` / `true` / `on` to enable, `0` / `false` / `off` / `disabled` to force off; empty or unrecognized counts as unset. **Default: off**; nothing changes for anyone who has not opted in. Can also be set via `~/.plannotator/config.json` (`{ "autoUpdate": true }`) or Settings → General → "Keep Plannotator up to date" (allowlisted on `POST /api/config` in both runtimes; `getServerConfig` always sends the config-file value plus `autoUpdateEnv` when this env var overrides it, and the toggle is disabled with a note in that case); the env var takes precedence (`resolveAutoUpdate` in `packages/shared/config.ts`). The whole mechanism is "run the normal install script": the compiled CLI (`apps/hook/server/index.ts`, `registerCliSession`) calls `scheduleAutoUpdateCheck` (`packages/server/auto-update.ts`) each time a session registers, which returns immediately and runs on a later, unref'd tick, so it is never awaited and adds no latency to any hook, review, annotate or CLI command. That tick first runs the shared release check (`PLANNOTATOR_UPDATE_CHECK` row: one GitHub request per 24h per data dir), then auto-update, which answers from the same `latestRelease` cache and never runs while update checks are off (outcome `checks-off`). The check is skipped for dev/source runs (no `__CLI_VERSION__`), when the running executable is not the file the install script manages (`~/.local/bin/plannotator`, `%LOCALAPPDATA%\plannotator\plannotator.exe`), and is throttled to once per 24h (`lastCheckAt` in `update-state.json` in the data dir). It reads the newest stable release from the shared cache (asking GitHub `releases/latest` only when the cache is stale; strictly newer, never a downgrade); if another Plannotator session is open (a live pid other than ours in the `sessions/` registry) it logs, does NOT spend the 24h window, and retries on the next session start. Otherwise it takes `update.lock` (stale after 1h) and spawns a detached wrapper that downloads `https://plannotator.ai/install.sh` (Windows: `install.ps1`) and runs it with `--version vX.Y.Z --non-interactive` (`-Version … -NonInteractive`), all output to `update.log`; the wrapper writes the exit code to `update-result.json` and releases the lock. The script applies the user's own `skipInstall` config and saved install prefs as a manual run would, and install flags are remembered: after a successful run each installer (`install.sh`, `install.ps1`, `install.cmd`, including the `--minimal` early exit) writes the install-affecting choices it took from its COMMAND LINE to `install-flags.json` in the data dir as neutral ids (`{"v":1,"flags":["minimal","skip-codex"]}`; the set is `minimal`, `no-minimal`, `verify-attestation`, `skip-attestation`, `with-call-flow`, `skip-codex`, `skip-gemini`, `skip-kiro`, `skip-vibe`, `skip-opencode`, `skip-skills`, `skip-claude-plugin`; never raw argv, paths or secrets), and a manual run with none of them overwrites it with an empty set, i.e. back to defaults. Auto-update reads it (`readInstallFlags`), keeps only allowlisted ids (unknown entries and a mutually-exclusive pair are dropped and logged), and passes them re-spelled per installer (`--skip-codex` / `-SkipCodex`): as argv after the `sh -c` wrapper (`"$@"`) on POSIX, and on Windows as the `PLANNOTATOR_UPDATE_FLAGS` env var that the `-EncodedCommand` wrapper re-checks against the same allowlist and splats as an array. A missing or malformed file means no flags (today's behavior) plus an `update.log` line. Inside the script the flag still beats the env var, which beats config. The OpenCode and Pi runtimes never arm the trigger. The next UI load (plan, annotate, review) shows a one-time toast from `autoUpdateNotice` on `/api/plan` / `/api/diff` (Bun servers only, and only when the compiled CLI enabled it): "Updated to vX" once the running binary is that version, or "Auto-update failed" with the log path when the script exited non-zero; "seen" is the `plannotator-auto-update-notice-seen` cookie. Install scripts replace the binary safely for this: `install.sh` stages next to the target and renames over it (a running process keeps its inode), `install.ps1` / `install.cmd` rename a running `plannotator.exe` aside to `plannotator.exe.old*` and sweep those on a later run. `plannotator uninstall --purge` removes the four `update*` files and `install-flags.json`. | | `PLANNOTATOR_UPDATE_CHECK` | Update notices (owner-approved design; design record, not in this repo). **Default: on.** `0` / `false` / `off` / `disabled` turn it off, `1` / `true` / `on` force it on; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `{ "updateCheck": false }` (allowlisted on `POST /api/config` in all six servers; `getServerConfig` always sends `updateCheck`, the config-file value, plus `updateCheckEnv` when this env var overrides it); the env var wins (`resolveUpdateCheck` in `packages/shared/config.ts`). The machine asks, never the browser (the review windows' own GitHub fetch is gone, below): `runReleaseCheck` in `packages/shared/update-check.ts` (vendored to Pi) sends one unauthenticated `GET https://api.github.com/repos/backnotprop/plannotator/releases/latest` with `user-agent: plannotator-update-check` (no version, no cookies) at most once per 24h per data dir, claims the attempt in `update-state.json` (`releaseCheck.attemptedAt`) before the request so concurrent starts ask once, and after a failure (offline, 403/429, a proxy) waits 6h, never retrying in a loop; a failure writes one `update.log` line and keeps the last known `latestRelease`. The tag is reduced to a canonical `X.Y.Z` before it is stored (pre-releases and junk are failures). Besides that one daily `api.github.com` request, the Inbox (and only the Inbox) downloads, in the background whether or not What's new is opened, up to 12 images per release that the saved notes show, from GitHub's release-attachment image hosts (`github.com/user-attachments`, `private-user-images.githubusercontent.com`, `user-images.githubusercontent.com`, this repository on `raw.githubusercontent.com`), with the same user agent and a per-version claim and back-off (`releaseImagesFetch`); only while update checks are on (see "Images in the notes" under the Inbox's update control). The release notes (the body from `## What's New in` up to `## Install / Update`, 64 KB cap, plus the "Missed recent releases?" table) go to `update-notes/.json`. Who asks: the compiled CLI at `registerCliSession` (the same unref'd tick as auto-update; never in a build that is not a release, whose `__CLI_VERSION__` is absent or not `X.Y.Z`, e.g. `0.0.0-dev`); and the Plannotator Inbox of a release build, on start and on each health tick (`packages/server/inbox-updates.ts`; it answers from the cache within the window, so it adds no request); and the Pi extension, once per process at its first review-window payload (`apps/pi-extension/server/update-advert.ts`). Everything else reads the cache only. Off means no automatic request to GitHub at all (machine or browser): no terminal hint, no review-window notice, no Inbox Update button for a release that is only available (a binary already on disk still shows Ready), and no auto-update; an explicit `plannotator update` (or `--check`) still asks, and says so on stderr. The terminal hint: after a `review` / `annotate` / `annotate-last` / `last` / `archive` session the compiled CLI prints `Plannotator X is available (you have Y). Update: plannotator update` on stderr, only when stderr is a TTY, never with `--json` / `--hook`, never with `CLAUDECODE` or `PLANNOTATOR_SESSION_TAG` set (an agent's shell or the mod), at most once per 24h (`hintShownAt`), read from the cache (`takeUpdateHint`). `plannotator --version` is unchanged (the Inbox parses it). `plannotator update [--check]` (`apps/hook/server/update-command.ts`) forces a check, then runs the SAME wrapper as auto-update in the foreground (`beginManualUpdate` + `runInstallerForeground`: `update.lock`, pinned tag, remembered `install-flags.json`, output shown and appended to `update.log`, `update-result.json`), without auto-update's 24h window and its open-sessions gate (open sessions keep their old binary); it refuses a build that is not a release (no request at all) and a binary the install script does not manage (printing the install command with the remembered flags); exit 0 up to date / updated / `--check`, 1 failed or could not run, 2 usage, 130 interrupted. Interruption: the POSIX wrapper traps INT / TERM / HUP and the PowerShell wrapper writes its result in `finally`, so a Ctrl-C records exit 130 in `update-result.json` and removes `update.lock` at once (it never waits out the 1h stale-lock window); `runInstallerForeground` passes an INT / TERM sent only to the CLI on to the wrapper, and a wrapper killed outright still ends with the lock released and `-1` recorded (`abortLaunch`). `update-command-entry.ts` runs the same function alone, for the process tests (the full entry imports the built HTML). `uninstall --purge` also removes `update-notes/`. Review windows (design §4.5, owner's answer 3): the browser never asks GitHub any more (`useUpdateCheck` lost its fetch; `?preview-update=X` still simulates one). The compiled CLI's plan, review and annotate servers add `updateAvailable: { version, current, releaseUrl, action, command, publishedAt? }` to `/api/plan` and `/api/diff` (`getUpdateAdvert(origin)` in `packages/server/auto-update.ts`, read from the cache only), where `action` is `opencode` for an OpenCode-launched CLI, `script` for the binary the install script manages in a session only this computer reaches, `copy` otherwise (an unmanaged binary, and every remote-mode or `--tailscale` session, so a device on the LAN or tailnet is shown the command, never offered the installer), and `command` is the install command with the remembered flags (`installCommandFor`; PowerShell scriptblock form on Windows). Pi's three servers send the same field with `action: "pi"` and `command: "pi update --extensions"`, compared with the Pi package's own version (`apps/pi-extension/server/update-advert.ts`, which also schedules the daily check once per process from the vendored module; nothing for `oh-my-pi`). Known gap: OpenCode's in-process embedded plan server sends nothing (it knows no version). The toast (`useUpdateToast`, `packages/ui/hooks/`) shows at most once per version and at most once every 3 days across versions (`claimUpdateToast`, cookie `plannotator-update-toast` = `@`, shared by every localhost port), first looks 5 s after load (past the first-run announcements' own window), waits while a first-run dialog of this load shows or a composer / dialog has focus (re-checking every 5 s for up to 2 min, unconsumed), never for a share link, archive or a session without a server, and never while auto-update is active. Its actions are **What's new** (the GitHub release) and, for `script`, **Update** (`POST /api/update`, `handleUpdateRoute`: `403 forbidden_host` unless the Host is loopback with this server's port (`isLoopbackHostHeader`), `403` cross-origin, `403 update_exposed` for a remote-mode or `--tailscale` session, `409 session_mismatch` for a body `serverSession` naming another session (the client sends it, `withServerSession`); then `startManualUpdateForThisBinary`, the same wrapper and `update.lock` as the Inbox's Update now; `409 update_locked` / `update_unmanaged` / `update_dev_build` / …), else **Copy command**. The Options button keeps its dot (no label shimmer any more) and its version section shows "X is available · What's new", the host's command with Copy, and Update now for `script`. Settings → General gains "Tell me when a new version is out" (`useUpdateCheckSetting`, shown when `serverConfig.updateCheck` is present; written with `POST /api/config`, deliberately NOT a settings-registry entry, because the registry is bundled into the pinned guides.show viewer and must not change its build) above "Keep Plannotator up to date", which is locked with "Needs update checks" while checks are off. | | `PLANNOTATOR_UPDATE_API_URL` | Testing / mirror knob for the update check: the URL answering like GitHub's `releases/latest` (`releaseApiUrl` in `packages/shared/update-check.ts`). Only `https:` or loopback `http:` is accepted; anything else is ignored. One release's notes by its tag go to the URL resolved against it (`tags/v` in place of its last segment, `releaseTagApiUrl`). The `plannotator update` process test serves a fake one on loopback. A loopback `http:` value also lets the Inbox fetch What's new images from that same origin (`allowedReleaseImageUrl` in `packages/server/inbox-whats-new-images.ts`); any other value adds no image host. | | `PLANNOTATOR_INSTALL_SCRIPT_URL` | Testing / mirror knob: the install script the update wrapper downloads (`installScriptUrl` in `packages/server/auto-update.ts`; default `https://plannotator.ai/install.sh`, `install.ps1` on Windows), for `plannotator update`, Update now and auto-update alike. Only `https:` or loopback `http:` is accepted. | | `PLANNOTATOR_CURSOR_SANDBOX` | Set to `0` / `false` / `disabled` to stop passing `--sandbox enabled` when launching Cursor's `agent` CLI for review jobs — the flag pair is omitted entirely, deferring to the user's own Cursor Agent sandbox configuration. For systems where Cursor's sandbox cannot start (NixOS, AppArmor-restricted Linux). Default: enabled (`--sandbox enabled` is passed). Can also be set via `~/.plannotator/config.json` (`{ "cursorSandbox": false }`); the env var takes precedence. Note: opting out means the review job's write protection relies on `--mode ask` plus the user's own Cursor configuration. | | `PLANNOTATOR_CLAUDE_SANDBOX` | Set to `0` / `false` / `disabled` to turn Claude Code's own sandbox OFF for Claude-engine agent jobs (code review, Code Tour, Guided Review) by passing `--settings '{"sandbox":{"enabled":false}}'` (#1627). For Linux systems where the sandbox cannot start (no bubblewrap/socat, AppArmor-restricted user namespaces) while `~/.claude/settings.json` enables it: jobs run with `--permission-mode dontAsk`, so Claude's unsandboxed fallback is refused and every Bash call fails. Default: enabled, meaning Plannotator passes nothing and the user's own Claude Code sandbox setting applies. Opting out runs the job's commands without OS containment, the same as for any user who never enabled Claude's sandbox (Claude Code's default). `--tools`, the `--allowedTools` prefixes, the `--disallowedTools` list and `dontAsk` are unchanged, but the command rules limit what the model can run rather than containing it (Claude Code matches rule text, so a deny rule covers the spellings a model normally writes, not every spelling a program accepts), so treat the opt-out as removing the only OS-level boundary. Managed (enterprise) settings still outrank `--settings`. When a Claude job finishes with no shell command that ran and at least one refused command its own `--allowedTools` admits outright — a plain invocation with no operators, substitutions or variables, which `dontAsk` never refuses unless the shell itself cannot run (`detectClaudeShellBlocked`), the job carries a `warning` pointing at this knob, shown in the Agents tab and the job detail panel; the sandbox is never turned off automatically. Independently of this knob, every Claude agent job passes `--strict-mcp-config` (no `--mcp-config`, so none of the user's MCP servers load into a job) and an `--append-system-prompt` telling the model to run one simple command at a time, since `dontAsk` refuses compound commands (`$(...)`, `&&`, pipes) as a whole (`claudeJobIsolationArgs` in `packages/server/claude-review.ts`, vendored to Pi), plus `--setting-sources user`, so the user's own settings (auth, env, sandbox preference) and managed policy apply but the job directory's project/local `.claude/settings*.json` (a PR checkout is someone else's branch) cannot add allow rules or hooks to a job. Side effects: Claude auth or provider env kept only in a repo's `.claude/settings.json` / `.claude/settings.local.json` does not apply to jobs, and neither does a sandbox setting or `permissions.deny` set only in project settings; managed settings still apply. All three job kinds share ONE command allow/deny definition (`CLAUDE_JOB_ALLOWED_TOOLS` / `CLAUDE_JOB_DISALLOWED_TOOLS` via `claudeJobToolArgs`, same file): exactly the read-only commands the prompts instruct (git status/diff/log/show/blame/merge-base/rev-parse/ls-files/ls-tree, `git branch --show-current`, `git remote -v` / `get-url`, the read-only jj commands, `gh pr view|diff` / `gh issue view` and the glab equivalents, `wc`), with deny rules for git `--output` and `-c` / `--config-env`, jj `--config*` / `--tool`, and `git grep`, `git ls-remote`, `gh api`, `glab api`. There is no `git -C`: workspace prompts send the combined diff inline and point the model at the folder-prefixed files. `claude-job-isolation.test.ts` reads the commands out of the real prompt text and fails if the allowlist stops admitting one. `detectClaudeShellBlocked` also takes the job's `--disallowedTools`, so a refusal a deny rule issues by design never reads as a blocked shell. Can also be set via `~/.plannotator/config.json` (`{ "claudeSandbox": false }`); the env var takes precedence. | | `PLANNOTATOR_GIT_REMOTE_CHECK` | Set to `0` / `false` / `disabled` to stop code review contacting the git remote (issue #1553). Default: enabled, which runs `git ls-remote --symref origin HEAD` to discover the remote default branch and to compare the remote tip against the local tracking ref for the "Baseline is behind GitHub" banner. That probe is **not** on a timer: it runs at startup (the compare-target detection plus the staleness probe), on `/api/diff` page load, on `/api/diff/switch` (the diff-type/base pickers and the "Diff out of date · Refresh" button), and on the explicit `/api/fetch-base`, each rate limited to once per 60s per session, with the interval DOUBLING on failure up to a 15-minute cap and resetting to 60s on the next success (`nextRemoteBaseCheckInterval` in `packages/shared/review-core.ts`, shared by both runtimes). It is deliberately absent from `/api/diff/fresh`, which the client polls every 5s for as long as the page is open — hanging the probe there made an idle review query the remote once a minute forever, which on a smartcard-backed SSH setup is one hardware-key touch prompt per minute with nothing on screen to explain it. With the check off the session makes **zero** `ls-remote` calls, startup probes included: the compare target stays whatever LOCAL ref discovery resolved (`getDefaultBranch`: `origin/HEAD` → `origin/main` → `main` → …), `baseBehindRemote` never rides any payload so the banner never shows, and `POST /api/fetch-base` stays available because an explicit Fetch is the user asking for the network — note that with the banner gone so is its one-click Fetch button, which lives in that banner, so the endpoint matters to API callers rather than to the reviewer, and its post-fetch `refreshRemoteBaseInfo()` is a no-op under the opt-out. Only plain local git sessions are affected (PR, jj, GitButler, P4, workspace and static-patch sessions never ran the check). Can also be set via `~/.plannotator/config.json` (`{ "gitRemoteCheck": false }`); precedence is `review --no-git-remote-check` flag > env var > config (`resolveGitRemoteCheck` in `packages/shared/config.ts`). The flag is parsed by the shared `parseReviewArgs`, so it works on every host that forwards review arguments — Claude Code, OpenCode, and Pi — not just the Bun CLI. | | `PLANNOTATOR_TODO_PROVIDER` | Set to `off` / `0` / `false` / `disabled` to stop mirroring the approved plan checklist into an editable todo provider during execution. Default: enabled, which syncs only when a provider is detected (currently pi-todos: detected when its todo directory exists — `/.pi/todos` by default, or wherever `PI_TODO_PATH` redirects it when set). The repo-implied `/.pi/todos` must realpath to a location inside the project or the provider reads as absent and never writes, so a symlink committed into a hostile repo cannot redirect todo writes out of it; an explicitly set `PI_TODO_PATH` is the user's own choice and is honored verbatim, including outside the project. The mirror is additive — the progress widget is unaffected either way — and sync is one-way, so provider-side edits never feed back into plan execution. Can also be set via `~/.plannotator/config.json` (`{ "todoProvider": "off" }`); the env var takes precedence. | | `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. | | `PLANNOTATOR_DATA_DIR` | Override the base data directory. Supports `~` expansion. Default: `~/.plannotator`. When unset, an existing `~/.plannotator` always wins; if it doesn't exist and `$XDG_DATA_HOME` is set to an absolute path, `$XDG_DATA_HOME/plannotator` is used; otherwise `~/.plannotator` (the XDG spec's implicit `~/.local/share` default is deliberately not applied). All data (plans, history, drafts, config, hooks, sessions, debug logs, IPC registry) is stored under this directory. | | `PLANNOTATOR_FILE_BROWSER_MAX_FILES` | File-discovery limit: regular files inspected by CLI markdown/folder resolution and startup code-file warming, supported files returned by the file browser, and directories scanned during multi-repo workspace discovery (symlinks may point outside the workspace, so the budget — not the root — bounds that walk). Must be a positive integer; invalid, zero, or negative values use the default of `5000`. | | `PLANNOTATOR_GLIMPSE` | Set to `0` / `false` to disable the Glimpse native window even when `glimpseui` is installed. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "glimpse": false }`). | | `PLANNOTATOR_GLIMPSE_WIDTH` | Width in pixels for the Glimpse native window. Default: `1280`. | | `PLANNOTATOR_GLIMPSE_HEIGHT` | Height in pixels for the Glimpse native window. Default: `900`. | | `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via `~/.plannotator/config.json` (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires the `gh` CLI, but not a login: the attestation bundle is fetched from GitHub's public attestations API (single unauthenticated attempt, never retried; the endpoint allows 60 requests/hour per IP) and verified with `--bundle`; the extraction needs one JSON tool on PATH (node, python3, or jq). gh's authenticated fetch is the fallback whenever the bundle path is unavailable or does not complete (missing extractor, fetch failure, or a gh that cannot verify the fetched bundle, e.g. an older gh without `--bundle`). Verification still needs network on every run because the Sigstore TUF trust root is fetched per-run; that failure is reported as connectivity, distinct from a real provenance failure, and both fail closed. | | `PLANNOTATOR_SKIP_CODEX_INSTALL` | **Read by the install scripts only.** Set to `1` / `true` to skip writing the Codex integration (`hooks.json` / `config.toml` under `CODEX_HOME`, and the Codex-home stale-skill cleanup) even when Codex is detected. The installer reports the honest state ("Codex: detected, skipped (...)" vs installed; an agent that was not detected gets no closing-summary section at all, so the summary lists only the agents the machine has) and never removes an integration a previous install wired. Also settable via `~/.plannotator/config.json` (`{ "skipInstall": { "codex": true } }`); precedence is `--skip-codex` flag > env var > config. Off by default. | | `PLANNOTATOR_SKIP_GEMINI_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Gemini CLI integration (`~/.gemini` policy file, settings hook, slash commands). Config key: `skipInstall.gemini`; flag: `--skip-gemini`. Off by default. | | `PLANNOTATOR_SKIP_KIRO_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Kiro CLI integration (`~/.kiro` skills and agent, including the `~/.kiro` stale-skill sweep). Config key: `skipInstall.kiro`; flag: `--skip-kiro`. Off by default. | | `PLANNOTATOR_SKIP_VIBE_INSTALL` | **Read by the install scripts only.** Same opt-out shape for the Mistral Vibe integration (`$VIBE_HOME` skills and the managed plan-review hook block in `$VIBE_HOME/hooks.toml`, plus the `~/.vibe` stale-skill sweep). Hooks are stable in Vibe 2.25+ (no config flag), so the installer writes only the `hooks.toml` block; its `command` is argv-only (absolute binary path, no env prefix) because Vibe's hook executor may be shell-free, and plannotator detects the Vibe origin from the hook payload (`pre_tool` + `exit_plan_mode`), with `PLANNOTATOR_ORIGIN=mistral-vibe` as the manual override. The hook is macOS/Linux-only, so the Windows installer never writes `hooks.toml` — it prints manual setup instructions, mirroring Codex-on-Windows. The Windows installers also skip the Vibe-specific skills (their `PLANNOTATOR_ORIGIN=mistral-vibe plannotator …` env-prefix only runs when Vibe's Windows shell tool resolves Git Bash, not on its PowerShell fallback), so they write nothing under `$VIBE_HOME`. Vibe still reads the shell-neutral core skills from `~/.agents/skills`, so `/plannotator-review` and `/plannotator-annotate` work there without a Vibe origin label; `/plannotator-last` is **not** supported for Vibe on Windows yet (with no `PLANNOTATOR_ORIGIN` it takes the Claude Code transcript path, so it fails or could read a Claude session in the same project). On Windows, Vibe counts as detected only when `$VIBE_HOME` exists, as in `install.sh`. Config key: `skipInstall.vibe`; flag: `--skip-vibe`. Off by default. | | `PLANNOTATOR_SKIP_OPENCODE_INSTALL` | **Read by the install scripts only.** Do-not-write switch for the OpenCode integration (command stubs under `~/.config/opencode/commands`, the OpenCode plugin cache clear, and the stale command-stub sweep). OpenCode has no detection leg, so there is no detected/not-detected reporting, just a skip note. Config key: `skipInstall.opencode`; flag: `--skip-opencode`. Off by default. | | `PLANNOTATOR_SKIP_SKILLS_INSTALL` | **Read by the install scripts only.** Set to `1` / `true` to skip the skills/slash-command sparse checkout entirely — no `git clone` of the release tag, so nothing is written to any skill or command scope (`~/.claude/skills`, `~/.agents/skills`, the OpenCode command stubs, the Gemini `.toml` commands, `~/.kiro`), the extras are not offered, and the skill-scope cleanup sweeps stay suspended (skip means do-not-write, never remove). The binary, sem sidecar, agent-terminal runtime, hooks, and per-agent config still install, and git stops being a hard requirement. The installer reports `Skills: skipped (...)` and the closing banner stops claiming the `/plannotator-*` commands are ready. Unlike the per-agent opt-outs this is not one agent's home — it covers every scope the checkout writes. Config key: `skipInstall.skills`; flags: `--skip-skills` (bash/cmd), `-SkipSkills` (PowerShell); precedence is flag > env var > config. Used by the `install-script-smoke` CI job, which installs a synthetic `v9.9.9` whose tag has no GitHub counterpart. Off by default. | | `PLANNOTATOR_SKIP_CLAUDE_PLUGIN_UPDATE` | **Read by the install scripts only.** Set to `1` / `true` to stop the installers from updating an already-installed Claude Code plugin. The plugin comes from a GitHub marketplace, which Claude Code does not auto-update, so by default each installer (`install.sh` `update_claude_plugin_if_installed`, `install.ps1` `Update-ClaudePluginIfInstalled`, `install.cmd` `:UpdateClaudePlugin`, keep the three in step), right after the legacy marketplace-clone `hooks.json` repair, runs `claude plugin marketplace update plannotator` then `claude plugin update plannotator@plannotator --scope user` (always `--scope user`: current Claude Code auto-detects the scope and, run inside a project with a project-scope install, would update that record instead; every printed manual command carries it too) when BOTH the `claude` CLI is on PATH and `plannotator@plannotator` has a user-scope record in `${CLAUDE_CONFIG_DIR:-~/.claude}/plugins/installed_plugins.json` (a record without `scope`, the v1 file, counts as user; a project- or local-scope-only install is left alone). It never installs the plugin and never touches another marketplace or plugin. A pinned install skips it with `Claude Code plugin: not updated: pinned to vX; updating would install the newest plugin.`: pinned means `--version` / `-Version` named a tag other than `PLANNOTATOR_UPDATE_VERSION`, which background auto-update exports with the newest release it just resolved and passes as `--version`, so auto-update runs are not pinned (no extra network call). Each command runs with stdin from `/dev/null` (on Windows the process is started directly, not with `Start-Process`, whose `-RedirectStandardInput` raised "Broken pipe" when claude exited first; its stdin pipe is closed at once in its own try/catch, and only a failing start reads as "could not run claude"), `CLAUDECODE` cleared (an auto-update started under Claude Code inherits it), and a time limit (120 s; pure shell on POSIX, with claude's output and the timeout marker in one private `mktemp -d` directory removed afterwards, the update skipped with the manual commands if `mktemp` fails; `WaitForExit` + kill on Windows; `PLANNOTATOR_CLAUDE_PLUGIN_TIMEOUT` seconds overrides it, an internal knob for the tests). When claude's output says the marketplace is missing (a line naming `marketplace` with `not found` / `not exist` / `no such` / `unknown`), it prints `claude plugin marketplace add backnotprop/plannotator` plus the user-scope plugin update instead of the two update commands. Before/after it reads the record's `version` / `gitCommitSha` / `installPath` and prints `Claude Code plugin: updated to X (restart Claude Code to load it).` or `already up to date (X).`; a non-zero exit or timeout never fails the install and prints `Claude Code plugin: could not update it (). Run these, then restart Claude Code:` plus the two commands. The closing banner drops the install/update instructions when it updated or found it current, prints only the two manual commands when the plugin is installed but was skipped, failed, or there is no `claude` CLI, and keeps the install instructions when it is not installed. `--non-interactive` does not skip it; `--minimal` exits before it. Config key: `skipInstall.claudePlugin`; flags: `--skip-claude-plugin` (bash/cmd), `-SkipClaudePlugin` (PowerShell), remembered in `install-flags.json` as `skip-claude-plugin`; precedence is flag > env var > config. Off by default (the update runs). | | `PLANNOTATOR_SKIP_AGENT_TERMINAL_INSTALL` | Set to `1` / `true` to skip installing the managed Node/WebTUI runtime used by compiled Bun builds for the annotate-mode agent terminal. Read by `plannotator install-runtime agent-terminal`, which the installers call automatically. | | `PLANNOTATOR_MINIMAL` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` / `yes` to have `scripts/install.sh` / `install.ps1` / `install.cmd` install **only** the `plannotator` binary, skipping the sem sidecar, the agent-terminal runtime, all per-agent skills, hooks, slash commands, and config, and the CallDiff runtime even when its opt-in is set. Equivalent to the `--minimal` (aliased to `--binary-only`) flag; `--no-minimal` overrides it. Off by default. | | `PLANNOTATOR_SKIP_SEM_INSTALL` | **Read by the install scripts only.** Set to `1` / `true` to skip installing the optional `sem` semantic-diff sidecar (used by code review). Off by default. | | `PLANNOTATOR_INSTALL_CALLDIFF` | **Read by the install scripts only.** Set to `1` / `true` / `yes` to ALSO install the optional pinned, pruned CallDiff core used by code review's Call Flow analysis (about 5 MB on macOS arm64, Node.js 22+). The runtime is strictly opt-in and is NOT installed by default. The normal path is in-app: enabling Call flow consents to one background install of core plus exactly the language packs required by the current changed files; later missing languages install automatically under the same consent, while the Languages list supports install-ahead. Each target gets one automatic attempt per review session; a failed target then requires an explicit Retry in that session. Equivalent to `--with-call-flow` (PowerShell: `-WithCallFlow`) or `{ "installCallFlow": true }` in `~/.plannotator/config.json`; precedence is flag > env var > config. `--minimal` always excludes it. Off by default. | | `PLANNOTATOR_CALLDIFF_PATH` | Development override for a built CallDiff `0.4.1` package root containing `dist/run.js`; the exact pinned Tree-sitter core and any desired optional grammars must already exist under its `node_modules`. Managed language-pack installation is disabled for overrides. Normal installs use the selective managed core and grammar cache under the Plannotator data directory. | **Config-only settings (`~/.plannotator/config.json`)**: Some settings have no env-var equivalent and are toggled by editing the config file directly: - `markdownExtensions` (array of strings, default none) — extra file extensions the **annotate** path treats as markdown, e.g. `{ "markdownExtensions": [".livemd"] }` for Livebook notebooks (#1307). A listed extension is accepted everywhere `.md` is on that path: CLI target resolution (`plannotator annotate notes.livemd`), folder discovery and the file browser, `/api/doc` plus relative and wiki-link navigation between sibling docs, the 2MB `MAX_ANNOTATABLE_FILE_BYTES` cap, and per-file version history. Listed extensions render as **markdown** (frontmatter stripped), never as raw HTML, and they only widen the set: nothing built in is removed. Entries must start with a dot and be free of path separators, globs, and whitespace (`".livemd"`, not `"livemd"` or `"*.livemd"`); invalid entries are dropped silently, built-in extensions are deduplicated, and the dotenv family can never be registered: `.env` itself plus any entry ending in `.env` or starting with `.env.` (such as `.prod.env` or `.env.local`) is denied, because annotate copies file contents into the data dir (the same reason `.env` is excluded from the built-in set). The value is read from `config.json` once per process. Predicates stay pure in `packages/core/annotatable.ts`, which is browser-safe and cannot read config; the node-side resolver that threads the normalized list into them is `packages/shared/markdown-extensions.ts` (vendored to Pi), and the annotate `/api/plan` payload ships the same list to the renderer so it can linkify links to sibling documents. Not applied to plan write (`ALLOWED_PLAN_EXTENSIONS` in `apps/pi-extension/tool-scope.ts`) or to Edit Mode source save (`SOURCE_SAVE_FILE_REGEX` in `packages/core/source-save.ts`), which keep their own narrower allowlists. - `agentTerminalSide` (`"left"` / `"right"` / `"hidden"`, default `"left"`): which edge the **annotate-mode** Agent TUI docks against, or `"hidden"` to keep it out of the layout entirely (#1050). Type and guard live in `packages/core/agent-terminal.ts:63-83`, the config declaration in `packages/shared/config.ts:116`. Unrecognized values are silently ignored rather than warned about: `getServerConfig()` omits the key behind `isAgentTerminalSide` (`packages/shared/config.ts:374`) and `resolveAnnotateAgentTerminalSide` independently falls back to `"left"` (`packages/core/agent-terminal.ts:90-94`). `"hidden"` is a default, not a lock: the terminal can still be opened for the session from the sidebar rail, the Shift-Shift shortcut, or a message routed to the agent, none of which rewrite the preference, and an opened `"hidden"` terminal docks left (`packages/core/agent-terminal.ts:101-105`). Settings is the only way back from `"hidden"`. Two UI surfaces write the key (the terminal's own Display popover Position control and Settings → General → "Agent TUI Position"), and the Display popover's reset button restores `"left"`. The side only decides where the terminal docks when opened; it never auto-opens (`packages/editor/App.tsx:523`), it is not rendered below 1024px or in wide mode (`packages/editor/agentTerminalLayout.ts:14`, `:73`), and `"right"` visually displaces the annotations/AI right panel while preserving its state (`packages/editor/agentTerminalLayout.ts:53-57`). - `agentTerminalDefaultAgent` (string agent id, e.g. `"claude"` or `"codex"`, default `""` meaning no recorded choice): which agent the annotate-mode Agent TUI preselects when the panel opens (#1050). Validation is `typeof === "string"` only, with no enum and no check against installed agents, so an unknown or currently unavailable id is inert rather than an error: `resolveAnnotateAgentId` uses the saved id only when it appears among the available agents and otherwise takes the first available one (`packages/ui/utils/annotateAgentTerminal.ts:45-54`). It is written only by the "save as default" checkbox in the terminal's agent picker (`packages/editor/components/AnnotateAgentTerminalPanel.tsx:257`); there is no Settings control for it. An empty string deletes the cookie and reads as unset, though the server allowlist will still write `""` into `config.json`, where it is then ignored. - Precedence for both agent-terminal keys follows the settings registry (`packages/ui/config/settings.ts`) and its resolver (`packages/ui/config/configStore.ts:3-5`): **server config file > cookie > built-in default**. `config.json` is the durable, cross-browser store; the cookie (`plannotator-annotate-agent-terminal-side`, `plannotator-annotate-agent-terminal-default`) is the browser-local fallback. There is no one-time cookie-to-config migration: those two cookie names were deliberately kept unchanged so a pre-registry cookie stays readable, and its value only reaches `config.json` if the user changes the setting again. The sync runs one direction at startup, with `init()` stamping a valid config value back into the cookie (`packages/ui/config/configStore.ts:157-161`). Neither key has an env-var equivalent, and only the annotate servers allowlist them on `POST /api/config` (`packages/server/annotate.ts:726-727`, mirrored in `apps/pi-extension/server/serverAnnotate.ts:690-691`), so setting them has no effect on plan or review sessions. - `agentSwitch` (OpenCode; `"disabled"`, an agent name, or `{ "switchTo": "custom", "customName": "" }`; default unset): which agent the session switches to after a plan approval or review feedback (#1390). A settings-registry entry (`agentSwitch` in `packages/ui/config/settings.ts`, the `plannotator-agent-switch` / `plannotator-agent-custom` cookies), so precedence is **server config file > cookie > default**, because Glimpse's webview keeps no cookies between runs and a lost choice used to hand the session to `build`. Unset keeps the surface defaults in `packages/ui/utils/agentSwitch.ts` (plan approval switches to `build`, review feedback stays); resolving that default writes no cookie. One parser, `parseAgentSwitchConfig` in `packages/core/agent-switch.ts` (any trimmed name up to 128 characters with no control characters, as before; agent lists are discovered at run time): the UI codec, `getServerConfig` (a value that does not parse is not advertised) and `agentSwitchConfigToSave` (`packages/shared/config.ts`) use it. Allowlisted on `POST /api/config` by the plan, review and annotate servers in both runtimes; an invalid value answers `400` and saves nothing of that request. No env var. - `pfmReminder` (`true` / `false`, default `false`) — when enabled, a Plannotator Flavored Markdown reminder is injected at plan-time describing the renderer's extensions (code-file links, callouts, tables, diagrams, task lists, question blocks, hex swatches, wiki-links). The text is one static constant (`PFM_REMINDER`, `packages/shared/pfm-reminder.ts`, vendored to Pi) with no per-request part, so it never breaks a prompt-cache prefix. Lets the planning agent enrich plans with PFM features without having to discover them. Composes cleanly with the compound-skill improvement hook. Supported across all three runtimes: Claude Code (`improve-context` PreToolUse hook in `apps/hook/server/index.ts`), OpenCode (`experimental.chat.system.transform` in `apps/opencode-plugin/index.ts`), and Pi (`before_agent_start` in `apps/pi-extension/index.ts`). - `inboxNotifications` (`{ enabled?, dismissed?, allowedOrigin? }`, default: on, not dismissed, no origin) — the Plannotator Inbox's browser notifications (see "Notifications" under "Plannotator Inbox"). Written by the Inbox page (its one-time ask and Settings) through `POST /api/inbox/settings` `{ notifications: { enabled?, dismissed?, allowed_origin? } }`, never localStorage, because the browser's permission is per origin and the Inbox's port can change; `allowedOrigin` is the page origin where the person last turned them on, so a page on a new port asks again. No env-var equivalent. `inboxTool` (the Inbox tool switch) is in the `PLANNOTATOR_INBOX_TOOL` row. - `inboxReminder` (a boolean for every host, or one per host: `{ "inboxReminder": { "claude-code": false } }`; default: on wherever the session has the Inbox tool): the Plannotator Inbox's one-line reminder at session start, `INBOX_REMINDER_TEXT` in `packages/shared/inbox/connection.ts` ("Plannotator Inbox is on: when you finish long work, or need a decision the person isn't here to give, send it with plannotator_inbox send_message and keep going. Their reply comes back here. Run `plannotator inbox --help` for how it works."; OpenCode 1, and an OpenCode 2 host without `session.prompt` (no wake), get `INBOX_REMINDER_TEXT_NO_WAKE`, which names `wait_for_reply` instead; `inboxReminderText(toolName, { wakes })` builds either with a host's own tool name). Only ever in a session that has `plannotator_inbox` and only once the Inbox has run on this computer (`inbox/inbox.json` exists at session start: on Pi and OpenCode the tool already needs it; the Claude Code mod registers the tool before the first run but gives no reminder then), so the tool switch (`PLANNOTATOR_INBOX_TOOL` / `inboxTool`) turns it off too; there is no env var of its own (`resolveInboxReminder(config, env, host)` = `resolveInboxTool(...) && (configuredInboxReminder(config, host) ?? true)` in `packages/shared/config.ts`; the mod's mirror `resolveInboxReminderEnabled` in `apps/hook/hooks/mod/enabled.ts`, kept equal by `enabled.test.ts`). Read once per session start with the tool, never mid-session. Fixed text in a cache-safe place per host: the Claude Code mod adds a `plannotatorInbox` block in `prompt.context` (the context of the conversation's first message, once per conversation, after the system prompt and tools, never invalidated), naming the tool by the full name `$.tool.register` returned (`mcp____plannotator_inbox`) so tool search finds the deferred tool, and never in a `-p` session or when the register call failed; Pi returns one hidden custom message (`customType: "plannotator-inbox-reminder"`, `display: false`) from `before_agent_start` on the session's first prompt, not again after `/reload` or `/resume` (`entriesCarryReminder` reads the branch), again only after a compaction that summarized it away, never `systemPrompt`, and the "Ask this session" bridge never reads it as a take-over; OpenCode 2 composes it into the system part (`addInboxReminder` in `server.ts`, a `context` hook: only on a request that offers `plannotator_inbox`, never for a subagent or a title request, the same bytes on every request; gated on the Inbox connection, since OpenCode 2 applies a tool transform lazily) and OpenCode 1 appends it to the end of the LAST system part in `experimental.chat.system.transform`, so the text before it stays byte for byte and the part count never changes (#1114: 1.18 sends one part, and single-system-message chat templates such as Qwen3's break on two), only for an agent that has the tool (`openCode1AgentHasInbox`: not a subagent, not an agent whose `tools` switch `plannotator_inbox` off, wildcards included); an agent lookup that fails reuses the session's last answer (none yet: no line), so the prompt never flips between requests. No env-var equivalent; set it in the Inbox's Settings → Agents (the reminder switch per agent) or in config.json. - `inboxAgentNotes` (`{ offered?, asked? }`): the Inbox window's note dialog, asked once PER AGENT. `asked` lists the agents it was asked about (Add, Not now, Don't add a note, the band's Settings link, a yes in Settings → Agents' note switch, or `plannotator inbox --notes` for each note it added), and always every agent that has the note (a record in `inbox/agent-notes.json`, or a found agent whose file holds the section: `noteHolderIds`, saved the first time one is missing), so removing a note never brings the question back; an agent not in it is asked when it becomes a candidate. `offered` is true once anything was answered (kept so an older Inbox reading the same file never shows its card). A config from 0.28.18 or older (`offered` alone) is turned, at its first read, into the agents that can take a note at that moment plus every agent that has the note (`notesAsked` in `packages/server/inbox-agent-notes.ts`), so nobody who answered then is asked again while an agent found since is. Which notes exist is kept in `inbox/agent-notes.json`. See "The note in each agent's instructions" under "Plannotator Inbox". No env-var equivalent. - `inboxTextSize` (12, 13, 14 or 15; default 14): the Plannotator Inbox window's text size, the body size its whole type ramp scales from (see "Text size" under the window's Settings in "Plannotator Inbox"). Written by the Inbox's Settings → General → Appearance through `POST /api/inbox/settings` `{ text_size }`; anything else is refused with `422`, and an unreadable value reads as 14. No env-var equivalent. - `inboxSound` (`{ enabled?, volume?, dialogDueFor?, dialogShownFor? }`, default: on, volume 60) — the Plannotator Inbox's question sound (see "The question sound" under "Plannotator Inbox"). `enabled` and `volume` (a whole number from 10 to 100) are written by the Inbox's Settings row and its one-time dialog through `POST /api/inbox/settings` `{ sound: { enabled?, volume? } }`; anything else is refused with `422`. The two dialog marks are the server's own: `dialogDueFor` is set at the first start of a release version that finds someone who used an earlier Inbox, and stays until a page claims it; the compare-and-set on `POST /api/inbox/sound/dialog` turns it into `dialogShownFor`, which a fresh install gets at its first start instead. Once `dialogShownFor` is set the dialog never shows again. Kept in config.json for the same reason as `inboxNotifications`. No env-var equivalent. - `inboxTailscaleAllow` (array of Tailscale logins, default none): logins the tailnet-published Inbox lets in besides the machine's owner, for a shared tailnet where you sign in as another login on another device, or a tagged machine, which has no owner (publishing is refused there until this names someone). Entries are trimmed and compared case-insensitively; non-strings and entries with whitespace are dropped (`resolveInboxTailscaleAllow`). Read at each publish, so a change applies at the next publish (the switch off and on, or the next start). No env var or flag (deferred). What a login let in can do: everything the window does, and a reply, and above all a New message, becomes a turn in a live agent session on this machine. So any allowed login, or anyone holding the owner's unlocked, signed-in phone, can drive agents that run commands here. Let in only logins you would let sit at this computer. `inboxTailscale` (the switch) is in the `PLANNOTATOR_INBOX_TAILSCALE` row. **Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode. **Devcontainer/SSH usage:** ```bash export PLANNOTATOR_REMOTE=1 export PLANNOTATOR_PORT=9999 ``` **Compressed app page for network sessions (#1617):** remote-mode and `--tailscale` sessions serve the single-file app HTML compressed, negotiated on `Accept-Encoding` (brotli q5, else gzip 6), with `Vary: Accept-Encoding`. Browsers advertise `br` only to HTTPS and localhost origins, so direct plain-http remote mode is served gzip and `--tailscale` (HTTPS) brotli; each server prewarms that likely encoding at start and compresses the other lazily, cached per process (bounded to 4 bodies, which persist for the life of long-lived Pi/OpenCode hosts). Plain local sessions serve the page exactly as before, because on loopback compression only added latency. One shared helper, `packages/shared/app-html.ts` (vendored to Pi), serves every app-HTML route in both runtimes; API responses are never compressed. The single-file builds also drop KaTeX's `woff`/`ttf` fallbacks (`build/katex-woff2-only.ts`); every supported browser loads `woff2`. **Tailnet sessions (`--tailscale`):** `plannotator review --tailscale` (also `annotate` and `annotate-last`/`last`; other subcommands reject the flag with a clear error; Bun CLI only, not mirrored to Pi) publishes the session over the user's tailnet instead of remote mode: the server stays **loopback-bound** and the CLI orchestrates `tailscale serve --bg --https= http://127.0.0.1:`, so devices on the tailnet reach the session over HTTPS while nothing listens beyond localhost and nothing is ever public (serve, never funnel). The advertised HTTPS URL prints on stderr with a terminal QR code (TTY only), and a publishing failure exits `1` — or `2` under a strict annotate gate (`--require-approval` / `--result-file`), where `1` is reserved for "the reviewer did not approve" and a publish failure is a startup failure like any other — with an actionable message instead of leaving the loopback server hanging (CLI missing, daemon down or logged out, unparsable `serve status` output — which fails closed, or no serve URL matching the session port). A pre-existing serve mapping on the chosen port — background or foreground session, which Tailscale prefers — aborts rather than being stolen, and mappings on other ports are never touched. Mappings the process creates are cleaned up on normal exit and on SIGINT/SIGTERM/SIGHUP (all routed through `process.exit` so exit handlers run; the SIGHUP route is installed by `enableTailscaleServe` only once a mapping exists — an unconditional SIGHUP listener would override the ignored disposition `nohup` depends on, so plain non-tailscale sessions keep no SIGHUP listener and `nohup plannotator review &` survives terminal close); a failed teardown retries once and then warns with the exact manual command, and a hard kill (SIGKILL) or reboot can leave the mapping — `tailscale serve --bg` state persists — so remove it with `tailscale serve --https= off`. Combined with `PLANNOTATOR_REMOTE`/SSH detection, `--tailscale` wins and forces local mode with a stderr notice — the wide `0.0.0.0` bind would only broaden exposure (`urlHost` is also suppressed for the run; the advertised URL comes from serve). `tailscale serve` forwards the browser's own Host header (the MagicDNS name), so the CLI adds the serve hostname to the server's Host allowlist once serve reports it (see "Host allowlist" under Server API). The annotate agent terminal is **off by default** in `--tailscale` sessions, exactly like remote mode, because the session is reachable across the tailnet and the PTY token is not an auth boundary; enable it with `PLANNOTATOR_AGENT_TERMINAL_REMOTE=1`. Open-in (`/api/open-in`, `/api/open-in/apps`) is off outright in `--tailscale` sessions, as in remote mode, with no override: a launch would open an app on this machine for whichever tailnet device sent it. Orchestration lives in `packages/server/tailscale-serve.ts` on shared parsers in `packages/shared/tailscale.ts`. The Plannotator Inbox has its own `plannotator inbox --tailscale` (owner-only, persistent, a separate listener; see "Over your tailnet" under "Plannotator Inbox"), which this per-session flag never handles (`index.ts` leaves `--tailscale` to the `inbox` subcommand). **The crash window (both `--tailscale` forms).** A `--bg` serve mapping outlives its process. After a SIGKILL, an OOM kill or a reboot it keeps pointing at a loopback port that nothing holds any more, and any local server that later binds that port is published to the tailnet until the mapping is removed. `review` / `annotate` sessions have no record to recover from: remove it with `tailscale serve --https= off`. The Inbox records its mapping in `inbox.json` before making it, and every Inbox start (and `uninstall --purge`) removes a recorded mapping whose proxy port is not its own, so the window closes at the next start; until then it is open. A loopback-only proxy target that no other process can take (a unix socket) is the follow-up that closes it. ## Plan Review Flow ``` Claude calls ExitPlanMode ↓ PermissionRequest hook fires ↓ Bun server reads plan from stdin JSON (tool_input.plan) ↓ Server starts on random port, opens browser ↓ User reviews plan, optionally adds annotations ↓ Approve → stdout: {"hookSpecificOutput":{"decision":{"behavior":"allow"}}} Deny → stdout: {"hookSpecificOutput":{"decision":{"behavior":"deny","message":"..."}}} ``` ### Plan review header decision control (#1057) Approved design: "plan-review-decision-control" (owner approval 2026-10-21, design record not in this repo; built as the proposal shows). Plan review's header uses the same `DecisionControl` annotate and code review use, from the pure `buildDecisionSpec({ app: 'plan', plan: { answers, directEdits, answersOnly, agentName, approveTarget } })` (`packages/ui/utils/decisionSpec.ts`), with no ghost Close (a plan review ends with a decision). It replaced the old pair (an outlined Send Feedback beside a green Approve, OpenCode's `ApproveDropdown`), the "Add Feedback First" dialog (the empty Send Feedback path is now **Request changes…** in the caret) and the Claude Code "Feedback Won't Be Sent" dialog with its faded Approve and hover note (#1457). - **States.** Nothing to send: **Approve** (OpenCode: `Approve → `, the agent switch target, cut at 24 characters), caret: "Approve with a note…" (only where notes on approval are delivered) and "Request changes…". Something to send: **Send Feedback**, or **Send answers** when the only feedback is question answers (`isAnswersOnlyFeedback`); caret: "Send with a note…" ("Send answers with a note…"), "Approve with notes" (only where delivered; `→ ` on OpenCode), and always "Approve, discard N annotations…" (the noun follows the feedback: answers, annotations, direct edits) with one confirm, "Discard & approve". Where notes would be lost, that row's subtitle and confirm name the agent ("Claude Code can't take notes on approval"). - **Which hosts take notes on approval is a server advert, not the agent's name.** Both plan servers advertise `approvalNotesSupported` on `/api/plan` (Bun `ServerOptions.approvalNotesSupported`, Pi `startPlanReviewServer`'s option; absent or false reads as false in the tab, so an older server offers no approve-with-notes row). Set true by: the Claude Code mod's `claude-mod-plan` (`planHostResult` composes approved-with-notes), the OpenCode CLI bridge (`opencode-plan`) and the embedded OpenCode plan server (the `submit_plan` executor), and Pi's `startPlanReviewBrowserSession` (`deliverPlanDecision`; the event API's result carries `feedback`). Left false: the classic Claude Code PermissionRequest hook (its allow carries no message), Gemini CLI (a `systemMessage` is shown to the person, not the model), Codex, Copilot CLI, Mistral Vibe, and archive. - **Transports are unchanged.** Primary and Mod+Enter are one `submitPrimaryDecision` (`submitPrimaryDecisionRef`): Approve posts `/api/approve` (with `permissionMode` for Claude Code, `agentSwitch`, `planSave`, the notes integrations, `serverSession`, `planRevision`), Send Feedback / Send answers post `/api/deny` (`answersOnly: true` only for answers alone). Routing is pure in `packages/editor/planDecision.ts` (`resolvePlanDecisionAction`): a note becomes a `GLOBAL_COMMENT` and is posted one render later (annotate's #1436 mechanism, `queueNoteDecision`), on `/api/deny` for Request changes / Send with a note and on `/api/approve` for Approve with a note; "Approve with notes" posts the approval with the session's feedback (what plan Approve always sent); the confirmed discard posts a bare approval (`handleApprove({ discardFeedback: true })`, no `feedback`). Every approval goes through `requestPlanApprove`, so the unsaved-file-edit warning and OpenCode's "Agent Not Found" check run on every approve path, and that dialog's "Approve Anyway" runs the captured continuation (a confirmed discard stays bare). A note on Send answers makes the deny mixed (the note is a comment), so it carries the denied prompt, as any mix does. Unlike annotate's L3 retry, a queued plan note decision is never left armed: after any refusal (409 `plan_revised`), cancelled warning (Agent Not Found, unsaved file edits) or failed POST it is dropped, the note stays as an ordinary general comment, and the primary means what its label says (Send Feedback posts the deny); the reviewer decides again from scratch. A plan revision waits while the decision note is open (`[data-decision-note-input]` and the compact `[data-decision-note-composer]` are in `PLAN_REVISION_COMPOSER_SELECTOR`, `packages/editor/hooks/usePlanRevisions.ts`). When the only feedback is saved file changes (a host that grants source save), the approve-away row reads "Approve without telling the agent…" and its confirm says the changes are already on disk (`plan.savedFilesOnly`); plan review's own servers grant no source save, so this does not arise there today. - **Menu rows that are not decisions** go through `DecisionControl`'s `menuFooter` (`packages/ui/components/PlanDecisionMenuExtras.tsx`): OpenCode's agent picker as an "After approval, switch to" radio section (`role="menuitemradio"`, the arrow keys reach it; a pick writes the `agentSwitch` setting and keeps the menu open; shown in both states), and for Claude Code one read-only line, "After approval, Claude Code runs in ", with **Change** (closes the menu, opens Settings). Settings' Permission Mode row now reports a change (`onPermissionModeChange`), so the mode Approve posts and the line show follow it within the session (before, a mid-session Settings change applied only to the next session). - **Compact/touch rows** come from the same spec (primary row, then one row per caret item; composers open `DecisionNoteDialog`, the discard row its confirm), as annotate's do. The agent picker and permission line are desktop only (Settings has both). - **No new keyboard shortcuts.** Tests: `packages/ui/utils/decisionSpec.test.ts` (the plan arm and the invariant sweeps), `packages/editor/planDecision.test.ts`, the DOM test `packages/editor/App.planDecisionControl.test.tsx` (each host and state, and the bodies), `packages/server/plan-approval-notes.test.ts` and its Pi mirror, the advert in `apps/hook/server/claude-mod-plan.test.ts` and `apps/opencode-plugin/plan-review-lifecycle.test.ts`. ### Pi plan review does not block On Pi, `plannotator_submit_plan` returns as soon as the review server is up ("submitted for review … end your turn and wait", `terminate: true`) instead of holding the tool call until the reviewer decides, so the session stays usable during review and "Ask this session" works there. The decision is delivered later by `deliverPlanDecision` (`apps/pi-extension/index.ts`) as a `pi.sendUserMessage(..., { deliverAs: "followUp" })`, the same delivery review and annotate feedback use: approval switches to the executing phase first (tools, model, `plannotator-execute` entry) and sends the approved / approved-with-notes prompt, which starts the execution turn and its framing; deny and answers-only send `composePlanDeniedMessage`; external execution mode hands off and records a non-triggering `plannotator-handoff` message. Nothing is implemented before approval because the phase stays `planning` while the review is open, so the planning write gate still holds. The open review is closed (and its decision dropped) when plan mode is left, when a `/tree` path is not planning, and on `session_shutdown`; a plan decision is never re-targeted to a replacement session. The Esc-aborts-the-review behavior of the old blocking tool is gone: leaving plan mode is how to abandon a review. Closing it first calls the plan server's in-process `closeKeepingDraft()` (never reachable over HTTP, where a plan review stays unclosable): open tabs get the `session-closed` event with `by: "session"` (the tab shows "Review Closed", not "Closed by the Agent", which stays for `by: "agent"` host closes), and a late approve/deny answers `409`. The saved draft survives the close, but the server stops right after, so the last unsaved keystrokes (the draft autosave's debounce window) may be lost. The same close runs when planning ended while a review was still starting. Reviews opened through the `plannotator:request` event API (`plannotator-events.ts`) deliberately do NOT close on `session_shutdown`: the README promises `review-status` lets a caller recover across session restarts, so a decision made in that tab after `/reload` is recorded for `review-status` and never emitted (the replaced runtime's bus is gone). That status file is written atomically (temp file + rename), and one that cannot be parsed is left untouched (logged, the write skipped) rather than restarted from `{}`. Decision listeners run on a microtask (`publishDecision` in `serverPlan.ts`), because a replaced runtime's `pi.events.emit` throws Pi's stale-ctx error synchronously and an escape inside the HTTP handler ends the Pi process (#1864). In `plannotator-events.ts` the review-result emit, `request.respond` and the status writes are each wrapped in try/catch; `index.ts`'s `handoffApprovedPlan` emit is not wrapped itself: it runs either inside the submit tool call (auto-approve, a live session) or behind `deliverPlanDecision`'s liveness check inside a caught promise chain. **Revisions while a review is open update the same tab**: a resubmission while a review is pending calls the Pi plan server's `updatePlan`, which saves a new history version (slug from the revised heading, `previousPlan` from that slug's prior version) and bumps a revision counter. `/api/plan` carries `planRevision`; the plan editor polls `/api/plan/revision` every 2s only when that field is present (`packages/editor/hooks/usePlanRevisions.ts`) and loads the revision in place through `applyEditedDocument` (annotations re-anchor by text, the diff base re-seeds through `usePlanDiff`'s docKey). It waits while Edit Mode is open or holds direct edits, or a linked document is open. Approve/deny bodies echo `planRevision`, and the server answers `409 { code: "plan_revised" }` to a stale one, so a reviewer cannot approve text the agent replaced; the tab then loads the revision and the reviewer decides again. Both plan servers implement it (`updatePlan` on the server result, `/api/plan/revision`, the 409) but advertise `planRevision` only when the caller passes `planRevisions: true`; today only Pi's `plannotator_submit_plan` and the Claude Code mod's `claude-mod-plan` do, so classic Claude Code, OpenCode and the Pi event-API `plan-review` action keep a tab that never polls and sends unchanged bodies. A decision claims the review right after the revision check (before the note integrations are awaited), so `updatePlan` refuses from then on; a Pi resubmission that hits that refusal tells the agent to wait for the decision instead of opening a second review. The tab also holds a revision while a comment composer or annotation popover is open. **Execution works from the approved text, not the file.** The Pi plan server's approve decision carries `plan`, the exact text on screen when the reviewer approved. `deliverPlanDecision` appends it to the approval message under "## Approved plan", records it on the `plannotator-execute` entry (`approvedPlan`), and executes from it: the checklist, the per-turn framing and resume (`resyncPhaseFromSession`) read the snapshot instead of re-reading the plan file, and checkmarks are written back to the file only while its checklist still matches the approved one. If the file differs from the approved text at approval, the message says so and tells the agent not to execute the unreviewed edits (to keep them, the user returns to plan mode and the agent resubmits). Auto-approved plans (no UI) keep the file as their source. ### Pi: the `plannotator` tool The extension registers the shared tool contract (`packages/shared/plannotator-tool.ts`, vendored as `generated/plannotator-tool.ts`) with `pi.registerTool`: name, description, validation (`parsePlannotatorToolInput`), argument mapping and result text all come from it, and `parameters` is `PLANNOTATOR_TOOL_INPUT_SCHEMA` itself (plain JSON Schema, which Pi validates without a TypeBox Kind since 0.79.1, the peer floor; `plannotator-tool.test.ts` runs the installed Pi's `validateToolArguments` against it). `executionMode: "sequential"` (#1622: an "edit, then open" batch opens the edited file). **Registration and activation are decided once per session.** The tool is OFF by default on Pi and registered only when the agent tool switch turns it on (`PLANNOTATOR_AGENT_TOOL=1` / `{ "agentTool": true }`, `resolveAgentTool(..., host)` read once when the extension instance loads; Pi builds a new instance per session and per `/reload`). Inside Oh My Pi (`detectPiHost(pi)` in `pi-host.ts` answers `oh-my-pi`) the host is `oh-my-pi` and the tool is ON by default, because OMP lists an extension tool as one short `xd://plannotator` line instead of sending its definition; `PLANNOTATOR_AGENT_TOOL=0` / `{ "agentTool": false }` keep it out there. `setPiHost` records the host for the extension's review servers, whose `serverConfig` then advertises `agentToolHost: "oh-my-pi"`. `omp.test.ts` (opt-in, the real `omp`) proves the default on, the one line and no tool-list entry. Off, nothing is registered and the slash commands are unchanged. It is registered with `defaultActive: false` (Pi 0.99+ then leaves it inactive; older Pi ignores the key and activates every registered tool), and the first `session_start` (`settleAgentToolActivation`) makes it active when `ctx.hasUI`, inactive in print/JSON mode, before the session's first request, and never touches it again. Plan mode's `setActiveTools` calls add and release only the tools they own, so `plannotator` stays in the tool list (and the prompt prefix) through planning, execution, completion, leaving plan mode, `/tree` and `/reload`: `agent-tool-stability.test.ts` drives that whole lifecycle over a fake Pi that models both activation rules and fails on any request without the tool or any `setActiveTools` that flips it after the start; it also pins the registered description/parameters to the shared constants, no `promptSnippet` / `promptGuidelines` on any Plannotator tool, and no `systemPrompt` from `before_agent_start`. **The bundled knowledge skill is user-invoked only.** `vendor.sh` adds `disable-model-invocation: true` to the vendored copy's frontmatter (the source `apps/skills/core/plannotator` stays model-invocable for the installers that ask, #1377), so an npm-only Pi install keeps the #842 promise of no Plannotator skill in Pi's system prompt; `/skill:plannotator` still loads it, and the model learns the tool from the tool's own description. A CLI install's `~/.agents/skills` copy still wins when present (#1642) and carries the installer's model-invocation choice. **One launch path.** `/plannotator-review`, `/plannotator-annotate`, `/plannotator-last` and the tool share `launchCodeReview` / `launchAnnotate` / `launchLastMessage` in `apps/pi-extension/index.ts` (in-process server, "Ask this session" bridge, decision as a `followUp` message). The commands notify a launch error; the tool throws it, which Pi reports as an error result. Tool differences: the annotate target is ONE argument, so the #1182 tolerant word split never runs on it (`tolerant: false`); an opened review returns `plannotatorToolOpenedText` with `terminate: true`, so the turn ends and the session is idle for Ask; a gated session the tool opened delivers a bare Approve as a message (`deliverApproval`, the mod's rule). The tool's `last` skips the assistant entry holding the calling tool call (`isAssistantEntryForToolCall` in `assistant-message.ts`): Pi saves an assistant message at message_end, before its tools run, so otherwise the agent's own "opening it now" message would open, and would head the picker. On one fixed port (remote mode or a single `PLANNOTATOR_PORT`, read from `getServerPorts`) a second open is refused while any review is open (`fixedPortBusyText` names it), because the new server would otherwise self-preempt the open one silently. A list target opens ONE `annotate-bundle` review through the same `launchAnnotate` the slash command uses: the list goes to the shared `selectAnnotateTokenTarget` as pre-split tokens (paths with spaces survive), only the `bundle` selection opens (a missing entry answers `buildMissingAnnotateFilesMessage`; a folder, URL or bare name refuses, so the tool never opens fewer files than named), `resolveAnnotateBundleFiles` checks types and size, and the subject is `plannotatorBundleSubject` (`2 files: spec.md, notes.md`) in the result, list, close and decision heading; feedback names every file (`Files:` + `annotateBundleTargetText`). A session without UI (print/JSON mode) never has the tool active (see above), and a call that reaches it anyway is refused, since nothing could deliver the decision later. **Sessions, list and close.** Every review the extension opens (tool, commands, `plannotator_submit_plan`) is recorded in ONE process-wide registry (`getProcessPiReviewRegistry` in `apps/pi-extension/plannotator-tool-host.ts`, on `globalThis`) with a `pn-` id until its decision settles. Process-wide because Pi builds a new extension instance on `/reload` and `/resume`, and a per-instance map would forget the open reviews of a session that merely reloaded. `list` and `close` see only entries whose owner is the calling ctx's `sessionManager.getSessionId()`, so another session's reviews (including the previous one after `/new`) are never in reach; the global `sessions/` registry is never read. `unsent` and `decided` come from the server's in-process `hostControl.status()`; `close` calls `hostControl.close()` (the reviewer's Close marked `closedBy: "agent"`, draft kept, tab told), so it works in remote mode too, where the HTTP endpoints are off. A review the agent closed delivers nothing: its decision handler notifies "the agent closed …" instead (user-facing notices name a last-message review "the agent's last message"; agent-facing texts say "your last message"). Plan reviews are listed as `Plan vN` (the history version, read from `updatePlan`'s no-op answer for the same text, and from each revision) and never closed. Review, annotate and last decisions (commands included) now start with `plannotatorDecisionHeading` (`Plannotator: notes.md (pn-3f2a9c) — Feedback · 2 comments.`); outcomes follow the mod: a code review that is not an approval is `Changes requested` (` · N comments` when it carries annotations), and the PR-platform status post, which the review server marks `platform: true` (#1719, `classifyReviewOutcome` in `apps/pi-extension/review-outcome.ts`), is `Review posted` (`PLANNOTATOR_OUTCOME_REVIEW_POSTED`, shared with OpenCode 2) and delivered verbatim, without the verification suffix. Every review decision whose feedback has content is delivered, zero annotations included (PR description, PR comment and editor notes ride only in the feedback text); nothing is inferred from the annotation count. Plan decisions are unchanged. Pi cannot take over an agent's shell `plannotator` command: its `tool_call` event can only block a call, which the model reads as an error, so the skill's "use the tool" line is what steers it. ### Pi: session events and the checklist window **`plannotator:session-opened` / `plannotator:session-closed` (#1589).** One pair per Pi review, whatever opened it (`apps/pi-extension/session-events.ts`): the `/plannotator-*` commands and the `plannotator` tool (`trackReview` in `index.ts`, at the registry's add and its decision or failure), plan review (the submit tool, closed by its decision, by `stopPendingPlanReview` or by its server stopping), and `plannotator:request` code-review / annotate / annotate-last / plan-review (`plannotator-events.ts`, which now starts the session and waits on it itself so the URL is known before the decision). Opened: `{ id, action, url, openedAt, piSessionId?, reviewId?, requestId? }`, `id` the registry's `pn-` id (a fresh `pn-` id for `plannotator:request` reviews, which are not in the registry; unique among the open reviews both ways: `newSessionEventId` skips ids the registry or an announced review holds, and the process registry skips ids an announced review holds, `isSessionEventIdOpen`), `action` one of `plan-review`, `code-review`, `annotate`, `annotate-last`. Closed: the same plus `outcome` (`approved`, `submitted`, or `closed` with `closedBy` `reviewer` | `agent` | `stopped` | `error`) and `closedAt` (`sessionEndOf` / `sessionEndOfError`). The handle emits closed at most once; an event with no live bus is dropped. Archive emits neither. **Cache safety:** they go on `pi.events` only, never a message, entry, system prompt or tool list. **Stale runtimes (#1864):** every emit runs on a microtask inside try/catch; it goes through the opener's `pi` when the opener's session was live at the moment of the open or close, else (or when that throws) through the instance that last claimed a process-wide slot (`claimSessionEventBus` at `session_start`, released at `session_shutdown`, on `globalThis` under a versioned key; a holder that throws, torn down without releasing it, is forgotten quietly), else nowhere, silently. So a review that ends after `/reload` reaches the replacement's bus and never throws out of a handler. `session_shutdown` now calls `stopPendingPlanReview()` first, so a plan review the session ends closes on its own bus. `plannotator:request` reviews use the listener's own `sessionLive`, so after a replacement their close goes to the live instance's bus or nowhere (never the dead one; `plannotator-events.test.ts` pins that nothing is logged). Proofs: `apps/pi-extension/session-events.test.ts` (over a fake Pi whose instances share one bus and whose replaced instance throws Pi's stale-ctx error: every model request, the active tools with their definitions, `before_agent_start`'s results and every message, is byte-identical while reviews open, close and outlive `/reload`, and identical again with a bus that throws; the pair per entry point and outcome) and the session-event cases in `plannotator-events.test.ts`. **The checklist widget scrolls (#1412).** Pi draws at most 10 lines of a widget (`InteractiveMode.MAX_WIDGET_LINES`), so `updateWidget` sends `checklistWindow(checklistItems)` (`apps/pi-extension/checklist-window.ts`): all items when there are 10 or fewer, else 10 items ending on the first open one (the first 10 until it is past them), and the last 10 once everything is done. `setWidget` only; the status line keeps the full count. ### Claude Code mod: non-blocking plan review, annotate, review and last Where Claude Code runs hooks modules ("Claude Mods": function hooks, CLI only, 2.1.287+), the plugin's `hooks/hooks.json` also loads `apps/hook/hooks/mod/register.ts` (`"modules": ["./mod/register.ts"]`). No Plannotator session then holds a tool call open: the mod starts the CLI detached, returns at once, and delivers the reviewer's decision later as a plugin turn (`$.prompt.submit`, read by Claude as a message from the `plannotator` plugin). The UX spec is an owner-reviewed draft (design record, not in this repo); its open questions are not answered yet, and the conservative choices below are the ones taken. **On by default; opt out with `PLANNOTATOR_CLAUDE_MOD=0` or `{ "claudeCodeMod": false }`.** The owner's call: every Claude Code that runs hooks modules (current Claude Code loads them even with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` unset) gets the non-blocking flows, the `plannotator` tool and the Bash take-over of agent-run `plannotator` commands (below). With the knob off `session.start` stops before doing anything (no `$.command.register` or `$.tool.register`, no `$.env.set`, no `$.store`, no process) and every other hook passes straight through with `next(e)`, so the classic PermissionRequest hook and the `/plannotator-*` skills run as they do without mods. See the env table row. **Inert without mods.** Checked live: Claude Code 2.1.150 (no hooks modules) validates and loads the plugin with the `modules` key, ignores it, and runs the classic PermissionRequest command hook (the blocking review) exactly as before; 2.1.288 with modules on runs the mod and never reaches that command hook. The mod also stands down by itself (every hook passes through) in `-p` / SDK sessions (`isInteractive` false: a later plugin turn would have no session to land in) and where `/bin/sh` is missing (Windows), leaving the classic hook and the `/plannotator-*` skills in charge. A mods-capable Claude Code whose rollout switch is off could not be reproduced locally; the engine only inserts modules into the hook chain when they load, so it should behave like 2.1.150. **Files.** `register.ts` holds every `$` call (`claude plugin validate` follows `$` only through top-level functions and inline hooks) and builds a `Host` of closures; `controller.ts` (`PlannotatorMod`) is the per-session state machine; `launch.ts` (detached launcher, launch-directory layout, command table, copy), `delivery.ts` (turn vs log, 12 KB inline limit), `plan.ts` (ExitPlanMode decisions and deny copy), `turns.ts` and `bridge.ts` (Ask this session), `shell-words.ts` (argument splitting), `tool.ts` (the `plannotator` tool contract, a copy of `packages/shared/plannotator-tool.ts`), `take-over.ts` (agent-run Bash `plannotator` commands answered through the tool), `inbox.ts` and `inbox-contract.ts` (the Plannotator Inbox connection, below; the contract a copy of `packages/shared/inbox/connection.ts`). The mod is self-contained: a plugin installed from the marketplace is only `apps/hook/`, and a hooks module may import only its own files. **Detached launch.** `$.process.run` is one-shot (10 minutes at most) and a hook has a 10 s budget of its own time, so the mod runs a tiny `/bin/sh` wrapper (`LAUNCH_SCRIPT`) that starts `plannotator ` in the background with stdin/stdout/stderr on files and returns in milliseconds. The launch directory is `${PLANNOTATOR_DATA_DIR or ~/.plannotator}/claude-code-mod///`: `stdin`, `ready` (`PLANNOTATOR_READY_FILE`), `result.json` (`PLANNOTATOR_HOST_RESULT_FILE`), `stdout`, `stderr`, `pid`, `exit` (written after the CLI exits), `revision.json` + `.ack` (plan revisions), `messages.json` (`PLANNOTATOR_HOST_MESSAGES_FILE`, `last`'s picker list), `watcher.json` and `settled/by` (see "Two processes on one session" below) and `feedback.md` (oversized feedback). Waiting inside a hook (for the ready file, a revision ack) happens in a `$.process.run` shell loop (`waitForAny`), never a `$.clock` wait, which would spend the hook's budget and let the engine run the call on without the mod. Once a review is open, a 1 s `$.clock.every` timer (the mod has no listener) reads `result.json`, notices an `exit` with no result ("the review server stopped … your draft is saved"), and every 15 s checks the pid with `kill -0`. Open launches and a pending plan approval persist in `$.store` and reattach on `session.start` for the same session id (`--resume`, `--continue`, a restart; on 2.1.290 `--continue` keeps the session id). The fork paths do NOT reattach: `--fork-session` and `/clear` start a new session id, so the reviews stay with the old id and come back only when that session is resumed (re-adopting them across a fork is a follow-up). `session.start` does not fire for `/clear` or an in-process resume (the process goes on under another session id), so `session.end` disposes the instance (timer and bridges stop; nothing is delivered into the next session) and the next hook that needs the mod makes a new one for the current id, restoring that session's open reviews. The launch directory is created owner-only (`umask 077`, `chmod 700`) before `stdin` is written; once a decision is delivered its files are removed (`cleanupArgv`), except `feedback.md`, which Claude reads afterwards. A launch whose server crashed keeps its directory (its `stderr` explains why). Servers outlive Claude Code on purpose: the wrapper ignores SIGHUP and starts the CLI under `nohup` (a closing terminal otherwise took the server with it), so closing the terminal does not lose a review; verified live, `claude --continue` reattached and delivered it. *Two processes on one session.* `claude --continue` while the first process still runs gives two Claude Code processes the same session id, and both reattach the same launches. One of them watches each launch: `watcher.json` in the launch directory is a lease (`{ owner, at, touchedAt }`, the owner a random id per mod instance) renewed every 5 s. The process the person works in wins it: restoring a launch, a prompt typed in that process (origin `composer`), a slash command, Claude's `plannotator` tool and ExitPlanMode all touch that process's launches and take the lease at once; a live holder keeps it against an older or equal touch, and anyone takes it once it is 20 s old (`LEASE_STALE_MS`: the holder exited, slept or hung). A disposed instance (`session.end`) releases it at once. Only the watcher runs the bridge (so the two never supersede each other) and delivers, re-reading the lease right before it settles, so a decision follows the person within a tick. Plan approvals live in `$.store` and every ExitPlanMode re-reads them, so an approval received (or already used) by the other process is honored once; ExitPlanMode and the tool also adopt this session's launches the other process started, so a revision goes into the open review instead of opening a new one. Delivery is claimed once per launch, whichever file settles it (`result.json`, `exit` with an older CLI's stdout, or a dead `pid`): `claimArgv` makes the launch's `settled/` directory (mkdir has exactly one winner) and writes the claimant's id to `settled/by`, so a claimant whose process call timed out wins again on the next tick, while every other process loses. A process that loses, or finds a `settled/` claim naming someone else or the launch's `stdin` gone (cleaned up after the other delivered), forgets the launch quietly, so its status line no longer says "waiting for you". A per-launch in-flight flag keeps the 1 s timer, which never waits for a slow check, from checking one launch twice at once. Residual window: `$.prompt.submit` resolves only once Claude is idle, so a claimed decision can wait for the whole of Claude's current turn, and a process that quits or dies in that time takes the delivery with it (nobody else delivers a claimed launch: the claimant may still be waiting to deliver it, so a second delivery could never be ruled out). It is not lost silently: the claimant keeps the launch's record in the store and renews its lease while it waits, writes `settled/delivered` once `$.prompt.submit` returns, and releases the lease at `session.end`. A restore of the session (and any process that watched the launch and saw the claim) checks a claimed, undelivered launch whose decision is still on disk: while its claimant's lease is fresh it waits; once the lease is released or 60 s stale (`UNDELIVERED_AFTER_MS`) it logs once, with a toast, "A decision for arrived but wasn't delivered — it's saved in /result.json" (`stdout` for an older CLI) and writes `settled/reported`, so it is said once. `cleanupArgv` removes `stdin` first and keeps `settled/` while `feedback.md` keeps the directory, so a claim made after cleanup started loses. `persist` keeps this session's records it does not know (the other process launched them) unless it settled them. After restore, off the session-start path (5 s cap), the stored launch records older than a minute are pruned (`pruneArgv`): any cleaned up (no `stdin`) or settled for good (`settled/` marked delivered or reported, or with no decision on disk; a claimed, undelivered decision stays for its session to report); for other sessions, any whose server died without a decision (`kill -0` fails, no `result.json`, no `exit`); and for other sessions older than 14 days (`LAUNCH_EXPIRED_MS`, a session nobody resumed), any whose server is gone, decision or not. A live server always keeps its record; this session's dead servers are left to the timer, which reports them. The store is read again before the write, so a record another process added meanwhile is kept. The debug log (`PLANNOTATOR_MOD_DEBUG=1`) is appended (`debugAppendArgv`, O_APPEND) with a per-process tag on every line, because rewriting the whole file from each process's own buffer clobbered the other's lines and left NUL bytes. Past 1 MiB it is rotated to `debug.log.1` under a lock (`debug.log.rotating`, mkdir; one older than a minute is a dead writer's) with the size checked again inside it, so two writers never rotate twice and move a fresh log over the old one. **Host result file (`PLANNOTATOR_HOST_RESULT_FILE`).** New CLI side channel (`apps/hook/server/host-result.ts`), taken from the env at startup and scrubbed like the bridge token: when a review, annotate, annotate-last or `claude-mod-plan` session settles, the CLI writes ONE JSON record there atomically (temp + rename): `{ v: 1, surface, decision, message, noop, annotationCount?, platform?, withNotes?, approvedPlan?, permissionMode?, documents?, target? }` (`documents`: a review of several files names each file with its comment count, in review order). **Decision targets.** Every decision a host delivers names its FULL target, as the server that took the decision resolved it, never the words the agent typed (a real report: a bare approval headed only `QUESTIONS.md` was acted on as an earlier decision about a different `QUESTIONS.md`). The CLI writes `target` (absolute file or folder path, URL, a bundle's files in order, the reviewed directory, patch file or PR URL, the plan file; none for annotate-last) into the host result record, the ready line (`ServerReadyMetadata.target`, set once per process by `setServerReadyTarget`), the `--json` annotate record and the `opencode-review` record (additive), and the `sessions/` registry (`apps/hook/server/decision-target.ts`). Hosts put it on the line after the heading (`plannotatorDecisionHeading(subject, id, outcome, target)` → `Target: ` / `Targets:` + `- ` lines; `plannotatorTargetLines`), and the tool's opened text carries the same line. The mod stores the target on its `LaunchRecord` (from the ready line, else its own resolution of the words against the session directory, for a CLI older than the field), names the record's target when there is one, and logs a line when the two differ. Pi sets it from its in-process resolution; the OpenCode bridge reads the ready line and the JSON records, and uses the CLI's resolved path in its `File:` line too. Same-named open reviews are told apart in headings, status and toasts by `plannotatorDistinctSubjects` (shortest distinct path tail: `releases-2026-10-04/QUESTIONS.md`). Plain CLI stdout ("The user approved.") and the strict-gate record are unchanged. A code review's target is taken at DECISION time, not at launch: both review servers put `target` on the decision (the active PR's URL after an in-place `/api/pr-switch`, the workspace root, or the working tree the active diff reads), and the CLI record, the `opencode-review` record and Pi use it (a static patch keeps the patch file the CLI named); a decision about a different PR than the launch opened is headed with that PR (`plannotatorDecisionSubject`). The mod's own fallback for an older CLI never guesses: only a URL or PR URL, an absolute path, or a review with no words (the session directory) counts; a bare or relative name, or review prose, gets no Target line rather than a confidently wrong one. `message` is composed from the configured prompts exactly as other hosts do (review: the CLI's own output; annotate: the file/message feedback and approved-with-notes prompts OpenCode and Pi use; plan: `composePlanDeniedMessage` / the approved prompts), so the mod never re-implements prompts. Stdout is unchanged for every caller. `noop` marks what never starts a turn: Done with nothing to send, review LGTM, Close, a truly empty review submit (no feedback text and no annotations), and a review posted straight to the PR platform (`platform: true`, logged plus a `$.prompt.suggest` to address the comments). A gated launch (`deliverApproval`: the tool's or a taken-over command's `gate: true`, annotate or review) still delivers a `noop` approval; a review LGTM record's `message` is the configured approved prompt for that (a CLI before #1907 wrote `""`, and the turn is then the heading alone). `plannotator review --gate` is accepted by the shared `parseReviewArgs` (`gate: true`) and changes nothing in the CLI, whose approval is printed anyway. **The platform post is marked explicitly, never inferred from zero annotations:** the review editor puts only code comments in `annotations`, while PR description comments, PR comment notes and VS Code editor comments ride only in the feedback markdown, so a review made only of those has an empty `annotations` and is ordinary feedback (delivered, `noop: false`, `annotationCount: 0`, and given the request-changes suffix). The platform path's status post sends `platform: true` in its `/api/feedback` body; both review servers carry it onto the decision only when it is boolean `true`, and every consumer decides on that flag: `reviewHostResult` (noop + `platform`), `buildReviewOutput` (CLI stdout and `--json`, which Amp reads; the status line gets no suffix), OpenCode's embedded `/plannotator-review`, the OpenCode CLI bridge (`opencode-review` always emits `platform` as a boolean; a missing field means an older CLI and falls back to the old `isPRMode` rule), Pi (`classifyReviewOutcome`, `apps/pi-extension/review-outcome.ts`; the event API's `code-review` result carries the same field). Client and server ship together in each binary and package, so the flag is always present where it is needed. The mod's `legacyResult` (an older CLI's stdout) never inferred the platform post; there a status line arrives as a feedback turn, as before. The feedback archive does not classify the platform post either: its decision comes from `approved` and whether the body has content. Introduced in 0.28.0, where zero annotations read as the platform post and the mod sent nothing (fixed 0.28.4; the mod also guards records from those CLIs, see "Version skew"). An annotate Done with nothing to send still posts the legacy zero-state sentence ("User reviewed the document and has no feedback.") as `feedback`, because plain stdout, `--json` and the OpenCode/Pi consumers print or deliver those bytes; the editor additionally marks that body `nothingToSend: true`, which both annotate servers carry onto the decision only alongside empty `annotations`/`codeAnnotations` (`isNothingToSendFeedbackBody`, `packages/shared/annotate-decision.ts`), and `annotateHostResult` turns it into a `noop` record. Every plugin host that turns a decision into an agent message skips it the same way: Pi's `classifyAnnotateOutcome` reads it as "Annotation closed (no feedback).", the OpenCode embedded handlers (`commands.ts`) send no prompt (on OpenCode 1 the command's own message still runs its one empty turn, the known OC1 limit; no second turn is added), and the OpenCode CLI bridge (OpenCode 1 CLI runtime and OpenCode 2 native commands) reads the additive `nothingToSend: true` the CLI adds to its `--json` / `opencode-annotate-last` record in that case only, as do the Amp plugin (`formatAnnotationFeedback`) and the Droid plugin (`emitAnnotateDecision`), which also read `annotate --json`. Skill-run hosts (the classic Claude Code skill, Codex, Gemini, Copilot, Vibe, Kiro) still hand the model the plain-stdout sentence, which is the unchanged stdout contract. The feedback archive still records a bare Done as `feedback` with the sentence. Multi-message annotate-last merges SSE external (agent / WebMCP) comments into the current message's entry (`mergeExternalsIntoMessageEntries`), so they reach the exported feedback, the submit body and the nothing-to-send check. The mod's `legacyResult` recognizes the two zero-state sentences printed by a CLI older than the result file (#1701). The submit body's `annotations` carries every document's comments (folder sessions, linked documents, every message in multi-message annotate-last; `collectSubmittedAnnotations` in `packages/editor/feedbackDocuments.ts`), so `annotationCount` matches the feedback text. **Version skew.** The plugin installs from the repo's main branch and the binary updates separately, so with the mod on by default it routinely runs against an older CLI. Verified by running the real mod controller against the released 0.27.25 and 0.27.10 binaries as processes: - ExitPlanMode: an old CLI has no `claude-mod-plan` (0.27.11+ prints `Unknown command: claude-mod-plan`; older CLIs read the unknown subcommand as the classic hook and print `No plan content in hook event`), exit 1 within ~0.3 s. The mod recognizes either (`cliLacksModPlan` in `plan.ts`), lets the call through to Claude Code's own flow, where the plugin's classic PermissionRequest hook runs the familiar blocking review, logs ONE line telling the user to update the CLI, removes the launch directory, and sends every later ExitPlanMode straight to the classic flow without probing again. The flag lives on the mod instance, not in `$.store`, so a restart or `--resume` probes once more (and picks up a CLI updated in between). An old CLI that refuses only after the hook stopped waiting (15 s) is caught by the timer: same fallback, plus a plugin turn asking Claude to call ExitPlanMode again, since it was told a review was open. While the hook waits, the timer leaves the launch alone (`starting`), so only one of the two reports it. Any other startup failure also falls back, with the CLI's error logged. Plan review is therefore blocking, not broken. - Review, annotate, last, the `plannotator` tool and taken-over Bash runs (the same launch as the tool): work. The old CLI writes the ready file (0.19.24+) and no result record; a decision that exits 0 is delivered from its stdout (`legacyResult` in `delivery.ts`), with the default review approval prompt read as an LGTM (logged, no turn) and the approved-with-notes prompt as "Approved with notes" (a user-customized approved prompt cannot be told apart and arrives as feedback). Close and an empty Done log only. - Review platform flag (0.28.0 to 0.28.3 CLIs): those CLIs wrote `platform: true, noop: true` for ANY request-changes review with zero code annotations, so feedback made only of PR description, PR comment or editor comments would be logged and never delivered. The mod carries a narrow old-CLI guard (`misreadAsPlatformPost` in `delivery.ts`): a review record is treated as the real platform post only when its message is one of the editor's status lines (`isPlatformStatusLine`: `Pull request|Merge request approved|reviewed on …` or `Changes requested on …`, the shapes `statusMessage` in `packages/review-editor/App.tsx` builds); any other non-empty `platform` record is delivered as review feedback (without the request-changes suffix, which those CLIs never added to it). A fixed CLI sets `platform` only on the real status post, which always matches, so it never takes this path. If the editor's status-line wording changes, update the pattern and its test. - `last`: the old CLI ignores `PLANNOTATOR_HOST_MESSAGES_FILE` and opens the newest message from stdin (no picker), though the command line still says "N messages, newest first". - Ask this session: the old server answers `404` on `/api/ai/bridge/poll`, the mod's bridge stops, and Ask AI offers only its providers. - A CLI before 0.19.24 writes no ready file: commands wait their full 45 s / 15 s and say "Starting…", and decisions still arrive at exit. - The tool's `list` and `close` (contract v2): a CLI without `/api/host/status` lists its reviews with `unsent: unknown`. `close` counts as closed ONLY a JSON answer with a numeric `unsentAnnotations` (`classifyHostCloseAnswer` in `controller.ts`): 0.24–0.28.3 answer a JSON `404` and 0.19.24–0.23.x (before the `/api/*` 404 guard, #748) their app page with `200 text/html`, and both read as "an older Plannotator", never as closed. A CURRENT CLI with host control turned off (remote mode) answers `404 { code: "host_control_disabled" }` instead, which the mod never TERMs: the close reports that the review runs in remote mode and should be closed from the tab. Only for an older CLI does the mod TERM the process (`STOP_SCRIPT`), and only if the launch's `result.json` / `exit` is still absent and the pid still names a `plannotator` process (`ps -o args=`); nothing answering on the port (a stale or reused pid after a reboot) is never signalled, and where `ps` cannot verify a pid (missing, as on Debian slim without procps, or without `-p`, as BusyBox's) the script signals nothing and the close says the review was left running. In every such case the close reports failure. After a TERM close, a decision record without `closedBy` (or an older CLI's exit 0) is the reviewer's and is delivered as usual. **Remaining window:** these CLIs publish a decision only after a 1.5 s post-decision sleep and nothing on their HTTP surface says a decision is pending, so a decision the reviewer makes in the ~1.5 s before the TERM is lost (and those CLIs had already deleted the draft on submit; the feedback archive record survives where it is on). A CLI with the endpoint has no such window: its close is refused with `409` once the reviewer decided. **Commands.** `/plannotator-review`, `/plannotator-annotate` and `/plannotator-last` keep their names (spec open question 7, conservative): when the user's core skills hold the names (Claude Code refuses `$.command.register` for a user skill's name), the mod's `command.run` hook answers the skill's command itself, so the skill's blocking bang line never runs; a name nobody holds is registered (`immediate: true`). Arguments are split like the skill's shell line (`splitShellWords`: quotes and backslashes, no expansion) and passed to the same CLI parsers (`review`, `annotate`; `last` runs `annotate-last --stdin`, see the next paragraph). The command returns "Opened in Plannotator · " once the ready file appears (up to 45 s for review, 15 s otherwise), or the CLI's own startup error. Under the mod the CLI's tolerant annotate handoff (several unresolvable words) is shown to the user as that error, not handed to Claude. **`last` and its message picker.** The classic path reads up to 25 recent assistant messages from the transcript file and shows a picker (newest open by default). The mod has no transcript file to hand over, so it builds the same list from `$.session.messages()` (`recentAssistantTexts` in `launch.ts`: newest first, at most 25, text-less rows skipped, consecutive assistant rows joined as one response, the way the transcript path groups chunks by message id) and, when there is more than one, writes `messages.json` `{ v: 1, messages: [{ messageId, text }] }` and sets `PLANNOTATOR_HOST_MESSAGES_FILE` to it. stdin still carries the newest text. `$.session.messages()` rows carry no id or timestamp, so ids are derived from the text (`cc-` + 16 hex of its SHA-256, a `-n` suffix for repeats), stable as newer messages arrive; there is no timestamp. The CLI (`apps/hook/server/host-messages.ts`) honors the variable only with `--stdin`, only for a `messages.json` under the data dir's `claude-code-mod/`, takes it at startup and scrubs it from the env, and validates fail-closed (v 1, 1..25 entries, string fields, unique ids, 2 MiB per message, 8 MiB file); a malformed file is a startup error (exit 1). The mod (`pickerFile`) budgets the SERIALIZED file, 256 KiB under that cap, because JSON escaping inflates text (ESC becomes `\u001b`): older messages that do not fit are left out, and when the newest alone does not fit no file is written (stdin only). Why a variable and not a flag: a CLI that predates it ignores the variable and opens the stdin text exactly as before, while an unknown flag to `annotate-last` is silently ignored (it would fall through to the transcript lookup) and a changed `--stdin` payload would be shown as the message. Old mod + new CLI: no variable, `--stdin` unchanged. With a picker the subject is "Claude's recent messages" (status line, open text, the plugin turn's first line), since the feedback may be about an older message; the feedback itself carries an excerpt of each message it covers. What `$.session.messages()` returns is the engine's live conversation: after `/rewind` that is the rewound conversation (inferred from the API docs, not checked live), and after `/compact` only the post-compaction messages, so the picker can be shorter there than on the classic path, which falls back to the pre-compaction file. **The `plannotator` tool (agent-initiated opens).** When the user tells Claude "open this in plannotator", Claude used to run the CLI through Bash, which blocks and gives Ask AI a separate AI. The mod registers a real tool instead: `$.tool.register({ name: "plannotator", description, inputSchema })` at `session.start`, right after the commands, and only when the mod is on (the knob not turned off, an interactive session, `/bin/sh` present) and the agent tool switch is on (on by default here; `PLANNOTATOR_AGENT_TOOL=0` / `{ "agentTool": false }` turn it off; `resolveAgentToolEnabled` in `hooks/mod/enabled.ts`, read with the mod switch at the first `session.start` of the process, never re-read); with either switch off, in `-p`/SDK runs and on Windows nothing is registered and Claude keeps the CLI through the `plannotator` skill. With only the agent tool off, the rest of the mod runs: the slash commands, non-blocking ExitPlanMode, Ask this session, and the Bash take-over below, which stays because it adds nothing to Claude's context (it changes only what a `plannotator` command Claude already chose to run does). The engine names it `mcp__plannotator__plannotator` (the register call returns the full name, which `register.ts` keeps) and serves it through a loopback MCP server; the mod's `tool.call` hook answers every call itself (`{ result }` / `{ deny }`), so no other hook or permission prompt runs for it. Input: `{ action: "annotate" | "review" | "last", target?, gate? (annotate and review), options?: { base? (review --base), markdown? (annotate --markdown) } }`, validated strictly (unknown keys, a target or base that would read as a flag, control characters, a field the action does not take; a `false` default is tolerated). The call becomes the words the slash command would carry (`plannotatorToolArgs`, one argument per element, never re-split) and goes through the SAME launch as the commands (`PlannotatorMod.open`: detached CLI, result file, bridge token, session tag, cleanup), so Ask this session works and the decision arrives later as a plugin turn. The tool result returns at once ("Opened in Plannotator: … End your turn now and wait"); a bad call or a CLI startup error is an error result. A gated tool session (`gate: true`) is the one delivery difference: a bare Approve is submitted as a turn (`deliverApproval` on the launch record), because Claude was told to wait for the sign-off; the slash command's bare gated approval still only logs. This holds for code review too (#1907, gate only, the owner's call: an ungated review's LGTM still only logs): `{ action: "review", gate: true }` delivers a bare Approve as `Plannotator: (pn-…) — Approved.` plus the configured approved prompt (the host record of a review LGTM is still `noop` but now carries that prompt as its `message`, as an annotate approval does), and a gated review posted straight to the PR platform is a turn too, `Plannotator: (pn-…) — Review posted.` plus the status line (an ungated one still only logs and suggests the follow-up). A review's gate is the HOST's alone: `plannotatorToolArgs` never passes it to the CLI (the review page always has Approve, and a CLI older than `review --gate` would refuse the flag), so it works against any CLI. Done and Close send nothing, as for the commands. A subagent may call the tool (its decision lands in the main session as a plugin turn), except `action: "last"`, which is refused there because it reads the main session's transcript. The contract (name, schema, description, validation, argument mapping, result text) lives once in `packages/shared/plannotator-tool.ts` for every host; the mod keeps a byte-for-byte copy of its CONTRACT section in `hooks/mod/tool.ts` and `tool.test.ts` fails when they differ (edit the shared file, then paste). The core `plannotator` skill carries one host-neutral line: use a `plannotator` tool when the agent has one. Plan review stays on ExitPlanMode. Checked live on 2.1.288: the tool is DEFERRED behind tool search (no `alwaysLoad` is possible through `$.tool.register`), so Claude sees only its name until it searches; with the updated skill it searched and called the tool for "open notes.md in plannotator", while a profile still holding the older installed skill text loaded that skill and ran the CLI instead. **Contract v2: session ids, `list` and `close` (agent sessions, 0.29).** Every review the mod opens has a session id, `pn-` + the six hex digits that end its launch id (`sessionIdOf` in `controller.ts`; unique among the session's open launches). The tool result's first line is `Session: pn-3f2a9c`, and every decision turn's first line names it (`plannotatorDecisionHeading`: `Plannotator: notes.md (pn-3f2a9c) — Feedback · 3 comments.`); slash-command output is unchanged. `action: "list"` reports the reviews opened in THIS Claude session, by the tool or by the user's `/plannotator-*` commands (the launch store is already per session id; the global `sessions/` registry is never read): id, kind, subject, url, age, state and `unsent: N` from the server's `GET /api/host/status` (`unknown` for an older CLI). `unsent` counts only the reviewer's own draft comments (`countUnsentDraftComments`: entries without a `source`, so review-agent, WebMCP and linter findings are not counted, plus code review's PR description and PR comment notes). `action: "close"` takes `session: "pn-…"` or `"all"` and calls `POST /api/host/close` (see "Host session control" under Server API): the reviewer's Close, marked `closedBy: "agent"`, with the draft KEPT and the open tab told over the external-annotation SSE (`session-closed`, which the editor and review app show as "Closed by the Agent" with the unsent count). The result reports how many unsent comments stay saved as a draft. The copy promises no restore beyond each surface's own draft key (`agentClosedSubtitle`: annotate drafts come back for the same unchanged document, code review drafts for the same changes; path-keyed annotate drafts are #1710). Nothing is delivered for that review afterwards (the launch is marked `closedByAgent` and settles with one log line), except a decision record WITHOUT `closedBy`, which is the reviewer's and is delivered. Plan reviews are listed but never closed (`close all` skips them, a plan id is refused). An id this session did not open is "not found". Against an older CLI the mod falls back to `TERM` under the checks described in "Version skew" above (`stopArgv`), which ends the server without a decision and never deletes a draft. The contract also accepts `target` as a list for annotate (validated, duplicates dropped, a one-item list is the plain call): a review of several files (a bundle, see "Several files in one review" under the Annotate Flow). `plannotatorToolArgs` passes the paths as the CLI's arguments, one each, in order (a bare list entry such as `README` is passed as `./README`, so a missing one fails the whole list instead of being read as prose), and the session is named `plannotatorBundleSubject` ("3 files: spec.md, mock.html, notes.md"), in the opened text, the session list and the decision heading. The tool description says "Pass a list as `target` to review several files together, in the order you want them read." Version skew: a CLI that predates bundles answers several paths with its ambiguity error WITHOUT the bundle hint line (`isOlderCliBundleRefusal`, which compares against `PLANNOTATOR_BUNDLE_HINT_LINE`, a copy of `ANNOTATE_BUNDLE_HINT` pinned equal by `annotate-target.test.ts`); the mod then answers `PLANNOTATOR_TOOL_BUNDLE_UNAVAILABLE_TEXT` ("update Plannotator to open several files at once") for the tool, and for `/plannotator-annotate` when every word reads as a file path (`isSeveralFilePaths`). There is no `reply` action: it was reserved for live comments and always errored, so it was taken out of the schema; it comes back with the comment loop. (The design record is not in this repo; what matters is written here.) **Agent-run CLI commands are taken over (Bash).** Because the tool is deferred and the skill also documents the CLI, Claude sometimes runs `plannotator annotate x.html --gate --json` in Bash anyway; that blocked the session and started a server with no bridge token, so Ask AI offered separate SDK agents instead of "Ask this session". With the mod on, the `tool.call` hook on the MAIN LOOP's `Bash` parses the command with the host-neutral `plannotatorCommandToToolInput` (CONTRACT section of `packages/shared/plannotator-tool.ts`, so it is in the mod's copy too) and, when it returns a tool input, answers the call itself through the SAME `runTool` → `open` launch the tool uses (`hooks/mod/take-over.ts`): the command never runs, the Bash result is the Bash tool's own record `{ stdout: , stderr: "", interrupted: false }` (a startup error is a `deny`), and a gated take-over delivers its bare approval as a turn exactly like a gated tool call. A subagent's Bash command always runs as written: a subagent may work in its own cwd or worktree, while the mod launches in the session's cwd, so its `review` or `annotate notes.md` would open the wrong diff or file. Taken over: ONE simple command (no `; & | < > ( )`, line break, `$`, backtick, unquoted glob/brace, comment, `~user`, unterminated quote; quoting follows `splitShellWords`, nothing is expanded) whose program is exactly `plannotator` (the binary on PATH; a path such as `./plannotator` or `/tmp/dev/plannotator` is a dev build and runs for real), subcommand `annotate` (one target, or several that all read as file paths, `looksLikeFilePath`: a separator, a leading `~` `.` `@`, or a file extension; they become `target: [...]`, a review of several files; plus `--gate`, `--markdown`), `review` (at most one target, `--base `, `--gate`), or `annotate-last` / `last` (no arguments); `--json` is accepted and dropped (the decision arrives as a message), and the result must pass `parsePlannotatorToolInput`. Everything else runs as written: other subcommands, every other flag (`--require-approval`, `--result-file`, `--hook`, `--tailscale`, `--static`, `--app`, `--no-jina`, `--render-html`, `--diff-type`, `--local`, `--patch-file`, `--stdin`, `--help`, ...), repeated flags, several targets that are not all file paths (prose, which the CLI's tolerant resolution reads) or several review targets, environment prefixes, and any compound command (`cd x && plannotator ...`, pipes, redirects, substitutions), so scripted strict gates keep the real CLI and its exit codes. With the mod off, in `-p`/SDK runs and on Windows nothing is taken over. The CLI the mod launches runs in the session's cwd (`$.process.run`'s default), the same as for the tool. Pi and OpenCode do not take over shell runs: there an agent-run `plannotator` command still runs the CLI (Pi registers the tool itself; see "Pi: the `plannotator` tool"). **Plan review.** The `tool.call` hook on the main loop's ExitPlanMode resolves the plan (the plan file when it is an absolute `.md` regular file within the 2 MB cap, else the inline `plan`: the #1667 rule), then: 1. no review open: starts `plannotator claude-mod-plan` (internal subcommand, `apps/hook/server/claude-mod-plan.ts`; stdin `{ plan, planFilePath, revisionFile }`; the plan server with `planRevisions: true`) and answers `{ deny }` with the waiting text (`waitingDenyText`). ExitPlanMode never runs, so plan mode stays on. 2. a review open: writes `revision.json` `{ seq, plan }`; the subcommand polls it every 500 ms, pushes it with `updatePlan` and acks; the deny text says "Plan vN replaced vN-1" (or, when a decision is already being recorded, to wait for it). Spec open question 2 resolved like Pi: revisions go into the same tab. 3. the plan's SHA-256 (trailing whitespace ignored) equals an approval's: the call passes (`next(e)`), and `classic.PermissionRequest` answers `allow` with `updatedInput.plan` = the approved text (#1670's "execute what was approved") and `setMode` to the reviewer's permission mode. Modules sit above the settings hooks in that chain, so the plugin's own PermissionRequest command hook never runs: no second review. A different plan opens a new review instead. **When a plugin seated above sends `classic.PermissionRequest` past the mod** (Claude Code's built-in guard `cc-plugin-sec-default` does on Team/Enterprise accounts, #2007; the transcript says "its classic.PermissionRequest hook was skipped"), the plugin's command hook IS reached. So while the approved call is in flight the mod also writes the approval (`{ v: 1, hash, plan, permissionMode?, at }`) to `/claude-code-mod//plan-approvals/.json` (owner-only folder; plan.ts `PLAN_APPROVALS_DIR`, launch.ts `planApprovalPathOf`) and removes it when the call settles. The classic hook (`apps/hook/server/mod-plan-approval.ts`, `takeModPlanApproval`, before anything else in the Claude Code plan path) takes a record for this `session_id` whose hash matches the requested plan (the #1667 file rule, or the inline plan), at most 10 minutes old, never for a subagent, deletes it (answers once) and prints the same allow (`updatedInput.plan`, `setMode`) without starting a server. Without the guard the record is written and removed and nothing else changes. A CLI older than this ignores the record (the double approval remains until the binary is updated). `mod-plan-approval.test.ts` keeps the mod's copy of the names equal and runs the hook as a process. Decisions: deny and answers-only submit the denied / answered prompt; approval submits the approved (or approved-with-notes) prompt plus `PLAN_APPROVAL_NEXT_STEP` ("Call ExitPlanMode once more …"), the two-hop approval of spec open question 1 (the one-hop `$.tool.call` path is untested). The claim the subcommand records (`currentPlan` after each accepted revision) is what `approvedPlan` carries. If the plan server cannot start, the call falls back to Claude Code's own flow (and the classic hook). A subagent's ExitPlanMode keeps the classic flow. Not built: the edit guard (spec: deny Edit/Write while a plan is in review after the user leaves plan mode), the `AbovePrompt` band with Send now / Hold, the `/plannotator` sessions pane, the `UserMessage` / `ToolUse` render hooks, and `tab-closed` detection; a decision is always sent (`$.prompt.submit` waits for idle; draft text in the composer is left alone). **Delivery.** Each decision is one plugin turn, in arrival order, prefixed `Plannotator: — .`; a message over 12 KB is written in full to the launch's `feedback.md` and Claude is told to Read it (the feedback archive may be off, so the mod writes its own copy). Status line (`$.ui.status`): ` · waiting for you` / `N open · …`; toasts for a plan waiting or replaced and for feedback received; `$.ui.log` for no-op decisions, stopped servers and reattachment. **Session tag.** `session.start` sets `PLANNOTATOR_SESSION_TAG=claude-code:` with `$.env.set`, so every process the session starts (Bash calls, hooks, the detached servers) inherits it; `registerCliSession` records it as `hostSession` in the `sessions/` registry, and `plannotator inbox mcp` sends as this session (the `plannotator_inbox` tool's author, so the reply wakes it). The tag follows `/clear` and an in-process resume: `currentMod` sets it again for the new id, at the latest on the next `turn.start`. **Ask this session.** Every launched server gets a 32-byte hex token (`PLANNOTATOR_SESSION_BRIDGE_TOKEN`, `_HOST=claude-code`, `_MODES=turn`). Once the ready file names the port, the timer starts `createBridge` (`bridge.ts`, the pull-bridge client over `$.http.fetch` to `http://127.0.0.1:`, no Origin): an `ask` is submitted as a real turn (`$.prompt.submit`), identified at `turn.start` by its header and last line (Claude Code frames a plugin prompt as "The plannotator plugin sent a message: …", seen live), streamed back from `turn.step` text/tool chunks, and finished from `turn.complete`'s `answer`. `busy` = a MAIN-LOOP turn is running (pushed on `turn.start` / `turn.complete`, not only at the next poll; a subagent's `turn.start`, which every hook hears, is ignored by `onTurnStart`: its end is never counted, so counting it left the session busy for good after a background subagent started, and the Inbox wake never delivered); `interrupt` aborts the running turn (`$.turn.abort`); cancel aborts our own turn, or, while it is still queued, confirms at once and aborts its turn the moment it starts. Claude Code never raises a plugin's own hooks for a prompt its code submitted (the debug log says "skipped: re-entry"), so the mod's `prompt.submit` hook sees every OTHER prompt (the user's Enter, a notification, another plugin); a turn whose text is exactly one of those is never claimed, so a prompt the user typed can neither be streamed to Plannotator nor aborted by a cancel, whatever it says. Another plugin's prompt reaches `turn.start` inside the engine's "The plugin sent a message:" frame while `prompt.submit` saw it bare, so the match also strips that frame. **Take-over** (`turns.ts`): a prompt typed or delivered while a turn runs fires `prompt.submit` at Enter with that turn's `turnId`, and the engine folds it into the turn's next model request. When that turn is our question's and the origin is on an ALLOWLIST of messages the model must now answer (`composer`, `bridge`, `slack-ping`, `channel`; never `peer` (owner's call: a peer session's reply streaming into the panel is better than losing the reviewer's answer), `task-notification`, `peer-send-message`, `scheduled-trigger`, `observer*`, `coordinator`, `projects-relay`, `auto-continuation`, `unclassified` or a kind added later), streaming stops the moment the prompt reaches the mod's `prompt.submit` hook, BEFORE `next(e)` runs the hooks beneath (a slow one cannot let the next step through); the take-over is confirmed when `next(e)` resolves with the prompt, and a prompt a hook beneath drops releases the hold. The step in flight was requested before their prompt, so its output is held and released when the question settles: at the turn's next `turn.step` (that request carries their prompt) as `done` when the response before it ended the answer (`stop` chunk `end_turn`, no tool call) and some answer text was shown, else as `taken_over` (a thinking-only final response would otherwise settle an empty answer); or at `turn.complete` when no step followed, as `done` with the turn's answer (their prompt then runs as a turn of its own). The note reads "You typed into this session…" for `composer` / `bridge` and the neutral "Another message entered this session…" otherwise. From the take-over on, a cancel closes only the question and an interrupt answers `ok: false`; Plannotator never aborts that turn. Plan review does not block the session under the mod, so the status is never `blocked` and plan review gets real turns too (verified live). Polls ask for 15 s (750 ms while our question streams, so deltas flush), and stop for good on 401/403/404/405/503 (`404` = an older CLI without the bridge), `closing`, or once the review settles. A loop that stops because the server stopped answering (six failures in a row; `$.http.fetch` gives up after 30 s, so a sleeping laptop or a paused server gets there in about three minutes) is not the end: `run()` reports why it ended (`BridgeEnd`), the controller clears the handle, and the timer starts a new loop with the same token after 5 s, doubling to 60 s while the server stays silent (`BRIDGE_RETRY_MS`), so Ask AI comes back once the server answers again. A poll answered `superseded: true` (another client polled the server after us) waits 10-15 s before polling again, instead of taking the server back at once and starting a busy loop with the other client. **The Plannotator Inbox (`plannotator_inbox` and the reply wake, `inbox.ts`).** Where `PLANNOTATOR_INBOX_TOOL` / `inboxTool` allows it at the first `session.start` (on by default here), `register.ts` reads the Inbox's tool list once (`discoverInboxTools`: `tools/list` on its `/mcp`; a stopped Inbox is described by the list it last answered with, kept in `$.store` as `inboxTools`; with neither, the list bundled with the mod, `INBOX_DEFAULT_TOOLS` in `inbox-default-tools.ts`, generated from a real Inbox by `bun run scripts/sync-mod-inbox-tools.ts` and pinned to one by `inbox-default-tools.test.ts`) and registers `plannotator_inbox` (`inboxAgentTool`), a SEPARATE tool, never actions on `plannotator`. So Claude Code has the tool before this computer ever ran the Inbox: the first call starts it. With no `inbox/inbox.json` the link stays silent: no HTTP request and no file (no lease, no claims folder) until a call starts the Inbox, and the wake polls from the next tick after. With the tool, `inbox/inbox.json` present at the first `session.start` (an Inbox has run here) and `inboxReminder` on (default), a `prompt.context` hook adds the `plannotatorInbox` block (`withInboxReminder`, the line naming the tool's registered full name) to the conversation's first message, once per conversation (see `inboxReminder` under Config-only settings). The session's `PlannotatorMod` owns an `InboxLink` (one per session id, so `/clear` and an in-process resume follow the new id: the old link is disposed at `session.end` and the next hook makes one for the new id). A call (`tool.call`, answered by the mod) re-reads the registry, checks health against its `serverSession`, starts a stopped Inbox with `plannotator inbox --background` (a one-shot that detaches the Inbox into its own session and waits for it; never a browser tab), and proxies to `/mcp` with `project_path` = `$.session.cwd()` (or the agent's optional `project_path`, resolved against it by `INBOX_PROJECT_DIR_SCRIPT` and refused before the Inbox is reached when it is not an existing folder) and `agent_session` = the host's real session id; `wait_for_reply` is held to 25 s through the tool (`$.http.fetch` gives up at 30 s). The wake: a 1 s `$.clock.every` tick long-polls `/api/inbox/bridge/poll` (20 s) with a 5 to 60 s backoff that never gives up; a reply waits in the mod (unless the person pressed Send now: see "Questions and Send"; the mod then puts it into the running turn with `$.session.append`) until the session has been idle for two ticks (a pending `$.prompt.submit` cannot be withdrawn, so a prompt the person typed, or a `/clear`, queued during a turn goes first), is checked once more with the Inbox (an agent may have read it meanwhile), claimed once across processes (`INBOX_CLAIM_SCRIPT`: mkdir of `claude-code-mod//inbox/claims/`), submitted (`inboxWakeText`), and acknowledged (`delivered`, retried until the Inbox answers). A prompt typed into the reply's own turn takes it over as Claude Code folds it in; the mod never aborts it and never delivers the reply again. Two processes on one session: the session's inbox lease (`claude-code-mod//inbox/watcher.json`, the rules of the review watcher lease: the person's last process wins, 20 s stale) picks the one that polls and delivers, and the claim makes a lease race harmless; a claimant writes `alive` while its turn waits, and a reply whose claimant stopped for 60 s without `delivered` is reported once with a toast (the only toast the connection raises); one marked `delivered` whose acknowledgement was lost is acknowledged by the other process. Nothing touches the system prompt; the tool list is fixed per process. Version skew: an Inbox without the bridge route (404) just leaves the poll backing off; an action the running Inbox lost answers "update Plannotator"; a `plannotator` without the `inbox` subcommand answers that the binary has no Inbox. **Tests.** Bun: `apps/hook/hooks/mod/*.test.ts` (controller flows over an in-memory `Host`, delivery, shell words, and `bridge.test.ts` against the REAL server half `createPullSessionBridge`), `apps/hook/server/host-result.test.ts`, `apps/hook/server/claude-mod-plan.test.ts` (the subcommand as a process: revision → tab → approval carries the revised text; answers-only), `apps/hook/server/host-messages.test.ts` (`annotate-last --stdin` as a process: the messages file yields the picker, a malformed one exits 1, plain `--stdin` unchanged). Engine harness: `apps/hook/tests/register.test.ts` via `scripts/test-claude-code-mod.sh` (stages the plugin without the CLI, whose bun tests `claude plugin test` would otherwise try to load; bun skips `apps/hook/tests/` through `pathIgnorePatterns`). Typecheck: `apps/hook/hooks/mod/tsconfig.json` (no DOM, no Node; `globals.d.ts` declares the few web APIs a hooks module has). `PLANNOTATOR_MOD_DEBUG=1` writes `claude-code-mod/debug.log` in the data dir. The Inbox connection is proved against a REAL Inbox in `apps/hook/hooks/mod/inbox.test.ts`: `plannotator inbox --background` under a temp data dir (the compiled binary when `PLANNOTATOR_INBOX_TEST_BINARY` names one, as `.github/workflows/inbox-claude-code.yml` runs it; the CLI from source otherwise) and the mod's own code on a Host of real processes, HTTP and files (`testing/claude-session.ts`, which simulates the conversation the way `register.ts` feeds it): with no registry the tool comes from the bundled list, nothing polls or writes, and the first call starts the Inbox and lands its message, after which the wake polls; the tool carries what the Inbox's `/mcp` offers, `send_message` lands a thread, the window's Send arrives once as a turn with the stable header and the reply verbatim (delivered, then replied), a typed prompt goes first and takes the reply's turn over, `/clear` follows the new id, two processes deliver once, and a dead Inbox is started by the first call with no browser tab. `INBOX_PROOF_DIR=` keeps a transcript per proof. The engine harness (`apps/hook/tests/register.test.ts`) covers the wiring with the engine's own `$`. ### OpenCode 2: the `plannotator` tool The OpenCode 2 plugin (`apps/opencode-plugin/server.ts`, `registerPlannotatorTool`) registers the same `plannotator` tool the Claude Code mod does, through `ctx.tool.transform` → `tools.add`, with the shared contract itself (`PLANNOTATOR_TOOL_NAME` / `_DESCRIPTION` / `_INPUT_SCHEMA` and `parsePlannotatorToolInput` from `packages/shared/plannotator-tool.ts`, never a copy; `plannotator-tool.test.ts` checks the registered schema IS the shared object). It is OFF by default on OpenCode 2; turned on (`PLANNOTATOR_AGENT_TOOL=1` / `{ "agentTool": true }`, `resolveAgentTool(loadConfig(), process.env, "opencode")`, read once at plugin setup; a change applies when OpenCode restarts) it is registered for every workflow (`manual` included), and not at all where the decision could never come back: a tool draft without `add` (an older V2 host, probed inside the callback like the native commands' draft) or a session domain without `prompt`. OpenCode 1 gets no tool in 0.29 (design decision; its `plugin.tool` route is feasible). **Open (annotate, review, last).** Each call runs the SAME launch the native slash commands use (`runNativeCommand` → `handleCliCommand`: a `plannotator` CLI child, the pull-bridge token so "Ask this session" works, the ready file, the decision delivered later to the calling session with `session.prompt`), and the tool returns as soon as the ready file names the url (up to 45 s for review, 15 s otherwise, then the "starting" text): `Session: pn-…` plus `plannotatorToolOpenedText`. A refused argument or the CLI's startup error is the result text (`Plannotator could not start: …`; results are always text, since the promise adapter turns a rejected `execute` into a defect). The annotate target is passed as ONE argument (`toolLaunchRequest` builds `ParsedAnnotateArgs` instead of re-parsing a string, so `my notes.md` stays one path); review words are quoted for `parseReviewArgs`' string form (`quoteReviewWord`). A gated tool session delivers its bare approval (`Plannotator: notes.md (pn-…) — Approved.`), like the mod; a slash command's bare gated approval still sends nothing. A code review's bare Approve was always delivered here (and on Pi), so a review's `gate` changes nothing on OpenCode or Pi; it is accepted because the contract is shared. A list of files (two or more after the contract drops duplicates) opens ONE bundle review through the same launch: `handleCliCommand`'s `annotateBundle` passes each entry as its own CLI argument in the agent's order (never re-split, and a single tool target is never read as several words), the subject is `plannotatorBundleSubject` (`2 files: a.md, b.html`) in the result, `list` and the decision heading, and the feedback is framed under `Files: …` as the slash command's bundle is. An older CLI's several-paths ambiguity error (`isOlderCliBundleRefusal`) makes the result `PLANNOTATOR_TOOL_BUNDLE_UNAVAILABLE_TEXT`. When a launch answered "starting" and its CLI then fails before the page opens, the session gets one message (`plannotatorLateFailureText`: `Plannotator: notes.md (pn-…) — Did not open.` plus the CLI's error), delivered with `session.prompt` (`queue`), so neither the agent nor the person waits for a decision that never comes; OpenCode 2's plugin context has no toast surface, so that transcript message is the visible signal. A command that merely ends without a ready file sends nothing (a CLI older than 0.19.24 writes none and delivers its decision at exit). Subagents work as on the mod: a subagent's (child) session resolves to its root session (`resolveRootSession` walks `parentID`), which owns the review, gets its decision and lists or closes it; only `last` is refused from a subagent (`PLANNOTATOR_TOOL_SUBAGENT_LAST_TEXT`, the mod's wording), since it reads the main session's messages. Ask this session for such a review asks the root session. Not verified live: delivery to the root while it still waits on the subagent relies on `queue` delivery. A tool launch posts its session-URL notice into the CALLING session (a subagent's own session, never the root that receives the decision) and only while the tool call is still open (`createV2BridgeClient`'s `notice: { sessionID, open }`, closed the moment `runPlannotatorTool` answers), so the notice is always promoted inside the caller's running turn; the tool's answer already carries the URL, and a server that comes up after a "starting" answer posts no notice (the URL is in `list` and the log). Posting it into the root was a leak (0.28.5 smoke, live on 2.0.22): a background subagent's root, or the parent of an API-created child session, is idle, so the pending steer sat there and was promoted ALONE as a model turn when the root next woke (the queued decision promotes one row at a time), and the model answered "Plannotator session ready: ". With the notice always inside a running turn, tool launches always queue their decision (`alwaysQueue`; co-promotion is also only attempted for a notice in the decision's own session), since the slash commands' co-promoting steer (#1515) would push a late decision into a running turn. No shell take-over: nothing in the OpenCode 2 plugin API can answer a shell call. **Session ids, list and close.** `OpenCodeLaunchRegistry` (one per plugin instance) records every review a session opens: tool calls, its slash commands (`runNativeCommand` with `launches`), and its `submit_plan` review (listed, never closable). Every recorded launch's decision message starts with `plannotatorDecisionHeading` (`withDecisionHeading` in `cli-bridge.ts`), slash commands included, so `list` and the decision name the same id. Its outcome uses the mod's words: `Feedback · N comments`, `Approved with notes · N comments`, `Approved`, and for code review (local or PR) `Changes requested · N comments`. The count comes from the CLI's additive `annotationCount` on the `annotate --json`, `opencode-annotate-last` and `opencode-review` records (an older CLI omits it and the heading has no count). A PR-platform status post reads `Review posted` (`PLANNOTATOR_OUTCOME_REVIEW_POSTED`, the same words as Pi) and is decided by the record's `platform` flag (#1719; `isPlatformPost`, falling back to `isPRMode` only for a CLI without the flag), never inferred from zero annotations, so description-only PR feedback is `Changes requested`. `list` and `close` see only the calling OpenCode session's launches (never the global `sessions/` registry; another session's id is "not found"). `list` reads `GET /api/host/status` with the launch's token for `unsent` and `decided` (`readHostStatusAnswer`; `unknown` for an older CLI). `close` posts `POST /api/host/close` and reads the answer with `classifyHostCloseAnswer` (`packages/shared/host-control.ts`, the mod's rule): closed (draft kept, the tab told, nothing delivered, since the CLI's record is then `dismissed`), decided (the reviewer's decision is on its way), refused, turned off (left running; the text says remote mode only when the ready file did, and otherwise names a server started without a host token), or not answering (left running). `close all` skips plan reviews silently; only an explicit plan id gets the "not closable" line. A close that stopped an older CLI is logged as the agent's close, not as a CLI failure (`isClosedByAgent`). Only a server that answered as an older Plannotator without the endpoint (an uncoded JSON `404` or its app page) is stopped instead: SIGTERM to the plugin's own child process (`terminate` from `runPlannotatorCli`'s observer), which never deletes a draft; a decision such a CLI is still publishing is lost, as on the mod. Tests: `apps/opencode-plugin/plannotator-tool.test.ts` (registration against older drafts and without `prompt`, refusals, and the real launch path against a stub CLI that serves the real host-control guards: open → url and id → list → decision heading in the right session, gated bare approval, close with the token, turned-off close leaves the server up, older CLI stopped, startup error, a slash command listed). Checked live on OpenCode 2.0.22 with a fake model: the model's tool call opened annotate and review, `list` showed the server's count, the reviewer's feedback and a gated approval arrived as new turns headed with the id, `close all` closed a review holding 2 draft comments with no turn delivered, and Ask AI showed "Ask this session". A list target (`["notes.md", "mock.html"]`) opened one `annotate-bundle` session in that order, `list` named it `2 files: notes.md, mock.html`, its feedback arrived headed `— Feedback · 2 comments.` under `Files: notes.md, mock.html`, and `close all` closed a second bundle. ### T3 Code: Ask this session through an optional Claude hook (opt-in, unreleased) `packages/t3-code` (user notes in its README). T3 Code runs Claude through the Agent SDK, which loads `~/.claude/settings.json` hooks, so a Claude Bash hook connects ordinary `plannotator review` / `annotate` / `last` commands in a T3 conversation to that conversation: Ask this session becomes a T3 turn, and the decision arrives as a T3 message. It uses T3's existing MCP API as a client (no T3 change, no provider-side MCP server, no skill change). Nothing of it runs for anyone who did not opt in. - **Opt in / out:** `install.sh --with-t3` writes one `PreToolUse` `Bash` hook into `${CLAUDE_CONFIG_DIR:-~/.claude}/settings.json` (`t3-hook install`, `packages/t3-code/install-hook.ts`: other hooks and settings kept, a `.plannotator-t3.bak` backup before the first change); `--without-t3` removes only it. Both are remembered in `install-flags.json` (`with-t3`, `without-t3`; POSIX only, no PowerShell spelling) and skipped by `--minimal`; a failing step warns and never aborts the install. Without a flag the installer touches nothing T3. `plannotator t3 login --url ` authorizes once (OAuth, PKCE); `plannotator t3 logout --url …` stops that endpoint's workers and deletes the saved grant. `t3` and `t3-hook` are in `HIDDEN_SUBCOMMANDS`. - **The gate (per Bash call cost):** the hook runs on every Bash call of every Claude session that loads the settings file. The command is `case "$CLAUDE_CODE_ENTRYPOINT" in sdk*) PLANNOTATOR_DATA_DIR=… '' t3-hook;; esac` (`t3HookCommand`, `packages/shared/t3-hook-command.ts`): only an Agent SDK session (`sdk-ts`, …) starts the binary; an interactive `claude` (`cli`) is answered by the shell with no output (~3 ms instead of ~290 ms of CLI startup). The binary repeats the check as the first statement of `apps/hook/server/index.ts` and in `prepareT3Hook`. Inside an SDK session the hook routes only when a saved grant exists and T3 shows the exact running tool call (`toolu_…`, Bedrock/Vertex ids included) owned by one conversation in the same folder; otherwise, and on any error, it answers `{}` (the command runs as written) and logs to `t3-code/hook.log`. The whole lookup, connecting included, is bounded at 6.5 s, under Claude's 10 s hook timeout. Limits: the gate opens for EVERY Agent SDK session, not only T3's: `claude -p` (`sdk-cli`), other SDK apps, and Plannotator's own Claude review-agent jobs (which load user settings). There an opted-in user pays the binary's start (~320 ms) per Bash call, and the hook then finds no T3 owner and answers `{}`. A Claude whose cwd is below the thread's root (it `cd`'d into a subfolder) is not routed: the command runs as the ordinary, blocking CLI. Claude Code matches permission rules against the rewritten command (`plannotator t3-hook open …`), not what Claude typed (from the hooks contract; not checked live). The launch file under `t3-code/hooks/` is removed after one use, whether the open succeeds or fails. - **Worker:** one detached `plannotator t3 serve` per thread (registry `t3-code//threads//daemon.json`, token-guarded loopback routes `/health`, `/review`, `/stop`) launches the review CLI with a pull bridge (`PLANNOTATOR_SESSION_BRIDGE_HOST=t3`), long-polls it, and delivers the host result record. It exits after 10 min with no open review, no pending delivery and no review request (`PLANNOTATOR_T3_WORKER_IDLE_MS`); the next command starts a new one (a request that meets one that just exited is retried once). The OAuth token is read per request and a refused MCP session is reopened, so a new login applies without a restart; only a delivery already attempted under the old grant is refused (T3's idempotency namespace changed). A review whose server died without a decision, a launch that never came up (no ready line within 60 s, or its CLI gone, e.g. the worker was killed while opening it), or a refused delivery stops being waited on, so no later worker inherits it. A settled review drops its bridge token from `launch.json` at once; its folder is pruned on worker start and idle exit after 24 h (7 days for failed or refused ones). - **Decisions** (`packages/t3-code/decision.ts`) are worded exactly as the Claude Code mod words them (`decision.test.ts` compares against the mod's `deliveryFor`): `Changes requested · N comments`, LGTM / Done / Close only logged (a gated approval, annotate or review, is sent; `t3 review --gate`), over 12 KB written to the review's `feedback.md` and named in the message (T3 caps a message at 120,000 characters). One deliberate difference: a review posted to the PR platform is sent as `Review posted` with its status line. `last` skips messages from runs still going (the run that called it). - **UI:** the bridge advertises `interruptible: false` (pull poll body → `sessionBridge.interruptible` on `/api/ai/capabilities`; absent for every other host and older servers), and the Ask AI panels then offer no "Interrupt and ask now" and label Stop as "Stop listening". - **Data:** `/t3-code/` (grants, hook launches, review results, pending deliveries, `feedback.md`, logs; feedback and conversation text, nothing pruned). `isAllowedHostResultPath` accepts `result.json` under it. Uninstall stops running workers (dry run lists them), removes the hook for the installed binary and the `settings.json.plannotator-t3.bak` backup the hook install made; `--purge` deletes `t3-code/`. - **Tests:** `bun test packages/t3-code` (unit); with `T3_TEST_EXECUTABLE=` also the end-to-end proof against a scripted T3 MCP server (`connected-session.test.ts`, run on the compiled binary in `test.yml`); `scripts/install.test.ts` ("optional T3 hook"), `packages/server/uninstall.test.ts`, `packages/ai/session-bridge-pull.test.ts`. ### Codex Stop hook: which turn the plan belongs to Codex has no `ExitPlanMode`, so plan review rides its experimental `Stop` hook: the hook reads the session rollout and returns the plan **the turn that just stopped** produced. Anchoring on a turn is what keeps an older, already-decided `` from being reopened (#1169), and the turn is resolved in `resolveCodexStopPlan` (`apps/hook/server/codex-session.ts`) in two tiers: 1. **The payload's own `turn_id`** when the Stop JSON carries the field. 2. **The rollout's own turn markers** when it does not: the id of the LAST id-carrying `turn_context` / `task_started` marker in the newest rollout segment. A turn still in flight owns the last markers by construction, and when Stop fires at turn end the last marker is that turn's own, so one rule covers both shapes. The scan then still anchors on that turn's **first** marker, so a mid-turn compaction (which re-emits `turn_context` with the same id) cannot hide a plan the turn produced before it. Tier 2 exists because `StopCommandInput.turn_id` only landed in Codex **rust-v0.117.0** (`rust-v0.116.0-alpha.12`), while the hooks engine itself shipped in **rust-v0.114.0**: stable `rust-v0.114.0`, `v0.115.0` and `v0.116.0` fire the Stop hook with no `turn_id` at all. Their rollouts do record the id on every turn marker (`TurnContext::to_turn_context_item` writes `turn_id: Some(sub_id)`), which is what the fallback reads. Using it writes one unconditional line to **stderr** (`logCodexStopTurnIdFallback`; stdout is the hook's JSON decision channel, and Codex only reads a Stop hook's stderr on exit code 2, which this hook never uses). Fail-closed cases are unchanged: a payload that carries `turn_id` as a blank string — or as anything that is not a string — is truncated or foreign, not an old Codex, and is refused **before the rollout is read**; a rollout with no id-carrying turn marker at all skips too. Both skips stay silent unless `PLANNOTATOR_DEBUG` is set. The deny→resubmit guard follows the same two shapes. When `stop_hook_active` is set, the plan is served only if it changed across the boundary the previous blocking Stop left in the turn. Codex >= 0.117 records that boundary as a `` **user** message (`build_hook_prompt_message`); `rust-v0.114.0`, `v0.115.0` and `v0.116.0` record it as a **developer** message (`DeveloperInstructions::new(continuation_prompt)`), so the rollout-fallback path — and only that path, which keeps every `turn_id`-carrying Codex byte-identical — also accepts the last developer message in the turn as the boundary. Without it the guard is inert on exactly the versions the fallback enables, and an unrevised denied plan is re-served on every Stop of the turn. ## Code Review Flow ``` User runs /plannotator-review command ↓ Claude Code: plannotator review subcommand runs OpenCode: event handler intercepts command ↓ VCS provider captures local changes (Git, GitButler, JJ, or P4 where supported). When review runs from a non-VCS parent that contains nested Git/JJ/GitButler repos, child diffs are combined with folder-prefixed paths. ↓ Review server starts, opens browser with diff viewer ↓ User annotates code, provides feedback ↓ Send Feedback → feedback sent to agent session Approve → approved prompt sent to agent session (with the note/annotations when approving with notes) ``` ### Color-free VCS output (#1661) Every git command the review servers parse goes through `prepareGitCommand` (`packages/shared/review-core.ts`, both runtimes), which prepends `GIT_COLOR_FREE_ARGS`: `--no-pager` plus `-c color.=never` for `ui`, `diff`, `status`, `branch`, `grep`, `showBranch` and `interactive`. `color.ui=never` alone is not enough, because an explicit `color.diff = always` in the user's config wins over it and colors a piped diff, which used to parse to 0 files and 0 viewed-file identities. jj gets `JJ_COLOR_FREE_ARGS` (`--color=never --no-pager`, `packages/shared/jj-core.ts`) in both runtimes' `runJj`, `gh pr diff` gets `--color=never`, and agent jobs inherit the same git settings as `GIT_CONFIG_*` variables (`gitColorFreeEnvironment`). As a backstop, `parseDiffToFiles` strips ANSI codes from a patch whose `diff --git` headers are colored (`stripPatchColor`, `packages/core/diff-files.ts`); a normal patch is returned byte for byte. A new git/jj spawn that bypasses these runners must add the same options. `packages/server/review-git-color-config.test.ts` runs both servers against a sandboxed config that forces color on. ### Review directory targets `plannotator review [DIRECTORY | PR_URL]` (and `/plannotator-review` on OpenCode and Pi) accepts one local repository/worktree or multi-repo workspace directory. The shared `parseReviewArgs` + `resolveReviewTarget` resolve it against the invoking terminal/session cwd, validate it before discovery, and reject multiple targets or a directory combined with `--patch-file`. Prose stays tolerated (#1483, the annotate-target precedent): a sole word or any path-shaped word is a directory candidate (path-shaped typos and files error), every other word is ignored with a notice and the invoking cwd is reviewed. Hosts pass the resolved cwd through existing VCS/workspace discovery and project detection; never use `process.chdir()` in a plugin host. Explicit targets inside a repository normalize to its detected VCS root. OpenCode uses the internal `opencode-review-directory` command for these targets so an older CLI rejects them rather than silently reviewing the caller's repo; ordinary reviews keep `opencode-review`. Feedback still goes to the invoking agent session, with `withReviewDirectory` naming the active directory at submission when feedback has content (a bare approval stays bare). File operations, AI jobs and persistence continue using the resulting `gitContext.cwd` / workspace root. ### Review header decision control (agent mode) The agent-destination review header uses the same adaptive split control the annotate surfaces adopted: a ghost-X Close plus `DecisionControl` (`packages/ui/components/DecisionControl.tsx`) rendered from the pure `buildDecisionSpec` mapping — `Approve` with no annotations, `Send Feedback` otherwise, with `Request changes…` / `Send with a note…` and the explicit `Approve, discard n annotations…` confirm behind the caret. One `submitPrimaryDecision()` callback serves the header primary, the global `Mod+Enter` handler, and the compact primary row. Transport routing is pure in `packages/review-editor/reviewDecision.ts` and single-endpoint: every decision POSTs `/api/feedback` with `approved` as the only fork; a change-request note becomes a `scope:'general'` `CodeAnnotation` (sentinel `filePath ''`/0/0, riding the export's `## General` section) with a one-render deferred submit — zero server change. Approve-carrying menu items (`Approve with notes`, `Approve with a note…`) are capability-gated on the server-sent `approvalNotesSupported` advert, which rides every diff payload (`/api/diff`, `/api/diff/switch`, `/api/pr-diff-scope`, `/api/pr-switch`, both runtimes) and reads as false when absent, so an old server renders no approve-carrying items. For the OpenCode CLI bridge the advert additionally requires the plugin's own `supportsApprovalNotes: true` declaration on the `opencode-review` stdin JSON (the binary and plugin version independently; an old plugin omits it and the advert fails closed, so a new binary can never hand an old bridge a note it would discard). A capable session's approvals post `buildReviewApprovalBody`: bare approve sends `feedback: ''` (the old `'LGTM - no changes requested.'` placeholder is gone — a bare approval now archives as `lgtm` with no sidecar), "Approve with a note…" sends the note as the feedback, and "Approve with notes" sends the live annotations plus their export (a note, if both are ever present, is folded in ahead of the export — never dropped). The four agent-facing decision consumers (Claude Code CLI, OpenCode native + CLI bridge, Pi) emit approvals through the shared `composeReviewApprovedMessage` (`packages/shared/prompts.ts`, vendored to Pi): a bare approval is the plain approved prompt; an approval carrying feedback uses the approved-with-notes framing (`prompts.review.approvedWithNotes`, default `DEFAULT_REVIEW_APPROVED_WITH_NOTES_PROMPT` — "non-blocking guidance, do not revise or reopen"), because the bare prompt plus a change-request-shaped export would read as a contradiction. The legacy placeholder is filtered there so a stale built client cannot get filler framed as guidance. The standalone dev server (`apps/review/server`) is the exception: it emits the raw decision JSON with the feedback unfiltered and does not route through the composer. Compact/touch rows are generated from the same spec, so a visible positive decision exists in every state; composer rows open `DecisionNoteDialog`. Platform (PR) mode renders the same ghost-X + `DecisionControl` shape from `buildDecisionSpec`'s platform arm, with **no composer items ever**: every menu action opens the existing `ReviewSubmissionDialog` (per-target state, retry, "leave PR open" toggle — whose general-comment textarea is the only note field on that side), and the self-approval mute is preserved — muted primary/items with the "You can't approve your own {PR/MR}" reason. **Request changes is a real review event (#1611).** `PRReviewAction` (`packages/shared/pr-types.ts`) is `'approve' | 'comment' | 'request_changes'`, carried end to end: `resolvePlatformDecisionAction` returns `{ action, chooseEvent }`; `Post comments, then…` opens the dialog with a Comment / Request changes choice (Comment selected) and the empty-state `Request changes…` preselects Request changes, while the primary `Post Comments` keeps the plain comment flow with no choice. `POST /api/pr-action` validates `action` with `parsePRReviewAction` in BOTH runtimes (anything else is `400`, never a silent COMMENT), and `submitGhPRReview` maps it to `REQUEST_CHANGES` on the single create-review call AND on the pending-review → `/events` submit path (#1600), with the same `See inline comments.` placeholder COMMENT gets when the body is empty (GitHub requires a body on both). GitLab has no request-changes review: `request_changes` posts exactly what `comment` posts, the menu subtitles say so (`requestChangesSupported: false` on the spec's platform input), and the dialog's Request changes option is disabled with that reason. GitHub refuses REQUEST_CHANGES on your own PR like APPROVE, so with `requestChangesSupported: true` and `selfAuthored` the empty-state `Request changes…` mutes with its own reason and a live `Comment…` row (`note-with-feedback`, compact id `comment`) keeps the state from being a dead end; `Post comments, then…` stays live with the dialog option disabled. The spec field is optional and absent keeps the pre-#1611 copy for hosts. In a stacked multi-PR submission every target posts the same event; the choice locks once any target may have been posted (success/partial/blocked) so retries and the tab-scoped recovery (which stores `action`) keep that event, while after a plain failure (nothing posted) it can still change. A refusal GitHub answers anyway surfaces its own reason through the #1600 `githubErrorDetail` path. **PR platforms are providers (#1583).** GitHub, GitLab and Bitbucket Cloud each implement one `PRProvider` (`packages/shared/pr-provider.ts`: `checkAuth`, `getUser`, `fetchPR`, `fetchContext`, `fetchFileContent`, `fetchFileBytes`, `submitReview`, plus optional `fetchViewedFiles` / `markFilesViewed` / `fetchStack` / `fetchList`); the dispatch functions look the provider up by `PRRef.platform`, and an absent optional method is the neutral answer (no viewed state, no stack, empty PR list). What the UI shows comes from `getPRPlatformCapabilities` (`packages/shared/pr-types.ts`, browser-safe): `requestChanges`, `selfReviewBlocked`, `fileLevelComments`, `viewedSync`, `artifacts`, `reviewBodyRequired`, `agentCliAccess`, labels and the CLI. Pure helpers replace the old `platform === "github" ? number : iid` ternaries: `getPRNumber`, `getDisplayRepo`, `getPRHeadFetchSpec` (where a local checkout fetches the head: `refs/pull/N/head`, `refs/merge-requests/N/head`, or Bitbucket's source branch, from the fork URL when `sourceRepo` is set) and `getPRCloneCommand`. GitHub/GitLab outputs of all of these are pinned byte-for-byte (`pr-platforms.test.ts`, including the draft target keys). Bitbucket specifics: REST API 2.0 over `fetch` (`packages/shared/pr-bitbucket.ts`, vendored to Pi), credentials per the `PLANNOTATOR_BITBUCKET_*` rows above; the PR object's 12-char hashes are resolved to full SHAs and the merge base comes from `/merge-base` (the PR diff is three-dot); existing inline threads get a GitHub-shaped `diffHunk` cut from the PR diff (`bitbucketDiffHunk`), since Bitbucket comments carry none; a review posts each line comment as an inline comment (`inline: { path, to | from, start_to | start_from }`), THEN the body as one general comment (the Activity feed lists newest first, so this puts the summary above the inline comments, #1583), then `POST /approve` or `POST /request-changes` (so `requestChangesSupported` is true, and an author may approve their own PR, so nothing is muted); it follows GitLab's partial contract, with `retry.action` naming the failed decision (`request_changes` included) and `retry.body` carrying the general comment when that is the part that failed after inline comments landed (`buildPRActionRequest` resends exactly it). File-level comments fold into the body, there is no viewed sync and no stack discovery, the PR Artifacts panel is hidden (`artifacts: false`), and agent review, Code Tour and Guided Review prompts carry the diff inline when no local checkout is ready (`agentCliAccess: false`: no CLI a job may run can read the PR). The portable guide format names only github/gitlab, so a Bitbucket guide omits `source.pr.platform`. Recorded (anonymized) API responses and a fake API server live in `tests/test-fixtures/bitbucket/`; `scripts/bitbucket-live-smoke.sh` runs the real flow against a throwaway repo. Interaction-model changes worth knowing (F8 and siblings): the agent-mode `Approve` primary follows the `FeedbackButton` responsive pattern and is **icon-only below the `lg` breakpoint**, where the old `ApproveButton` showed a compact `OK` label — the `title` carries the accessible name, and compact/touch rows keep full labels. Approving despite annotations is now two clicks (caret → `Approve, discard n annotations…` → `Discard & approve`) instead of the old dimmed one-click Approve with its warning dialog, and `Mod+Enter` never stacks with the removed approve-warning dialog — an open confirm dialog owns `Mod+Enter` outright (the `data-plannotator-confirm-dialog` sentinel guard in the app's keydown effect; without it one keystroke over the discard confirm would post two contradictory decisions). Accepted edge: the compact `DecisionNoteDialog` keeps its draft locally and discards it if the item behind it leaves the live spec (the dialog closes), while the desktop popover composer keeps drafts keyed by item id — an intentional asymmetry, not a bug. The review sidebar carries the durable human producer for review-level comments: **"+ General comment"** renders in the Annotations tab's General section header (even with zero general comments) AND in the all-empty state, opening the shared `DecisionNoteField` in a small anchored popover whose width clamps to the resizable panel (200-600px persisted) so it never clips inside the sidebar's `overflow-x: hidden` scroll area. Composer state (open + draft) lives in `ReviewSidebar`, shared by both placements: the draft survives a dismissal, a placement flip (an external annotation arriving mid-sentence moves the button from the empty state to the section header), and a tab switch; collapsing the sidebar discards it. The producer is deliberately present in platform (PR) mode too — a session-level comment there rides the posted review body through the pre-existing `scope:'general'` handling in `buildFileScopedBody` / `ReviewSubmissionDialog`. Unlike the header composer's submit note (one-submit lifetime), a sidebar general comment goes through `addCodeAnnotationsWithHistory` — undoable, draft-persisted, deletable — and both producers share one shape factory, `createGeneralReviewComment` in `reviewDecision.ts`: `scope:'general'`, sentinel `filePath ''`/0/0, `review-note-` UUID id, and deliberately **no PR context**, so the comment passes every PR scope predicate and survives an in-place PR switch. Creating one raises `totalAnnotationCount`, which is what flips the header control to `Send Feedback` — the control is state-driven, not wired to the button. The feedback archive records each annotation's `scope` (additive `scope?: string` in `packages/shared/feedback-archive.ts`'s normalizer, vendored to Pi), so a review-level general comment stays distinguishable from a line comment in `index.jsonl`. ### Since-main default review view The default code-review diff is **`since-base`** — a composite of `merge-base(base, HEAD)` vs the working tree plus untracked files ("everything a PR would show if you committed and pushed now"). It can render as a three-section **git status** panel (Committed / Changes / Untracked) via `SectionsPanel`, with a `Tree | Git status | Commits` toggle (`PanelViewToggle`). The Commits segment (local git and jj sessions; not GitButler, P4, workspace or PR mode) is a linear `--first-parent` history rail (`CommitsPanel`): clicking a commit opens its own diff (`commit:`, vs its first parent) as the all-files view headed by the commit message rendered as markdown. In a jj session the same rail and contract run over the jj family (`listJjCommitHistory` / `getJjCommitDiffInfo` in `packages/shared/commit-history.ts`, vendored to Pi): the rail is `first_ancestors(@) ~ root()` (jj >= 0.33), dropping `@` when it is the usual blank working copy (empty, no description, exactly ONE parent: a blank merge `jj new A B` stays, since its first-parent diff is real), `isHead` marks `@` (badge `@`, not `HEAD`), `shortSha` carries the short CHANGE id while `sha` stays the full commit id, and the divider is the jj compare target (`resolveJjCommitRailBase`: the session base, except an Evolution-diff base, which is not a line base, falls back to the context's line base; labeled by its bookmark). A click opens `jj-commit:` — a separate family because providers claim diff types by prefix and git owns `commit:` (a pure jj repo has no git work tree), always addressed as `commit_id()` so a hex-named bookmark cannot shadow it — diffed `--from --to ` (never `jj diff -r`, which compares a merge against all its parents), with hunk expansion, image previews, the fingerprint and the analysis snapshot going through the same per-VCS tables as every other jj type (`resolveJjSideRevs`, `getJjSnapshotRevsets`). jj REWRITES a mutable revision on every edit (the working copy on every snapshot) while the old id keeps resolving as a hidden commit, so the jj-commit fingerprint is visibility-aware (`commit_id(X) & ::visible_heads()`: `visible` / `hidden` / `gone`) — an opened `@` goes "Diff out of date" on the next save — and both servers' `/api/diff/switch` pass jj types through `canonicalizeJjCommitDiffType` (`packages/shared/jj-core.ts`), which maps a hidden id to its change's single current version (`change_id()`; divergent or abandoned changes stay put). Refresh therefore lands on the new `@`, and the client adopts the returned commit-family `diffType` even on an in-place (`preserveFile`) refresh. The feedback export labels a jj revision by its short change id (`commitShortId` from the `commitInfo` sidecar); git keeps the 7-char sha. Client code asks one question for both families: `commitShaFromMode` / `isCommitDiffType` (`packages/review-editor/utils/`), server code `commitFamilyId` (`packages/shared/review-core.ts`). The Commits view is a self-contained detour: entering it memoizes the previously active diff, exiting to Tree restores that diff verbatim (exiting to Git status resets to `since-base` as always), the memo clears whenever any non-commit diff is applied, and a reload that serves a commit-family diff with a non-Commits panel view snaps once to the session default so the commit diff cannot outlive the visit. The toggle never writes the persisted `reviewPanelView`/`defaultDiffType` pair (no server writes from a toggle click), but it does record a cookie-only last-used memo (`reviewPanelViewLastUsed`, `sections` | `tree` — never `commits`; the Commits view is session-only). A review OPENS on caller-pinned flags (`plannotator review --base ` / `--diff-type `, session-only: `openStatePinned` on `/api/diff` disables the panel-pair self-heal and never persists anything) ?? session choice ?? last-used memo ?? persisted `reviewPanelView` ?? the registry default, which is **`tree`** (cookie-only, written only by Settings through `setReviewPanelView()`, which also syncs the memo so an explicit choice is never shadowed by a stale one — except the App self-heal, which passes `recordLastUsed: false` to repair the diff half of a conflicted pair without touching the memo). There is no first-run chooser: a profile with no `reviewPanelView` cookie opens on Tree, which renders every diff type, and the `Tree | Git status | Commits` toggle plus Settings → Git are the two ways to change it. The persisted pair is coupled: the Sections view only renders `since-base`, so choosing a classic diff default snaps the persisted view to Tree and vice-versa (enforced in the Settings Git tab and by the coupled setters in `packages/ui/config/reviewView.ts`); with `tree` as the default the pair is trivially consistent for a reviewer who has chosen neither, so the App self-heal only ever fires for a profile whose `reviewPanelView` cookie already reads `sections` — chosen in Settings, or seeded by a build whose registry default was still `sections`. **Staging display invariant:** `useGitAdd`'s `stagedFiles` is the EFFECTIVE staged set (sections-sidecar snapshot + session stage/unstage overrides) and is the only source any surface may render staging state from. The sidecar entry's `staged` flag is a snapshot — ORing it back in makes files unstaged mid-session render as staged (and inverts the next toggle). `since-base` is only offered when the base ref actually resolves — on a repo whose trunk isn't discoverable (`trunk`, no `origin/HEAD`) `getGitContext` omits it and the default falls through to `uncommitted`, so committed branch work is never silently hidden. The since-base patch/sections/fingerprint/file-content paths all degrade to `HEAD` together when merge-base fails for a resolvable-but-unrelated base. There is no first-run review-setup dialog (`ReviewSetupDialog` was removed, as `DiffTypeSetupDialog` was before it): the panel view simply defaults to Tree and the panel toggle / Settings → Git change it. The one-time dialog chain is guide intro → look-and-feel → Edit Mode → token hover cards → the terminal-tools announcement; none of the dialogs stack. The token hover announcement is last and additionally skips a session where hover cards cannot run at all (no live workspace), WITHOUT consuming its cookie, and never shows to a reviewer whose trigger is already non-default (which after the boolean-to-trigger migration is exactly the early adopter who turned cards off). Analysis layers no longer add a startup dialog: Semantic Changes retains its enabled default, while Call Flow remains disabled until the user explicitly enables it in Settings, which is also consent for its managed runtime installation. ### First-run terminal-tools announcement A one-time panel announcing Plannotator's two terminal clients, **Plannotator TUI** (`plannotator/plannotator-tui`) and **Herdr Annotate** (`plannotator/herdr-annotate`). One cookie covers every surface — `plannotator-announce-tui-herdr-seen`, value `'1'`, read and written by `needsTerminalToolsAnnouncement()` / `markTerminalToolsAnnouncementSeen()` in `packages/ui/utils/terminalToolsAnnouncement.ts` — so dismissing it in plan review retires it in annotate and code review too, and vice versa. It is a plain storage key rather than a settings-registry entry for the same reason `lookAndFeelAnnouncement.ts` is: `configStore.ensureLoaded` seeds every registry default into a cookie on first access, so a registry-backed flag could never tell "never seen" from "seeded default". The component is `packages/ui/components/TerminalToolsAnnouncementDialog.tsx` (shared shell: portal, `z-[100]`, hand-rolled Escape + Tab wrap + focus restore, `data-terminal-tools-announcement-dialog`). It is video first: the real demo footage fills the top of the panel edge to edge, and the text under it is one headline and one sentence, then one action row (star the two repos, "Watch on X", "Got it"). There is no mock terminal, no install commands, no feature list; the repo pages carry all of that. Two deliberate departures from its siblings: the backdrop dismisses (the panel collects no decision, so there is nothing to lose by closing it impatiently), and its keydown listener is registered on the CAPTURE phase and swallows `Mod+Enter`, so a keystroke aimed at the announcement cannot approve a plan or post a review behind it. **Media is hosted, not bundled.** The two demos (`tui-herdr-full-demo` and `tui-herdr-lite-demo`, mp4 + webm + poster jpg each, ~23MB together) live in `apps/marketing/public/assets/` and are served from `https://plannotator.ai/assets/` once the marketing deploy syncs them, the same precedent as `GuideIntroDialog`'s hero image (the Edit Mode recording is small enough to inline; these are not). The `