Repository navigation
Plannotator Inbox, step 1: server, store, MCP and the inbox subcommand (no UI yet) - #1745
Merged
Merged
Conversation
- `plannotator inbox` (person: start here and open the browser, or open the running one), `--background` (detached, prints the URL, never a tab), `--no-open`, and `plannotator inbox mcp` (stdio JSON-RPC proxy to /mcp that re-reads inbox/inbox.json per message and starts a stopped Inbox). - packages/server/inbox.ts: Bun-only, always 127.0.0.1, last port first then random, never 19432; health, list model, thread, picks, Send, resolve, SSE with a cursor, stateless /mcp, token-guarded stop route. - packages/shared/inbox/: append-only JSONL store (one seq across the Inbox, torn-last-line recovery, idempotent sends), registry (0600, token rotated per start, kept after exit). - packages/core/inbox-*.ts: wire shapes (Workspaces' Question + QuestionAnswer v1), question parsing with Stopped/Holds up, derived state, Send text. - MCP tools: send_message, read_thread, resolve_message, wait_for_reply (@modelcontextprotocol/server pinned 2.2.0). - Security: Host allowlist (local rule), no Origin on /mcp, same-origin + serverSession on writes, bearer token on connection routes, no CORS. - uninstall --purge stops a running Inbox first; inbox in the purge list. - DAST scans a seeded Inbox; the target also allows its own Host name, which the Host allowlist (#1742) otherwise refuses. - Skill reference and AGENTS.md (Server Runtimes exception, Inbox section, Inbox Server API table). - Proofs: live server tests, SDK client over HTTP and stdio, the subcommand as real processes, fast-check store properties.
… keys never collide - sendReply writes the questions' sent lines before the reply line. When the reply append fails, the questions are put back in memory; at load, a question naming a reply that does not exist reads as picked again. Before, the person was stuck on question_already_sent and the agent got nothing. - A failed append marks the file torn, so the next line starts fresh. - Two same-named roots whose 6-hex project keys collide get a longer hash instead of sharing one folder (the second project.json replaced the first). - The round-trip property now sends arbitrary text (newlines, quotes, NULs, U+2028, lone surrogates) and a crafted JSONL-injection body.
…e probe; edge fixes - A live pid whose port takes the health request but does not answer (a paused or blocked Inbox) read as stopped, so the shim and --background started a second Inbox on the same store: proven live with SIGSTOP, both processes then wrote duplicate seqs. inboxStatus now reports it as busy; callers wait for it (20 s, then an error) and never replace it, and uninstall --purge treats it as still running. - Restart to update ran the compiled binary with --version every 60 s (~0.2 s CPU, ~440 MB peak RSS each time). It now stats the binary and runs it only when its identity changed. - The detached Inbox runs in inbox/ (not the starter's project folder, which Windows would refuse to delete) with windowsHide. - An HTTP/1.0 request with no Host (allowed by the Host guard) threw in new URL() and answered 500; it is read against this server now. - wait_for_reply on an already-resolved thread answers resolved at once instead of holding the call for 50 s. - DAST target: binaryPath null (no update probe of the bun binary).
backnotprop
added a commit
that referenced
this pull request
Oct 7, 2026
The Inbox back end (#1745) ships in the binary, but its window is still a stub, so 0.28.7 must not advertise it. `plannotator inbox` keeps working (and keeps `inbox --help`) for developers and the coming connections; it is only taken out of discovery. - cli.ts: HIDDEN_SUBCOMMANDS (today `inbox`). Top-level help no longer lists the inbox lines. - unknown-subcommand.ts: hidden subcommands stay known to the dispatcher but are never offered as a "Did you mean" suggestion. - The plannotator knowledge skill drops its Inbox section and table row. - plannotator-skill-reference.test.ts: every non-hidden subcommand must still be documented; a hidden one must be ABSENT from the skill and the top-level help, and must still be a real subcommand. - AGENTS.md: one line on HIDDEN_SUBCOMMANDS and how to un-hide. Co-authored-by: Claude <noreply@anthropic.com>
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 1 of the owner-approved Plannotator Inbox plan: back end only. It adds the subcommand, the server, the store and the MCP tools. The window (
packages/inbox,apps/inbox) waits for an approved design spec. Until then,/serves a page marked as a stub: the list model and a read-only thread.What works
plannotator inbox(run by a person) starts the Inbox in the foreground and opens it in the browser. If an Inbox is already running, it opens that one instead.plannotator inbox --backgroundstarts the Inbox detached, prints its URL and exits 0. It never opens a browser. Agents and the shim use this form.--no-openruns the Inbox in the foreground without opening a browser.plannotator inbox mcpis the stdio MCP entry. It is a thin JSON-RPC proxy to the server's/mcp, so the tool list lives once, in the server.inbox/inbox.jsonon every message.--backgroundpath. No tab opens.project_path(its working folder) andagent_session(oneses_id per shim process).packages/server/inbox.ts):127.0.0.1and ignoresPLANNOTATOR_REMOTE,PLANNOTATOR_PORTand--tailscale./mcp, and a token-guarded stop route.--versionand reportsupdatewhen the version differs.packages/shared/inbox/, RESEARCH.md §8): per-project append-only JSONL with oneseqcounter across the Inbox.inbox/inbox.jsonholds{ v, pid, port, url, version, token, serverSession, startedAt }.serverSession, or a live pid with nothing answering.sessions/, so it never blocks auto-update.inbox/start.lock.packages/core/inbox-*.ts):Questionshape, plusmessage_id, with derived state.QuestionAnswerv1.Stopped:andHolds up:are read the way Workspaces reads them.findQuestionBlocks.formatQuestionAnswersSection.send_message,read_thread(asked_by: "me"),resolve_message,wait_for_reply.wait_for_replyreturns the reply as soon as it lands. Otherwise it returns{ status: "waiting", cursor }after 50 s.structuredContentplus one line. A refusal isisErrorwith<snake_code>: <message>.uninstall --purgestops a running Inbox first, through its token route; SIGTERM is the fallback, sent only while the port still answers as that Inbox.--dry-runlists the Inbox.inboxis now inPURGE_OWNED_TOP_LEVEL.plannotator inboxis documented, and the freshness test passes.PLANNOTATOR_PORTrow.Security layers (every request)
createRequestHostGuard({ localOnly: true })option applies the local rule whatever the environment says./mcprefuses any request that carries an Origin: 403origin_not_allowed./api/inbox/control/*) need three things: the registry's bearer token (constant-time compare), a loopback Host naming this port, and no Origin. This is the pull-bridge pattern.isSameOriginOrNoOrigin(403cross_origin), then theserverSessionstale-tab guard (409session_mismatch).default-src 'none'),frame-ancestors 'none', and agent text is set only throughtextContent.dast.yml): a seeded Inbox sits behind its own read-only guard on port 19435, with a second ZAP scan and a second evidence validation.Found while adding DAST: the guard forwards the scanner's Host,
plannotator-dast-target:<port>, and since #1742 the annotate target refuses that name with a 403. The workflow's health check expects a 200, so the scheduled scan would have failed. The target now setsPLANNOTATOR_ALLOWED_HOSTS=plannotator-dast-target. I checked this locally by runningscripts/dast/target.tsand probing it with that Host: 200 on both targets.How it was proven (no mocks)
packages/server/inbox.test.tsruns a real server on loopback. Requests go through realfetchand raw TCP (so Host and Origin are exactly what the test sends), and the MCP side through the official SDK client over streamable HTTP. It covers:PLANNOTATOR_REMOTE=1andPLANNOTATOR_PORT=19432set: a connect to the external interface is refused, and the port is not 19432;serverSession;/mcpincluded;/mcpis refused, even the Inbox's own origin;wait_for_replywaking on Send, idempotent Send,read_threadasked_by me/others, resolve, then closed state and a late Send refused;wait_for_replywith nothing arriving returnswaitingafter 50.16 s.packages/shared/inbox/store.test.tshas fast-check properties over generated sequences of operations: round trip (reopening from disk gives exactly the live view), seq unique and increasing, a torn last line at a random cut point (recovers, and the next append to that same file reads back whole), idempotency keys for agent sends and person Sends, and question keys equal to core'squestionKey, with the-2suffix for a repeated prompt.apps/hook/server/inbox-cli.test.tsruns the subcommand as real processes, each with a temp HOME and data dir:--backgroundwith no browser (a fake browser script is never called) and nosessions/entry;StdioClientTransport, starts a stopped Inbox with no tab, fillsproject_path(the git toplevel) and the session, and carries the person's answer back;plannotator inboxopens the browser;inbox/.bun run typecheckpasses.bun testpasses on packages/core, packages/shared, packages/server, apps/hook, tests/, apps/pi-extension, apps/opencode-plugin and scripts: 0 failures.check:release-versionpasses.actionlintpasses on dast.yml.bun run --cwd apps/review build && bun run build:hook && bun build … --compile --define __CLI_VERSION__="0.0.0-dev"), with a temp HOME and data dir:inbox --background: exit 0 in 544 ms, prints the URL,lsofshows only127.0.0.1:<port>,inbox.jsonmode 600, health OK, and a second start reuses the same pid;plannotator inbox mcp: 4 tools;send_messagewith a:::questionblock lands in projectbilling, the git root;waiting_on_personandstopped;wait_for_replyreturns "Answered 1 question. Go ahead. ## Answers to your questions … Answer: Retry with the same idempotency key (your recommendation)";--background: same port, new pid, token rotated,serverSessionrotated, store intact;uninstall --purge --dry-runlists "Stop the running Plannotator Inbox (pid …)" and…/.plannotator/inbox;update: { available: true, version: "0.0.1-dev" }on the next tick.Deviations from PLAN.md
@modelcontextprotocol/serveris pinned at 2.2.0, not 2.3.1. 2.3.1 was published 2026-10-05, and the repo'sbunfig.tomlrefuses packages younger than 7 days (minimumReleaseAge), so it cannot install yet. 2.2.0 has the samecreateMcpHandler(factory, { legacy: "stateless" })API and is MIT; 2.3.x moved to Apache-2.0. Bump to 2.3.1 after 2026-10-12 if wanted. The repo has no NOTICE file yet, so there is no line to add for it.QUESTION_AUTHORING_GUIDEis insend_message's description, not the server instructions. The guide is 2,213 characters, and Claude Code drops instructions after 2,048. The instructions are short (under 2,048, asserted) and point tosend_message.--backgroundchild is started under the starter's lock, through an internal env flag,PLANNOTATOR_INBOX_LOCK_HELD, scrubbed at start. This keeps two agents from starting two writers on one store.deliveryfield on messages yet. It belongs to the connections (steps 6 and 7). Fields are add-only, so adding it later is safe.Left for later steps
packages/inbox,apps/inbox,build:hookwiring, DOM tests,typecheckentries.PLANNOTATOR_INBOX_TOOLknob.plannotator inbox.