diff --git a/AGENTS.md b/AGENTS.md index 8e8aacbd16..c104d5f43a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1789,7 +1789,7 @@ 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) and **browser notifications** (step 4, see "Notifications" below). Not yet: attachments, decisions (the list and record-at-Send), guides, New message, the Mac notification helper, and the Claude Code / Pi / OpenCode connections. +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). Not yet: attachments, guides, New message, the Mac notification helper, and the Claude Code / Pi / OpenCode connections. **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. @@ -1797,21 +1797,23 @@ One long-lived local server per machine where agents leave messages and `:::ques **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`; `` 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. +**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`). 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 drawn disabled ("coming next", step 3), 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; a switched state is kept client-side in localStorage (`plannotator-inbox-decision-switch`) until step 3's decision record, and `onOpenQuestionDecision` (the card) is step 3's. New message and an attachment's Open are drawn disabled ("coming next"). First run (no project yet) is the record's empty state: the three Plannotator connections (the mod, the Pi extension, the OpenCode plugin) each with "Prefer the MCP? Add it anyway", then "Any other agent" with the harness picker (`packages/inbox/harnesses.ts`, one verified artefact 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); delete thread and delete project are drawn disabled (they come with step 2). 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'`. +**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 and an attachment's Open are drawn disabled ("coming next"). First run (no project yet) is the record's empty state: the three Plannotator connections (the mod, the Pi extension, the OpenCode plugin) each with "Prefer the MCP? Add it anyway", then "Any other agent" with the harness picker (`packages/inbox/harnesses.ts`, one verified artefact 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); delete thread and delete project are drawn disabled (they come with step 2). 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'`. **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. -**MCP** (`packages/server/inbox-mcp.ts`, `@modelcontextprotocol/server` pinned exactly at 2.2.0, MIT; `createMcpHandler(factory, { legacy: "stateless" })`): `send_message`, `read_thread`, `resolve_message`, `wait_for_reply`. Server instructions stay under Claude Code's 2,048 characters, so `QUESTION_AUTHORING_GUIDE` (2,213 characters) rides `send_message`'s description instead. Claude Code also hands a model an MCP tool description only up to its first 2,048 characters (`CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH`), so the guide's tail is cut there: `send_message`'s lead stays one short sentence (the routing rules live in the instructions and in `thread`'s own description) and `inbox.test.ts` pins that the cut keeps the guide through "Ask only what you cannot decide alone". Success is `structuredContent` plus one line; a refusal is `isError` with `: `. `wait_for_reply` returns a person's reply as soon as it lands, or `{ status: "waiting", cursor }` after 50 s (hosts' tool timeouts are often 60 s); without a cursor any reply after the agent's last message counts. No tool answers, approves or sends for the person. +**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`). + +**MCP** (`packages/server/inbox-mcp.ts`, `@modelcontextprotocol/server` pinned exactly at 2.2.0, MIT; `createMcpHandler(factory, { legacy: "stateless" })`): `send_message`, `read_thread`, `resolve_message`, `wait_for_reply`, `list_decisions`, `record_decision`. Server instructions stay under Claude Code's 2,048 characters, so `QUESTION_AUTHORING_GUIDE` (2,213 characters) rides `send_message`'s description instead. Claude Code also hands a model an MCP tool description only up to its first 2,048 characters (`CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH`), so the guide's tail is cut there: `send_message`'s lead stays one short sentence (the routing rules live in the instructions and in `thread`'s own description) and `inbox.test.ts` pins that the cut keeps the guide through "Ask only what you cannot decide alone". Success is `structuredContent` plus one line; a refusal is `isError` with `: `. `wait_for_reply` returns a person's reply as soon as it lands, or `{ status: "waiting", cursor }` after 50 s (hosts' tool timeouts are often 60 s); without a cursor any reply after the agent's last message counts. No tool answers, approves or sends for the person. **Security**, every request: the Host allowlist (`createRequestHostGuard({ localOnly: true })`, see "Host allowlist"); `/mcp` refuses any request carrying an Origin (403 `origin_not_allowed`); connection routes (`/api/inbox/control/*`, today only `stop`) take the registry's bearer token, a loopback Host naming this port and no Origin (the pull-bridge pattern); state-changing window routes run `isSameOriginOrNoOrigin` (403 `cross_origin`) and then the `serverSession` stale-tab guard (409 `session_mismatch`). No CORS headers. The window's CSP: `default-src 'self'`, inline script and style (the single-file build), `connect-src 'self'`, `frame-ancestors 'none'`, `base-uri 'none'`, `form-action 'none'`; agent text reaches the page only through React and Plannotator's markdown renderer. DAST (`.github/workflows/dast.yml`) scans a seeded Inbox behind its own read-only guard on port 19435. -Tests (proofs in action, temp data dirs): `packages/server/inbox.test.ts` (a real server: registry, health, port reuse and fallback, restart-to-update, every refusal by real requests, the MCP tools through the SDK's own client, the event stream, a real 50 s `wait_for_reply`), `packages/server/inbox-threads.test.ts` (the row model live through the SDK client: a session's sends share a thread, `thread` splits and joins across sessions, resolved starts anew, `reply_to` wins, name validation; look-alike spellings joining, bidi and invisible-only names refused, a key reused with another name refused, a reopened older named thread joined, sessions racing over two MCP connections landing in one thread, `wait_for_reply` in a shared thread, `/seen` writing nothing when nothing is new and refusing cross-site, foreign-Host and stale-tab requests; every section across two projects with and without the filter, Sent → Quiet when the agent reads, New → Quiet on a look; the step-1 store fixture `tests/test-fixtures/inbox-step1/`, written by step 1's own store code, replays, lists and routes), `packages/shared/inbox/store.test.ts` (fast-check properties: round trip including the sections, seq, torn last line, idempotency, question keys, and routing determinism: an independent oracle walking the log by seq assigns every message the thread the store did, and a replayed copy routes every next send the same way), `apps/hook/server/inbox-cli.test.ts` (the subcommand as processes: `--background`, concurrent starts, kill and restart, the three stopped readings, the shim driven by the SDK's stdio client starting a stopped Inbox with no browser, a person's `inbox` opening the browser, uninstall). The window: `tests/e2e/inbox.spec.ts` (`bun run test:e2e:inbox`, Playwright 1.63.0 headless Chromium against the compiled binary under a temp `PLANNOTATOR_DATA_DIR` and HOME; agents write through `plannotator inbox mcp` with the SDK's stdio client, `scripts/inbox-sim.ts`): first run and Copy, three threads in two projects in their sections with badges and the named-thread chip, the folder filter, the thread with its cards, the decision toggle, a pick read back by `read_thread`, Send read back by `wait_for_reply` and "Delivered", "N new" holding the list until Show, Settings and the knob in config.json, Restart onto the binary on disk, and no page or CSP errors; PNGs of every state in light and dark land in `.local/proof/` with a contact sheet. `tests/e2e/notifications.spec.ts` (same command, Chromium's new headless mode, because the headless shell answers every notification permission with denied): the ask after the first thread and not on first paint, "Not now" across a reload and a port change, Settings on and the "moved" line after a port change, one notification with the subject for a Stopped thread, a second item in the thread replacing it with the title following the thread's section, none while focused, a click opening the thread, a burst of three threads in two projects ending as one "3 waiting in 2 projects" whose click opens the list, none with the switch off; the permission is Chromium's (granted per origin through the context) and `new Notification` is the real constructor subclassed to record calls; PNGs in `.local/proof/notifications/`. Build the binary first (see the spec's header). `packages/inbox/held.test.ts` and `harnesses.test.ts` cover the pure logic (the notice counts only agent activity; any CLI path survives shell quoting, the install links and the JSON snippets). The settings, restart and favicon routes are proved in `packages/server/inbox.test.ts`. +Tests (proofs in action, temp data dirs): `packages/server/inbox.test.ts` (a real server: registry, health, port reuse and fallback, restart-to-update, every refusal by real requests, the MCP tools through the SDK's own client, the event stream, a real 50 s `wait_for_reply`), `packages/server/inbox-threads.test.ts` (the row model live through the SDK client: a session's sends share a thread, `thread` splits and joins across sessions, resolved starts anew, `reply_to` wins, name validation; look-alike spellings joining, bidi and invisible-only names refused, a key reused with another name refused, a reopened older named thread joined, sessions racing over two MCP connections landing in one thread, `wait_for_reply` in a shared thread, `/seen` writing nothing when nothing is new and refusing cross-site, foreign-Host and stale-tab requests; every section across two projects with and without the filter, Sent → Quiet when the agent reads, New → Quiet on a look; the step-1 store fixture `tests/test-fixtures/inbox-step1/`, written by step 1's own store code, replays, lists and routes), `packages/shared/inbox/store.test.ts` (fast-check properties: round trip including the sections, seq, torn last line, idempotency, question keys, and routing determinism: an independent oracle walking the log by seq assigns every message the thread the store did, and a replayed copy routes every next send the same way), `apps/hook/server/inbox-cli.test.ts` (the subcommand as processes: `--background`, concurrent starts, kill and restart, the three stopped readings, the shim driven by the SDK's stdio client starting a stopped Inbox with no browser, a person's `inbox` opening the browser, uninstall). The window: `tests/e2e/inbox.spec.ts` (`bun run test:e2e:inbox`, Playwright 1.63.0 headless Chromium against the compiled binary under a temp `PLANNOTATOR_DATA_DIR` and HOME; agents write through `plannotator inbox mcp` with the SDK's stdio client, `scripts/inbox-sim.ts`): first run and Copy, three threads in two projects in their sections with badges and the named-thread chip, the folder filter, the thread with its cards, the decision toggle, a pick read back by `read_thread`, Send read back by `wait_for_reply` and "Delivered", "N new" holding the list until Show, Settings and the knob in config.json, Restart onto the binary on disk, and no page or CSP errors; PNGs of every state in light and dark land in `.local/proof/` with a contact sheet. `tests/e2e/inbox-decisions.spec.ts` (same command, same world): the tag on for `Decision: when answered` and off for a plain question, switching off hides the row and holds across a reload, the words open the card under the tag drafted answer first, Done keeps edits, Send records only where the switch is on (read from `decisions.jsonl`) and the thread says "Settled", a sent card has no switch and, in a half-sent message, only the sent card loses it (read-only per question, ui `questionDecisionLocked`), `list_decisions` and `record_decision` through the shim, the Decisions page's groups, Retire folding, Replace keeping history; PNGs in `.local/proof/decisions/`. `packages/server/inbox-decisions.test.ts`: a decision that cannot be written never undoes the sent answer and the Send's retry records it, the records after a restart, and the refusals. `tests/e2e/notifications.spec.ts` (same command, Chromium's new headless mode, because the headless shell answers every notification permission with denied): the ask after the first thread and not on first paint, "Not now" across a reload and a port change, Settings on and the "moved" line after a port change, one notification with the subject for a Stopped thread, a second item in the thread replacing it with the title following the thread's section, none while focused, a click opening the thread, a burst of three threads in two projects ending as one "3 waiting in 2 projects" whose click opens the list, none with the switch off; the permission is Chromium's (granted per origin through the context) and `new Notification` is the real constructor subclassed to record calls; PNGs in `.local/proof/notifications/`. Build the binary first (see the spec's header). `packages/inbox/held.test.ts` and `harnesses.test.ts` cover the pure logic (the notice counts only agent activity; any CLI path survives shell quoting, the install links and the JSON snippets). The settings, restart and favicon routes are proved in `packages/server/inbox.test.ts`. ## Archive Flow diff --git a/apps/hook/server/inbox-cli.test.ts b/apps/hook/server/inbox-cli.test.ts index a3261dae55..6b384c3c66 100644 --- a/apps/hook/server/inbox-cli.test.ts +++ b/apps/hook/server/inbox-cli.test.ts @@ -261,7 +261,7 @@ describe("plannotator inbox mcp (the stdio shim)", () => { expect((await inboxStatus(box.dataDir)).state).toBe("running"); expect(existsSync(box.browserMarker)).toBe(false); - expect((await client.listTools()).tools.map((t) => t.name).sort()).toEqual(["read_thread", "resolve_message", "send_message", "wait_for_reply"]); + expect((await client.listTools()).tools.map((t) => t.name).sort()).toEqual(["list_decisions", "read_thread", "record_decision", "resolve_message", "send_message", "wait_for_reply"]); const sent = ( await client.callTool({ name: "send_message", diff --git a/apps/hook/server/inbox-mcp-shim.ts b/apps/hook/server/inbox-mcp-shim.ts index 3a6d6891a0..30d46cd80d 100644 --- a/apps/hook/server/inbox-mcp-shim.ts +++ b/apps/hook/server/inbox-mcp-shim.ts @@ -19,7 +19,7 @@ import { inboxId } from "@plannotator/core/inbox-types"; import { inboxStatus, type InboxRegistryEntry } from "@plannotator/shared/inbox/registry"; -const FILLED_TOOLS = new Set(["send_message", "read_thread", "wait_for_reply"]); +const FILLED_TOOLS = new Set(["send_message", "read_thread", "wait_for_reply", "list_decisions", "record_decision"]); /** The 2026-07-28 per-request envelope's protocol-version key. */ const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; diff --git a/packages/core/inbox-questions.ts b/packages/core/inbox-questions.ts index 466f0f6316..927702f2ea 100644 --- a/packages/core/inbox-questions.ts +++ b/packages/core/inbox-questions.ts @@ -29,6 +29,7 @@ import { type QuestionKind, } from "./question-block"; import type { + InboxDecisionDraft, InboxQuestion, InboxQuestionFacts, InboxQuestionRecord, @@ -175,6 +176,8 @@ export function toInboxQuestion(record: InboxQuestionRecord, threadResolved: boo sent_reply_id: record.sent_reply_id, decision_id: record.decision_id, message_id: record.message_id, + decision_recording: inboxDecisionRecording(record), + decision_draft: record.decision_draft ?? null, }; } @@ -302,3 +305,69 @@ function firstLine(body: string): string | null { } return null; } + +// ─────────────────────── Decisions drafted from an answer (step 3) ─────────────────────── +// +// The words of a decision drafted from an answer, and whether a question +// records one. The window's decision card shows these words before Send, and +// the server records the same words at Send when the person left them as +// drafted. The approved Workspaces words (`inbox-decision-toggle`, +// 2026-10-06, item 4, "the answer first"): the statement is the answer, the +// Why line is "Asked by : ". + +const oneDecisionLine = (value: string): string => value.replace(/\s+/gu, " ").trim(); + +/** "A", "A and B", "A, B and C". */ +function joinWords(parts: readonly string[]): string { + if (parts.length <= 1) return parts.join(""); + return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`; +} + +/** + * The statement drafted from an answer: the chosen label(s) as a sentence + * ("Retry with the same idempotency key."), several joined with commas and + * "and", Other or a written answer as written. Null when the answer decides + * nothing: a Skip, or nothing picked or written. + */ +export function draftInboxDecisionText(answer: Pick | null): string | null { + if (!answer || answer.skipped) return null; + const parts = [...answer.selected, ...(answer.other ? [answer.other] : []), ...(answer.text ? [answer.text] : [])] + .map(oneDecisionLine) + .filter((part) => part !== ""); + if (parts.length === 0) return null; + const joined = joinWords(parts); + const sentence = `${joined.charAt(0).toUpperCase()}${joined.slice(1)}`; + return /[.!?…]$/u.test(sentence) ? sentence : `${sentence}.`; +} + +/** The Why line: "Asked by : ". */ +export function draftInboxDecisionReason(askerName: string, prompt: string): string { + return `Asked by ${oneDecisionLine(askerName) || "An agent"}: ${oneDecisionLine(prompt)}`; +} + +/** + * Whether Send records this question's answer as a decision: the person's + * switch when they touched it, else on exactly when the block says + * `Decision: when answered`. + */ +export function inboxDecisionRecording(record: Pick): boolean { + return record.decision_recording ?? record.decision_on_answer; +} + +/** + * The words a decision from this answer records: the card's edits where the + * person made them, the drafted words elsewhere. `text` is null when there + * is nothing to record (a Skip, nothing picked, and no edited statement). + */ +export function inboxDecisionWords(input: { + answer: Pick | null; + prompt: string; + askerName: string; + draft: InboxDecisionDraft | null | undefined; +}): { text: string | null; reason: string } { + const edited = input.draft?.text?.trim() || null; + return { + text: input.answer?.skipped ? null : (edited ?? draftInboxDecisionText(input.answer)), + reason: input.draft?.reason ?? draftInboxDecisionReason(input.askerName, input.prompt), + }; +} diff --git a/packages/core/inbox-types.ts b/packages/core/inbox-types.ts index a181425532..0a8bf1232f 100644 --- a/packages/core/inbox-types.ts +++ b/packages/core/inbox-types.ts @@ -41,7 +41,7 @@ export function ulid(now: number = Date.now()): string { return timePart + randomPart; } -export type InboxIdPrefix = "msg" | "prj" | "ses"; +export type InboxIdPrefix = "msg" | "prj" | "ses" | "dec"; export function inboxId(prefix: InboxIdPrefix, now?: number): string { return `${prefix}_${ulid(now)}`; @@ -51,6 +51,7 @@ const ID_RE: Record = { msg: /^msg_[0-9A-HJKMNP-TV-Z]{26}$/, prj: /^prj_[0-9A-HJKMNP-TV-Z]{26}$/, ses: /^ses_[0-9A-HJKMNP-TV-Z]{26}$/, + dec: /^dec_[0-9A-HJKMNP-TV-Z]{26}$/, }; export function isInboxId(prefix: InboxIdPrefix, value: unknown): value is string { @@ -130,6 +131,24 @@ export type InboxAuthor = name: string | null; }; +/** The hosts whose names the Inbox knows, keyed by `author.host`. */ +export const INBOX_HOST_NAMES: Readonly> = { + "claude-code": "Claude Code", + claude: "Claude Code", + pi: "Pi", + opencode: "OpenCode", + codex: "Codex", + cursor: "Cursor", +}; + +/** How the person sees an agent: the name it gave, else its host's name, else "An agent". */ +export function inboxAgentName(author: { kind: string; host?: string | null; name?: string | null } | null | undefined): string { + if (!author || author.kind !== "agent") return "An agent"; + if (author.name) return author.name; + if (author.host) return INBOX_HOST_NAMES[author.host] ?? author.host; + return "An agent"; +} + /** A message or reply. A thread is its root message's id. */ export interface InboxMessage { id: string; @@ -215,6 +234,24 @@ export interface InboxQuestionRecord { picked_at: string | null; sent_reply_id: string | null; decision_id: string | null; + /** + * The card's "Records a decision" switch, as the person set it. Absent + * until they touch it: then a `Decision: when answered` question records + * and any other does not (`inboxDecisionRecording`). Added in step 3. + */ + decision_recording?: boolean; + /** The decision card's words after Done; null or absent: the drafted words. Added in step 3. */ + decision_draft?: InboxDecisionDraft | null; +} + +/** + * The decision card's words as the person left them on Done. A null field + * was not edited and keeps following the drafted words (the answer, and + * "Asked by : "), so a later pick still drafts anew. + */ +export interface InboxDecisionDraft { + text: string | null; + reason: string | null; } export type InboxQuestionState = "open" | "picked" | "sent" | "closed"; @@ -247,6 +284,10 @@ export interface InboxQuestion { sent_reply_id: string | null; decision_id: string | null; message_id: string; + /** Whether Send records this answer as a decision (the card's switch, or its default). */ + decision_recording: boolean; + /** The decision card's edited words, or null for the drafted ones. */ + decision_draft: InboxDecisionDraft | null; } /** Workspaces' `NotificationQuestionSummary`: a row's questions, summed. */ @@ -327,15 +368,70 @@ export interface InboxThread { messages: InboxMessageWire[]; } +// ─────────────────────────────── Decisions ─────────────────────────────── + +/** + * A decision's lifecycle (Workspaces' `project_decisions.state`): current + * holds; replaced points at its replacement; retired no longer holds. + */ +export type InboxDecisionState = "current" | "replaced" | "retired"; + +export interface InboxDecisionAgent { + host: string | null; + session: string | null; + name: string | null; +} + +/** Where a decision came from. */ +export interface InboxDecisionSource { + /** + * `answer`: recorded at Send from the person's answer to a question; + * `agent`: an agent's record_decision; `person`: the person wrote it (a + * replacement on the Decisions page). + */ + kind: "answer" | "agent" | "person"; + /** The answered question (`/`), for `answer`. */ + question_id: string | null; + message_id: string | null; + thread_id: string | null; + /** The asking agent (`answer`) or the recording one (`agent`). */ + agent: InboxDecisionAgent | null; +} + +/** + * One standing decision in a project: Workspaces' decision fields (text, + * reason, source, state, version, replaces_id, replacement_id), so a later + * sync maps it. `version` counts changes to this record (a retire or a + * replace bumps it; a stale version is refused); a replacement is a new + * record at version 1 that names what it replaces. + */ +export interface InboxDecision { + id: string; + project_id: string; + text: string; + reason: string | null; + source: InboxDecisionSource; + state: InboxDecisionState; + version: number; + replaces_id: string | null; + replacement_id: string | null; + created_at: string; + /** When the state last changed (a retire or a replace); null while it was never changed. */ + changed_at: string | null; + /** record_decision's idempotency key, when one was given. */ + idempotency_key: string | null; +} + // ─────────────────────────────── Lines ─────────────────────────────── -export type InboxRecordKind = "project" | "message" | "question"; +export type InboxRecordKind = "project" | "message" | "question" | "decision"; /** One line of the store: a full snapshot of one record. */ export type InboxLine = | { v: 1; seq: number; at: string; kind: "project"; id: string; record: InboxProject } | { v: 1; seq: number; at: string; kind: "message"; id: string; record: InboxMessage } - | { v: 1; seq: number; at: string; kind: "question"; id: string; record: InboxQuestionRecord }; + | { v: 1; seq: number; at: string; kind: "question"; id: string; record: InboxQuestionRecord } + | { v: 1; seq: number; at: string; kind: "decision"; id: string; record: InboxDecision }; // ─────────────────────────────── Health ─────────────────────────────── diff --git a/packages/inbox/App.tsx b/packages/inbox/App.tsx index 7f778be544..d647a8a6a9 100644 --- a/packages/inbox/App.tsx +++ b/packages/inbox/App.tsx @@ -3,17 +3,27 @@ * `.product/approved/plannotator-inbox-window-2026-10-07/`. A mail client for * what agents send: the list in the approved sections (a row is a thread), * the thread read as an email with Plannotator's question cards, pick then - * Send, and Settings. + * Send, the project's decisions, and Settings. * - * Routing is the URL hash (`#thread=msg_…`, `#project=prj_…`, `#settings`), - * the same `#thread=` the MCP tools hand agents. + * Routing is the URL hash (`#thread=msg_…`, `#project=prj_…`, `#settings`, + * `#decisions=prj_…&decision=dec_…`), the same `#thread=` the MCP tools hand + * agents. */ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; import { configurePlannotatorUI } from '@plannotator/ui/configure'; -import type { InboxHealth, InboxListRow, InboxListSection, InboxQuestion, InboxThread } from '@plannotator/core/inbox-types'; -import { InboxApiError, inboxApi, setPageSession, subscribeInboxEvents, type AgentToolHost, type ListModel, type SettingsModel } from './api'; +import type { InboxDecision, InboxHealth, InboxListRow, InboxListSection, InboxQuestion, InboxThread } from '@plannotator/core/inbox-types'; +import { + InboxApiError, + inboxApi, + setPageSession, + subscribeInboxEvents, + type AgentToolHost, + type DecisionsModel, + type ListModel, + type SettingsModel, +} from './api'; import { filterSections, heldNotice, waitingCount } from './held'; import { tildePath } from './format'; import type { ConnectContext } from './harnesses'; @@ -24,28 +34,39 @@ import { EmptyState } from './components/EmptyState'; import { SettingsPage } from './components/Settings'; import { NotificationAsk } from './components/NotificationAsk'; import { useInboxNotifications } from './notify'; +import { DecisionsPage } from './components/Decisions'; // The Inbox has no /api/config and no browser-agent tools: settings stay in // the page's own cookies, and WebMCP is off. configurePlannotatorUI({ serverSync: () => {}, webmcp: { enabled: false, namePrefix: 'plannotator.' } }); interface Route { - page: 'inbox' | 'settings'; + page: 'inbox' | 'settings' | 'decisions'; project: string | null; thread: string | null; + /** The decision open beside the Decisions list. */ + decision?: string | null; } function readRoute(): Route { const hash = window.location.hash.replace(/^#/, ''); if (hash === 'settings') return { page: 'settings', project: null, thread: null }; const params = new URLSearchParams(hash); + if (params.has('decisions')) { + return { page: 'decisions', project: params.get('decisions') || null, thread: null, decision: params.get('decision') }; + } return { page: 'inbox', project: params.get('project'), thread: params.get('thread') }; } function writeRoute(route: Route): void { let hash = ''; if (route.page === 'settings') hash = 'settings'; - else { + else if (route.page === 'decisions') { + const params = new URLSearchParams(); + params.set('decisions', route.project ?? ''); + if (route.decision) params.set('decision', route.decision); + hash = params.toString(); + } else { const params = new URLSearchParams(); if (route.project) params.set('project', route.project); if (route.thread) params.set('thread', route.thread); @@ -73,6 +94,9 @@ function Inbox() { /** The list on screen: held until the person acts (the "N new" notice). */ const [shown, setShown] = useState(null); const [thread, setThread] = useState(null); + const [threadDecisions, setThreadDecisions] = useState([]); + const [decisions, setDecisions] = useState(null); + const [decisionsError, setDecisionsError] = useState(null); const [settings, setSettings] = useState(null); const [settingsError, setSettingsError] = useState(null); const [update, setUpdate] = useState(null); @@ -125,7 +149,10 @@ function Inbox() { const refreshThread = useCallback(async (threadId: string) => { try { const next = await inboxApi.thread(threadId); - if (routeRef.current.thread === threadId) setThread(next.thread); + if (routeRef.current.thread === threadId) { + setThread(next.thread); + setThreadDecisions(next.decisions); + } return next.thread; } catch (cause) { if (cause instanceof InboxApiError && cause.status === 404 && routeRef.current.thread === threadId) setThread(null); @@ -133,6 +160,26 @@ function Inbox() { } }, []); + /** The Decisions page's project: the route's, else the one with the newest activity. */ + const decisionsProjectOf = useCallback((route: Route, model: ListModel | null): string | null => { + if (route.project) return route.project; + const rows = (model?.sections ?? []).flatMap((s) => s.threads).sort((a, b) => b.last_at.localeCompare(a.last_at)); + return rows[0]?.project.id ?? model?.projects[0]?.id ?? null; + }, []); + + const refreshDecisions = useCallback(async (projectId: string | null) => { + if (!projectId) return; + try { + const next = await inboxApi.decisions(projectId); + setDecisions(next); + setDecisionsError(null); + } catch (cause) { + setDecisionsError(cause instanceof Error ? cause.message : String(cause)); + } + }, []); + const latestRef = useRef(latest); + latestRef.current = latest; + const refreshSettings = useCallback(async () => { try { setSettings(await inboxApi.settings()); @@ -158,6 +205,7 @@ function Inbox() { void refreshList(false).then((next) => next && flushNotifications(next)); const open = routeRef.current.thread; if (open) void refreshThread(open); + if (routeRef.current.page === 'decisions') void refreshDecisions(decisionsProjectOf(routeRef.current, latestRef.current)); }, 80); }, onStatus: (next) => setUpdate(next), @@ -168,7 +216,7 @@ function Inbox() { unsubscribe(); if (timer) clearTimeout(timer); }; - }, [refreshList, refreshSettings, refreshThread, observeEvent, flushNotifications]); + }, [refreshList, refreshSettings, refreshThread, observeEvent, flushNotifications, refreshDecisions, decisionsProjectOf]); // Opening a thread: read it, record the look, and put the list as it now is on screen. useEffect(() => { @@ -193,6 +241,11 @@ function Inbox() { if (route.page === 'settings') void refreshSettings(); }, [route.page, refreshSettings]); + const decisionsProject = route.page === 'decisions' ? decisionsProjectOf(route, latest) : null; + useEffect(() => { + if (decisionsProject) void refreshDecisions(decisionsProject); + }, [decisionsProject, refreshDecisions]); + useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key !== 'Escape' || !routeRef.current.thread) return; @@ -277,11 +330,23 @@ function Inbox() { ? { command: settings.mcp_command, mcpUrl: settings.mcp_url, platform: platformOf() } : null; - const filterProject = route.project ? projects.find((p) => p.id === route.project) ?? null : null; + const retireDecision = async (decision: InboxDecision) => { + await inboxApi.retireDecision(decision.id, decision.version); + await refreshDecisions(decision.project_id); + }; + + const replaceDecision = async (decision: InboxDecision, text: string, reason: string) => { + const result = await inboxApi.replaceDecision(decision.id, { version: decision.version, text, reason }); + await refreshDecisions(decision.project_id); + go({ page: 'decisions', project: decision.project_id, thread: null, decision: result.decision.id }); + }; + + const filterProject = route.page === 'inbox' && route.project ? projects.find((p) => p.id === route.project) ?? null : null; const notice = shown && latest ? heldNotice(filterSections(shown, route.project), filterSections(latest.sections, route.project)).text : null; const firstRun = latest !== null && projects.length === 0; let main: ReactNode; + const decisionsFolder = decisionsProject ? projects.find((p) => p.id === decisionsProject) ?? null : null; if (route.page === 'settings') { main = ( void notifications.setEnabled(next)} /> ); + } else if (route.page === 'decisions' && decisionsFolder) { + main = ( + go({ page: 'decisions', project: projectId, thread: null })} + onOpen={(decisionId) => go({ page: 'decisions', project: decisionsFolder.id, thread: null, decision: decisionId })} + onOpenThread={(threadId) => go({ page: 'inbox', project: null, thread: threadId })} + onRetire={retireDecision} + onReplace={replaceDecision} + /> + ); + } else if (route.page === 'decisions') { + main =
; } else if (!latest || !shown) { main =
{loadError &&
{loadError}
}
; } else if (firstRun) { @@ -319,6 +401,8 @@ function Inbox() { onQuestions={patchQuestions} onChanged={afterWrite} onClose={() => go({ ...route, thread: null })} + decisions={threadDecisions} + onOpenDecisionPage={(decision) => go({ page: 'decisions', project: decision.project_id, thread: null, decision: decision.id })} /> )} @@ -332,6 +416,7 @@ function Inbox() { page={route.page} projectId={route.project} inboxCount={waitingCount(latestSections)} + decisionsCount={latest?.decisions_waiting ?? 0} projects={projects} projectCounts={projectCounts} updateReady={update !== null} @@ -339,6 +424,7 @@ function Inbox() { onInbox={() => go({ page: 'inbox', project: null, thread: null })} onProject={(projectId) => go({ page: 'inbox', project: projectId, thread: null })} onSettings={() => go({ page: 'settings', project: null, thread: null })} + onDecisions={() => go({ page: 'decisions', project: null, thread: null })} onRestart={() => void restart()} />
{main}
diff --git a/packages/inbox/api.ts b/packages/inbox/api.ts index c0a4bb9151..aa14ab2642 100644 --- a/packages/inbox/api.ts +++ b/packages/inbox/api.ts @@ -6,6 +6,9 @@ */ import type { + InboxDecision, + InboxDecisionAgent, + InboxDecisionDraft, InboxHealth, InboxListSection, InboxMessage, @@ -40,12 +43,43 @@ export interface ListModel { projects: ProjectFolder[]; project: string | null; sections: InboxListSection[]; + /** Questions in every project that record a decision once sent: the sidebar's Decisions count. */ + decisions_waiting: number; } export interface ThreadModel { serverSession: string; cursor: number; thread: InboxThread; + /** The decisions this thread's questions recorded, for the "Settled: ..." lines. */ + decisions: InboxDecision[]; +} + +/** A question that records a decision once answered and sent (the Decisions page's Waiting group). */ +export interface WaitingDecision { + question_id: string; + message_id: string; + thread_id: string; + project_id: string; + prompt: string; + agent: InboxDecisionAgent | null; + asked_at: string; +} + +export interface DecisionsModel { + serverSession: string; + cursor: number; + project_id: string; + waiting: WaitingDecision[]; + decisions: InboxDecision[]; +} + +export interface ReplyResult { + reply: unknown; + questions: InboxQuestion[]; + replayed: boolean; + decisions: InboxDecision[]; + decisions_refused: { key: string; code: string; message: string }[]; } export interface SettingsModel { @@ -126,10 +160,15 @@ export const inboxApi = { questions: [{ key, revision, answer }], }), reply: (messageId: string, input: { idempotency_key: string; words: string; questions: { key: string; revision: number }[] }) => - post<{ reply: unknown; questions: InboxQuestion[]; replayed: boolean }>( - `/api/inbox/messages/${encodeURIComponent(messageId)}/reply`, - input, - ), + post(`/api/inbox/messages/${encodeURIComponent(messageId)}/reply`, input), + /** The card's switch and its words on one question: `recording`, and `draft` on Done. */ + setDecision: (messageId: string, key: string, input: { recording?: boolean; draft?: InboxDecisionDraft | null }) => + post<{ question: InboxQuestion }>(`/api/inbox/messages/${encodeURIComponent(messageId)}/decision`, { key, ...input }), + decisions: (projectId: string) => get(`/api/inbox/decisions?project=${encodeURIComponent(projectId)}`), + retireDecision: (id: string, version: number) => + post<{ decision: InboxDecision }>(`/api/inbox/decisions/${encodeURIComponent(id)}/retire`, { version }), + replaceDecision: (id: string, input: { version: number; text: string; reason: string }) => + post<{ decision: InboxDecision; replaced: InboxDecision }>(`/api/inbox/decisions/${encodeURIComponent(id)}/replace`, input), resolve: (messageId: string, resolved: boolean) => post(`/api/inbox/messages/${encodeURIComponent(messageId)}/resolve`, { resolved }), health: () => get('/api/inbox/health'), diff --git a/packages/inbox/components/DecisionCard.tsx b/packages/inbox/components/DecisionCard.tsx new file mode 100644 index 0000000000..41a4302b12 --- /dev/null +++ b/packages/inbox/components/DecisionCard.tsx @@ -0,0 +1,158 @@ +import { useId, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { InboxDecisionDraft } from '@plannotator/core/inbox-types'; +import { DecisionDiamond, Icon } from '../icons'; + +/** The card's width in the record (3.1). */ +const CARD_WIDTH = 400; +const GAP = 6; + +/** + * "Record as a decision": the full card of the approved decision toggle + * (`inbox-decision-toggle` 2026-10-06, option A; the window record's 3.1), + * opened from the words of a question's "Records a decision" tag and placed + * under them, right edges aligned. "Decision" holds the draft (the answer, + * focused), "Why (optional)" holds "Asked by : ", and "In + * , when you send" says where and when it records. The card only + * keeps the words: Done saves them and turns recording on; the decision is + * written at Send. Cancel, Escape and a click outside keep nothing. + */ +export function DecisionCard({ + anchor, + drafted, + draft, + projectName, + onCancel, + onDone, +}: { + /** The tag's words the card opened from. */ + anchor: HTMLElement; + /** The drafted words: the answer as a statement (null when nothing is picked), and the Why line. */ + drafted: { text: string | null; reason: string }; + /** The words kept by an earlier Done, or null. */ + draft: InboxDecisionDraft | null; + projectName: string; + onCancel: () => void; + onDone: (draft: InboxDecisionDraft | null) => Promise; +}) { + const id = useId(); + const card = useRef(null); + const statement = useRef(null); + const focused = useRef(false); + const [text, setText] = useState(draft?.text ?? drafted.text ?? ''); + const [reason, setReason] = useState(draft?.reason ?? drafted.reason); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [place, setPlace] = useState<{ top: number; left: number } | null>(null); + + // Under the tag, right edges aligned; above it when the window has no room below. + useLayoutEffect(() => { + const position = () => { + const tag = anchor.getBoundingClientRect(); + const height = card.current?.offsetHeight ?? 0; + const left = Math.min(Math.max(12, tag.right - CARD_WIDTH), window.innerWidth - CARD_WIDTH - 12); + const below = tag.bottom + GAP; + const top = below + height > window.innerHeight - 12 && tag.top - GAP - height > 12 ? tag.top - GAP - height : below; + setPlace({ top, left }); + }; + position(); + window.addEventListener('resize', position); + window.addEventListener('scroll', position, true); + return () => { + window.removeEventListener('resize', position); + window.removeEventListener('scroll', position, true); + }; + }, [anchor]); + + // The statement takes focus once the card is placed (it is hidden until then). + useLayoutEffect(() => { + if (!place || focused.current) return; + focused.current = true; + statement.current?.focus(); + }, [place]); + + useLayoutEffect(() => { + const onDown = (event: MouseEvent) => { + const target = event.target as Node; + if (card.current?.contains(target) || anchor.contains(target)) return; + onCancel(); + }; + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [anchor, onCancel]); + + const submit = async () => { + const statement = text.trim(); + if (!statement) { + setError('Write the decision first.'); + return; + } + setSaving(true); + setError(null); + try { + // Unedited words stay the draft, which follows the answer. + const keptText = statement === (drafted.text ?? '') ? null : statement; + const keptReason = reason.trim() === drafted.reason ? null : reason.trim(); + await onDone(keptText === null && keptReason === null ? null : { text: keptText, reason: keptReason }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'The decision was not kept.'); + setSaving(false); + } + }; + + return createPortal( +
+
{ + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onCancel(); + } else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + void submit(); + } + }} + onSubmit={(event) => { + event.preventDefault(); + void submit(); + }} + > +

+ + Record as a decision +

+ +