From bb10e5a8be32a795c6d7432ed1fea58bb9ef7b73 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 7 Oct 2026 12:22:11 -0700 Subject: [PATCH 1/2] Launch the Plannotator Inbox: un-hide the command, document it - Remove inbox from HIDDEN_SUBCOMMANDS (the mechanism stays, now empty; its checks are driven with their own set so they still bite) - Top-level --help lists plannotator inbox and inbox mcp; the inbox mcp help line names all eight MCP tools - plannotator skill: a ## plannotator inbox section and a table row - AGENTS.md: the shipped-product Inbox section; env and config rows (PLANNOTATOR_INBOX_TOOL, inboxNotifications); settings route fields - Marketing: /docs/reference/inbox, the env var row, the purge note --- AGENTS.md | 322 +++++++++++++++--- apps/hook/server/cli.ts | 15 +- .../plannotator-skill-reference.test.ts | 56 +-- apps/hook/server/unknown-subcommand.test.ts | 19 +- apps/hook/server/unknown-subcommand.ts | 21 +- .../docs/getting-started/installation.md | 3 +- .../docs/reference/environment-variables.md | 1 + .../src/content/docs/reference/inbox.md | 196 +++++++++++ apps/skills/core/plannotator/SKILL.md | 19 +- 9 files changed, 573 insertions(+), 79 deletions(-) create mode 100644 apps/marketing/src/content/docs/reference/inbox.md diff --git a/AGENTS.md b/AGENTS.md index ee74b2b967..7ee2698211 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ plannotator/ │ │ └── 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 `inbox` until the Inbox window launches) 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/ # 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, empty today; `inbox` was the last, until the Inbox launched) 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 @@ -68,7 +68,7 @@ plannotator/ │ │ ├── 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) +│ │ ├── inbox-mcp.ts # The Inbox's MCP tools (send_message, read_thread, resolve_message, wait_for_reply, list_decisions, record_decision, get_guide_brief, submit_guide) │ │ ├── storage.ts # Re-exports from @plannotator/shared/storage │ │ ├── share-url.ts # Server-side share URL generation for remote sessions │ │ ├── remote.ts # isRemoteSession(), getServerPort() @@ -159,7 +159,7 @@ claude --plugin-dir ./apps/hook | `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), **off on Pi and OpenCode 2** (a full tool definition in every request). `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 the other state-changing endpoints use (review progress, guide share links, the CallDiff install), 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`, `same-site` and `cross-site`. 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: always `pi`, 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`; 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 since step 6, see "The Plannotator Inbox" in the mod section; Pi and OpenCode, step 7, see "Agent connections" in "Plannotator Inbox"; OpenCode 1 gets the tool without the wake). Only ever on where `inbox/inbox.json` exists in the data dir (the person ran the Inbox once); otherwise nothing is registered and nothing polls. 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, so a change applies to the next session. | +| `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). Only ever on where `inbox/inbox.json` exists in the data dir (the person ran the Inbox once); otherwise nothing is registered and nothing polls. 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_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. Not meant to be set by hand. | @@ -207,6 +207,7 @@ claude --plugin-dir ./apps/hook - `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. - `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. **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. @@ -1791,41 +1792,278 @@ The advertised `appUrl` is the proxy under its LOCALHOST spelling with the targe ## Plannotator Inbox -One long-lived local server per machine where agents leave messages and `:::question` blocks for the person, who answers when they can; nothing holds an agent session open on it. Built so far: **step 1** of the owner-approved Inbox plan (the subcommand, the server, the store and the MCP tools, with the row model ruled 2026-10-07: a row is a thread; see below) **the window** (see "The window" below), **step 3, decisions** (see "Decisions" below) and **browser notifications** (step 4, see "Notifications" below), **guided reviews** (step 5, see "Guided reviews" below), and **step 2, attachments** (see "Attachments" below), and **step 6, the Claude Code connection** ("Agent connections" below, and "The Plannotator Inbox" in the mod section), and **step 7, the Pi and OpenCode connections** ("Agent connections" below), and **step 8, New message** ("New message" below). Not yet: the Mac notification helper (a spike, `research/findings/INBOX-MAC-HELPER-SPIKE-2026-10-07.md` in the Workspaces tree). - -**The command** (`apps/hook/server/inbox-command.ts`). `plannotator inbox` starts the Inbox in the foreground and opens it in the browser, or opens the running one. `plannotator inbox --background` starts it detached (its own session, output in `inbox/inbox.log`), prints the URL and exits 0, and never opens a browser: on demand never pops a tab mid-work. `--no-open` runs it here without a browser. `plannotator inbox mcp` is the stdio MCP entry (`apps/hook/server/inbox-mcp-shim.ts`): a thin JSON-RPC proxy that POSTs each stdin message to the Inbox's `/mcp` and writes every answer to stdout, re-reading `inbox/inbox.json` on every message and starting a stopped Inbox through the `--background` path (no tab). It fills `project_path` (its working folder) and `agent_session` (one `ses_` id per shim process) on the Inbox's tools when the agent left them out. Starts are serialized by `inbox/start.lock` (mkdir; stale after 30 s), so two agents that find it stopped at once start one Inbox; the detached child runs under its starter's lock (`PLANNOTATOR_INBOX_LOCK_HELD`, internal, scrubbed at start). The Inbox never registers in `sessions/` and never arms auto-update, so it never holds an update back. - -**Local only in v1.** It always binds `127.0.0.1` and ignores `PLANNOTATOR_REMOTE`, `PLANNOTATOR_PORT` and `--tailscale`: it holds tokens and everything agents sent. Port: the last one (from the registry) first, so the browser's notification permission holds; when something else holds it, a random port and a one-time notice (stderr and the list model's `notice`); never 19432. - -**Registry** `${dataDir}/inbox/inbox.json` (`packages/shared/inbox/registry.ts`): `{ v, pid, port, url, version, token, serverSession, startedAt }`, mode 0600, written atomically at every start and kept after exit (found = the file exists). The token rotates every start. Running = `GET /api/inbox/health` on that port answers with this entry's `serverSession`; stopped = a dead pid with no answer, a port answering for another Inbox (a different `serverSession`), or a live pid with nothing listening (a reused pid); busy = a live pid whose port takes the health request but does not answer in time (a paused or blocked Inbox), which callers wait for (up to 20 s, then an error) and never replace, because a second process would be a second writer on the store. Callers re-read it on every call. The detached Inbox runs with `inbox/` as its working directory (never the starter's project folder, which Windows would then refuse to delete) and `windowsHide`. `uninstall --purge` stops a running Inbox first (its token-guarded stop route, then SIGTERM only while the port still answers with that `serverSession` and pid) and `--dry-run` lists it; `inbox` is in `PURGE_OWNED_TOP_LEVEL`; a plain uninstall keeps the data. On a health tick (60 s) a compiled Inbox stats the binary on disk and, only when its identity (device, inode, size, mtime) changed since the last probe, runs it with `--version` (a run costs ~0.2 s CPU and a ~440 MB peak RSS, so it is never run on an unchanged file) and, when it differs from its own, reports `update: { available, version }` on health, the list model and the event stream ("restart to update"; install.sh renames over a running binary). - -**Store** (`packages/shared/inbox/`): `inbox/projects//project.json`, `messages.jsonl`, `questions.jsonl`, `decisions.jsonl`; `` is the project name plus 6 hex of the root's hash. A project is the repository (git toplevel of the path an agent names) or the folder. Every line is a full snapshot `{ v, seq, at, kind, id, record }`; the last line per id is current, nothing is rewritten in place, fields are only added. `seq` is one counter across the Inbox (recovered at start as the highest seen), and the union of lines by seq is the event log the window's stream and `wait_for_reply`'s cursor read. One writer (the server process), one append-mode write per line; a torn last line is skipped on read and a newline is appended before the next write to that file. No storage caps (owner, 2026-10-06). Records are snake_case with `msg_` / `prj_` / `ses_` ULIDs. A question record keeps Workspaces' `Question` wire fields beside Plannotator's `QuestionAnswer` v1 (`packages/core/inbox-types.ts`); state (`open | picked | sent | closed`) is derived, never stored; `Stopped:` and `Holds up:` lines are read as Workspaces reads them (`packages/core/inbox-questions.ts`, over core's `findQuestionBlocks` / `parseQuestionBlock`, so keys equal the card's). A pick saves at once and bumps the revision; a stale revision is `409 question_revision_conflict`. A Send writes one person reply (`Answered N questions.`, the person's words, then `formatQuestionAnswersSection`), is idempotent on its `idempotency_key`, and marks the sent questions before the reply line lands; when the reply line never lands (a failed append is rolled back in memory; after a crash the load repairs it), questions naming a missing reply read as picked again, so the Send can be retried instead of reading "already sent" with nothing sent. A project key that collides with another project's takes more of the root's hash, so one folder never holds two projects. An agent's send is idempotent on its key too (a different body under the same key is `409 idempotency_key_reused`); the key is checked before routing, so a retry answers the first message even after its thread was resolved. - -**A row is a thread** (owner ruling 2026-10-07, `.product/approved/plannotator-inbox-decisions-2026-10-06/approval.md`, "The row model, ruled"; it supersedes "a row is a project"). The project is a label on the row and a filter (the future sidebar's folders), never the row. `send_message` routes in `InboxStore.sendMessage`, first match wins: (1) `reply_to`: that message's thread, as a reply, whatever `thread` says; (2) `thread` (a name: trimmed, 1..120 characters, no control characters or line breaks, no bidi controls (`\p{Bidi_Control}`: they make one name draw as another), and something visible (a name of only zero-width characters is empty), else `validation_error: thread: …`): the newest OPEN thread of that name in the project, across sessions (one the person reopened counts, even after a later one of that name was resolved); a new thread under the name when none is open; names compare by `inboxThreadNameKey` (NFKC, default-ignorable code points such as zero-width spaces and joiners dropped, whitespace runs as one space, case folded upper-then-lower so `ß` is `ss`), so look-alike spellings are one thread; the stored name keeps the first sender's spelling; a named thread is never its session's default one; (3) otherwise the sender session's default thread: the newest UNNAMED thread that `agent_session` started in the project, while open, else a new thread. Resolved means done: the next message starts a new thread rather than reopening it (a resolved thread still takes `reply_to` replies, as in step 1). A sender with no `agent_session` has nothing to join, so each of its messages is a thread (step 1's behaviour; the stdio shim always fills the session, one `ses_` id per shim process). A thread keeps its first message's subject; a joining message's `subject` is dropped. Routing reads only the log: two indexes (`sessionThreads`, `namedThreads`) are kept from each root's FIRST line in seq order, so a replay routes exactly as the live store did, and a step-1 store (no `thread_name` anywhere) replays unchanged, its session's next message joining that session's newest open root. Additive record fields (absent on older lines, never repurposed): `thread_name` (on every agent message sent with one; on a root, the thread's name), and on roots `person_seen_seq`, `agent_checked_seq`, `agent_checked_at`. Idempotency is checked BEFORE routing: a retry with the same `idempotency_key` answers the first message (in the thread it joined then, even one resolved since), and the same key with another body, `reply_to` or thread name (compared by the key) is `idempotency_key_reused`. `send_message` answers with `new_thread` and `thread_name`; `read_thread`'s list mode lists the threads the session sent in (started, joined or replied in; `asked_by: "me"`), each as a list row; `wait_for_reply` without an id waits on those same threads. Without a cursor, a reply counts when it is after the CALLER's last message in the thread (its `agent_session`'s; any agent's when the session never wrote there), so another session writing in a shared thread after the person replied never hides the reply. Routing is synchronous inside the one server process (no await between routing and the append), so sessions racing to start one name, or one session's parallel sends, land in one thread. - -**The list: per-thread sections** (`packages/shared/inbox/list.ts`, pure; labels `INBOX_SECTIONS` in `packages/core/inbox-types.ts`). Workspaces' approved sections and order, read per thread: Stopped on you, Holding up work, Waiting on you, Sent, New since you looked, Quiet. Strongest reason wins: resolved → Quiet; an open question with `Stopped:` → Stopped on you; one with `Holds up:` → Holding up work; any other open question → Waiting on you; no open question but some picked and unsent → New since you looked (`answered_not_sent`: "Answered, not sent"); the person's reply is the last message and the agent has not read it → Sent; agent messages after the person's last look → New since you looked (`unseen`); else Quiet. Stopped and Waiting sort oldest waiting first (`waiting_since`: the first message with an open question), Holding most held-up first then oldest, Sent / New / Quiet newest activity first; ties break on the thread id. `unread` (bold) is Stopped, Holding, Waiting or New. "Looked" is `POST /api/inbox/threads/:id/seen` (the stub page sends it when a thread opens; a person's own reply also counts as a look); "the agent read it" is recorded when `wait_for_reply` returns a person's reply or `read_thread` reads the thread, which moves a Sent row to Quiet (the approved Sent band); `wait_for_reply` checks only the reply it returned, so a later reply it was not given keeps the row in Sent. Any agent's read counts (the reader's session is not compared with the asker's). Both write a root snapshot only when something changed (a look with no agent message after the last look or the person's own reply writes nothing). `read_thread` and `wait_for_reply` keep `readOnlyHint`: the receipt is the read itself, idempotent, and changes nothing an agent reads back. The list is linear in the store (an index of questions per message): about 10 ms for 4,000 threads. The held order and the "N new" notice are the window's (below). - -**The window** (`packages/inbox`, built by `apps/inbox`, a Vite single-file app like `apps/review`; `build:hook` builds it and copies `dist/index.html` to `apps/hook/dist/inbox.html`, which `apps/hook/server/index.ts` embeds with `{ type: "text" }` and `runInboxCommand` hands to `startInboxServer` as `htmlContent`; without it `/` says to run `build:hook`). The design of record is `.product/approved/plannotator-inbox-window-2026-10-07/` in the Workspaces meta repo (sections 1.x, 2.1, 2.6, 3.1's tag, 7.x); `packages/inbox/inbox.css` carries its values on Plannotator's theme tokens with an `ib-` prefix, and Plannotator's own components (the question card inside `Viewer`) draw themselves. Routing is the hash (`#thread=msg_…`, the same link the MCP tools hand agents; `#project=prj_…`; `#settings`; `#decisions=prj_…&decision=dec_…`). The sidebar: the Tater mark (`TaterMark`: Workspaces' sprite sheet and frame logic, 24 frames over 3.5 s, paused offscreen, the first frame under reduced motion), Inbox and each project folder with the count of rows waiting on the person (Stopped, Holding, Waiting), Decisions with the count of questions that record a decision once sent (see "Decisions"), Settings, and "A new version is ready, Restart" when health reports `update` (Restart is `POST /api/inbox/restart`: the server stops listening, the command starts the binary on disk detached through `ensureInboxRunning`, the page reloads when a new `serverSession` answers). The list is `GET /api/inbox/threads` filtered client-side by the folder (the sections stay); a wide row is project label, badge ("Stopped", "Holds up N"), the agent's mark, the agent-named thread's chip, subject, the count from two ("2 questions", or "Answered, not sent"), and time; a Sent row reads "Saved for . It sees it when it checks."; Quiet is folded. **Held order** (`packages/inbox/held.ts`): the rows on screen change only on an action (opening a row, a pick, a Send, a folder, Show); new agent activity waits behind "N new in " (the person's own writes never count). A row opens the thread beside a narrowed list and records a look (`/seen`). The thread reads as email: subject, sender row, each message through `Viewer` with the new `answerOnly` seam (no comments on the text, no document actions; cards keep their handlers) and `questionAnswers`; a click on a card is a pick saved at once (`/picks`, serialized per thread so revisions never race); the card's footer says "Picked 10:51 AM, not sent" or "Sent 10:52 AM"; "Waiting on this answer · Holds up N" under the asking message. The foot: "Not sent · Send · Edit the reply" when picks wait (Send carries them with no words), the reply box with its "N picks" chip (Send with an idempotency key per target message, kept for a retry; Mod+Enter sends), and after a Send the person's reply in the thread with "Saved for …" until the agent reads it (the row's `sent.checked_at`, by seq), then "Delivered to , 10:52 AM". The decision tag is the switch on every question (ui 0.52.1: `questionDecisionScope="any"`, `questionDecisionRecording` / `onToggleQuestionDecisionRecording`): on by default where the block says `Decision: when answered`, off elsewhere; the switch and the card are step 3's (see "Decisions"). New message is drawn disabled ("coming next"). First run (no project yet) is the record's empty state as the owner ruled it built ("No agent has written yet", headers with no comma, one vertical rhythm): three equal cards for the Plannotator connections (Claude Code, Pi, OpenCode, one line each, "Use MCP instead" opening that host's command below the row), then "Other agents" with the harness picker (`packages/inbox/harnesses.ts`, one verified artefact, one short note and the folded "Another way" per harness from the MCP add-methods research), every command naming this CLI's absolute path (`mcp_command` from `GET /api/inbox/settings`: the compiled binary's `process.execPath`, or bun plus the entry script in a source run). Settings: the `inboxTool` knob per host ("applies to the next session"; disabled while `PLANNOTATOR_INBOX_TOOL` decides), the compact picker (Other MCP client: the command and `http://127.0.0.1:/mcp`), and the store on disk per project and thread (`InboxStore.diskUsage`, read from the files, the sent files' blobs counted once per thread and project); Delete thread and Delete project ask "Delete? Click again" and then delete (see "Attachments"). The theme follows the OS (`ThemeProvider defaultTheme="system"`) unless Plannotator's theme cookies say otherwise; `configurePlannotatorUI` turns off `/api/config` sync and WebMCP. The page's CSP allows inline code from this page only, `connect-src 'self'`, `frame-ancestors 'none'`, `base-uri 'self'`. - -**Attachments** (step 2; record 2.1 to 2.8 and Q2: files shown as they are now, versioned). `send_message` takes `attachments: [path]` (absolute, or relative to `project_path`; the shim fills `project_path`). At send time `recordInboxAttachments` (`packages/shared/inbox/attachments.ts`) resolves each path to its realpath, refuses one outside the project root, one that is not a regular file, `.env` (by the named path's name and by its target's, so a symlink to `.env` is refused too) and anything outside annotate's annotatable set (`packages/core/annotatable.ts`), with NO size bound (owner, 2026-10-06), checks every file before writing anything, copies the bytes into a content-addressed blob `inbox/blobs/` (the sent version) and puts an attachment record on the message (`attachments?: InboxAttachment[]`, additive; `packages/core/inbox-attachments.ts`: `att_` id, `path` the realpath, `named_path` when the agent named a symlink, `name`, `kind` markdown, text, html, mermaid or graphviz by extension, `sent_sha256`, `size`, `sent_at`, `sent_mtime`). A file is reached ONLY by its id; no route takes a path. Reading the current file (`readInboxAttachmentCurrent`) requires the recorded realpath to still be that path (no component turned into a symlink), a named symlink to still resolve to it, and a regular file there; otherwise `409 attachment_changed_type` (or `404 attachment_missing`), and the sent version still opens. Routes are in `packages/server/inbox-attachments.ts`, mounted at the end of the route table. Raw bytes always go out as `text/plain` with CSP `sandbox` and nosniff. HTML is drawn through annotate's own serving path: `/view` returns the page rewritten by `createHtmlAssetRegistry` (`packages/server/html-assets.ts`: a `` at `/api/html-assets//`, one unguessable token per folder, `resolveHtmlAssetRoute`, the `sandbox allow-scripts` CSP and `Access-Control-Allow-Origin: *` on asset answers, the 2MB cap on embedded documents as in annotate) inside `HtmlViewer`'s sandboxed srcdoc frame, so relative images and frames load from the file's folder, a frame never draws the Inbox (#1554), and the page cannot read the Inbox API (opaque origin, no CORS on the Inbox routes). A page's links to other origins open in a new tab; relative links are not followed (no linked documents in the Inbox). External `https:` scripts, styles and images in an attached page are blocked by the window CSP the srcdoc inherits. The person's annotations live in `inbox/projects//annotations.jsonl` (kind `annotation`, in the seq log; `InboxAnnotationRecord`: the ui `Annotation` as made, `attachment_id`, `path`, `version` `current` or the sent blob's sha256, `removed_at`, `sent_reply_id`), keyed by the file's path and version (the #1710 lesson: a draft on `current` survives the agent's edit and re-anchors by its text; one whose text is gone shows Unanchored). The window (`packages/inbox/components/AttachmentTiles.tsx`, `AttachmentPane.tsx`, `AnnotationsChip.tsx`, `packages/inbox/attachments.ts`): tiles at the foot of each agent message ("Markdown, 2 annotations", or "Changed since it was sent, edited 11:05 AM"); Open puts the file beside the thread (`#thread=…&file=att_…`, `&v=sent`, `&at=`; the list and the sidebar fold away) with Full screen and Exit full screen, HTML full screen by default; markdown and text through `Viewer` with `AnnotationToolstrip` and its selection toolbar and `CommentPopover`; diagrams through `Viewer` over `diagramDocumentBlocks` (DiagramViewer, lazy, node comments); HTML through `HtmlViewer` (pinpoint armed, `HtmlSurfaceControls` pen, Esc to Interact); `AnnotationPanel` at the right, grouped by file (This file / All files) when other files of the thread carry annotations; the changed line "Changed since sent it at 10:42 AM. Edited 11:05 AM. Open the version it sent", and on the sent version "The version sent at 10:42 AM. Open the file as it is now"; full screen's strip "Reply to · N picks, N annotations · ride your next Send · Back to the thread · Write the reply". The reply box carries the "N annotations" chip beside "N picks" (dimmed "No annotations" on a thread with files and none); it opens the list (file, tag such as `button` or `node B`, quote, comment; Edit in place, Remove, the file name opens the file at that annotation). Send carries the picks, the words, then the annotations as Plannotator's feedback text (`exportLinkedDocAnnotations`, the multi-file form of `exportAnnotations` with the same entry renderer, under "Feedback on the attached files", one section per file path relative to the project and version; each version's text is read at Send so line numbers are what the person read), on the reply route's `feedback` and `annotation_ids`; the store marks them sent with the reply (a crash before the reply line puts them back). Comments take no images (no upload route) and offer no skill references. Delete thread rewrites the project's messages, questions and annotations files without the thread's lines (temp file, then rename; the project's decisions stay) and Delete project removes its folder; both then read the store again from disk (so routing is what a restart reads) and remove every blob no remaining record uses; `inbox/seq.json` keeps the highest seq across a deletion so a cursor never sees a seq twice. - -**Notifications** (step 4, `packages/inbox/notify.ts`; record 6.x and 7.2; owner Q8: the open page first on every platform, the Mac helper later; no Web Push, no service worker). The page raises a browser `Notification` for each new question the event stream brings in an agent's message (a question line at revision 0, state open, counted once per page; a `Stopped:` line, a `Holds up:` line or any other question; news never notifies). Every Inbox notification carries one tag (`plannotator-inbox`), so the browser shows one at a time. While the tab is away the page keeps the threads it notified about: with one, the banner is ": stopped on you" when the thread's list section is Stopped on you, else "is waiting on you", with the thread's subject as the body, and a click focuses the tab and opens the thread; with more, a burst becomes one notice, "3 waiting in 2 projects" (or "2 waiting in billing-svc"), and a click opens the list at rest. The set clears when the tab comes to the front, where nothing is raised (`visibilityState === "visible"` and `document.hasFocus()`). The one-time ask (`components/NotificationAsk.tsx`) is drawn above the list when an item arrives and the browser has not decided (never on first paint); Turn on calls `Notification.requestPermission()` on that click; "Not now" is remembered. When the person turned them on at another origin (the Inbox moved port), the same line returns once as "The Inbox moved to a new address. Allow notifications again." Settings carries the drawn row, one switch ("for questions and stops"; turning it on asks the browser when it has not decided). The state lives in config.json as `inboxNotifications` (`{ enabled, dismissed, allowedOrigin }`), read and saved through `GET`/`POST /api/inbox/settings` (`notifications`), never localStorage, because the permission is per origin and the port can change. - -**Decisions** (step 3; owner Q5: the Inbox's own structured files; the approved `inbox-decision-toggle` and the window record's 3.1 and 3.2). Rules and window routes in `packages/server/inbox-decisions.ts`, the drafted words at the end of `packages/core/inbox-questions.ts` (no new core export, so core needs no publish), the records in the store (`decisions.jsonl`, `InboxDecision` in `inbox-types.ts`: Workspaces' fields `text`, `reason`, `source` `{ kind: answer | agent | person, question_id, message_id, thread_id, agent }`, `state` `current | replaced | retired`, `version` (bumped by each change; a stale one is `409 decision_version_conflict`), `replaces_id`, `replacement_id`, `created_at`, `changed_at`, `idempotency_key`). The card's tag is the switch: `decision_recording` on the question record (absent: on exactly when the block says `Decision: when answered`), saved at once by `POST /api/inbox/messages/:id/decision` `{ key, recording?, draft? }`, which never touches the answer's revision; refused once the answer is sent. The tag's words open the decision card (`DecisionCard`, 400 px, under the words, right edges aligned): "Decision" drafted from the answer ("Retry with the same idempotency key."), "Why (optional)" drafted as "Asked by : ", "In , when you send", Cancel and Done; Done saves `decision_draft` (a field left as drafted is null, so it keeps following the answer) and turns recording on. At Send, after the reply landed, `recordDecisionsForReply` writes one decision per sent question with recording on (a Skip records nothing) and sets the question's `decision_id`; a decision that cannot be written comes back in `decisions_refused` and never undoes the sent answer; a replayed Send fills in what is missing and records nothing twice. The thread shows "Settled: " under the cards, linked to the decision. The Decisions page (`#decisions`): a project switch, Waiting (questions with recording on, not yet sent, in open threads, each with Open), Settled, and "Replaced or retired" folded; a row opens beside the list with its text, why, source (Open the thread) and dates, and Retire or Replace (a new record at version 1 naming `replaces_id`; the old one `replaced` with `replacement_id`). MCP `list_decisions` (`state`: current by default, replaced, retired, all) and `record_decision` (text, reason, idempotent on `idempotency_key`; source `agent`). - -**Agent connections (step 6: Claude Code; step 7: Pi and OpenCode).** A connection gives its host the `plannotator_inbox` tool and wakes the session that asked when the person sends. The contract lives once in `packages/shared/inbox/connection.ts` (pure, so the mod keeps a byte-for-byte copy of its CONTRACT section in `apps/hook/hooks/mod/inbox-contract.ts`, pinned by `inbox-contract.test.ts`): the tool is ONE tool whose `action` is one of the Inbox's MCP tools, built from the running Inbox's own `tools/list` (`inboxAgentTool`; an older Inbox offers fewer actions; the connection fills `project_path`, `agent_session`, `agent_host` and `agent_name` itself and keeps them out of the schema; the question guide rides the `body` field so the tool description stays under Claude Code's 2,048-character cut), the bridge paths, and the wake text (`inboxWakeText`: `Plannotator Inbox: ()`, the fixed `INBOX_WAKE_INSTRUCTION` line, a blank line, then the reply verbatim; never the thread, never the system prompt). The bridge is two connection routes (bearer token from `inbox.json`, loopback Host naming this port, no Origin; the pull-bridge pattern): `POST /api/inbox/bridge/poll` `{ session, host?, waitMs? }` answers `{ commands: [{ type: "reply", id, thread_id, reply_to, subject, body, url }] }` at once when a reply waits for that session, else holds up to 25 s (`INBOX_BRIDGE_POLL_MAX_MS`) and answers the moment the person sends; `POST /api/inbox/bridge/event` `{ session, host, type: "delivered", id }` records the delivery. Nothing is queued in memory: the replies a session waits for are derived from the store (`InboxStore.pendingReplies`: person replies to a message that session sent, with no `delivery` and not yet read by an agent through `wait_for_reply` or `read_thread`; any agent's read counts, as for the Sent band), so a reply is handed out on every poll until it is delivered, survives an Inbox restart, and a reply the agent already read through the MCP never wakes it again. `recordDelivery` writes `delivery: { state: "delivered", host, session, at }` on the reply (an additive field, `InboxDelivery` in `packages/core/inbox-types.ts`) and marks the thread read by the agent at the same time, so the window's Sent row reads "Delivered to Claude Code,