diff --git a/docs/agent-guides/inbox-compose.md b/docs/agent-guides/inbox-compose.md
index b570bf7a0..daf921c26 100644
--- a/docs/agent-guides/inbox-compose.md
+++ b/docs/agent-guides/inbox-compose.md
@@ -52,7 +52,7 @@
- The catalog: `GET /api/inbox/skills` (`packages/server/inbox-skills.ts`), plan review's `handleReferenceSkills`: the global skill folders only, read fresh. The window installs it as ui's `skillCatalogTransport`, so the comment composers on attached files list it too, and sets `skillInstructionsDestination: 'your reply'` (their human-only line says the instructions go with the reply).
- At Send the window composes ONE block at the end of the message (`packages/inbox/skills.ts`, Option B: the client composes the body, as plan review composes its feedback): references are read from the words and every comment on an attached file the Send carries (not a guided review's diff comments), once each; `renderSkillReferencesBlock` (ui) writes Plannotator's block in "the person … this reply / this message" wording (`@plannotator/core/skill-reference-block`): a model-invocable skill by name, a human-only skill's SKILL.md (read then from `GET /api/inbox/skills/content`, 20,000-character cap, truncation notice, marker lines neutralized) between BEGIN/END markers with `Skill directory:` naming its folder. At most 40,000 characters of instructions per message (`INBOX_SKILL_INSTRUCTIONS_BUDGET`); past it, or unreadable, a skill is named with "read SKILL.md in
". A reply carries the block in its `feedback` (after the annotations' feedback, so it is the body's end; the server is unchanged); a New message appends it to `body`. The per-comment "Skills referenced" list is off in the Inbox (`configurePlannotatorUI({ skillReferenceExport: false })`), so a comment's `$name` stays plain text and the block covers it.
- Trust: only the person's Send composes a block; MCP has no skills field, and an agent's `$readback` is plain text. The window folds a block only under the person's own messages: `MessageBody` splits it off with `splitInboxSkillsBlock`, which takes the LAST Inbox heading (after a blank line) whose tail is exactly the shape the window composes: list lines, then nothing but blank lines and closed BEGIN/END sections for skills the list includes, to the end of the body. The real block is always last, so agent text a reply quotes (a guided-review comment's diff lines, an attached-file comment's `Feedback on:` quote) can never fold the person's feedback, not even with a fake unterminated BEGIN running on to the real END; fence parity is not read, since the agent controls it. Both code exporters (the review app's `exportFeedback.ts` and the Inbox's `guideCommentEntry`) also fence quoted code with more backticks than any run in it (`codeFenceFor`, `@plannotator/core/markdown-structure`; unchanged bytes for code without a backtick run). It draws `SkillsLine` (`components/SkillsLine.tsx`): "Skills: readback, write-better · readback's instructions went with this reply (0.4 KB) · Show", a `` that opens on the block as the agent got it. The references in the sent words are painted as the composer painted them (`skillHighlight.ts`: the CSS Custom Highlight API, `::highlight(ib-skill-ref)` / `ib-skill-ref-human` with the dotted underline, matched against the skills the block names, never the live catalog, only in the person's own words (before the answers or feedback headings), at a priority below search's landing highlight; nothing painted where the API is missing). An agent's message is shown whole.
-- Snippets and search read the person's words: `replyWords` and the folded line's snippet (`folding.ts`), and search's `inboxSearchReplyText`, drop the block (the `$name` stays in the words, so search still finds the reply by the skill's name; the SKILL.md text is not indexed for that reply). The stored body, `read_thread`, `wait_for_reply`, the wake and the phone carry the whole text (the accepted cost of Option B).
+- Snippets and search read the person's words: `replyWords` and the folded line's snippet (`@plannotator/core/inbox-folding`), and search's `inboxSearchReplyText`, drop the block (the `$name` stays in the words, so search still finds the reply by the skill's name; the SKILL.md text is not indexed for that reply). The stored body, `read_thread`, `wait_for_reply`, the wake and the phone carry the whole text (the accepted cost of Option B).
- Phone (`PHONE_QUERY`): the picker opens full width directly above the reply box (no sheet, so the keyboard stays up and a tap inserts on pointerdown), 44 px rows with the description under the name, at most about 4½ rows.
- On the phone's device door (contract 7.46, 7.47). Tests: `packages/server/inbox-skills.test.ts`, `packages/inbox/skills.test.ts`, the DOM test `packages/inbox/skillReferences.test.tsx` (the whole window over a real Inbox; the DOM server pins the skill folders under the test's scratch HOME).
diff --git a/docs/agent-guides/inbox-messages.md b/docs/agent-guides/inbox-messages.md
index 697ddc1b2..ceffad332 100644
--- a/docs/agent-guides/inbox-messages.md
+++ b/docs/agent-guides/inbox-messages.md
@@ -12,7 +12,7 @@
**Mark read and the Send that skips** (design record "inbox-mark-read", not in this repo; approved 2026-10-11, with the owner's later rulings that Mark read closes the questions it leaves and that Sent sits below New). Two ways to get a thread out of the top without answering every question.
- **A Send skips a question only when the person presses a Skip** (owner approval "inbox-explicit-skip", 2026-10-21, design brief "explicit-skip", option A "never skip on its own"; it replaces the rule of 2026-10-11 that every Send skips what it leaves unanswered, and the 2026-10-13 amendment that skipped once a Send answered one question: on 2026-10-21 the owner answered 1 of 11 questions in one session's 8 messages, and that rule skipped the other 10 in 8 replies). Sending an answer never skips anything else. The reply box (and the foot while picks or annotations wait) shows the chip "N questions stay open · Skip them", always, whatever the Send carries; "Skip them" (or "Skip it") turns it into "↷ N questions will be skipped · Keep open" for that one Send, and is the only way a Send skips. The window remembers exactly the questions the chip counted when it was pressed (`skipArmed` in `Thread.tsx`, ids `/`), so a question that lands while the person types is never skipped; at Send it passes them to `sendTargetsOf`, which keeps those still open (a pick saved meanwhile is sent as the pick), and the reply names them in `skip` (see "Questions and Send" for one reply per session). Keep open, the thread changing, or the Send going clears the choice. Skip on a card, Mark read and Re-ask work as before. The server takes only the explicit list: `skip: [key | { message_id?, key }]` (`InboxReplyInput.skip`, checked by `planSkips` in the store: a list, each entry a question of a message `sendMessageOf` accepts, else `422 validation_error` / `404 question_not_found`, nothing written; a listed question that is no longer open, answered, picked, closed or replaced, is left alone so a race never refuses a Send). The old `skip_unanswered` flag is ignored by the reply route and gone from the store (the iPhone app never shipped it; contract 7.11 is rewritten): an older client that sends it skips nothing. A skip-only Send whose listed questions all closed meanwhile answers `{ reply: null, nothing_to_send: true }`, a no-op, so the rest of a Send to several sessions still goes. Each skip is a sent skip, `answer: { skipped: true, by_reply: true }` (`inboxSkipAnswer`; `by_reply` is additive on `QuestionAnswer`, kept by `parseQuestionAnswer` only beside `skipped`), so the card says "Skipped · "; the reply's first line counts it ("Skipped 3 questions.", "Answered 1 question, skipped 2."), and each reads "Skipped by the person" in the answers section (a skip is always the person's choice now, so the old "not answered in this reply; treat them as skipped" sentence is gone; a Skip on the card still reads "Skipped"). read_thread's one-line answers say "skipped by the person". Core's `inboxDefaultSkip` (the list row's delivery reads it for which session asked) now always answers false. A Stopped question a Send skipped no longer blocks: the agent has its answer; an unskipped one stays open, so the agent stays stopped and the row stays in Stopped on you or Holding up work (Skip on the card, Skip them, or Mark read free it without deciding). A note on a card (with Skip, or alone) is a pick like any other and goes with the Send as `Skipped` / `Note: …`; each saved pick also updates the pane's `threadRef` at once, so a Send pressed while a note is still saving sends its saved revision instead of a stale one (`question_revision_conflict`) or leaving the note out (`packages/inbox/skipNote.test.tsx`). Tests: `packages/shared/inbox/store-explicit-skip.test.ts` (the owner's case: 11 questions in 8 messages, 1 answered, one reply, 10 open and the line says 10; answers on 3 messages as one reply; Skip them across messages; a question that lands after the press; another session's or thread's message refused; a stale revision on one message refuses all; a replay; a reopen), `packages/shared/inbox/store-mark-read.test.ts` (the skip list, `skip_unanswered` ignored, refusals), `packages/server/inbox-mark-read.test.ts` (the route), the door's 7.11 in `packages/server/inbox-devices.test.ts`, `packages/inbox/sendTargets.test.ts`, the DOM tests `packages/inbox/markRead.test.tsx`, `wordsOnlyTwoAsks.test.tsx` (one reply with answers on both messages; Skip them for the other one) and `skipNote.test.tsx`, and `tests/e2e/inbox-mark-read.spec.ts`.
- **Mark read** (`POST /api/inbox/threads/:id/read`; Undo `…/unread`). Offered where something waits on the person (Stopped, Holding, Waiting, or New with news or picks; `canToggleRead`, `packages/inbox/markRead.ts`) and never on an archived thread (`409 thread_resolved`). The store (`markRead`) writes the root with `person_read_seq` (the seq of that line) and `person_read_at`, counts it as a look, and closes every question still waiting on the person (open, or picked and not sent): each gets a skip answer and `set_aside: { at, read_seq, previous }` (`InboxQuestionRecord`, additive; `read_seq` is that Mark read's `person_read_seq`), so it reads `state: "closed"` with `set_aside_at` on the wire, and refuses a pick (`409 question_set_aside`). The row is Quiet ("Read") while no message but its set-aside note came after the Mark read (`readAt`, derived: an agent's next message, or the person's own reply, ends it with no write; the closed questions stay closed). Undo reopens only the questions that Mark read closed (owner ruling 2026-10-12), exactly as they were, a saved pick included (revision + 1), and clears the root's fields; a question an earlier Mark read closed (and told the agent about) stays closed, so Mark read again then Undo no longer reopens it. A record older than `read_seq` is matched by `set_aside.at` = the root's `person_read_at`. With no Mark read on the root (after an Undo, or an Unarchive that dropped it) Undo writes nothing. On a thread that came back from the archive, a Mark read made after the agent's latest message sends it back to Archived at once, even while the person's earlier reply there waits on the agent (`back_from_archive` reads past that reply while the Mark read stands); a reply the person sends after it brings the thread back as Sent.
-- **The agent is told once.** After the Undo window (`INBOX_SET_ASIDE_DELAY_MS`, 10 s: the 7 s toast with 3 s to spare; the server option `setAsideDelayMs`), while the questions are still closed, the server writes one person note per asking message (`tellSetAside`): a reply to that message, `set_aside: []` on it, body "Set aside for now; don't wait on these:" (`INBOX_SET_ASIDE_HEADING`) and the questions as `- Q. ` (`composeInboxSetAsideBody`). It reaches the agent like any reply (the wake, `wait_for_reply`, `read_thread`), never pushes to a phone (`pushFor` pushes agent messages only), and never ends the read. It is not the person's reply and not a look: the row's `sent`, `unseen`, `waiting_on_person` and "came back", the window's look boundary (`lookBoundary` in `packages/inbox/folding.ts`), its "Saved for / Delivered to" line and the held list's agent activity (the row's additive `last_agent_at`) all read past it, so news the agent wrote inside the Undo window stays New after the note goes. Each question is named once, across Mark reads; an Undo inside the window tells nothing. An Undo after the note went sends one more person note per asking message, for the questions that Undo reopened only, "The person reopened these:" (`INBOX_REOPENED_HEADING`, `composeInboxReopenedBody`, `reopened: []` on it), so the agent does not go on believing it need not wait; a later Mark read may then tell it again. A timer per Mark read, one on Unarchive (an archive holds the note back), and a sweep at start (`setAsideDue`) for a note a stop interrupted.
+- **The agent is told once.** After the Undo window (`INBOX_SET_ASIDE_DELAY_MS`, 10 s: the 7 s toast with 3 s to spare; the server option `setAsideDelayMs`), while the questions are still closed, the server writes one person note per asking message (`tellSetAside`): a reply to that message, `set_aside: []` on it, body "Set aside for now; don't wait on these:" (`INBOX_SET_ASIDE_HEADING`) and the questions as `- Q. ` (`composeInboxSetAsideBody`). It reaches the agent like any reply (the wake, `wait_for_reply`, `read_thread`), never pushes to a phone (`pushFor` pushes agent messages only), and never ends the read. It is not the person's reply and not a look: the row's `sent`, `unseen`, `waiting_on_person` and "came back", the window's look boundary (`lookBoundary` in `@plannotator/core/inbox-folding`), its "Saved for / Delivered to" line and the held list's agent activity (the row's additive `last_agent_at`) all read past it, so news the agent wrote inside the Undo window stays New after the note goes. Each question is named once, across Mark reads; an Undo inside the window tells nothing. An Undo after the note went sends one more person note per asking message, for the questions that Undo reopened only, "The person reopened these:" (`INBOX_REOPENED_HEADING`, `composeInboxReopenedBody`, `reopened: []` on it), so the agent does not go on believing it need not wait; a later Mark read may then tell it again. A timer per Mark read, one on Unarchive (an archive holds the note back), and a sweep at start (`setAsideDue`) for a note a stop interrupted.
- **The window.** The thread header (owner, 2026-10-16: Archive beside the close, Mark read furthest from it): **Mark read** leads (after `‹ Inbox` and its divider when that shows), with its `E` key; pressed it reads **Read** (`aria-pressed`) and moves it back; disabled ("Nothing here waits on you") in Sent or Quiet. Everything at the right is an icon with a component tooltip (approval "inbox-thread-artifacts", 2026-10-23): **Artifacts** with its count (only on a thread with artifacts, see "A thread's Artifacts"), Expand all / Fold all (`data-expand-all`, named "Expand all messages" / "Fold the earlier messages"; none on a one-message thread or over the Artifacts grid), the Archive icon, a hairline (`ib-vsep`), then the thread's ×; Archive ends the header where no × shows (stacked, a file beside the thread, a phone); the tab order follows (`tests/e2e/inbox-thread-header.spec.ts`). Under the subject while read: "Read 3:14 PM. It stays out of the top until writes again." and how many questions it closed, with **Move back**. The list row's hover action (and keyboard focus, `:focus-visible`) shows **Mark read** on rows that wait on the person, without opening the thread. `E` marks the open thread (or the row with keyboard focus) and again moves it back; `Z` undoes while the toast is up; neither runs while typing or with a modifier, inside the open file's viewer (`.ib-view`, its stepping guard row) or with a file open unless aimed at the thread or the list, in a guided review, or while the sound dialog is up. The toast (`components/Toast.tsx`, 7 s, `MARK_READ_UNDO_MS`; design record "inbox-toast", approved 2026-10-14): a 40 px line at most 520 px wide (with `--ib-soft-float` a pill on the popover's colour with Soft's floating shadow, its border kept transparent; same size and place) in the look of plan and code review's Sonner toasts (card, 1 px border, 8 px radius, 13 px): an icon, a bold lead, a muted detail, Undo as a filled button with a `Z` hint, and a × at its end. **Closing it early** (owner, 2026-10-20, amending the design's "no close": "a way to grab toasts to swipe remove them or way to clear a toast manually"): the × (`data-toast-dismiss`, named "Dismiss", after Undo / Reply / Open; 24 px drawn, muted until hovered, the Soft hover and pill radius; on a phone a 44 px target from its `::after`; Undo and the × are 8 px apart and their targets meet in the middle of the gap without overlapping, so a tap just past the Undo pill is still Undo) or a swipe: a horizontal drag (touch, pen or mouse; pointer events in `useSwipeAway`, `Toast.tsx`) moves the toast with the pointer through the CSS `translate` property (it composes with the desktop toast's centering `transform`) and fades it; let go past 40% of its width (`SWIPE_DISMISS_SHARE`), or flick it (0.5 px/ms over the last 100 ms, at least 16 px), and it slides out that way (180 ms, played on an inert, `aria-hidden` copy, `data-toast-ghost`, so the window drops the toast at once); short of that it springs back (260 ms, a slight overshoot). While a drag is in progress the 7 s timer is paused (`onHold`, `holdToast` in `App.tsx`) and a spring-back resumes it with what was left (at least 1.5 s); once a drag starts the entrance animation is turned off inline, so a spring-back never replays it. A drag that starts vertical is the page's (`touch-action: pan-y`), one that starts on a button is the button's, and a cancelled one (the browser took a scroll) springs back. Under `prefers-reduced-motion` the toast still follows the pointer but nothing slides: it goes, or snaps back. No library: plain pointer events and WAAPI. Closing it, either way, is `dismissToast` in `App.tsx`: the 7 s timer is cancelled and the toast's Undo goes with it, so `Z` no longer undoes once it is closed, and closing never performs the undo. When a toast leaves by any route (the ×, a swipe, Undo, its timer, a new toast) with focus inside it (a person Tabbed onto Undo or the ×), focus goes back to the element it came from (the `focusin`'s `relatedTarget`), else to the open pane's visible header (the thread's `‹ Inbox`, else its subject), else the page's `.ib-main h1` (or `main.ib-main`), never the body; each is tried until one actually takes focus (on a phone the list's heading is hidden while a thread is open). No key closes it (Escape belongs to the thread), and the live region says nothing about a close. The ×'s room comes from the toast's edges and gaps (`ib-toast-closable`: on a desktop 4 px less padding at its end and none before the actions, and where the words would still take a second line, Undo's `Z` hint is not drawn and the gaps close by 2 px (`data-tight`, decided before paint and on each resize by `useRoomForClose` in `Toast.tsx`; `Z` still undoes and Undo still names its key), which frees more than the × takes (a toast with no Undo has no hint to give up, so its copy keeps room for the ×); on a phone 8 px gaps, 12 px at its start and 9 px inside Undo), so a toast that fit one line without it still does, on a desktop at 520 px and on a phone down to an iPhone SE (`tests/e2e/inbox-mark-read.spec.ts` measures the widest desktop copies both ways). Its words always show whole (owner, 2026-10-18): nothing in a toast is cut with "…"; a lead and detail that do not fit one line wrap to a second (the toast grows from 40 px; the actions keep their line), so copy is kept short enough for one line at 520 px with Undo, `Z` and the ×. "Marked read · Moves to Quiet" from inside a thread (the list holds still until it closes; "Marked read · Goes back to Archived" for a thread that came back from the archive), "Moved to Quiet · “”" / "Back in Archived · “”" from the row's hover action with no thread open, and "Marked read · “”" (phone "Read · “”") for a row that is not the open thread while one is open (`markReadToast`'s `place`: `open`, `held`, `list`), so it never reads as the open thread; a thread an agent was stopped or held up on gets the warning icon, "Marked read · was stopped" / "Marked read · Held up N things", and **Reply** (moves it back and opens the reply box). Archive: "Archived" with Undo; Settings' Unarchive: "Back in your Inbox · “”"; the sound: "Sound off" / "Sound setting not saved" (4.2 s); a failed write: its message, which may wrap. Placement (desktop): always centered on the window, never on a pane (owner, 2026-10-15), 16 px from the window's bottom edge, at most 520 px wide and 16 px from each side, whatever is open (owner, 2026-10-18: a toast is brief, so covering Reply, the reply box or a file's stepping strip for a moment is fine; it no longer measures a pane's or a file's foot and never waits for a loading pane). It is CSS alone (`position: fixed` in `inbox.css`, z-index 41, above a full-screen file's 40); nothing in `Toast.tsx` measures the page on a desktop. A phone keeps its own placement (below). The toast is not a live region: the window's live region (`[data-inbox-live]`) says it once as sentences, with how to undo when it can be (`toastSpeech`: "Marked read. Moves to Quiet. Press Z to undo."; a phone: "Undo is available."). The window has ONE toast (`showToast` in `App.tsx`): a new toast replaces the last so two never stack, and none shows while the sound dialog is open.
- **Archive** is the window's name for resolve (tooltip "Archive: the thread ends; the next message starts a new one"; the foot "Archived. The next message from starts a new thread." · **Unarchive**). Agents keep `resolve_message`; its description says the person sees the thread as Archived. An archived thread leaves the window's list and is listed in Settings' Archived block, which also unarchives it; an agent's `reply_to` after the archive brings it back while something there waits, answerable without unarchiving, and Mark read sends it back (see "Archived" under "The list").
- The phone door allows `threads/:id/read` and `threads/:id/unread` (`inbox-devices.ts` ALLOWLIST); the list row's `read_at` and the question's `set_aside_at` are additive. The surface draws no list or thread, so it is unchanged.
@@ -45,7 +45,7 @@
- **The window.** Feature-detected: without `pins` on the list (an older Inbox) there is no Pinned section, no menu item and no header toggle. Pin from the row menu's first item ("Pin thread" / "Unpin thread", its own group) or the thread header's icon toggle (`data-pin-toggle`, `aria-pressed`, one fixed accessible name "Pin to the sidebar" whatever its state, as Mark read keeps its key; only the component tooltip says "Unpin from the sidebar" while pinned; it turns off while its request is in flight, so a quick second click is never lost), placed after Mark read and the header subject, before Artifacts, Expand all and Archive, on a desktop and on a phone (a phone's way to pin: it has no row menu). On a phone narrower than 360 px one more 44 px icon has no room, so there Mark read shows its icon alone, its words kept as visually hidden text (its name); `inbox-thread-header.spec.ts` checks the 320 px header fits. Pin shows no toast; the live region says "Pinned “subject”". Unpin shows the one toast, "Unpinned · Still in " ("Still in Archived" for an archived thread) with Undo (`Z`), which posts a restore of that one thread at its old index with its original `pinned_at`. The sidebar's **Pinned** section (`.ib-side-label` "Pinned", then ``) sits after Inbox and Drafts, before Projects; nothing is drawn when nothing is pinned. A row (`PinnedRow`, Count): the agent's mark, the subject cut with "…" (the whole subject over its project and state in a component tooltip), then the state as the project rows show a count: a Stopped or Holds N pill in the badges' tints (`.ib-spill`), the open question count (`.ib-n`), a dot for news (`.ib-pdot`), the archive glyph for an archived thread (`.ib-parch`), nothing for Sent or Quiet (`pinnedState`). The open question count reads as the list's count does (`waitCount`: "1 question, 1 action item"), the news dot follows unseen agent messages (`unseen > 0`, never a row in New only for the person's own draft), and archived is the list's own rule (`inboxRowArchived`, in `@plannotator/core/inbox-types`, which `shared/inbox/list.ts` re-exports). Bold when the list row would be (`unread`); calm (muted) when nothing waits on the person. Its name is "subject, project, state". The open thread's row wears the list's selection pill (`--ib-sel`) and `aria-current="true"`, never the current page's lifted card. ⋯ (`data-pin-more`, " options", the project row's pattern: on hover, keyboard focus or while open, in place of the state; always on a phone, 44 px, the menu a bottom sheet titled by the subject) and a right-click on the row open **Unpin**, **Move up**, **Move down** (disabled at the ends) and **Copy link**. After a move focus stays on that row's ⋯ in its new place; after Unpin it goes to the next pinned row's ⋯, else the first project, else Inbox. On a phone Unpin and Copy link close the navigation sheet so the toast shows; a move keeps it open. A click opens the thread (`#thread=`) and keeps the project filter, as a search result does, a new history entry; the open thread's pinned row does nothing. An archived pinned thread stays in Pinned and opens as an archived thread does (its row from the thread read). Order is pin order; nothing reorders itself; no drag in v1. A pinned list row keeps its section and place, with a small pin (`.ib-pinmark`, aria-hidden) after the subject (wide and two-line rows) or before the time (a phone's row), and its accessible name ends ", pinned". Pinned and Projects share ONE scroll area (`.ib-sidescroll`, which took over `.ib-projs`'s scroll and fade), so 20 or more pins scroll with the projects and never push the foot off the window. No new keyboard shortcut; tooltips are `Tip`, no native `title`.
- Tests: `packages/shared/inbox/store-pins.test.ts` (the store, and a fast-check property: a store that pins beside a twin that never does never differs in a row, a section, the cursor or where a send lands), `packages/server/inbox-pins.test.ts` (routes, guards, restart, delete prune, a stale move from a second window after an Unpin there, Undo's restore with the original `pinned_at`, `event: pins` to windows only, archived stays, no limit, agents never see pins, the door read and its refusal, the owner over the tailnet), the DOM tests `packages/inbox/pinnedThreads.test.tsx` (with a quick pin-then-unpin on the header) and the pin case in `openStaysPut.test.tsx` (the held order and "N new" unchanged), and `tests/e2e/inbox-pins.spec.ts` (compiled binary, desktop Chromium and an iPhone in WebKit; screenshots light and dark to `PLANNOTATOR_PINS_SHOTS`).
-**Folding a thread** (design record, not in this repo; variant "Land"; `packages/inbox/folding.ts`, drawn by `components/Thread.tsx`).
+**Folding a thread** (design record, not in this repo; variant "Land"; `@plannotator/core/inbox-folding` (`packages/core/inbox-folding.ts`; `packages/inbox/folding.ts` re-exports it), drawn by `components/Thread.tsx`).
- What is new: the agent messages after the person's last look or their own last reply, whichever is later (`lookBoundary`, the list's rule). The window reads it from the thread payload BEFORE it records the look: `GET /api/inbox/threads/:id` carries each message's `seq` (its first line's) and, on a person's reply, `sent_annotations` (`InboxStore.windowThread`; additive, absent from an older Inbox, not on the MCP tools). `App.tsx` keeps the boundary per open thread (`look`, from the first read of that thread) and passes it to `ThreadPane` as `lookSeq`, so the "New" marks last until the person leaves the thread. A reply the person sends during the visit is a look too: the pane's effective boundary is the later of `lookSeq` and the highest seq of any person message in the thread now, so what the reply answers stops being new at once (the fold plan is not re-decided). A guided review opens as an overlay (`GuidePane`), so the pane and its fold state stay mounted under it. A payload without seqs folds nothing and marks nothing new.
- When the thread opens, a message is open if it is new, holds a pick not sent, or is the last one (and the last real one, when the Inbox's set-aside or reopened notes trail it: it carries the reply's "Saved for / Delivered to" line, which never sits under a note); every other message, the person's own included, folds to one line (`planFolds`). A message that arrives while the thread is open shows open, new, at the bottom. The fold state is in memory per open thread; nothing is saved.
- A message's head is one row, folded or open (design record "inbox-polish", item 5; the owner chose a 24 px mark): the mark, the name, a third column, what the message holds, the time, in the same cells, so opening a message moves nothing the person clicked; only the third column changes and the body appears below, indented 34 px to the name (`.ib-msg`). Folded, the whole row is the fold button. Open (`[data-open-head]`, `.ib-fline.ib-is-open`), the third column is "in " (`.ib-fl-in`, which gives way first with "…"), "· to you" with the session-ended mark (`.ib-fl-to`) and the attachments count (`.ib-fl-files`), neither of which ever shrinks: the column wraps instead and the row grows downward, its mark and name staying put; the time column holds New, the time (its tooltip the whole moment) and Reply; the fold button (`.ib-fl-hit`) lies under the row as its hit area, since the row holds buttons of its own, and the row's text is `aria-hidden` there (the button's label says it). Search's landing highlight and the skill highlight skip the head row, folded or open (`[data-fold-head], [data-open-head]`). A one-message thread draws the same open row with no fold button. Opening with the head fades the body in (160 ms, a 4 px rise, `data-revealed`) and the third column with it; folding is instant, and so is everything under reduced motion; nothing else that opens messages (the first draw, Expand all, a jump) animates. In a thread with under 560 px of text (a container query on `.ib-pbody`, `ib-thread`: beside a file, a thread of about 568 px, a phone; a 1280 or 1366 px window beside the list keeps one row) every head row, folded or open, takes two lines, the snippet or the third column on the second (`tests/e2e/head-row.ts`: the count and "session ended" are drawn whole at 440 and about 568 px).
@@ -87,7 +87,7 @@
- Re-ask (see "Questions and Send") is how the person asks for this: its note lists the open ids and asks for a `Replaces:` line per question asked again and `supersedes` for the rest.
- Tests: `packages/shared/inbox/store-supersede.test.ts`, `packages/server/inbox-supersede.test.ts`, the DOM test `packages/inbox/replaced.test.tsx`, and `tests/e2e/inbox-replaced.spec.ts` on the compiled binary.
-**Open questions in a thread** (approval `.product/approved/inbox-thread-open-questions/`, option A "Button in the foot", 2026-10-22; `packages/inbox/openQuestions.ts` pure, `packages/inbox/components/OpenQuestions.tsx`, wired in `Thread.tsx`). A long thread spreads its questions over many messages, some folded; this lists them without leaving the thread.
+**Open questions in a thread** (approval `.product/approved/inbox-thread-open-questions/`, option A "Button in the foot", 2026-10-22; `@plannotator/core/inbox-open-questions` pure (`packages/core/inbox-open-questions.ts`; `packages/inbox/openQuestions.ts` re-exports it), `packages/inbox/components/OpenQuestions.tsx`, wired in `Thread.tsx`). A long thread spreads its questions over many messages, some folded; this lists them without leaving the thread.
- The button: "N open questions" (`data-open-questions`, the question-circle icon and a chevron) in the closed foot's button row, right after Reply / Edit draft. Not in the open reply box, the route choice or the stale guard. Counts the questions of live agent messages (`liveMessage`: not closed by an archive) that are `open` or `picked` (not sent); `superseded` ones are left out and counted apart; action items are not questions and are left out (`threadOpenQuestions`); with no question waiting but Re-ask offered (only action items wait), it counts those instead ("1 open action item") and its list is Re-ask alone. The "N questions stay open · Skip them" chip is unchanged beside it.
- When it shows (`openQuestionsShown(questions, reaskShown)`): whenever one or more questions wait, or Re-ask is offered (owner, 2026-10-23: the list holds Re-ask, so it is never out of reach). The earlier exception (one question shown only in a long or folded thread) is gone.
- The list (Base UI `Menu`: `aria-haspopup="menu"` / `aria-expanded` on the button, `role="menu"` named "N open questions in this thread", rows `menuitem`; arrows, Home/End, type-ahead by prompt; Escape and Tab close it with focus back on the button): no drawn heading, count line or foot line (owner, 2026-10-23: "I don't think we need these labels"; the `aria-label` still names it, and a pick shows on its row as "Draft"), one row per question in thread order (a number, the prompt clamped to two lines, then the state: "Draft · " (the card's pick words) first, else a Stopped badge, else "Holds up N"; then the message's time (`whenTime`) and its place: "in a folded message", else "above" or "below" from the card's middle against the middle of the thread's view (`placeOf`, measured when the list opens), then, after a separator, the last row: Re-ask when it is offered (see "Re-ask") and Skip… (below). Choosing a row closes the list and runs the thread's `jumpTo` (the one the stale guard and replaced-question links use): a folded or hidden message opens first, the card scrolls to the centre, takes focus and is ringed (`ib-q-target`); focus does not go back to the button (`finalFocus` false after a pick). Answering stays on the real card, except skipping.
@@ -109,7 +109,7 @@
**Action items an agent needs you to go do** (approval `.product/approved/inbox-next-actions/`, 2026-10-21, direction "In the thread": the action item is a card in its thread and nothing else; the owner rejected a list section, a Action item page and a top strip: "Not yet convinced we need a higher level surface". `packages/core/inbox-actions.ts`, `packages/inbox/components/ActionChecklist.tsx`, `packages/inbox/actions.ts`; the card redrawn as a checklist by approval `.product/approved/action-items-checklist/`, option A, 2026-10-22).
- The block: an Inbox-only `:::action` (plan review's question grammar is unchanged: `:::action` is not a question kind, `findQuestionBlocks` and `parseInboxQuestionBlocks` never see it). The first non-empty line is what to do (an order, plain: a heading mark, list marker or emphasis around the whole line dropped, cut at 400); every other line is the why, except `Where:` (a URL, `[name](url)` or `name url` becomes an "Open" button, http(s) only; else a place in words), `Run:` (a command to copy; backticks around it dropped), `Stopped:` / `Holds up:` (read as for questions, with the same negative-word guard), `Watching:` (what the agent watches, to notice it done by itself) and `Replaces:` (only when every token is a `msg_…[/q-…|/a-…]` id). The first of each label wins. Found with core's `findDirectiveBlocks` (the directive scanner `findQuestionBlocks` now uses too, so code fences and HTML blocks hide it the same way). Key `a-` + hash8 (`blockKeyHash8`) of `action\n` + the normalized first line (`inboxActionKey`), `-2`, `-3` for repeats.
- Stored as a question record of kind `"action"` (`InboxQuestionKind = QuestionKind | "action"`; read it with `readInboxQuestionKind`, which answers `"unknown"` for a kind it does not know, never answerable) in `questions.jsonl`, numbered on its own (`position`: A1 is 0, beside Q1 at 0), with additive `action` (`{ where, where_url, run, watching }`) and `action_result` (`{ outcome: "done" | "cant", note, at, by: "person" | "agent", message_id? }`; the record's `answer` stays null). State (`inboxQuestionState`): open; `picked` while the person's Done is inside its Undo window; `sent` once the reply carrying it is written; `closed` when the agent saw it done (`by: "agent"`), a Mark read set it aside, or the archive closed it; `superseded` as for a question (`supersedes` and `Replaces:` take `a-` keys; a question is replaced only by a question and an action item only by an action item: `supersedes` names the new message's only question, or only action item, of the old record's kind as its replacement, and a `:::action` block's `Replaces:` reads only `a-` ids; an action item the person marked inside its Undo window is never superseded: refused by its own id with "the person already marked it", left out of a message-wide id, so their answer goes as they said; send_message's result names a superseded action item as one, ", superseding 1 earlier action item"). The wire lists a message's action items in `questions` after its questions; the window moves them into `actions` as it reads a thread (`splitThreadActions` in `api.ts`, and picks', Send's and Re-ask's answers through `questionsOnly`), so the picks, the Send, the skip chip and Re-ask's picks never meet one. The store refuses an action item's key in a pick, a Send's answers or its `skip` (`validation_error: … is an action item, not a question`), the reply's question sections (`composeInboxReplyBody`: the answers section, the still-open lines) leave action items out, and Re-ask (owner, #2059) asks for the session's open action items together with its open questions: the note lists each as `- : (action item) …` and tells the agent to ask a still-needed one again in a `:::action` block with a `Replaces: ` line, put one that no longer applies in `supersedes`, and one it saw done in `done` (`composeInboxReaskBody`; the question-only wording is unchanged); an action item the person marked inside its Undo window is not open and is left out. The window counts open action items for the button (`reaskOffered`, `reaskTargets`' `actions`), its tooltip and the note's line name both ("the 1 open question and 1 action item"), and rule 2a (`reask_pending`) covers them, since they are listed in the note. The wake names only what the note lists: the bridge command carries `reask_items` (`actions` or `both`, from `inboxReaskItemsOf`; absent for questions only, so that wake stays `INBOX_REASK_INSTRUCTION` byte for byte), read by `inboxReaskInstruction` and `inboxReaskRedirectedLine`. `nothing_to_reask` says "No open question or action item is left to ask again."
-- The list: no new section, page, strip, notification kind or tab-count change. An open action item counts like an open question for every list rule (`summarizeInboxQuestions`: `open` counts both; `Stopped:` puts the thread in Stopped on you, `Holds up:` in Holding up work, neither in Waiting on you), for the notification (" stopped on you", word for word; the event stream's question line) and the question sound. An action item inside its Undo window waits on nobody's Send: it is not `picked` in the summary, so never "Answered, not sent". The summary's additive `actions` (open action items among `open`) makes the row's count read "1 action item" or "1 question, 1 action item" (`waitCount` in `format.ts`; plain text, an action item always named, "2 questions" unchanged). A message that asks nothing but carries an action item is named by it (`deriveInboxSubject` over the action items, `subject_question` its id). The folded line's holdings add "1 action item". 👍 Looks good stays hidden while an action item waits (the store's `question_open` covers it).
+- The list: no new section, page, strip, notification kind or tab-count change. An open action item counts like an open question for every list rule (`summarizeInboxQuestions`: `open` counts both; `Stopped:` puts the thread in Stopped on you, `Holds up:` in Holding up work, neither in Waiting on you), for the notification (" stopped on you", word for word; the event stream's question line) and the question sound. An action item inside its Undo window waits on nobody's Send: it is not `picked` in the summary, so never "Answered, not sent". The summary's additive `actions` (open action items among `open`) makes the row's count read "1 action item" or "1 question, 1 action item" (`waitCount` in `@plannotator/core/inbox-format`; plain text, an action item always named, "2 questions" unchanged). A message that asks nothing but carries an action item is named by it (`deriveInboxSubject` over the action items, `subject_question` its id). The folded line's holdings add "1 action item". 👍 Looks good stays hidden while an action item waits (the store's `question_open` covers it).
- The card (a checklist, approval "action-items-checklist", option A): ONE card per run of adjacent `:::action` blocks (only blank lines between them; prose between two blocks makes two cards, each where the agent put it), drawn through ui `Viewer`'s `renderDirectiveBlock` seam with no ui change: `MessageBody`'s `renderActions` (`recordFor`, matching a block to its record by its opening line with `actionForBlock`, and `render`) groups `Viewer`'s blocks into runs, draws the whole run in place of its first block and returns `null` for the others (their empty wrapper is hidden by `inbox.css`, `[data-block-type="directive"]:empty`); a block with no record stays text and ends the run. Header: the checkbox icon, "N things only you can do" ("1 thing…"), "x of N done" (done = done, the agent saw it, or Done inside its Undo window), with replaced items counted apart so the header never contradicts the rows ("· 1 replaced" after the count; a card whose items are all replaced, routine after a Re-ask, reads "2 action items, replaced" with no count), and one badge for the open items (Stopped wins, else Holds up with their hold-ups summed). One row per item (``): a round tick (a real button with the stable name "Done: "; inside the Undo window it is `aria-pressed` and a click undoes, but never the second click of a double-click or a click within 500 ms of Done; while a mark is saving it is `aria-disabled`, never `disabled`, so keyboard focus stays on it; a settled row's tick is a plain mark: a check, an ✕ for Can't do it, a dashed empty dot when set aside, closed or replaced), the task (inline markdown, as a question's prompt, but never a link inside its toggle button: `taskMarkdown`), a chip once it is not open (Done, Can't do it, Set aside, Closed, Replaced), a chevron. Rows are disclosures (`aria-expanded` / `aria-controls`, the panel `hidden`), one open at a time, the first open item open to start with. The open row: the why (markdown, read only, through `ContextMarkdown`, the path decision mode's card draws a question's context with: lists, bold, code and links that open in a new tab; long code and URLs wrap), "Stopped " / "Holds up ", **Open ↗** for a link (`target="_blank" rel="noopener noreferrer"`), a place in words, the command whole (wrapped, never cut; `$` before it on a desktop, a "Command" label on a phone) with Copy (its result said in a polite live region; Open and the command both show when an item has both), then **Done**, **Can't do it…** (a reason box: "Why can't you? gets this word for word."; its Send is disabled while empty) and **Add note…** (a note box, then Done); the note and reason boxes stop at 2,000 characters; Escape in a box closes it. Once per card at its foot: " is watching: …" (the distinct watching lines of the items still open or inside their Undo window; none once every item is settled or replaced) and, while an item is open and the card takes Done, "Done reaches after 10 s, so you can undo.". After Done (the tick or the row's button) the next open item opens; from the row's button focus goes to that row's toggle (else the row's own). Inside the window the row is struck through with "Done 3:12 PM. Goes to in a few seconds. Undo" under it (Can't do it: its reason, the same way); the Undo puts focus on the row's toggle. Settled, opening the row says "You marked it done, 3:12 PM. Sent to .", " saw it done, 3:14 PM.", "Set aside when you marked the thread read. was told not to wait.", " replaced this action item." or "Closed when the thread was archived."; a sent Can't do it keeps its reason under the task. No keyboard shortcut. 44 px targets on a phone (tick, toggle, Open, the buttons, Undo). The window's toast on Done / Can't do it: "Done · Goes to in 10 s" with Undo (`Z`), as 👍's.
- Done and Can't do it (`POST /api/inbox/messages/:id/actions/:key`, `{ serverSession?, outcome: "done" | "cant" | null, note? }`, the window's guards; `store.markAction`): recorded at once (`picked`), sent to the agent once the Undo window (`setAsideDelayMs`, 10 s) passes; `outcome: null` inside the window is the Undo (nothing is ever sent); a second mark inside the window replaces the first and starts the window again. Refused: `action_already_sent` (409) once sent, `action_closed` (409) on an action item no longer open, `validation_error` (422) for a question's key, a bad outcome, or Can't do it without a reason (a note is at most 2,000 characters). On the device door as 7.63.
- What the agent gets (`store.settleActions`, timers like 👍's plus a sweep at start): ONE person reply per asking message, sent once EVERY action item the person marked on that message is past its window (two Done clicks seconds apart go in one reply). Archive holds a pending Done back (the message's action items read closed); Unarchive (the thread's, or a project batch's Undo) sends it at once, or when its window passes. The reply answers that message (`reply_to`), the action items marked sent with it first (`sent_reply_id`; `repairUnsentAnswers` re-sends one whose reply never landed), the reply's additive `action_report` naming them. It is the person's reply for delivery (`sent`, Send now, the wake), but never a look (`isInboxActionReport`: `lastPersonSeq`, `markSeen`, the window's `lookBoundary` and seen-on-sight read past it), so agent news that landed inside the window stays new, and a row whose report follows unseen news is placed by the news (New), not Sent. It is delivered by the reply machinery (the wake, Send now, `wait_for_reply`, claims), so its wake is the plain reply wake (`INBOX_WAKE_INSTRUCTION`). Body (`composeInboxActionsReply`): `Did 1 action item.` (`Couldn't do 1 action item.`, `Did 1 action item, couldn't do 1.`), then `## Action items you asked for`, `x of y done.` (y the message's action items not superseded; the agent's own `done` counts), then per action item `### A1. ` and `Done, 3:12 PM.` (the Inbox's local time) plus `Note: …`, or `Can't do it: `, and, when that message's questions or action items still wait on the person, one last line naming them (`inboxActionsStillOpenLine`: "Still open: A2. Approve the 2FA prompt for the npm org — the person will do it in the Inbox; don't ask again."). wait_for_reply's closing line reads `questions and action items of ` only when an action item is there (a question-only one is unchanged) and names an action item's result in short (`done`, `can't do it`, `you saw it done`). Mark read sets an open action item aside exactly as a question (`set_aside`, no skip answer; Undo puts `action_result` back), and its note lists it as `- A1. …` after the questions (`numberedLines`); an action item inside its Undo window is left on its way.
diff --git a/docs/agent-guides/inbox-window.md b/docs/agent-guides/inbox-window.md
index 4f705aa3a..ec4987ae7 100644
--- a/docs/agent-guides/inbox-window.md
+++ b/docs/agent-guides/inbox-window.md
@@ -107,7 +107,7 @@
- A Send is `POST /api/inbox/messages/:id/reply`, ONE per agent session it answers (owner approval "inbox-explicit-skip", 2026-10-21: "answering questions across messages should just become one reply"), with an idempotency key per (Send, session), kept for a retry. Before it, a Send posted one reply per message, so an agent that asked across 8 messages woke on the first reply and acted before it saw the rest. `sendTargetsOf` (`packages/inbox/sendTargets.ts`) groups the picks waiting, and the questions chosen with "Skip them", by the session of their message (`author.host` + `author.session`; a message with no session is a reply of its own); each group posts to the latest of that session's messages it touches, and every pick or skip on another of its messages names it: `questions: [{ message_id?, key, revision, answer? }]`, `skip: [key | { message_id?, key }]` (`message_id` absent = the route's message). The words, annotations and pasted files ride the reply of the session that wrote the latest agent message (that group goes last), or a last reply to the latest agent message when that session has nothing else to send. The store (`sendReply`) checks each `message_id` (`sendMessageOf`: the same thread and the same agent session as the reply's message, else `422 validation_error`; a message the archive closed, `409 thread_resolved`), plans each message's answers with its own revisions (a stale one refuses the whole reply, nothing written), and writes ONE person reply `reply_to` the route's message, marking every question it sends with that reply's id. Re-ask's picks-only reply (`reaskTargets`' `picks`) is one reply the same way. The reply's body, in this order: its first line (`Answered N questions.`, `Skipped N questions.` or `Answered N questions, skipped M.`, counted across every message by the answers section's own rule, so it never disagrees with "x of y answered"), the person's words, the still-open line, the answers section, then the annotations' feedback. The answers section is `formatQuestionAnswersSection` for the one message the reply answers; when the answers are on several messages, or on another message than the one replied to, it is one "Answers to your questions" section with a `### Your message ` heading per message, in thread order, each with its own "x of y answered" line and its questions numbered as its cards are (`composeInboxReplyBody`'s `messages` and `replyTo`, `packages/core/inbox-questions.ts`), so the agent can tell which "Q1" is which. The still-open line (approval "inbox-words-only-reply", 2026-10-13, extended by "inbox-explicit-skip"): counts every question the replied-to message's session asked in this thread that is still open, or picked and not sent, leaving out the ones this reply sends (another session's questions are never counted, and a message with no session counts only its own); none when nothing is left open. A reply that answers or skips something says "You have N other questions open in this thread. They stay open; the person will answer them in the Inbox. Don't re-ask them." (`inboxOtherStillOpenLine`; for one: "You have 1 other question open in this thread. It stays open; …"). A reply that sends no answer and no skip (words, annotations, or both) keeps its own wording, with no first line and no answers section: "No questions answered yet; all N are still open. Don't re-ask them; the person will answer in the Inbox." (`inboxStillOpenLine`; for one: "No question answered yet; it is still open. Don't re-ask it; …"; when an earlier reply already answered some: "No new answers in this reply; N questions are still open. …"). `INBOX_STILL_OPEN_LINE` matches every form, and the folded line's `replyWords` cuts the reply there, so a snippet shows only the person's words; `withoutAnswersSection` (read_thread's earlier messages) cuts the grouped section too, and search's `inboxSearchReplyText` cuts at the still-open line as `replyWords` does. `wait_for_reply`'s structured `questions` and its facts line cover every message the reply answered or skipped on (its `reply_to` and each other message holding a question the reply sent), each question with its `message_id`. A retried Send keeps each reply's key unless its words, files or comments changed or it carries a pick or skip the last try did not (`replyKeysToRenew` in `sendTargets.ts`), so a new answer is never dropped by a replay. When the chosen session ends mid-Send, the route's error counts agents: "N of M agents got their reply". The wake text is unchanged and carries this body. Plan review's export (`formatQuestionAnswersSection`, `packages/core/question-block.ts`) is untouched.
- The sent questions are marked before the reply line lands. When the reply line never lands, they read as picked again, so a retry never reads "already sent" with nothing sent.
- **The person's own sent reply, in their words** (owner approval 2026-10-24, the onboarding audit's finding 7; display only). The thread draws a person's reply as their words, then a short answers list ("You answered 2 questions, skipped 1", one row per answer, `Q1 → `, a skip marked, grouped under the asking message when the reply answered several; a row shows its card), then their comments' feedback and files as before (`SentAnswers` in `components/ReplyAnswers.tsx`, `sentReply.ts`). The answers come from the questions themselves (`sent_reply_id` names the reply that carried each); the agent-facing parts of the body (the first line "Answered N questions.", the still-open line, the "## Answers to your questions" section) come out through core's `splitInboxReplyBody`, which cuts the section only when it follows its grammar exactly (`inboxAnswersSectionEnd`, also behind the server's `withoutAnswersSection`) and whose words match what the folded line (`replyWords`) and search read for a body the store wrote (those two keep their own, looser rule). A body the store did not shape, an Inbox note, a Re-ask note or an action report is drawn as before, and so is a reply whose section is there but whose answers cannot be listed. The stored body and what agents receive are unchanged. Tests: `packages/shared/inbox/reply-display.test.ts`, the DOM test `packages/inbox/sentReply.test.tsx`.
-- After a Send, the line under the person's last reply or message says where it is (`deliveryLine`, `packages/inbox/deliveryLine.ts`; the owner's request 2026-10-16, "it should say this message is queued up … and then maybe an option just to send it immediately"). For a session whose connection wakes it (a reply to a message with `wakes`, or a message with `to`), the window reads that session's connection every 3 s (`GET /api/inbox/threads/:id/sessions?asker=`, the additive `asker.connection` = `{ busy, idle_since, send_now, waits }` while it polls, null when it does not): "Undelivered 3 min · Claude Code is working · goes in by itself in 2 min" with **Send now** (`data-send-now`, only while `send_now`; see the next bullet but one; "Queued · Claude Code is working; it gets this when its turn ends." in the few seconds before the server starts the reply's clock), "Sending to Claude Code…" while idle, "Queued for Claude Code; it gets this as its next turn." when the connection does not say (or an older Inbox), "… when its session is back." when it does not poll (also after Send now was pressed or the Inbox marked it: a session gone after the mark is never "Sending … now"), "Sending to Claude Code now…" once Send now was pressed (`deliver_now_at`) while the session polls. A reply to an agent that only reads the thread (MCP, OpenCode 1) reads "Saved for . It sees it when it next reads this thread." Then "Delivered to , 10:52 AM", unchanged. The ended-session and Not delivered lines are unchanged.
+- After a Send, the line under the person's last reply or message says where it is (`deliveryLine`, `@plannotator/core/inbox-delivery-line`, re-exported at `packages/inbox/deliveryLine.ts`; the owner's request 2026-10-16, "it should say this message is queued up … and then maybe an option just to send it immediately"). For a session whose connection wakes it (a reply to a message with `wakes`, or a message with `to`), the window reads that session's connection every 3 s (`GET /api/inbox/threads/:id/sessions?asker=`, the additive `asker.connection` = `{ busy, idle_since, send_now, waits }` while it polls, null when it does not): "Undelivered 3 min · Claude Code is working · goes in by itself in 2 min" with **Send now** (`data-send-now`, only while `send_now`; see the next bullet but one; "Queued · Claude Code is working; it gets this when its turn ends." in the few seconds before the server starts the reply's clock), "Sending to Claude Code…" while idle, "Queued for Claude Code; it gets this as its next turn." when the connection does not say (or an older Inbox), "… when its session is back." when it does not poll (also after Send now was pressed or the Inbox marked it: a session gone after the mark is never "Sending … now"), "Sending to Claude Code now…" once Send now was pressed (`deliver_now_at`) while the session polls. A reply to an agent that only reads the thread (MCP, OpenCode 1) reads "Saved for . It sees it when it next reads this thread." Then "Delivered to , 10:52 AM", unchanged. The ended-session and Not delivered lines are unchanged.
- **Undelivered replies go in by themselves** (owner approval "inbox-busy-agent-reply", 2026-10-21, behavior and look; `.product/approved/inbox-busy-agent-reply/`). A reply, a message or a Re-ask note that waits on a BUSY session (never the note carrying a 👍, below) goes into the running turn by itself after `INBOX_AUTO_SEND_AFTER_MS` (5 min, `packages/core/inbox-delivery.ts`), exactly as if the person pressed Send now: `deliver_now_at` plus `deliver_now_by: "auto"` (a person's press writes `"person"`; on the message, or on a tag for a tagged session's own copy) and `wait_started_at` (when it began to wait on the busy session), and the bridge command's `now: true`, delivered into the turn per host as below. Only where Send now is possible (the session polls `busy: true` and `send_now: true`); elsewhere it keeps waiting and the line says why. **The clock is the server's, never the item's `created_at`** (`createInboxLiveSessions`' `trackWaits` in `packages/server/inbox-sessions.ts`, in memory): each item a session is handed (`pendingReplies`) gets `since` the first time the server sees that session busy while the item waits for it (the "Undelivered N min" number, row and band alike), and `autoSince` the first time it also sees the session able to take it mid-turn (`send_now`; the countdown "goes in by itself in N min" runs from it, so a session that becomes able after a `/reload-plugins` still gives the person the full 5 minutes). Clocks are looked at on every poll and `state` event, when a person's item is written, and every `INBOX_AUTO_SEND_SWEEP_MS` (5 s). A delivered item, or one moved to another session ("Send to another session", which also clears `deliver_now_at`, `deliver_now_by` and `wait_started_at` in `readdressMessage`), leaves the old session's clocks; a session that stops being live loses all its clocks; an Inbox restart starts every clock again. So a reply left for a session that ended, or a message moved to a busy session, waits its whole 5 minutes from when that session is busy, and the wait is never shorter than 5 minutes (a restart can make it longer). The sessions route carries the clocks as `connection.waits` (`{ [item id]: { since, auto_since } }`); the store reads them through `InboxDeliveryPresence.waitOf` for the rows. `InboxStore.autoSendDue(session, { afterMs, tags, clockOf })` marks every pending item whose `autoSince` is at least the wait ago and that has no `deliver_now_by` yet; the server calls it for every live busy `send_now` session on the sweep and on each such session's poll (`packages/server/inbox.ts`). Never twice: `deliver_now_by` stays when a failed attempt (`send_now_failed`) clears `deliver_now_at`, and the Inbox never tries again by itself (the person's Send now still can). Never for the Inbox's set-aside and reopened notes. **A 👍 waits for the turn to end** (owner's decision 2026-10-21, approval "inbox-busy-agent-reply": "a thumbs-up isn't worth interrupting a turn"): the note carrying a 👍 never goes in by itself. One switch, `INBOX_AUTO_SEND_ACK_NOTES` (`false`, `packages/core/inbox-delivery.ts`), decides it in the server's marking (`autoSendDue` skips `ack_of` notes), the row (`rowDelivery`: no `auto_send_at`) and the 👍 line (`ackPillLine`: no countdown) alike. **How it went in:** when the connection reports `delivered` for an item the Inbox marked, the server writes `delivery.into_turn` (additive): false when the session was seen idle after the mark (its own Ask this session question held the turn, so the item went in when the turn ended), true otherwise (`live.deliveredIntoTurn`). `startInboxServer` takes `autoSendAfterMs` / `autoSendSweepMs` (tests), `plannotator inbox` the testing knob `PLANNOTATOR_INBOX_AUTO_SEND_MS` (see the env table). The look (one rule for row and thread: `inboxDeliveryState`'s `wait` / `autoAfterMin` / `turnEnded`, inputs `waitingSince` (the server's `since`, after the mark `wait_started_at`), `autoSince`, `nowBy`, `nowAt`, `intoTurn`, `autoSendAfterMs`; the window gathers them with `waitInputs` in `deliveryLine.ts`): the row's additive `delivery.waiting_since`, `steerable`, `auto_send_at`, `auto_after_min`, `turn_ended`, and the sessions route's `connection.auto_send_after_ms` and `waits` (absent from an older Inbox: no countdown). A session that stops polling wins over a mark: the line reads "… when its session is back.", never "Sending … now" (`connection === null` is decided before `sendingNow`). The list row draws "Undelivered N min" in place of the count (`UndeliveredBadge`, `components/Undelivered.tsx`; blue `--ib-wait-*` while it can go in now, grey `data-stuck` when not; whole minutes, "Undelivered" under one; its own 5 s clock, so a memoized row is not rendered again); the wide and two-line rows offer **Send now** on hover and keyboard focus only (`data-row-send-now`, a sibling of the row like Mark read; never on a phone row). Under the reply the band (`UndeliveredBand`): "Undelivered 3 min · Claude Code is working · goes in by itself in 2 min" ("in under a minute"; "it gets this when its turn ends" when it will not) with a filled Send now; grey with no button and no countdown when it cannot go in sooner, then why and the fix (`StuckReason` in `RestartNote.tsx`: the older-plugin sentence with `/reload-plugins` and Copy, the update steps, else "It can't go in sooner."). Tagged sessions' lines say the same; a 👍's line says how long it waited and "it gets this when its turn ends", with no countdown and no Send now. After: "Delivered to Claude Code, 1:53 PM · went into its turn by itself after 5 min" (`data-auto`, the delivered tick's colour) only when it went into the running turn; "Delivered to Claude Code, 1:53 PM · went in when its turn ended" (`TURN_ENDED_TEXT`) when `into_turn` is false. "Not delivered" for a gone session is unchanged. No toast, no shortcut. Tests: `packages/shared/inbox/store-auto-send.test.ts` (injected clock: never without a clock, not before, at 5 min, once, the countdown from `autoSince`, a failed attempt, a move to another session, notes, the 👍 switch, tags, the row, `turn_ended`, a session gone after the mark), `packages/server/inbox-auto-send.test.ts` (the server's timer with a short wait: the poll's `now`, never without `send_now` or for an idle session, a restart starting the clock again, a reply left for an ended session, a message moved to a busy session, `into_turn`), `apps/hook/server/inbox-auto-send-knob.test.ts`, the DOM test `packages/inbox/undelivered.test.tsx`, `tests/e2e/inbox-undelivered.spec.ts` (compiled binary: the badges and hover, the bands with the page's clock moved forward, going in by itself with a one-minute knob across a restart, the live timer; 1440 light and dark, a phone).
- **Send now** (`POST /api/inbox/messages/:id/send-now`, window guards; door 7.58): the store writes `deliver_now_at` on the message (`requestDeliverNow`; a second press writes nothing; `already_delivered`, `not_addressed`), after the server checked the recipient's connection polls now and said it can deliver into a running turn (`send_now_unavailable` otherwise; `recipientOf` names the session). The Inbox writes the same mark by itself (`deliver_now_by: "auto"`) for a reply, message or Re-ask note that waited 5 minutes on a busy session that can take it (see "Undelivered replies go in by themselves"); never for the note carrying a 👍, which waits for the turn to end (owner's decision 2026-10-21). The bridge command carries `now: true` only on a poll that says `send_now` (so a connection that cannot steer never skips its idle wait), and a held poll answers at once. Once a reply carries `now`, the connection delivers the waiting replies at once, OLDEST FIRST (an urgent reply never overtakes an older one to the same session): the Claude Code mod as a user row in the running turn (`$.session.append`, the one way a plugin puts words into a running turn: `$.prompt.submit` never folds into one); Pi as `sendUserMessage(..., { deliverAs: "steer" })`, delivered when Pi starts it (`message_start`); OpenCode 2 as `session.prompt({ delivery: "steer" })`, delivered only on `session.inbox.delivered` for that row id (a `session.inbox.delivery.changed` to `queue` for it counts too: OpenCode will deliver it); when the run ends first and the row is not delivered within `STEER_IDLE_GRACE_MS` (3 s after the end event), it is sent again as a queued turn ending with `inboxRepeatLine`. There is no cap while the run goes on (a tool call may take minutes, and OpenCode promotes the row at the run's next step): a steer still waiting is looked at every `STEER_WATCH_MS` (2 s), a run with no event for `RUNNING_CROSS_CHECK_MS` (20 s) is checked with `session.wait` (idle: the grace starts), and after the event stream ends the row waits until `session.wait` says idle, then counts as delivered when `session.context` holds it (by its id, or a user message with its text), else the queued turn goes. OpenCode cannot withdraw the steer row and promotes it with that turn (#1734, live on 2.0.22), so the plugin's `context` and `generate` hooks drop the later of the two copies from every model request (`dropInboxDuplicates`, the way they drop the session-URL notice; pairs kept for the plugin's life, at most 200); a later copy that is its message's whole content is never removed (the request would end on an assistant message: a wasted turn or a provider error) but replaced by `inboxRepeatPointer`, the same text in every request so the prompt prefix stays stable; idle, as a turn at once. Pi: Esc during the run puts a steer that has not started into the editor (Pi's `restoreQueuedMessagesToEditor`); when the steer's watchdog then finds the session idle without it, the extension takes the reply's exact text back out of the editor (`editorWithout`, the person's own words and other queued messages kept; an edited copy is left alone) and the reply waits for idle and arrives as a follow-up, so it is never sent twice. Pi and OpenCode never steer into this session's own Ask question either: their wake target's `canSteer` is false while it runs (Pi: `isAskActive`; OpenCode 2: `isAskTurnActive`, a per-session mark the session bridge holds while its Ask turn is in progress), so the poll and the wake's `state` events (`InboxWake` now carries `send_now` on them, as the mod does) stop offering Send now and a pressed reply waits for idle. The mod: the decision is taken right before the append, after every await of the delivery (fresh check, lease, claim), from `steerableTurn` = the running MAIN turn that is not this session's own Ask question (so an Inbox reply never streams into the Ask AI panel; while the Ask question runs or waits queued, Send now waits for idle) and not a turn another plugin's prompt started (`TurnTracker.isOtherPluginTurn`, see "Ask this session" in the mod section: that plugin may read the answer as its own; Send now and auto-send wait for idle then; engine-started turns, a task notification or a scheduled trigger, still take it, as before); a turn that ended by then means a turn instead; after the append, a turn that ended or changed means one short pointer turn at once (`inboxSentNowFollowUpText`), else the row is tied to that turn id and a turn that ends with no model request after it gets the pointer (several per turn). A refused append (or a failed steer on Pi or OpenCode) posts `send_now_failed`: the server clears `deliver_now_at` (`clearDeliverNow`), the line reads queued again, and the reply waits for idle. A connection says it can with the poll field `send_now: true` (`INBOX_SEND_NOW_POLL_FIELD`): the mod on an engine at least `SESSION_APPEND_MIN_ENGINE` (2.1.301, the first build whose published API documents `$.session.append`; decided from `$.session.version()`, because `claude plugin validate` refuses reading `$.session.append` as a value) until an engine refuses a row; Pi where the wake target has `steer`; OpenCode 2 while its event stream runs (`canSteer`, how a steer's delivery is seen). OpenCode 1 and older connections never, so Send now is never offered there. Nothing is ever aborted. Claim-once holds: the same claim folder and lease as a normal delivery; the mod refreshes `alive` for the claim it is submitting under, so another process never reports a reply this one is still delivering; a claim it won but cannot use at once (its own Ask question took the turn, a turn started right after the claim, or the append was refused) it gives back (`INBOX_RELEASE_SCRIPT`: the claim folder removed while still its own and neither delivered nor reported), and a process that lost that claim takes the reply back as pending when the folder is gone, so the process the person works in delivers it even when the lease moves while the turn runs. The mod's `send_now` (poll and `state` events) is true only while a reply could go in now, so while this session's own Ask question holds it Send now is not offered and a pressed one reads "Queued", not "Sending … now". The line under the reply also reads "Queued" for a message sent without the wake mark (the stdio shim run inside Claude Code) while its session's connection polls. Tests: `packages/server/inbox-send-now.test.ts` (routes, guards, the poll, `send_now_failed`, agent-link's steer and order), `apps/hook/hooks/mod/inbox.test.ts` 12 to 17 (the mod against a real Inbox: into the turn, the pointer turn, the turn ending before or during the append, the Ask question running or queued, oldest first), `apps/hook/hooks/mod/send-now-engine.test.ts` (the version gate), `apps/hook/tests/register.test.ts` (the engine harness: the row through `$.session.append`; an older engine never offers it), `apps/opencode-plugin/inbox-v2-wake.test.ts` (the steer confirmed by its delivery, and the fallback; a tool call longer than the old 60 s cap with no fallback; the event stream ending mid-run, then the row found in the context or the fallback once idle; the pointer for a whole-message copy), `apps/opencode-plugin/opencode-session-bridge.test.ts` (the Ask turn mark), `apps/pi-extension/inbox-send-now.test.ts` (the steer, delivered on `message_start`; no Send now during an Ask; `editorWithout`), `apps/hook/hooks/mod/inbox.test.ts` 16a (another plugin's turn: no Send now, a press refused, the reply once as a turn at idle), 16b (a claim it cannot use given back) and 16c (two processes: the lease moves while the claimant's turn runs, and the other process delivers once), `packages/inbox/deliveryLine.test.ts`, the DOM test `packages/inbox/sendNow.test.tsx`, `tests/e2e/inbox-send-now.spec.ts` (compiled binary; light, dark and a phone).
- **A session that needs a restart after an update** (owner approval "restart-warning", 2026-10-19; design record not in this repo; `packages/server/inbox-restart.ts`). The Claude Code mod reports what it loaded on every bridge poll, additive: `plugin: { version, root, loaded_at }` (plugin.json's version read once from `${$.plugin.root}/.claude-plugin/plugin.json`, the folder, and the module's load time; `readLoadedPlugin` in `register.ts`, `Host.loadedPlugin`), ALWAYS, with `version` or `root` null when it could not read them, so a poll without `plugin` only ever means a mod older than `INBOX_PLUGIN_REPORTS_LOADED_SINCE` (0.28.24, in core; this must merge before the version bump to it); `engine` (`$.session.version()`'s `base`, `Host.engineVersion`); and `session_folder` (`$.session.root()` read once when the link starts, so a later `/cd` does not move it: the folder `claude --resume` runs in; `project_path` stays the current working folder). Pi, Oh My Pi and OpenCode 2 send `plugin: { version, on_disk, host? }` (see the next item). The Inbox reads Claude Code's plugin registry (`/plugins/installed_plugins.json`, only `plannotator@plannotator`, the repo marketplace's name, so a fork is never judged against; at most every 2 s through the Inbox's agent environment) and judges each session against the record a restart would load (`chooseClaudePluginInstall`: the record whose `installPath` is the folder the session loaded, then a project- or local-scope record whose `projectPath` holds the session's folder, the deepest, then the user record). A session is outdated (`claudeSessionOutdated`) when what it loaded is not what a restart loads: the versions differ, its folder is not `installPath`, or it loaded before BOTH the record's `lastUpdated` and the install folder's last change (`pluginFolderChangedAt`: `.claude-plugin/plugin.json`'s mtime, else the folder's), an update that reused the folder and the version (`lastUpdated` alone also moves for a project-scope install of the same folder with no new code; whether Claude Code ever reinstalls into the same folder was not verified live). Every path compares by its real path (`realish`: the deepest existing part realpath'd), so a symlinked `~/.claude` or CLAUDE_CONFIG_DIR neither hides nor invents a difference. A session that sends no `plugin` (a mod older than the field) is outdated with its version unknown once the installed plugin is at least `PLUGIN_REPORTS_LOADED_SINCE` (0.28.24). A plugin loaded from outside Claude Code's plugin folder (`claude --plugin-dir`, and a local or directory marketplace, which Claude Code loads in place) reloads that folder on restart and is never outdated. When the installed plugin is itself behind this Inbox's release, a restart alone does not help: `fix: "update_plugin"` with `update_command` (the install script's two commands, `--scope user`); otherwise `fix: "restart"`. Pi and OpenCode 2: see the next item. Where it shows: (1) `asker.connection` gains additive `outdated: { loaded, installed, fix, update_command? } | null`, `engine` and `restart_command` (`cd && claude --resume `, the folder being `session_folder` (else `project_path`) with `~`, quoted when unusual; `claudeRestartCommand`); the line under a reply (`restartNote` in `deliveryLine.ts`, drawn by `RestartLine` in `components/RestartNote.tsx`, the approved "Steps") shows ONLY while that session works, has no Send now, and is outdated with a loaded version that is unknown or older than `INBOX_SEND_NOW_SINCE` (0.28.24, the first release with Send now: a session on that or newer that lacks Send now lacks it for another reason, its own Ask question or a refused row, so the line never blames the plugin), and never for an engine older than Send now's floor (`inboxEngineTooOldForSendNow`, a copy of `SESSION_APPEND_MIN_ENGINE` pinned by `send-now-engine.test.ts`; the owner chose to say nothing about the engine itself, 2026-10-19). Its shape (simplified 2026-10-21, owner: "simple buttons win"; `ReloadLead` and `Fold` in `components/RestartNote.tsx`, shared with Settings): one lead line, "Type /reload-plugins in that Claude Code to get Send now." with a Copy that copies `/reload-plugins` (the owner checked live that `/reload-plugins` loads the new plugin in place and keeps the session). With `update_plugin` the lead is two steps: the update command with Copy, then `/reload-plugins` with Copy. The manual path is folded behind "Still no Send now after reloading?" (`aria-expanded` / `aria-controls`): `/exit` in that Claude Code, then the resume command with Copy, the warning that `/exit` stops the running turn, and "Your reply waits here." `/restart` is never mentioned. Each Copy says so in a polite live region ("/reload-plugins copied.", "Restart command copied."). (2) The list model and the event stream's `status` carry additive `restart_claude_code` (live Claude Code sessions whose fix is `restart`; pushed when it changes, checked every 5 s): the sidebar's "Reload Claude Code N" (`data-restart-sidebar`, a button under the Update button / "What's new in X", above Settings; in the navigation sheet on a phone) opens Settings → Agents and goes away by itself once they reload or restart; not on a page opened over the tailnet, where Agents is local only. (3) `GET /api/inbox/agents` carries additive `sessions: { "claude-code"?, pi?, opencode? }` (`InboxHostSessions`: `running`, `outdated`, `installed`, `update_command`, `sessions: [{ session, project, folder, started_at, busy, plugin, outdated, restart_command }]`, newest first, live polling sessions only); Settings → Agents shows an "Open sessions" row per host ("3 running · 2 on plugin 0.28.21"). For Claude Code sessions that need it, a box under the row leads with the same `ReloadLead` ("Type /reload-plugins in each of these N Claude Code sessions." with Copy; with `update_plugin`, the update command first), then the sessions as a short list with no buttons (project, "since ", idle or working; two in one folder are told apart by start time), then "Still listed after reloading?" folded, which opens `/exit` and a Copy restart per session (named "Copy restart: the command that restarts the session started "). Pi and OpenCode: see the next item. The head reads "Connected · 2 need a reload", the Agents nav gets the warning dot, and Settings → Agents re-reads every 5 s while open. Only sessions that poll are seen (the Inbox tool on; one process of a two-process session). No keyboard shortcut. Tests: `packages/server/inbox-restart.test.ts` (every rule, the registry's shapes, the command, and over a real Inbox with a scratch `CLAUDE_CONFIG_DIR`), `apps/hook/hooks/mod/inbox-loaded.test.ts` (the poll fields over the in-memory Host), `packages/inbox/deliveryLine.test.ts`, the DOM test `packages/inbox/restartWarning.test.tsx` (the whole window over `tests/helpers/inbox-dom-server.ts` `restart`).
diff --git a/packages/core/inbox-delivery-line.ts b/packages/core/inbox-delivery-line.ts
new file mode 100644
index 000000000..39d746a2a
--- /dev/null
+++ b/packages/core/inbox-delivery-line.ts
@@ -0,0 +1,339 @@
+/**
+ * The line under the person's last reply or message: where it is now.
+ *
+ * The owner's request (2026-10-16): "The message should be less deceiving.
+ * It should say this message is queued up or something. And then maybe an
+ * option just to send it immediately." A reply to a session whose connection
+ * wakes it (the Claude Code mod, Pi, OpenCode 2) is delivered as a turn by
+ * itself once the session is idle, so the line says what it waits for, from
+ * what the Inbox knows about that session's connection right now (its poll's
+ * `busy`), and offers Send now while the session works and its connection
+ * can put the reply into the running turn (`send_now`). A reply to an agent
+ * that only reads the thread (MCP, OpenCode 1) keeps the truthful "it sees
+ * it when it next reads this thread". Pure: Thread.tsx draws it.
+ */
+
+import {
+ inboxAutoSendInMinutes,
+ inboxDeliveryState,
+ wholeMinutes,
+ type InboxDeliverNowBy,
+ inboxEngineTooOldForSendNow,
+ inboxLoadedPredatesSendNow,
+ type InboxDeliveryReason,
+ type InboxDeliveryState,
+ type InboxReloadRuntime,
+ type InboxSessionOutdated,
+} from './inbox-types';
+
+/** How the session the message goes to is connected now (the sessions route's `asker.connection`). */
+export interface RecipientConnection {
+ busy: boolean | null;
+ idle_since: string | null;
+ send_now: boolean;
+ /** It runs an older Plannotator plugin than a restart loads (packages/server/inbox-restart.ts). Absent from an older Inbox. */
+ outdated?: InboxSessionOutdated | null;
+ /** Claude Code's version as its poll said (absent or null: unknown). */
+ engine?: string | null;
+ /** `cd && claude --resume `, when the Inbox knows the folder. Absent from an older Inbox. */
+ restart_command?: string | null;
+ /** How long a reply waits on this busy session before it goes in by itself. Absent from an older Inbox (never). */
+ auto_send_after_ms?: number;
+ /**
+ * The server's clocks for the items waiting on this session, by id: since
+ * when each waits on the busy session, and since when it may go in by
+ * itself (null: not yet). Absent from an older Inbox.
+ */
+ waits?: Record;
+ /**
+ * Items that may not have reached this session (a Codex thread whose
+ * `codex queue` call ran out of time): their line says saved, not queued.
+ * Absent from an older Inbox and when none.
+ */
+ uncertain?: string[];
+}
+
+/** Whether an item may not have reached the session it was handed to (`connection.uncertain`). */
+export function uncertainItem(connection: RecipientConnection | null | undefined, itemId: string): boolean {
+ return connection?.uncertain?.includes(itemId) === true;
+}
+
+/** The wait inputs of `deliveryLine` for one item: the server's clock while it waits, its own record after the mark. */
+export function waitInputs(
+ connection: RecipientConnection | null | undefined,
+ itemId: string,
+ item: { wait_started_at?: string | null; deliver_now_by?: InboxDeliverNowBy | null; deliver_now_at?: string | null; into_turn?: boolean | null },
+): Pick {
+ const clock = connection?.waits?.[itemId];
+ return {
+ waitingSince: clock?.since ?? item.wait_started_at ?? null,
+ autoSince: clock?.auto_since ?? null,
+ nowBy: item.deliver_now_by ?? null,
+ nowAt: item.deliver_now_at ?? null,
+ intoTurn: item.into_turn ?? null,
+ };
+}
+
+/**
+ * Why Send now is not offered for a busy session, and what brings it back
+ * (owner approval "restart-warning", 2026-10-19, the "Steps" line): the
+ * session runs an older plugin than a restart loads (`restart`), or the
+ * installed plugin is itself behind this Inbox (`update_plugin`).
+ */
+export interface RestartNote {
+ fix: InboxSessionOutdated['fix'];
+ /** The command that brings the session back as itself (Claude Code); null when the Inbox does not know its folder. */
+ command: string | null;
+ /** The plugin update command (`update_plugin`, `update_extension`). */
+ updateCommand: string | null;
+ /**
+ * The runtime and its reload (owner approval "reload-hosts"): Claude Code
+ * `/reload-plugins`, Pi `/reload`, Oh My Pi `/restart` (typed in the
+ * session), OpenCode 2 `opencode2 reload` or `opencode2 plugin update` (run
+ * in a shell), with OpenCode's fallback `opencode2 service restart`.
+ */
+ runtime: InboxReloadRuntime;
+ reloadCommand: string;
+ reloadWhere: 'session' | 'shell';
+ fallbackCommand: string | null;
+ /** OpenCode 2: the config pins this exact spec; it changes to `pinTo`, then `opencode2 reload`. */
+ pinnedSpec: string | null;
+ pinTo: string | null;
+ /** OpenCode 2's `opencode2 plugin update` with no target: it updates every outdated plugin. */
+ updatesAll: boolean;
+}
+
+/** Claude Code's reload (the mod reloads with the plugins). */
+export const CLAUDE_RELOAD_COMMAND = '/reload-plugins';
+
+/**
+ * The restart note for a session that works now and has no Send now
+ * because it runs an older plugin. Only then: an idle session gets the reply
+ * at once anyway, and a session with Send now is not missing anything. An
+ * engine too old for Send now stays quiet (a restart would not bring it; the
+ * owner chose to say nothing about that, 2026-10-19).
+ */
+export function restartNote(connection: RecipientConnection | null | undefined): RestartNote | null {
+ const outdated = connection?.outdated;
+ if (!connection || !outdated || connection.busy !== true || connection.send_now) return null;
+ if (inboxEngineTooOldForSendNow(connection.engine)) return null;
+ // A session that loaded a plugin with Send now lacks it for another reason (its own Ask question holds the
+ // turn, the engine refused a row): a restart would not change that, so the line never blames the plugin.
+ if (!inboxLoadedPredatesSendNow(outdated.loaded)) return null;
+ const command = connection.restart_command ?? null;
+ const runtime = outdated.runtime ?? 'claude-code';
+ const reload = {
+ runtime,
+ reloadCommand: outdated.reload_command ?? CLAUDE_RELOAD_COMMAND,
+ reloadWhere: outdated.reload_where ?? 'session',
+ fallbackCommand: outdated.fallback_command ?? null,
+ pinnedSpec: outdated.pinned_spec ?? null,
+ pinTo: outdated.pin_to ?? null,
+ updatesAll: outdated.update_scope === 'all',
+ } as const;
+ if (runtime === 'claude-code') {
+ if (outdated.fix === 'update_plugin') return { fix: outdated.fix, command, updateCommand: outdated.update_command ?? null, ...reload };
+ if (outdated.fix !== 'restart') return null;
+ return { fix: outdated.fix, command, updateCommand: null, ...reload };
+ }
+ // Pi, Oh My Pi, OpenCode 2: a reload alone, or the update first.
+ if (outdated.fix === 'reload') return { fix: outdated.fix, command: null, updateCommand: null, ...reload };
+ if (outdated.fix === 'update_extension' && (outdated.update_command || outdated.pinned_spec)) {
+ return { fix: outdated.fix, command: null, updateCommand: outdated.update_command ?? null, ...reload };
+ }
+ return null;
+}
+
+export type DeliveryLineState = InboxDeliveryState;
+
+export interface DeliveryLine {
+ state: DeliveryLineState;
+ text: string;
+ /** The asking session ended: a clock, not a check. */
+ ended?: boolean;
+ /** Offer Send now: the session works and its connection can take the reply mid-turn. */
+ sendNow?: boolean;
+ /**
+ * It waits on a busy session (approval "inbox-busy-agent-reply"): since
+ * when, whether it can go in now (`steerable`), and when it goes in by
+ * itself (null: it will not). The band under the reply draws it with its
+ * own minute clock (`undeliveredWords`).
+ */
+ wait?: { since: string; steerable: boolean; autoSendAt: string | null };
+ /** It went into the turn by itself after this many minutes ("went into its turn by itself after 5 min"). */
+ autoAfterMin?: number;
+ /** Marked to go in by itself, but it went in when the turn ended (the session's own Ask question held it). */
+ turnEnded?: boolean;
+}
+
+/** The parts of the "Undelivered" band at `now`: "Undelivered 3 min", and what happens next. */
+export interface UndeliveredWords {
+ /** "Undelivered 3 min" (whole minutes; "Undelivered" under one). */
+ label: string;
+ /** "goes in by itself in 2 min" / "in under a minute", or "it gets this when its turn ends". */
+ next: string;
+ /** It goes in by itself (the countdown shows). */
+ auto: boolean;
+}
+
+/** "Undelivered 3 min": whole minutes, never seconds. */
+export function undeliveredLabel(since: string, now: number): string {
+ const minutes = wholeMinutes(since, now);
+ return minutes < 1 ? 'Undelivered' : `Undelivered ${minutes} min`;
+}
+
+export function undeliveredWords(wait: NonNullable, now: number): UndeliveredWords {
+ const label = undeliveredLabel(wait.since, now);
+ if (wait.autoSendAt) {
+ const left = inboxAutoSendInMinutes(wait.autoSendAt, now);
+ const next = left === null ? 'goes in by itself now' : left === 0 ? 'goes in by itself in under a minute' : `goes in by itself in ${left} min`;
+ return { label, next, auto: true };
+ }
+ return { label, next: 'it gets this when its turn ends', auto: false };
+}
+
+export interface DeliveryLineInput {
+ /** "Claude Code", "Pi (another session)". */
+ name: string;
+ /** Delivered (or read by the agent) at; null while it waits. */
+ deliveredAt: string | null;
+ /** Formats a time for the line ("5:12 PM"). */
+ time: (iso: string) => string;
+ /** The session it goes to is woken by its connection (a waking agent message, or a message addressed to a live session). */
+ wakes: boolean;
+ /** The person pressed Send now (`deliver_now_at`). */
+ sendingNow: boolean;
+ /**
+ * That session's connection as the Inbox knows it now: undefined while not
+ * read yet or from an older Inbox (no `connection`), null when it does not
+ * poll now.
+ */
+ connection: RecipientConnection | null | undefined;
+ /** The asking session ended (a reply), with the asker's name for the line. */
+ askerEnded?: { name: string } | null;
+ /** A message addressed to a session that stopped before it took it (compose). */
+ undelivered?: { host: string } | null;
+ /** When it began to wait on the busy session (the server's clock): the line says how long. */
+ waitingSince?: string | null;
+ /** When the clock toward going in by itself started (the server's); null: it does not go in by itself yet. */
+ autoSince?: string | null;
+ /** Delivered after the Inbox's own mark: whether it went into the running turn (`delivery.into_turn`). */
+ intoTurn?: boolean | null;
+ /** Override the connection's wait before it goes in by itself (null: never, e.g. a 👍 note while they wait for the turn). */
+ autoSendAfterMs?: number | null;
+ /** Who asked for it to go now (`deliver_now_by`), and when (`deliver_now_at`). */
+ nowBy?: InboxDeliverNowBy | null;
+ nowAt?: string | null;
+ /** `now`: the clock the words are written at (default `Date.now()`). */
+ now?: number;
+}
+
+export function deliveryLine(input: DeliveryLineInput): DeliveryLine {
+ const { name } = input;
+ // The state is the shared rule's (packages/core/inbox-delivery.ts, through @plannotator/core/inbox-types), which also decides the list row's glyph.
+ const decision = inboxDeliveryState({
+ delivered: !!input.deliveredAt,
+ undelivered: !!input.undelivered,
+ askerEnded: !!input.askerEnded,
+ wakes: input.wakes,
+ sendingNow: input.sendingNow,
+ connection: input.connection,
+ waitingSince: input.waitingSince ?? null,
+ autoSince: input.autoSince ?? null,
+ intoTurn: input.intoTurn ?? null,
+ nowBy: input.nowBy ?? null,
+ nowAt: input.nowAt ?? null,
+ autoSendAfterMs: input.autoSendAfterMs !== undefined ? input.autoSendAfterMs : (input.connection?.auto_send_after_ms ?? null),
+ });
+ const text = deliveryText(decision.reason, {
+ name,
+ at: input.deliveredAt,
+ time: input.time,
+ undeliveredHost: input.undelivered?.host ?? name,
+ endedName: input.askerEnded?.name ?? name,
+ wait: decision.wait,
+ autoAfterMin: decision.autoAfterMin,
+ turnEnded: decision.turnEnded,
+ now: input.now,
+ });
+ return {
+ state: decision.state,
+ text,
+ ...(decision.reason === 'ended' ? { ended: true } : {}),
+ ...(decision.reason === 'working' ? { sendNow: decision.sendNow } : {}),
+ ...(decision.wait ? { wait: decision.wait } : {}),
+ ...(decision.autoAfterMin !== undefined && decision.reason === 'delivered' ? { autoAfterMin: decision.autoAfterMin } : {}),
+ ...(decision.turnEnded ? { turnEnded: true } : {}),
+ };
+}
+
+/** The words for each case of the rule: the thread's line, and the list row's tooltip. */
+export function deliveryText(
+ reason: InboxDeliveryReason,
+ words: {
+ name: string;
+ at: string | null;
+ time: (iso: string) => string;
+ undeliveredHost: string;
+ endedName: string;
+ /** It waits on a busy session (approval "inbox-busy-agent-reply"): "Undelivered 3 min · …". */
+ wait?: DeliveryLine['wait'];
+ /** It went into the turn by itself after this many minutes. */
+ autoAfterMin?: number;
+ /** Marked to go in by itself, it went in when the turn ended instead. */
+ turnEnded?: boolean;
+ now?: number;
+ },
+): string {
+ const { name } = words;
+ switch (reason) {
+ case 'delivered': {
+ const base = words.at ? `Delivered to ${name}, ${words.time(words.at)}` : `Delivered to ${name}`;
+ if (words.autoAfterMin !== undefined) return `${base} · ${autoAfterText(words.autoAfterMin)}`;
+ return words.turnEnded ? `${base} · ${TURN_ENDED_TEXT}` : base;
+ }
+ case 'undelivered':
+ return `Not delivered. That ${words.undeliveredHost} session stopped before it took this. If it is resumed, it still gets it.`;
+ case 'ended':
+ return `Saved for ${words.endedName}. The session that asked has ended, so it waits here until an agent reads this thread.`;
+ case 'reads':
+ return `Saved for ${name}. It sees it when it next reads this thread.`;
+ case 'sending_now':
+ return `Sending to ${name} now…`;
+ case 'working': {
+ if (!words.wait) return `Queued · ${name} is working; it gets this when its turn ends.`;
+ const parts = undeliveredWords(words.wait, words.now ?? Date.now());
+ return `${parts.label} · ${name} is working · ${parts.next}`;
+ }
+ case 'idle':
+ return `Sending to ${name}…`;
+ case 'away':
+ return `Queued for ${name}; it gets this when its session is back.`;
+ case 'next_turn':
+ return `Queued for ${name}; it gets this as its next turn.`;
+ case 'ack_pending':
+ return `Goes to ${name} in a few seconds`;
+ }
+}
+
+/** The Sent row's line in the list, which knows no connection: queued for a waking agent, saved otherwise. */
+export function sentRowLine(name: string, wakes: boolean | undefined): string {
+ return wakes === true ? `Queued for ${name}; it gets this as its next turn.` : `Saved for ${name}. It sees it when it next reads this thread.`;
+}
+
+/**
+ * A Sent row whose last message is the person's Re-ask note (approval
+ * "inbox-reask"): it stays Sent until the agent asks again, read or not.
+ */
+export function reaskRowLine(name: string, checkedAt: string | null, wakes: boolean | undefined): string {
+ if (checkedAt) return `Asked ${name} to re-ask; waiting for its message.`;
+ return wakes === true ? `Re-ask queued for ${name}; it gets this as its next turn.` : `Re-ask saved for ${name}. It sees it when it next reads this thread.`;
+}
+
+/** An item the Inbox marked to go in by itself that waited for the turn to end (its own Ask question held it). */
+export const TURN_ENDED_TEXT = 'went in when its turn ended';
+
+/** "went into its turn by itself after 5 min". */
+export function autoAfterText(minutes: number): string {
+ return `went into its turn by itself after ${minutes} min`;
+}
diff --git a/packages/core/inbox-folding.ts b/packages/core/inbox-folding.ts
new file mode 100644
index 000000000..5a8018109
--- /dev/null
+++ b/packages/core/inbox-folding.ts
@@ -0,0 +1,346 @@
+/**
+ * Folding a thread (design record, not in this repo; variant "Land"): which
+ * messages open and which fold to one line when a thread opens, which read
+ * messages hide in the middle of a long run, what a folded line says, and
+ * which messages are new since the person's last look. Pure: the thread pane
+ * (components/Thread.tsx) draws what these decide.
+ */
+
+import { isInboxActionReport, isInboxNote, type InboxMessageWire, type InboxQuestion, type InboxThread } from './inbox-types';
+import { QUESTION_ANSWERS_HEADING, type QuestionAnswer } from './question-block';
+import { INBOX_STILL_OPEN_LINE, sliceText } from './inbox-questions';
+import { withoutInboxSkillsBlock } from './skill-reference-block';
+
+/**
+ * Where "new since you looked" starts: the seq of the person's last look at
+ * the thread, or of their own last reply (a reply is a look at everything
+ * before it), whichever is later. The Inbox's own notes (Mark read's
+ * set-aside note, a late Undo's reopened note) are not a reply and not a
+ * look. Read from the thread as it was BEFORE the window records the next
+ * look, and kept until the person leaves the thread. Null when the payload
+ * carries no seqs (an older Inbox): nothing is new then.
+ */
+export function lookBoundary(thread: InboxThread): number | null {
+ if (thread.messages.length === 0 || thread.messages.some((m) => typeof m.seq !== 'number')) return null;
+ let seen = thread.messages[0]!.person_seen_seq ?? 0;
+ for (const message of thread.messages) if (isPersonLook(message)) seen = Math.max(seen, message.seq!);
+ return seen;
+}
+
+/** A thread's last message with the Inbox's own notes read past (the one a reply's line sits on). */
+export function lastNonNote(messages: readonly InboxMessageWire[]): InboxMessageWire | undefined {
+ for (let i = messages.length - 1; i >= 0; i--) if (!isInboxNote(messages[i]!)) return messages[i];
+ return undefined;
+}
+
+/** The person's own message (a reply or a New message), never one of the Inbox's notes. */
+export function isPersonReply(message: InboxMessageWire): boolean {
+ return message.author.kind === 'person' && !isInboxNote(message);
+}
+
+/**
+ * The person's own message that counts as a look: a reply or a New message,
+ * never the Inbox's notes nor an action item's report (the Inbox writes it seconds
+ * after the click, so agent news that landed meanwhile stays new).
+ */
+export function isPersonLook(message: InboxMessageWire): boolean {
+ return isPersonReply(message) && !isInboxActionReport(message);
+}
+
+/** The agent messages after the boundary: new since the person's last look. */
+export function newSinceLook(messages: readonly InboxMessageWire[], boundary: number | null): Set {
+ const out = new Set();
+ if (boundary === null) return out;
+ for (const message of messages) {
+ if (message.author.kind === 'agent' && typeof message.seq === 'number' && message.seq > boundary) out.add(message.id);
+ }
+ return out;
+}
+
+/** A question that still asks something: not replaced, not orphaned. */
+const live = (q: InboxQuestion) => q.state !== 'superseded' && !q.orphaned;
+
+/** The message holds a pick the person has not sent. */
+export function holdsUnsentPick(message: InboxMessageWire): boolean {
+ return (message.questions ?? []).some((q) => q.state === 'picked');
+}
+
+export interface FoldPlan {
+ /** The messages that open folded to one line. */
+ folded: Set;
+ /**
+ * The middle of each long run of read, folded messages, hidden behind
+ * "N earlier messages": a run of 4 or more keeps its first and its last
+ * two. Decided once when the thread opens.
+ */
+ hidden: string[][];
+}
+
+/**
+ * How a thread opens: a message is shown in full when it is new since the
+ * last look, holds a pick not yet sent, or is the last message; every other
+ * message folds. A one-message thread folds nothing (it looks as it always
+ * did), and so does a thread whose seqs are unknown (an older Inbox: the
+ * window cannot tell what was read).
+ */
+export function planFolds(messages: readonly InboxMessageWire[], fresh: ReadonlySet, boundary: number | null): FoldPlan {
+ const folded = new Set();
+ const hidden: string[][] = [];
+ if (messages.length < 2 || boundary === null) return { folded, hidden };
+ const last = messages[messages.length - 1]!;
+ // The last real message stays open too when the Inbox's notes trail it: it
+ // carries the reply's "Saved for / Delivered to" line.
+ const lastReal = lastNonNote(messages);
+ let run: string[] = [];
+ const flush = () => {
+ if (run.length >= 4) hidden.push(run.slice(1, run.length - 2));
+ run = [];
+ };
+ for (const message of messages) {
+ const open = fresh.has(message.id) || holdsUnsentPick(message) || message === last || message === lastReal;
+ if (open) {
+ flush();
+ continue;
+ }
+ folded.add(message.id);
+ run.push(message.id);
+ }
+ flush();
+ return { folded, hidden };
+}
+
+/** The words an answer reads as: "Pagefind", "Guides, API reference", "skipped". */
+export function answerWords(answer: QuestionAnswer): string {
+ if (answer.skipped) return 'skipped';
+ const parts = [...answer.selected];
+ if (answer.other) parts.push(answer.other);
+ if (answer.text) parts.push(answer.text);
+ return parts.join(', ');
+}
+
+/** One answered question on a folded line: its prompt, then the answer. */
+export interface AnsweredPart {
+ /** The prompt as the line shows it: plain, cut at a word near 40 characters. */
+ prompt: string;
+ /** The whole prompt, plain: the line's title and its screen-reader label. */
+ full: string;
+ answer: string;
+}
+
+/**
+ * A folded line's snippet: the person's answers, or first words (see
+ * `foldSnippet`).
+ */
+export type FoldSnippet = { kind: 'answers'; parts: AnsweredPart[] } | { kind: 'words'; text: string };
+
+/** How long a prompt may run on a folded line before it is cut, so the answer after it stays in view. */
+export const SNIPPET_PROMPT_MAX = 40;
+
+/** A prompt without its inline markdown and its closing punctuation. */
+function plainPrompt(prompt: string): string {
+ return cleanInline(prompt).replace(/\s+/g, ' ').trim().replace(/[?.!:]+$/, '');
+}
+
+/** Cut at a word boundary near `max` characters, with "…". */
+export function cutAtWord(text: string, max: number): string {
+ if (text.length <= max) return text;
+ // One character more, so a word that ends exactly at `max` is kept whole.
+ const space = text.slice(0, max + 1).lastIndexOf(' ');
+ return `${(space > max * 0.5 ? text.slice(0, space) : sliceText(text, max)).replace(/[\s,;:.]+$/, '')}…`;
+}
+
+function answeredParts(questions: readonly InboxQuestion[]): AnsweredPart[] {
+ return questions.map((q) => {
+ const full = plainPrompt(q.prompt);
+ return { prompt: cutAtWord(full, SNIPPET_PROMPT_MAX), full, answer: answerWords(q.answer!) };
+ });
+}
+
+const ANSWERS_HEADING_LINE = new RegExp(`^#{1,6}\\s+${QUESTION_ANSWERS_HEADING}\\s*$`);
+
+/**
+ * A person's reply without what the store wrote around the person's words
+ * (core's `composeInboxReplyBody`): the leading "Answered N questions." / "Skipped N questions." /
+ * "Answered N questions, skipped M." and
+ * everything from the "Answers to your questions" heading on (the answers,
+ * then the annotations' feedback), or, on a reply that answered nothing,
+ * from the "No questions answered yet; …" line on, and the skills block the
+ * window pasted at the end (a human-only skill's SKILL.md text).
+ */
+export function replyWords(body: string): string {
+ const lines = withoutInboxSkillsBlock(body).split(/\r?\n/);
+ const at = lines.findIndex((line) => ANSWERS_HEADING_LINE.test(line.trim()) || INBOX_STILL_OPEN_LINE.test(line.trim()));
+ const kept = (at >= 0 ? lines.slice(0, at) : lines).join('\n');
+ return kept.replace(/^\s*(?:Answered \d+ questions?(?:, skipped \d+)?|Skipped \d+ questions?)\.\s*/, '');
+}
+
+/**
+ * A folded line's snippet. An agent message whose live questions were all
+ * sent shows the answers: what the person decided is the reason to look
+ * back. Otherwise its first words; a message that is only a question block,
+ * its first live question's prompt. A person's reply shows its own words;
+ * with none, the answers it sent (from `messages`, the thread); else the
+ * names of the files it carries; else its first words.
+ */
+export function foldSnippet(message: InboxMessageWire, messages: readonly InboxMessageWire[] = [message]): FoldSnippet {
+ if (message.author.kind === 'person') {
+ // A Re-ask note: what the person did, not the instructions the agent got.
+ const reasked = message.reask?.length ?? 0;
+ if (reasked > 0) {
+ // Action items it asked about too (ids `/a-…`, owner, #2059).
+ const actions = (message.reask ?? []).filter((id) => /\/a-[0-9a-f]/.test(id)).length;
+ if (actions > 0) {
+ const questions = reasked - actions;
+ const what =
+ questions === 0
+ ? actions === 1
+ ? 'the open action item'
+ : `${actions} open action items`
+ : `${questions} open question${questions === 1 ? '' : 's'} and ${actions} action item${actions === 1 ? '' : 's'}`;
+ return { kind: 'words', text: `Asked to re-ask ${what}${reasked > 1 ? ' in one message' : ''}` };
+ }
+ return { kind: 'words', text: reasked === 1 ? 'Asked to re-ask the open question' : `Asked to re-ask ${reasked} open questions in one message` };
+ }
+ const words = firstWords(replyWords(message.body));
+ if (words) return { kind: 'words', text: words };
+ const sent = messages.flatMap((m) => (m.questions ?? []).filter((q) => q.sent_reply_id === message.id && q.answer !== null));
+ if (sent.length > 0) return { kind: 'answers', parts: answeredParts(sent) };
+ // Files pasted in with no words: name them, not the still-open line the agent reads.
+ const files = message.attachments ?? [];
+ if (files.length > 0) return { kind: 'words', text: files.map((file) => file.name).join(', ') };
+ return { kind: 'words', text: firstWords(withoutInboxSkillsBlock(message.body)) };
+ }
+ const questions = (message.questions ?? []).filter(live);
+ if (questions.length > 0 && questions.every((q) => q.state === 'sent' && q.answer !== null)) {
+ return { kind: 'answers', parts: answeredParts(questions) };
+ }
+ const words = firstWords(message.body);
+ if (!words && questions.length > 0) return { kind: 'words', text: plainPrompt(questions[0]!.prompt) };
+ // A message that is only an action item: its line ("Action item: Approve the deploy").
+ const todo = (message.actions ?? []).find((a) => a.state !== 'superseded');
+ if (!words && todo) return { kind: 'words', text: `Action item: ${plainPrompt(todo.prompt)}` };
+ return { kind: 'words', text: words };
+}
+
+/** The snippet as one line of text, whole: a line's title and its screen-reader label. */
+export function snippetText(snippet: FoldSnippet): string {
+ return snippet.kind === 'words' ? snippet.text : snippet.parts.map((p) => `${p.full}: ${p.answer}`).join('; ');
+}
+
+/**
+ * Inline markdown as plain text: images out, link and wiki-link labels kept,
+ * tags out, and paired emphasis marks (`**x**`, `__x__`, `~~x~~`, `*x*`,
+ * `_x_`) dropped only where they open and close at word boundaries, so
+ * `snake_case` and `2 * 3` keep their characters. Inline code keeps its text
+ * (only the backticks go).
+ */
+export function cleanInline(text: string): string {
+ const code: string[] = [];
+ let out = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (_m, _ticks: string, inner: string) => {
+ code.push(inner.trim());
+ return `\u0000${code.length - 1}\u0000`;
+ });
+ out = out
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/\[\[([^\]|]*)(?:\|([^\]]*))?\]\]/g, (_m, target: string, label?: string) => label ?? target)
+ .replace(/<\/?[A-Za-z][^>]*>/g, '');
+ // A closing mark ends a word: whitespace, the end, or punctuation that does
+ // not run into another word (`__init__.py` is a name, not emphasis).
+ const close = `(?=$|\\s|[,;:!?)\\]"'’]|\\.(?!\\w))`;
+ for (const mark of ['\\*\\*', '__', '~~', '\\*', '_']) {
+ const pair = new RegExp(`(^|[^\\w*~])${mark}(?=\\S)([\\s\\S]*?\\S)${mark}${close}`, 'g');
+ out = out.replace(pair, '$1$2');
+ }
+ return out.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)] ?? '');
+}
+
+/**
+ * The first words of a message's markdown as plain text: question blocks,
+ * fences, tables, images and raw HTML dropped, link and emphasis marks
+ * removed, whitespace collapsed, cut at a word near `max` characters.
+ */
+export function firstWords(markdown: string, max = 240): string {
+ const lines: string[] = [];
+ let fence: string | null = null;
+ let directive = 0;
+ for (const raw of markdown.split(/\r?\n/)) {
+ const line = raw.trim();
+ if (fence !== null) {
+ if (line.startsWith(fence)) fence = null;
+ continue;
+ }
+ const open = /^(`{3,}|~{3,})/.exec(line);
+ if (open) {
+ fence = open[1]!;
+ continue;
+ }
+ if (/^:{3,}\s*[A-Za-z]/.test(line)) {
+ directive += 1;
+ continue;
+ }
+ if (/^:{3,}\s*$/.test(line)) {
+ if (directive > 0) directive -= 1;
+ continue;
+ }
+ if (directive > 0) continue;
+ if (line.startsWith('|') || /^[-*_]{3,}$/.test(line) || /^<\/?[A-Za-z][^>]*>$/.test(line)) continue;
+ // Block marks: a heading's hashes, a list or task marker, a quote.
+ lines.push(line.replace(/^#{1,6}\s+/, '').replace(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/, '').replace(/^>\s?/, ''));
+ }
+ const text = cleanInline(lines.join(' ')).replace(/\s+/g, ' ').trim();
+ return cutAtWord(text, max);
+}
+
+/** One thing a message holds, for its folded line. */
+export interface Holding {
+ kind: 'picks' | 'open' | 'todo' | 'answered' | 'files' | 'annotations' | 'guide' | 'delivered';
+ /** What the line shows. */
+ text: string;
+ /** What a screen reader hears. */
+ say: string;
+ count?: number;
+}
+
+const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
+
+/**
+ * What a message holds: an agent message's questions (open, picked and not
+ * sent, or answered), its files and guided review; a person's reply's sent
+ * annotations and whether it was delivered. Open and unsent states are words,
+ * never colour alone.
+ */
+export function holdingsOf(message: InboxMessageWire, options: { delivered: boolean; picked?: (q: InboxQuestion) => boolean }): Holding[] {
+ const out: Holding[] = [];
+ const questions = (message.questions ?? []).filter(live);
+ const isPicked = options.picked ?? ((q: InboxQuestion) => q.state === 'picked');
+ const picks = questions.filter((q) => q.state !== 'sent' && isPicked(q)).length;
+ const open = questions.filter((q) => q.state !== 'sent' && !isPicked(q)).length;
+ const sent = questions.filter((q) => q.state === 'sent').length;
+ if (picks > 0) out.push({ kind: 'picks', text: `${plural(picks, 'pick')} in your draft`, say: `${plural(picks, 'pick')} in your draft`, count: picks });
+ if (open > 0) out.push({ kind: 'open', text: `${open} open ${open === 1 ? 'question' : 'questions'}`, say: `${open} open ${open === 1 ? 'question' : 'questions'}`, count: open });
+ if (sent > 0 && open === 0 && picks === 0) out.push({ kind: 'answered', text: `${sent} answered`, say: `${plural(sent, 'question')} answered`, count: sent });
+ // Action items (approval "inbox-next-actions"): the ones still waiting on the person.
+ const todos = (message.actions ?? []).filter((a) => a.state === 'open').length;
+ if (todos > 0) out.push({ kind: 'todo', text: `${todos} action item${todos === 1 ? '' : 's'}`, say: `${todos} open action item${todos === 1 ? '' : 's'}`, count: todos });
+ const files = message.attachments?.length ?? 0;
+ if (files > 0) out.push({ kind: 'files', text: String(files), say: plural(files, 'attachment'), count: files });
+ if (message.author.kind === 'person' && (message.sent_annotations ?? 0) > 0) {
+ const n = message.sent_annotations!;
+ out.push({ kind: 'annotations', text: String(n), say: `${plural(n, 'annotation')} sent`, count: n });
+ }
+ if (message.guide) out.push({ kind: 'guide', text: 'Guided review', say: 'a guided review' });
+ if (options.delivered) out.push({ kind: 'delivered', text: 'Delivered', say: 'delivered' });
+ return out;
+}
+
+/**
+ * A person's message reached the agent: a connection delivered it as a turn,
+ * or (a reply, not a New message) the asking agent read it.
+ */
+export function isDelivered(message: InboxMessageWire, root: InboxMessageWire): boolean {
+ if (message.author.kind !== 'person') return false;
+ if (message.delivery) return true;
+ if (message.to) return false;
+ return typeof message.seq === 'number' && (root.agent_checked_seq ?? 0) >= message.seq;
+}
diff --git a/packages/core/inbox-format.ts b/packages/core/inbox-format.ts
new file mode 100644
index 000000000..088a31478
--- /dev/null
+++ b/packages/core/inbox-format.ts
@@ -0,0 +1,118 @@
+/** Small display helpers for the Inbox window. Pure. */
+
+import { inboxAgentName } from './inbox-types';
+
+/** How the person sees an agent: the name it gave, else its host's name, else "An agent" (core's rule, shared with the server's drafts). */
+export const agentName = inboxAgentName;
+
+const TIME = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
+const DAY = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' });
+const DAY_YEAR = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+const FULL = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
+const COUNT = new Intl.NumberFormat('en-US');
+
+const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
+
+/** "Oct 3" this year, "Sep 2, 2025" another year: a day that never reads as this year's when it is not. */
+function dayOf(date: Date, now: Date): string {
+ return date.getFullYear() === now.getFullYear() ? DAY.format(date) : DAY_YEAR.format(date);
+}
+
+/** "10:42 AM" today, "Oct 3" before, "Sep 2, 2025" in another year. */
+export function shortTime(iso: string, now: Date = new Date()): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return '';
+ return sameDay(date, now) ? TIME.format(date) : dayOf(date, now);
+}
+
+/**
+ * When something happened, as a line in a thread says it: "8:22 PM" today,
+ * "Oct 10, 8:22 PM" another day, "Sep 2, 2025, 8:22 PM" another year. A
+ * yesterday's message and today's reply never both read "8:22 PM".
+ */
+export function whenTime(iso: string, now: Date = new Date()): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return '';
+ return sameDay(date, now) ? TIME.format(date) : `${dayOf(date, now)}, ${TIME.format(date)}`;
+}
+
+/** "Friday, October 10, 2026 at 8:22 PM": the whole moment, for a time's hover text. */
+export function fullTime(iso: string): string {
+ const date = new Date(iso);
+ return Number.isNaN(date.getTime()) ? '' : FULL.format(date);
+}
+
+/** "1,284": a count with its thousands separated. */
+export function formatCount(n: number): string {
+ return COUNT.format(n);
+}
+
+/** "today", "yesterday", else "Oct 3": a day in a sentence ("Asked by Claude Code, today"). */
+export function dayWords(iso: string, now: Date = new Date()): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return '';
+ const start = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
+ const days = Math.round((start(now) - start(date)) / 86_400_000);
+ if (days === 0) return 'today';
+ if (days === 1) return 'yesterday';
+ return dayOf(date, now);
+}
+
+/** "Oct 3, 10:42 AM": a day and a time ("Sep 2, 2025, 10:42 AM" in another year). */
+export function dateTime(iso: string, now: Date = new Date()): string {
+ const date = new Date(iso);
+ return Number.isNaN(date.getTime()) ? '' : `${dayOf(date, now)}, ${TIME.format(date)}`;
+}
+
+/** Always the clock time ("10:42 AM"), for lines that say when within a thread. */
+export function clockTime(iso: string): string {
+ const date = new Date(iso);
+ return Number.isNaN(date.getTime()) ? '' : TIME.format(date);
+}
+
+/** "2 questions" from two up (the count in plain words); nothing for one or none. */
+export function questionCount(open: number): string {
+ return open >= 2 ? `${formatCount(open)} questions` : '';
+}
+
+/**
+ * A row's count of what waits on the person (approval "inbox-next-actions"):
+ * "2 questions" as before, and an action item always named ("1 action item", "1 question,
+ * 2 action items"), since it says why the agent waits before the thread is opened.
+ * `open` counts questions and action items together, as the list's summary does.
+ */
+export function waitCount(open: number, actions = 0): string {
+ const todos = Math.min(Math.max(actions, 0), open);
+ const questions = open - todos;
+ if (todos === 0) return questionCount(questions);
+ const todoWords = `${formatCount(todos)} action item${todos === 1 ? '' : 's'}`;
+ if (questions === 0) return todoWords;
+ return `${formatCount(questions)} question${questions === 1 ? '' : 's'}, ${todoWords}`;
+}
+
+/** "1 thread", "1,284 threads". */
+export function plural(n: number, one: string, many = `${one}s`): string {
+ return `${formatCount(n)} ${n === 1 ? one : many}`;
+}
+
+/** "412.6 MB", "3.1 KB", "512 B". */
+export function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let value = bytes / 1024;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return `${value.toFixed(1)} ${units[unit]}`;
+}
+
+/** An absolute path with the home folder written as `~`. */
+export function tildePath(path: string, home: string | null | undefined): string {
+ if (!home) return path;
+ const trimmed = home.replace(/[\\/]+$/, '');
+ if (path === trimmed) return '~';
+ if (path.startsWith(`${trimmed}/`) || path.startsWith(`${trimmed}\\`)) return `~${path.slice(trimmed.length)}`;
+ return path;
+}
diff --git a/packages/core/inbox-open-questions.ts b/packages/core/inbox-open-questions.ts
new file mode 100644
index 000000000..3bd72a1be
--- /dev/null
+++ b/packages/core/inbox-open-questions.ts
@@ -0,0 +1,181 @@
+/**
+ * Open questions inside one thread (approval "inbox-thread-open-questions",
+ * option A, 2026-10-22): the "N open questions" button in the thread's foot
+ * and the short list it opens. Pure: which questions are listed, how the
+ * list names them, and when the button is shown at all.
+ *
+ * Listed: every question of a live agent message (not one an archive closed)
+ * that waits on the person: open, or picked and not sent. Replaced questions
+ * (`superseded`) are left out and counted apart; action items are not
+ * questions and are left out. Answering stays on the real card: the list only
+ * takes the person there.
+ */
+
+import type { InboxMessageWire, InboxQuestion } from './inbox-types';
+import { readInboxQuestionKind } from './inbox-types';
+import { inboxQuestionWaitsOnPerson } from './inbox-questions';
+import { isQuestionAnswerEmpty, type QuestionAnswer } from './question-block';
+import { formatCount } from './inbox-format';
+
+/** One row of the list. */
+export interface OpenQuestion {
+ /** `/`. */
+ id: string;
+ messageId: string;
+ key: string;
+ prompt: string;
+ /** The message's time (ISO), which the row names. */
+ at: string;
+ /** Picked and not sent: the row reads "Draft · ". */
+ picked: boolean;
+ question: InboxQuestion;
+}
+
+/** A question the person can still answer: not an action item, not a kind this window does not know. */
+const isQuestion = (q: InboxQuestion) => {
+ const kind = readInboxQuestionKind(q.kind);
+ return kind !== 'unknown' && kind !== 'action';
+};
+
+/** Open, or picked and not sent: core's one "waits on the person" rule (decision mode and the list read it too). */
+const waits = (q: InboxQuestion) => inboxQuestionWaitsOnPerson(q);
+
+/**
+ * The thread's open questions, in thread order, and how many replaced ones
+ * were left out. `live` says which messages still take answers.
+ */
+export function threadOpenQuestions(
+ messages: readonly InboxMessageWire[],
+ live: (message: InboxMessageWire) => boolean = () => true,
+): { questions: OpenQuestion[]; replaced: number } {
+ const questions: OpenQuestion[] = [];
+ let replaced = 0;
+ for (const m of messages) {
+ if (m.author.kind !== 'agent' || !live(m)) continue;
+ for (const q of m.questions ?? []) {
+ if (!isQuestion(q)) continue;
+ if (q.state === 'superseded') {
+ replaced += 1;
+ continue;
+ }
+ if (!waits(q)) continue;
+ questions.push({ id: `${m.id}/${q.key}`, messageId: m.id, key: q.key, prompt: q.prompt, at: m.created_at, picked: q.state === 'picked' && q.answer != null, question: q });
+ }
+ }
+ return { questions, replaced };
+}
+
+/**
+ * Whether the button is shown: whenever a question waits (owner, 2026-10-23:
+ * the list now also holds Re-ask, so it must never be out of reach), or Re-ask
+ * is offered with no question listed (only action items wait).
+ */
+export function openQuestionsShown(questions: readonly OpenQuestion[], reaskShown = false): boolean {
+ return questions.length > 0 || reaskShown;
+}
+
+/** "5 open questions" / "1 open question"; `noun` "action item" when only action items wait. */
+export function openQuestionsLabel(n: number, noun: OpenQuestionsNoun = 'question'): string {
+ return `${formatCount(n)} open ${noun}${n === 1 ? '' : 's'}`;
+}
+
+/** What the button counts: questions, or action items when no question waits. */
+export type OpenQuestionsNoun = 'question' | 'action item';
+
+/** The list's accessible name: "5 open questions in this thread" (not drawn). */
+export function openQuestionsHeading(n: number, noun: OpenQuestionsNoun = 'question'): string {
+ return `${openQuestionsLabel(n, noun)} in this thread`;
+}
+
+/**
+ * The Re-ask item at the list's foot (owner, 2026-10-23): "Re-ask in a new
+ * message" when it asks for one thing again, else "Re-ask in one new message".
+ */
+export function reaskItemLabel(asked: number): string {
+ return asked === 1 ? 'Re-ask in a new message' : 'Re-ask in one new message';
+}
+
+/** Where a question's card sits, as a row says it. */
+export type OpenQuestionPlace = 'folded' | 'above' | 'below';
+
+export function openQuestionPlaceWords(place: OpenQuestionPlace): string {
+ return place === 'folded' ? 'in a folded message' : place;
+}
+
+/**
+ * Above or below, from the card's middle against the middle of the thread's
+ * view: a card half in view still reads where the most of it is, and the
+ * list never needs a fourth word.
+ */
+export function placeOf(card: { top: number; bottom: number } | null, view: { top: number; bottom: number }): 'above' | 'below' {
+ if (!card) return 'below';
+ return (card.top + card.bottom) / 2 < (view.top + view.bottom) / 2 ? 'above' : 'below';
+}
+
+/** The state a row shows: the draft pick first, else Stopped, else Holds up, else nothing. */
+export type OpenQuestionState = { kind: 'draft' } | { kind: 'stopped' } | { kind: 'holds'; count: number } | null;
+
+export function openQuestionState(q: OpenQuestion): OpenQuestionState {
+ if (q.picked) return { kind: 'draft' };
+ if (q.question.stopped) return { kind: 'stopped' };
+ if (q.question.holds_up.length > 0) return { kind: 'holds', count: q.question.holds_up.length };
+ return null;
+}
+
+/** The row's question is skipped in the person's draft: the row goes quiet, and the card's Skip reads "Unskip". */
+export function openQuestionSkipped(q: OpenQuestion): boolean {
+ return q.question.answer?.skipped === true;
+}
+
+/**
+ * What the select mode's button does with the ticked rows (approval
+ * "oq-skip-select", variant A): a ticked question not skipped yet is skipped,
+ * a ticked skipped one is unskipped. Only questions that take a Skip count.
+ */
+export function skipSelection(questions: readonly OpenQuestion[], ticked: ReadonlySet): { skip: OpenQuestion[]; unskip: OpenQuestion[] } {
+ const skip: OpenQuestion[] = [];
+ const unskip: OpenQuestion[] = [];
+ for (const q of questions) {
+ if (!ticked.has(q.id) || !openQuestionSkippable(q)) continue;
+ (openQuestionSkipped(q) ? unskip : skip).push(q);
+ }
+ return { skip, unskip };
+}
+
+/**
+ * What "Skip all N" covers: the open questions with nothing picked. A draft
+ * pick, and a question already skipped, stay as they are.
+ */
+export function skipAllCandidates(questions: readonly OpenQuestion[]): OpenQuestion[] {
+ return questions.filter((q) => !q.picked && !openQuestionSkipped(q) && openQuestionSkippable(q));
+}
+
+/**
+ * Whether a row can be skipped: exactly when its card offers Skip (an open or picked
+ * question; a sent, closed or replaced one, and an action item, take none).
+ * The list holds only such questions, so this is a guard, not a filter.
+ */
+export function openQuestionSkippable(q: OpenQuestion): boolean {
+ return isQuestion(q.question) && waits(q.question);
+}
+
+/**
+ * What a Skip from the list saves: the answer the card's own Skip makes from
+ * the same starting answer (ui's QuestionBlock `toggleSkip`, then its
+ * `commit`). Skipped already: the skip comes off (Unskip), the note stays.
+ * Otherwise the choices, Other and the text go and `skipped` is set, the note
+ * staying. An answer left with nothing in it is null (the pick is cleared).
+ * `base` is the card's: its saved or pending answer, else the question's
+ * empty answer.
+ */
+export function skipToggledAnswer(base: QuestionAnswer): QuestionAnswer | null {
+ let next: QuestionAnswer;
+ if (base.skipped) {
+ const { skipped: _skipped, ...rest } = base;
+ next = rest;
+ } else {
+ const { other: _other, text: _text, ...rest } = base;
+ next = { ...rest, selected: [], skipped: true };
+ }
+ return isQuestionAnswerEmpty(next) ? null : next;
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index e5755924e..ee2df8f54 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -44,6 +44,10 @@
"./inbox-questions": "./inbox-questions.ts",
"./inbox-actions": "./inbox-actions.ts",
"./inbox-search": "./inbox-search.ts",
+ "./inbox-folding": "./inbox-folding.ts",
+ "./inbox-delivery-line": "./inbox-delivery-line.ts",
+ "./inbox-open-questions": "./inbox-open-questions.ts",
+ "./inbox-format": "./inbox-format.ts",
"./diagram-anchor": "./diagram-anchor.ts",
"./image-region": "./image-region.ts",
"./usage-counts": "./usage-counts.ts",
diff --git a/packages/inbox/deliveryLine.ts b/packages/inbox/deliveryLine.ts
index 82bd349a9..b6c823b0f 100644
--- a/packages/inbox/deliveryLine.ts
+++ b/packages/inbox/deliveryLine.ts
@@ -1,339 +1,6 @@
/**
- * The line under the person's last reply or message: where it is now.
- *
- * The owner's request (2026-10-16): "The message should be less deceiving.
- * It should say this message is queued up or something. And then maybe an
- * option just to send it immediately." A reply to a session whose connection
- * wakes it (the Claude Code mod, Pi, OpenCode 2) is delivered as a turn by
- * itself once the session is idle, so the line says what it waits for, from
- * what the Inbox knows about that session's connection right now (its poll's
- * `busy`), and offers Send now while the session works and its connection
- * can put the reply into the running turn (`send_now`). A reply to an agent
- * that only reads the thread (MCP, OpenCode 1) keeps the truthful "it sees
- * it when it next reads this thread". Pure: Thread.tsx draws it.
+ * Moved to @plannotator/core (packages/core/inbox-delivery-line.ts) unchanged, so a host of the
+ * shared thread parts reads the delivery line's words from core. This path re-exports it so every
+ * existing import and test keeps working.
*/
-
-import {
- inboxAutoSendInMinutes,
- inboxDeliveryState,
- wholeMinutes,
- type InboxDeliverNowBy,
- inboxEngineTooOldForSendNow,
- inboxLoadedPredatesSendNow,
- type InboxDeliveryReason,
- type InboxDeliveryState,
- type InboxReloadRuntime,
- type InboxSessionOutdated,
-} from '@plannotator/core/inbox-types';
-
-/** How the session the message goes to is connected now (the sessions route's `asker.connection`). */
-export interface RecipientConnection {
- busy: boolean | null;
- idle_since: string | null;
- send_now: boolean;
- /** It runs an older Plannotator plugin than a restart loads (packages/server/inbox-restart.ts). Absent from an older Inbox. */
- outdated?: InboxSessionOutdated | null;
- /** Claude Code's version as its poll said (absent or null: unknown). */
- engine?: string | null;
- /** `cd && claude --resume `, when the Inbox knows the folder. Absent from an older Inbox. */
- restart_command?: string | null;
- /** How long a reply waits on this busy session before it goes in by itself. Absent from an older Inbox (never). */
- auto_send_after_ms?: number;
- /**
- * The server's clocks for the items waiting on this session, by id: since
- * when each waits on the busy session, and since when it may go in by
- * itself (null: not yet). Absent from an older Inbox.
- */
- waits?: Record;
- /**
- * Items that may not have reached this session (a Codex thread whose
- * `codex queue` call ran out of time): their line says saved, not queued.
- * Absent from an older Inbox and when none.
- */
- uncertain?: string[];
-}
-
-/** Whether an item may not have reached the session it was handed to (`connection.uncertain`). */
-export function uncertainItem(connection: RecipientConnection | null | undefined, itemId: string): boolean {
- return connection?.uncertain?.includes(itemId) === true;
-}
-
-/** The wait inputs of `deliveryLine` for one item: the server's clock while it waits, its own record after the mark. */
-export function waitInputs(
- connection: RecipientConnection | null | undefined,
- itemId: string,
- item: { wait_started_at?: string | null; deliver_now_by?: InboxDeliverNowBy | null; deliver_now_at?: string | null; into_turn?: boolean | null },
-): Pick {
- const clock = connection?.waits?.[itemId];
- return {
- waitingSince: clock?.since ?? item.wait_started_at ?? null,
- autoSince: clock?.auto_since ?? null,
- nowBy: item.deliver_now_by ?? null,
- nowAt: item.deliver_now_at ?? null,
- intoTurn: item.into_turn ?? null,
- };
-}
-
-/**
- * Why Send now is not offered for a busy session, and what brings it back
- * (owner approval "restart-warning", 2026-10-19, the "Steps" line): the
- * session runs an older plugin than a restart loads (`restart`), or the
- * installed plugin is itself behind this Inbox (`update_plugin`).
- */
-export interface RestartNote {
- fix: InboxSessionOutdated['fix'];
- /** The command that brings the session back as itself (Claude Code); null when the Inbox does not know its folder. */
- command: string | null;
- /** The plugin update command (`update_plugin`, `update_extension`). */
- updateCommand: string | null;
- /**
- * The runtime and its reload (owner approval "reload-hosts"): Claude Code
- * `/reload-plugins`, Pi `/reload`, Oh My Pi `/restart` (typed in the
- * session), OpenCode 2 `opencode2 reload` or `opencode2 plugin update` (run
- * in a shell), with OpenCode's fallback `opencode2 service restart`.
- */
- runtime: InboxReloadRuntime;
- reloadCommand: string;
- reloadWhere: 'session' | 'shell';
- fallbackCommand: string | null;
- /** OpenCode 2: the config pins this exact spec; it changes to `pinTo`, then `opencode2 reload`. */
- pinnedSpec: string | null;
- pinTo: string | null;
- /** OpenCode 2's `opencode2 plugin update` with no target: it updates every outdated plugin. */
- updatesAll: boolean;
-}
-
-/** Claude Code's reload (the mod reloads with the plugins). */
-export const CLAUDE_RELOAD_COMMAND = '/reload-plugins';
-
-/**
- * The restart note for a session that works now and has no Send now
- * because it runs an older plugin. Only then: an idle session gets the reply
- * at once anyway, and a session with Send now is not missing anything. An
- * engine too old for Send now stays quiet (a restart would not bring it; the
- * owner chose to say nothing about that, 2026-10-19).
- */
-export function restartNote(connection: RecipientConnection | null | undefined): RestartNote | null {
- const outdated = connection?.outdated;
- if (!connection || !outdated || connection.busy !== true || connection.send_now) return null;
- if (inboxEngineTooOldForSendNow(connection.engine)) return null;
- // A session that loaded a plugin with Send now lacks it for another reason (its own Ask question holds the
- // turn, the engine refused a row): a restart would not change that, so the line never blames the plugin.
- if (!inboxLoadedPredatesSendNow(outdated.loaded)) return null;
- const command = connection.restart_command ?? null;
- const runtime = outdated.runtime ?? 'claude-code';
- const reload = {
- runtime,
- reloadCommand: outdated.reload_command ?? CLAUDE_RELOAD_COMMAND,
- reloadWhere: outdated.reload_where ?? 'session',
- fallbackCommand: outdated.fallback_command ?? null,
- pinnedSpec: outdated.pinned_spec ?? null,
- pinTo: outdated.pin_to ?? null,
- updatesAll: outdated.update_scope === 'all',
- } as const;
- if (runtime === 'claude-code') {
- if (outdated.fix === 'update_plugin') return { fix: outdated.fix, command, updateCommand: outdated.update_command ?? null, ...reload };
- if (outdated.fix !== 'restart') return null;
- return { fix: outdated.fix, command, updateCommand: null, ...reload };
- }
- // Pi, Oh My Pi, OpenCode 2: a reload alone, or the update first.
- if (outdated.fix === 'reload') return { fix: outdated.fix, command: null, updateCommand: null, ...reload };
- if (outdated.fix === 'update_extension' && (outdated.update_command || outdated.pinned_spec)) {
- return { fix: outdated.fix, command: null, updateCommand: outdated.update_command ?? null, ...reload };
- }
- return null;
-}
-
-export type DeliveryLineState = InboxDeliveryState;
-
-export interface DeliveryLine {
- state: DeliveryLineState;
- text: string;
- /** The asking session ended: a clock, not a check. */
- ended?: boolean;
- /** Offer Send now: the session works and its connection can take the reply mid-turn. */
- sendNow?: boolean;
- /**
- * It waits on a busy session (approval "inbox-busy-agent-reply"): since
- * when, whether it can go in now (`steerable`), and when it goes in by
- * itself (null: it will not). The band under the reply draws it with its
- * own minute clock (`undeliveredWords`).
- */
- wait?: { since: string; steerable: boolean; autoSendAt: string | null };
- /** It went into the turn by itself after this many minutes ("went into its turn by itself after 5 min"). */
- autoAfterMin?: number;
- /** Marked to go in by itself, but it went in when the turn ended (the session's own Ask question held it). */
- turnEnded?: boolean;
-}
-
-/** The parts of the "Undelivered" band at `now`: "Undelivered 3 min", and what happens next. */
-export interface UndeliveredWords {
- /** "Undelivered 3 min" (whole minutes; "Undelivered" under one). */
- label: string;
- /** "goes in by itself in 2 min" / "in under a minute", or "it gets this when its turn ends". */
- next: string;
- /** It goes in by itself (the countdown shows). */
- auto: boolean;
-}
-
-/** "Undelivered 3 min": whole minutes, never seconds. */
-export function undeliveredLabel(since: string, now: number): string {
- const minutes = wholeMinutes(since, now);
- return minutes < 1 ? 'Undelivered' : `Undelivered ${minutes} min`;
-}
-
-export function undeliveredWords(wait: NonNullable, now: number): UndeliveredWords {
- const label = undeliveredLabel(wait.since, now);
- if (wait.autoSendAt) {
- const left = inboxAutoSendInMinutes(wait.autoSendAt, now);
- const next = left === null ? 'goes in by itself now' : left === 0 ? 'goes in by itself in under a minute' : `goes in by itself in ${left} min`;
- return { label, next, auto: true };
- }
- return { label, next: 'it gets this when its turn ends', auto: false };
-}
-
-export interface DeliveryLineInput {
- /** "Claude Code", "Pi (another session)". */
- name: string;
- /** Delivered (or read by the agent) at; null while it waits. */
- deliveredAt: string | null;
- /** Formats a time for the line ("5:12 PM"). */
- time: (iso: string) => string;
- /** The session it goes to is woken by its connection (a waking agent message, or a message addressed to a live session). */
- wakes: boolean;
- /** The person pressed Send now (`deliver_now_at`). */
- sendingNow: boolean;
- /**
- * That session's connection as the Inbox knows it now: undefined while not
- * read yet or from an older Inbox (no `connection`), null when it does not
- * poll now.
- */
- connection: RecipientConnection | null | undefined;
- /** The asking session ended (a reply), with the asker's name for the line. */
- askerEnded?: { name: string } | null;
- /** A message addressed to a session that stopped before it took it (compose). */
- undelivered?: { host: string } | null;
- /** When it began to wait on the busy session (the server's clock): the line says how long. */
- waitingSince?: string | null;
- /** When the clock toward going in by itself started (the server's); null: it does not go in by itself yet. */
- autoSince?: string | null;
- /** Delivered after the Inbox's own mark: whether it went into the running turn (`delivery.into_turn`). */
- intoTurn?: boolean | null;
- /** Override the connection's wait before it goes in by itself (null: never, e.g. a 👍 note while they wait for the turn). */
- autoSendAfterMs?: number | null;
- /** Who asked for it to go now (`deliver_now_by`), and when (`deliver_now_at`). */
- nowBy?: InboxDeliverNowBy | null;
- nowAt?: string | null;
- /** `now`: the clock the words are written at (default `Date.now()`). */
- now?: number;
-}
-
-export function deliveryLine(input: DeliveryLineInput): DeliveryLine {
- const { name } = input;
- // The state is the shared rule's (packages/core/inbox-delivery.ts, through @plannotator/core/inbox-types), which also decides the list row's glyph.
- const decision = inboxDeliveryState({
- delivered: !!input.deliveredAt,
- undelivered: !!input.undelivered,
- askerEnded: !!input.askerEnded,
- wakes: input.wakes,
- sendingNow: input.sendingNow,
- connection: input.connection,
- waitingSince: input.waitingSince ?? null,
- autoSince: input.autoSince ?? null,
- intoTurn: input.intoTurn ?? null,
- nowBy: input.nowBy ?? null,
- nowAt: input.nowAt ?? null,
- autoSendAfterMs: input.autoSendAfterMs !== undefined ? input.autoSendAfterMs : (input.connection?.auto_send_after_ms ?? null),
- });
- const text = deliveryText(decision.reason, {
- name,
- at: input.deliveredAt,
- time: input.time,
- undeliveredHost: input.undelivered?.host ?? name,
- endedName: input.askerEnded?.name ?? name,
- wait: decision.wait,
- autoAfterMin: decision.autoAfterMin,
- turnEnded: decision.turnEnded,
- now: input.now,
- });
- return {
- state: decision.state,
- text,
- ...(decision.reason === 'ended' ? { ended: true } : {}),
- ...(decision.reason === 'working' ? { sendNow: decision.sendNow } : {}),
- ...(decision.wait ? { wait: decision.wait } : {}),
- ...(decision.autoAfterMin !== undefined && decision.reason === 'delivered' ? { autoAfterMin: decision.autoAfterMin } : {}),
- ...(decision.turnEnded ? { turnEnded: true } : {}),
- };
-}
-
-/** The words for each case of the rule: the thread's line, and the list row's tooltip. */
-export function deliveryText(
- reason: InboxDeliveryReason,
- words: {
- name: string;
- at: string | null;
- time: (iso: string) => string;
- undeliveredHost: string;
- endedName: string;
- /** It waits on a busy session (approval "inbox-busy-agent-reply"): "Undelivered 3 min · …". */
- wait?: DeliveryLine['wait'];
- /** It went into the turn by itself after this many minutes. */
- autoAfterMin?: number;
- /** Marked to go in by itself, it went in when the turn ended instead. */
- turnEnded?: boolean;
- now?: number;
- },
-): string {
- const { name } = words;
- switch (reason) {
- case 'delivered': {
- const base = words.at ? `Delivered to ${name}, ${words.time(words.at)}` : `Delivered to ${name}`;
- if (words.autoAfterMin !== undefined) return `${base} · ${autoAfterText(words.autoAfterMin)}`;
- return words.turnEnded ? `${base} · ${TURN_ENDED_TEXT}` : base;
- }
- case 'undelivered':
- return `Not delivered. That ${words.undeliveredHost} session stopped before it took this. If it is resumed, it still gets it.`;
- case 'ended':
- return `Saved for ${words.endedName}. The session that asked has ended, so it waits here until an agent reads this thread.`;
- case 'reads':
- return `Saved for ${name}. It sees it when it next reads this thread.`;
- case 'sending_now':
- return `Sending to ${name} now…`;
- case 'working': {
- if (!words.wait) return `Queued · ${name} is working; it gets this when its turn ends.`;
- const parts = undeliveredWords(words.wait, words.now ?? Date.now());
- return `${parts.label} · ${name} is working · ${parts.next}`;
- }
- case 'idle':
- return `Sending to ${name}…`;
- case 'away':
- return `Queued for ${name}; it gets this when its session is back.`;
- case 'next_turn':
- return `Queued for ${name}; it gets this as its next turn.`;
- case 'ack_pending':
- return `Goes to ${name} in a few seconds`;
- }
-}
-
-/** The Sent row's line in the list, which knows no connection: queued for a waking agent, saved otherwise. */
-export function sentRowLine(name: string, wakes: boolean | undefined): string {
- return wakes === true ? `Queued for ${name}; it gets this as its next turn.` : `Saved for ${name}. It sees it when it next reads this thread.`;
-}
-
-/**
- * A Sent row whose last message is the person's Re-ask note (approval
- * "inbox-reask"): it stays Sent until the agent asks again, read or not.
- */
-export function reaskRowLine(name: string, checkedAt: string | null, wakes: boolean | undefined): string {
- if (checkedAt) return `Asked ${name} to re-ask; waiting for its message.`;
- return wakes === true ? `Re-ask queued for ${name}; it gets this as its next turn.` : `Re-ask saved for ${name}. It sees it when it next reads this thread.`;
-}
-
-/** An item the Inbox marked to go in by itself that waited for the turn to end (its own Ask question held it). */
-export const TURN_ENDED_TEXT = 'went in when its turn ended';
-
-/** "went into its turn by itself after 5 min". */
-export function autoAfterText(minutes: number): string {
- return `went into its turn by itself after ${minutes} min`;
-}
+export * from '@plannotator/core/inbox-delivery-line';
diff --git a/packages/inbox/folding.ts b/packages/inbox/folding.ts
index 0bc5c43f9..7ce4a34b2 100644
--- a/packages/inbox/folding.ts
+++ b/packages/inbox/folding.ts
@@ -1,346 +1,6 @@
/**
- * Folding a thread (design record, not in this repo; variant "Land"): which
- * messages open and which fold to one line when a thread opens, which read
- * messages hide in the middle of a long run, what a folded line says, and
- * which messages are new since the person's last look. Pure: the thread pane
- * (components/Thread.tsx) draws what these decide.
+ * Moved to @plannotator/core (packages/core/inbox-folding.ts) unchanged, so a host of the
+ * shared thread parts reads the fold rules from core. This path re-exports it so every
+ * existing import and test keeps working.
*/
-
-import { isInboxActionReport, isInboxNote, type InboxMessageWire, type InboxQuestion, type InboxThread } from '@plannotator/core/inbox-types';
-import { QUESTION_ANSWERS_HEADING, type QuestionAnswer } from '@plannotator/core/question-block';
-import { INBOX_STILL_OPEN_LINE, sliceText } from '@plannotator/core/inbox-questions';
-import { withoutInboxSkillsBlock } from '@plannotator/core/skill-reference-block';
-
-/**
- * Where "new since you looked" starts: the seq of the person's last look at
- * the thread, or of their own last reply (a reply is a look at everything
- * before it), whichever is later. The Inbox's own notes (Mark read's
- * set-aside note, a late Undo's reopened note) are not a reply and not a
- * look. Read from the thread as it was BEFORE the window records the next
- * look, and kept until the person leaves the thread. Null when the payload
- * carries no seqs (an older Inbox): nothing is new then.
- */
-export function lookBoundary(thread: InboxThread): number | null {
- if (thread.messages.length === 0 || thread.messages.some((m) => typeof m.seq !== 'number')) return null;
- let seen = thread.messages[0]!.person_seen_seq ?? 0;
- for (const message of thread.messages) if (isPersonLook(message)) seen = Math.max(seen, message.seq!);
- return seen;
-}
-
-/** A thread's last message with the Inbox's own notes read past (the one a reply's line sits on). */
-export function lastNonNote(messages: readonly InboxMessageWire[]): InboxMessageWire | undefined {
- for (let i = messages.length - 1; i >= 0; i--) if (!isInboxNote(messages[i]!)) return messages[i];
- return undefined;
-}
-
-/** The person's own message (a reply or a New message), never one of the Inbox's notes. */
-export function isPersonReply(message: InboxMessageWire): boolean {
- return message.author.kind === 'person' && !isInboxNote(message);
-}
-
-/**
- * The person's own message that counts as a look: a reply or a New message,
- * never the Inbox's notes nor an action item's report (the Inbox writes it seconds
- * after the click, so agent news that landed meanwhile stays new).
- */
-export function isPersonLook(message: InboxMessageWire): boolean {
- return isPersonReply(message) && !isInboxActionReport(message);
-}
-
-/** The agent messages after the boundary: new since the person's last look. */
-export function newSinceLook(messages: readonly InboxMessageWire[], boundary: number | null): Set {
- const out = new Set();
- if (boundary === null) return out;
- for (const message of messages) {
- if (message.author.kind === 'agent' && typeof message.seq === 'number' && message.seq > boundary) out.add(message.id);
- }
- return out;
-}
-
-/** A question that still asks something: not replaced, not orphaned. */
-const live = (q: InboxQuestion) => q.state !== 'superseded' && !q.orphaned;
-
-/** The message holds a pick the person has not sent. */
-export function holdsUnsentPick(message: InboxMessageWire): boolean {
- return (message.questions ?? []).some((q) => q.state === 'picked');
-}
-
-export interface FoldPlan {
- /** The messages that open folded to one line. */
- folded: Set;
- /**
- * The middle of each long run of read, folded messages, hidden behind
- * "N earlier messages": a run of 4 or more keeps its first and its last
- * two. Decided once when the thread opens.
- */
- hidden: string[][];
-}
-
-/**
- * How a thread opens: a message is shown in full when it is new since the
- * last look, holds a pick not yet sent, or is the last message; every other
- * message folds. A one-message thread folds nothing (it looks as it always
- * did), and so does a thread whose seqs are unknown (an older Inbox: the
- * window cannot tell what was read).
- */
-export function planFolds(messages: readonly InboxMessageWire[], fresh: ReadonlySet, boundary: number | null): FoldPlan {
- const folded = new Set();
- const hidden: string[][] = [];
- if (messages.length < 2 || boundary === null) return { folded, hidden };
- const last = messages[messages.length - 1]!;
- // The last real message stays open too when the Inbox's notes trail it: it
- // carries the reply's "Saved for / Delivered to" line.
- const lastReal = lastNonNote(messages);
- let run: string[] = [];
- const flush = () => {
- if (run.length >= 4) hidden.push(run.slice(1, run.length - 2));
- run = [];
- };
- for (const message of messages) {
- const open = fresh.has(message.id) || holdsUnsentPick(message) || message === last || message === lastReal;
- if (open) {
- flush();
- continue;
- }
- folded.add(message.id);
- run.push(message.id);
- }
- flush();
- return { folded, hidden };
-}
-
-/** The words an answer reads as: "Pagefind", "Guides, API reference", "skipped". */
-export function answerWords(answer: QuestionAnswer): string {
- if (answer.skipped) return 'skipped';
- const parts = [...answer.selected];
- if (answer.other) parts.push(answer.other);
- if (answer.text) parts.push(answer.text);
- return parts.join(', ');
-}
-
-/** One answered question on a folded line: its prompt, then the answer. */
-export interface AnsweredPart {
- /** The prompt as the line shows it: plain, cut at a word near 40 characters. */
- prompt: string;
- /** The whole prompt, plain: the line's title and its screen-reader label. */
- full: string;
- answer: string;
-}
-
-/**
- * A folded line's snippet: the person's answers, or first words (see
- * `foldSnippet`).
- */
-export type FoldSnippet = { kind: 'answers'; parts: AnsweredPart[] } | { kind: 'words'; text: string };
-
-/** How long a prompt may run on a folded line before it is cut, so the answer after it stays in view. */
-export const SNIPPET_PROMPT_MAX = 40;
-
-/** A prompt without its inline markdown and its closing punctuation. */
-function plainPrompt(prompt: string): string {
- return cleanInline(prompt).replace(/\s+/g, ' ').trim().replace(/[?.!:]+$/, '');
-}
-
-/** Cut at a word boundary near `max` characters, with "…". */
-export function cutAtWord(text: string, max: number): string {
- if (text.length <= max) return text;
- // One character more, so a word that ends exactly at `max` is kept whole.
- const space = text.slice(0, max + 1).lastIndexOf(' ');
- return `${(space > max * 0.5 ? text.slice(0, space) : sliceText(text, max)).replace(/[\s,;:.]+$/, '')}…`;
-}
-
-function answeredParts(questions: readonly InboxQuestion[]): AnsweredPart[] {
- return questions.map((q) => {
- const full = plainPrompt(q.prompt);
- return { prompt: cutAtWord(full, SNIPPET_PROMPT_MAX), full, answer: answerWords(q.answer!) };
- });
-}
-
-const ANSWERS_HEADING_LINE = new RegExp(`^#{1,6}\\s+${QUESTION_ANSWERS_HEADING}\\s*$`);
-
-/**
- * A person's reply without what the store wrote around the person's words
- * (core's `composeInboxReplyBody`): the leading "Answered N questions." / "Skipped N questions." /
- * "Answered N questions, skipped M." and
- * everything from the "Answers to your questions" heading on (the answers,
- * then the annotations' feedback), or, on a reply that answered nothing,
- * from the "No questions answered yet; …" line on, and the skills block the
- * window pasted at the end (a human-only skill's SKILL.md text).
- */
-export function replyWords(body: string): string {
- const lines = withoutInboxSkillsBlock(body).split(/\r?\n/);
- const at = lines.findIndex((line) => ANSWERS_HEADING_LINE.test(line.trim()) || INBOX_STILL_OPEN_LINE.test(line.trim()));
- const kept = (at >= 0 ? lines.slice(0, at) : lines).join('\n');
- return kept.replace(/^\s*(?:Answered \d+ questions?(?:, skipped \d+)?|Skipped \d+ questions?)\.\s*/, '');
-}
-
-/**
- * A folded line's snippet. An agent message whose live questions were all
- * sent shows the answers: what the person decided is the reason to look
- * back. Otherwise its first words; a message that is only a question block,
- * its first live question's prompt. A person's reply shows its own words;
- * with none, the answers it sent (from `messages`, the thread); else the
- * names of the files it carries; else its first words.
- */
-export function foldSnippet(message: InboxMessageWire, messages: readonly InboxMessageWire[] = [message]): FoldSnippet {
- if (message.author.kind === 'person') {
- // A Re-ask note: what the person did, not the instructions the agent got.
- const reasked = message.reask?.length ?? 0;
- if (reasked > 0) {
- // Action items it asked about too (ids `/a-…`, owner, #2059).
- const actions = (message.reask ?? []).filter((id) => /\/a-[0-9a-f]/.test(id)).length;
- if (actions > 0) {
- const questions = reasked - actions;
- const what =
- questions === 0
- ? actions === 1
- ? 'the open action item'
- : `${actions} open action items`
- : `${questions} open question${questions === 1 ? '' : 's'} and ${actions} action item${actions === 1 ? '' : 's'}`;
- return { kind: 'words', text: `Asked to re-ask ${what}${reasked > 1 ? ' in one message' : ''}` };
- }
- return { kind: 'words', text: reasked === 1 ? 'Asked to re-ask the open question' : `Asked to re-ask ${reasked} open questions in one message` };
- }
- const words = firstWords(replyWords(message.body));
- if (words) return { kind: 'words', text: words };
- const sent = messages.flatMap((m) => (m.questions ?? []).filter((q) => q.sent_reply_id === message.id && q.answer !== null));
- if (sent.length > 0) return { kind: 'answers', parts: answeredParts(sent) };
- // Files pasted in with no words: name them, not the still-open line the agent reads.
- const files = message.attachments ?? [];
- if (files.length > 0) return { kind: 'words', text: files.map((file) => file.name).join(', ') };
- return { kind: 'words', text: firstWords(withoutInboxSkillsBlock(message.body)) };
- }
- const questions = (message.questions ?? []).filter(live);
- if (questions.length > 0 && questions.every((q) => q.state === 'sent' && q.answer !== null)) {
- return { kind: 'answers', parts: answeredParts(questions) };
- }
- const words = firstWords(message.body);
- if (!words && questions.length > 0) return { kind: 'words', text: plainPrompt(questions[0]!.prompt) };
- // A message that is only an action item: its line ("Action item: Approve the deploy").
- const todo = (message.actions ?? []).find((a) => a.state !== 'superseded');
- if (!words && todo) return { kind: 'words', text: `Action item: ${plainPrompt(todo.prompt)}` };
- return { kind: 'words', text: words };
-}
-
-/** The snippet as one line of text, whole: a line's title and its screen-reader label. */
-export function snippetText(snippet: FoldSnippet): string {
- return snippet.kind === 'words' ? snippet.text : snippet.parts.map((p) => `${p.full}: ${p.answer}`).join('; ');
-}
-
-/**
- * Inline markdown as plain text: images out, link and wiki-link labels kept,
- * tags out, and paired emphasis marks (`**x**`, `__x__`, `~~x~~`, `*x*`,
- * `_x_`) dropped only where they open and close at word boundaries, so
- * `snake_case` and `2 * 3` keep their characters. Inline code keeps its text
- * (only the backticks go).
- */
-export function cleanInline(text: string): string {
- const code: string[] = [];
- let out = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (_m, _ticks: string, inner: string) => {
- code.push(inner.trim());
- return `\u0000${code.length - 1}\u0000`;
- });
- out = out
- .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
- .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
- .replace(/\[\[([^\]|]*)(?:\|([^\]]*))?\]\]/g, (_m, target: string, label?: string) => label ?? target)
- .replace(/<\/?[A-Za-z][^>]*>/g, '');
- // A closing mark ends a word: whitespace, the end, or punctuation that does
- // not run into another word (`__init__.py` is a name, not emphasis).
- const close = `(?=$|\\s|[,;:!?)\\]"'’]|\\.(?!\\w))`;
- for (const mark of ['\\*\\*', '__', '~~', '\\*', '_']) {
- const pair = new RegExp(`(^|[^\\w*~])${mark}(?=\\S)([\\s\\S]*?\\S)${mark}${close}`, 'g');
- out = out.replace(pair, '$1$2');
- }
- return out.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)] ?? '');
-}
-
-/**
- * The first words of a message's markdown as plain text: question blocks,
- * fences, tables, images and raw HTML dropped, link and emphasis marks
- * removed, whitespace collapsed, cut at a word near `max` characters.
- */
-export function firstWords(markdown: string, max = 240): string {
- const lines: string[] = [];
- let fence: string | null = null;
- let directive = 0;
- for (const raw of markdown.split(/\r?\n/)) {
- const line = raw.trim();
- if (fence !== null) {
- if (line.startsWith(fence)) fence = null;
- continue;
- }
- const open = /^(`{3,}|~{3,})/.exec(line);
- if (open) {
- fence = open[1]!;
- continue;
- }
- if (/^:{3,}\s*[A-Za-z]/.test(line)) {
- directive += 1;
- continue;
- }
- if (/^:{3,}\s*$/.test(line)) {
- if (directive > 0) directive -= 1;
- continue;
- }
- if (directive > 0) continue;
- if (line.startsWith('|') || /^[-*_]{3,}$/.test(line) || /^<\/?[A-Za-z][^>]*>$/.test(line)) continue;
- // Block marks: a heading's hashes, a list or task marker, a quote.
- lines.push(line.replace(/^#{1,6}\s+/, '').replace(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/, '').replace(/^>\s?/, ''));
- }
- const text = cleanInline(lines.join(' ')).replace(/\s+/g, ' ').trim();
- return cutAtWord(text, max);
-}
-
-/** One thing a message holds, for its folded line. */
-export interface Holding {
- kind: 'picks' | 'open' | 'todo' | 'answered' | 'files' | 'annotations' | 'guide' | 'delivered';
- /** What the line shows. */
- text: string;
- /** What a screen reader hears. */
- say: string;
- count?: number;
-}
-
-const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
-
-/**
- * What a message holds: an agent message's questions (open, picked and not
- * sent, or answered), its files and guided review; a person's reply's sent
- * annotations and whether it was delivered. Open and unsent states are words,
- * never colour alone.
- */
-export function holdingsOf(message: InboxMessageWire, options: { delivered: boolean; picked?: (q: InboxQuestion) => boolean }): Holding[] {
- const out: Holding[] = [];
- const questions = (message.questions ?? []).filter(live);
- const isPicked = options.picked ?? ((q: InboxQuestion) => q.state === 'picked');
- const picks = questions.filter((q) => q.state !== 'sent' && isPicked(q)).length;
- const open = questions.filter((q) => q.state !== 'sent' && !isPicked(q)).length;
- const sent = questions.filter((q) => q.state === 'sent').length;
- if (picks > 0) out.push({ kind: 'picks', text: `${plural(picks, 'pick')} in your draft`, say: `${plural(picks, 'pick')} in your draft`, count: picks });
- if (open > 0) out.push({ kind: 'open', text: `${open} open ${open === 1 ? 'question' : 'questions'}`, say: `${open} open ${open === 1 ? 'question' : 'questions'}`, count: open });
- if (sent > 0 && open === 0 && picks === 0) out.push({ kind: 'answered', text: `${sent} answered`, say: `${plural(sent, 'question')} answered`, count: sent });
- // Action items (approval "inbox-next-actions"): the ones still waiting on the person.
- const todos = (message.actions ?? []).filter((a) => a.state === 'open').length;
- if (todos > 0) out.push({ kind: 'todo', text: `${todos} action item${todos === 1 ? '' : 's'}`, say: `${todos} open action item${todos === 1 ? '' : 's'}`, count: todos });
- const files = message.attachments?.length ?? 0;
- if (files > 0) out.push({ kind: 'files', text: String(files), say: plural(files, 'attachment'), count: files });
- if (message.author.kind === 'person' && (message.sent_annotations ?? 0) > 0) {
- const n = message.sent_annotations!;
- out.push({ kind: 'annotations', text: String(n), say: `${plural(n, 'annotation')} sent`, count: n });
- }
- if (message.guide) out.push({ kind: 'guide', text: 'Guided review', say: 'a guided review' });
- if (options.delivered) out.push({ kind: 'delivered', text: 'Delivered', say: 'delivered' });
- return out;
-}
-
-/**
- * A person's message reached the agent: a connection delivered it as a turn,
- * or (a reply, not a New message) the asking agent read it.
- */
-export function isDelivered(message: InboxMessageWire, root: InboxMessageWire): boolean {
- if (message.author.kind !== 'person') return false;
- if (message.delivery) return true;
- if (message.to) return false;
- return typeof message.seq === 'number' && (root.agent_checked_seq ?? 0) >= message.seq;
-}
+export * from '@plannotator/core/inbox-folding';
diff --git a/packages/inbox/format.ts b/packages/inbox/format.ts
index a0c8027f0..a269238ce 100644
--- a/packages/inbox/format.ts
+++ b/packages/inbox/format.ts
@@ -1,118 +1,6 @@
-/** Small display helpers for the Inbox window. Pure. */
-
-import { inboxAgentName } from '@plannotator/core/inbox-types';
-
-/** How the person sees an agent: the name it gave, else its host's name, else "An agent" (core's rule, shared with the server's drafts). */
-export const agentName = inboxAgentName;
-
-const TIME = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
-const DAY = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' });
-const DAY_YEAR = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
-const FULL = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
-const COUNT = new Intl.NumberFormat('en-US');
-
-const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
-
-/** "Oct 3" this year, "Sep 2, 2025" another year: a day that never reads as this year's when it is not. */
-function dayOf(date: Date, now: Date): string {
- return date.getFullYear() === now.getFullYear() ? DAY.format(date) : DAY_YEAR.format(date);
-}
-
-/** "10:42 AM" today, "Oct 3" before, "Sep 2, 2025" in another year. */
-export function shortTime(iso: string, now: Date = new Date()): string {
- const date = new Date(iso);
- if (Number.isNaN(date.getTime())) return '';
- return sameDay(date, now) ? TIME.format(date) : dayOf(date, now);
-}
-
/**
- * When something happened, as a line in a thread says it: "8:22 PM" today,
- * "Oct 10, 8:22 PM" another day, "Sep 2, 2025, 8:22 PM" another year. A
- * yesterday's message and today's reply never both read "8:22 PM".
+ * Moved to @plannotator/core (packages/core/inbox-format.ts) unchanged, so a host of the
+ * shared thread parts reads the display helpers from core. This path re-exports it so every
+ * existing import and test keeps working.
*/
-export function whenTime(iso: string, now: Date = new Date()): string {
- const date = new Date(iso);
- if (Number.isNaN(date.getTime())) return '';
- return sameDay(date, now) ? TIME.format(date) : `${dayOf(date, now)}, ${TIME.format(date)}`;
-}
-
-/** "Friday, October 10, 2026 at 8:22 PM": the whole moment, for a time's hover text. */
-export function fullTime(iso: string): string {
- const date = new Date(iso);
- return Number.isNaN(date.getTime()) ? '' : FULL.format(date);
-}
-
-/** "1,284": a count with its thousands separated. */
-export function formatCount(n: number): string {
- return COUNT.format(n);
-}
-
-/** "today", "yesterday", else "Oct 3": a day in a sentence ("Asked by Claude Code, today"). */
-export function dayWords(iso: string, now: Date = new Date()): string {
- const date = new Date(iso);
- if (Number.isNaN(date.getTime())) return '';
- const start = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
- const days = Math.round((start(now) - start(date)) / 86_400_000);
- if (days === 0) return 'today';
- if (days === 1) return 'yesterday';
- return dayOf(date, now);
-}
-
-/** "Oct 3, 10:42 AM": a day and a time ("Sep 2, 2025, 10:42 AM" in another year). */
-export function dateTime(iso: string, now: Date = new Date()): string {
- const date = new Date(iso);
- return Number.isNaN(date.getTime()) ? '' : `${dayOf(date, now)}, ${TIME.format(date)}`;
-}
-
-/** Always the clock time ("10:42 AM"), for lines that say when within a thread. */
-export function clockTime(iso: string): string {
- const date = new Date(iso);
- return Number.isNaN(date.getTime()) ? '' : TIME.format(date);
-}
-
-/** "2 questions" from two up (the count in plain words); nothing for one or none. */
-export function questionCount(open: number): string {
- return open >= 2 ? `${formatCount(open)} questions` : '';
-}
-
-/**
- * A row's count of what waits on the person (approval "inbox-next-actions"):
- * "2 questions" as before, and an action item always named ("1 action item", "1 question,
- * 2 action items"), since it says why the agent waits before the thread is opened.
- * `open` counts questions and action items together, as the list's summary does.
- */
-export function waitCount(open: number, actions = 0): string {
- const todos = Math.min(Math.max(actions, 0), open);
- const questions = open - todos;
- if (todos === 0) return questionCount(questions);
- const todoWords = `${formatCount(todos)} action item${todos === 1 ? '' : 's'}`;
- if (questions === 0) return todoWords;
- return `${formatCount(questions)} question${questions === 1 ? '' : 's'}, ${todoWords}`;
-}
-
-/** "1 thread", "1,284 threads". */
-export function plural(n: number, one: string, many = `${one}s`): string {
- return `${formatCount(n)} ${n === 1 ? one : many}`;
-}
-
-/** "412.6 MB", "3.1 KB", "512 B". */
-export function formatBytes(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`;
- const units = ['KB', 'MB', 'GB', 'TB'];
- let value = bytes / 1024;
- let unit = 0;
- while (value >= 1024 && unit < units.length - 1) {
- value /= 1024;
- unit += 1;
- }
- return `${value.toFixed(1)} ${units[unit]}`;
-}
-
-/** An absolute path with the home folder written as `~`. */
-export function tildePath(path: string, home: string | null | undefined): string {
- if (!home) return path;
- const trimmed = home.replace(/[\\/]+$/, '');
- if (path === trimmed) return '~';
- if (path.startsWith(`${trimmed}/`) || path.startsWith(`${trimmed}\\`)) return `~${path.slice(trimmed.length)}`;
- return path;
-}
+export * from '@plannotator/core/inbox-format';
diff --git a/packages/inbox/openQuestions.ts b/packages/inbox/openQuestions.ts
index 720e2a069..44bfcc8b3 100644
--- a/packages/inbox/openQuestions.ts
+++ b/packages/inbox/openQuestions.ts
@@ -1,181 +1,6 @@
/**
- * Open questions inside one thread (approval "inbox-thread-open-questions",
- * option A, 2026-10-22): the "N open questions" button in the thread's foot
- * and the short list it opens. Pure: which questions are listed, how the
- * list names them, and when the button is shown at all.
- *
- * Listed: every question of a live agent message (not one an archive closed)
- * that waits on the person: open, or picked and not sent. Replaced questions
- * (`superseded`) are left out and counted apart; action items are not
- * questions and are left out. Answering stays on the real card: the list only
- * takes the person there.
+ * Moved to @plannotator/core (packages/core/inbox-open-questions.ts) unchanged, so a host of the
+ * shared thread parts reads the open questions list's rules from core. This path re-exports it so every
+ * existing import and test keeps working.
*/
-
-import type { InboxMessageWire, InboxQuestion } from '@plannotator/core/inbox-types';
-import { readInboxQuestionKind } from '@plannotator/core/inbox-types';
-import { inboxQuestionWaitsOnPerson } from '@plannotator/core/inbox-questions';
-import { isQuestionAnswerEmpty, type QuestionAnswer } from '@plannotator/core/question-block';
-import { formatCount } from './format';
-
-/** One row of the list. */
-export interface OpenQuestion {
- /** `/`. */
- id: string;
- messageId: string;
- key: string;
- prompt: string;
- /** The message's time (ISO), which the row names. */
- at: string;
- /** Picked and not sent: the row reads "Draft · ". */
- picked: boolean;
- question: InboxQuestion;
-}
-
-/** A question the person can still answer: not an action item, not a kind this window does not know. */
-const isQuestion = (q: InboxQuestion) => {
- const kind = readInboxQuestionKind(q.kind);
- return kind !== 'unknown' && kind !== 'action';
-};
-
-/** Open, or picked and not sent: core's one "waits on the person" rule (decision mode and the list read it too). */
-const waits = (q: InboxQuestion) => inboxQuestionWaitsOnPerson(q);
-
-/**
- * The thread's open questions, in thread order, and how many replaced ones
- * were left out. `live` says which messages still take answers.
- */
-export function threadOpenQuestions(
- messages: readonly InboxMessageWire[],
- live: (message: InboxMessageWire) => boolean = () => true,
-): { questions: OpenQuestion[]; replaced: number } {
- const questions: OpenQuestion[] = [];
- let replaced = 0;
- for (const m of messages) {
- if (m.author.kind !== 'agent' || !live(m)) continue;
- for (const q of m.questions ?? []) {
- if (!isQuestion(q)) continue;
- if (q.state === 'superseded') {
- replaced += 1;
- continue;
- }
- if (!waits(q)) continue;
- questions.push({ id: `${m.id}/${q.key}`, messageId: m.id, key: q.key, prompt: q.prompt, at: m.created_at, picked: q.state === 'picked' && q.answer != null, question: q });
- }
- }
- return { questions, replaced };
-}
-
-/**
- * Whether the button is shown: whenever a question waits (owner, 2026-10-23:
- * the list now also holds Re-ask, so it must never be out of reach), or Re-ask
- * is offered with no question listed (only action items wait).
- */
-export function openQuestionsShown(questions: readonly OpenQuestion[], reaskShown = false): boolean {
- return questions.length > 0 || reaskShown;
-}
-
-/** "5 open questions" / "1 open question"; `noun` "action item" when only action items wait. */
-export function openQuestionsLabel(n: number, noun: OpenQuestionsNoun = 'question'): string {
- return `${formatCount(n)} open ${noun}${n === 1 ? '' : 's'}`;
-}
-
-/** What the button counts: questions, or action items when no question waits. */
-export type OpenQuestionsNoun = 'question' | 'action item';
-
-/** The list's accessible name: "5 open questions in this thread" (not drawn). */
-export function openQuestionsHeading(n: number, noun: OpenQuestionsNoun = 'question'): string {
- return `${openQuestionsLabel(n, noun)} in this thread`;
-}
-
-/**
- * The Re-ask item at the list's foot (owner, 2026-10-23): "Re-ask in a new
- * message" when it asks for one thing again, else "Re-ask in one new message".
- */
-export function reaskItemLabel(asked: number): string {
- return asked === 1 ? 'Re-ask in a new message' : 'Re-ask in one new message';
-}
-
-/** Where a question's card sits, as a row says it. */
-export type OpenQuestionPlace = 'folded' | 'above' | 'below';
-
-export function openQuestionPlaceWords(place: OpenQuestionPlace): string {
- return place === 'folded' ? 'in a folded message' : place;
-}
-
-/**
- * Above or below, from the card's middle against the middle of the thread's
- * view: a card half in view still reads where the most of it is, and the
- * list never needs a fourth word.
- */
-export function placeOf(card: { top: number; bottom: number } | null, view: { top: number; bottom: number }): 'above' | 'below' {
- if (!card) return 'below';
- return (card.top + card.bottom) / 2 < (view.top + view.bottom) / 2 ? 'above' : 'below';
-}
-
-/** The state a row shows: the draft pick first, else Stopped, else Holds up, else nothing. */
-export type OpenQuestionState = { kind: 'draft' } | { kind: 'stopped' } | { kind: 'holds'; count: number } | null;
-
-export function openQuestionState(q: OpenQuestion): OpenQuestionState {
- if (q.picked) return { kind: 'draft' };
- if (q.question.stopped) return { kind: 'stopped' };
- if (q.question.holds_up.length > 0) return { kind: 'holds', count: q.question.holds_up.length };
- return null;
-}
-
-/** The row's question is skipped in the person's draft: the row goes quiet, and the card's Skip reads "Unskip". */
-export function openQuestionSkipped(q: OpenQuestion): boolean {
- return q.question.answer?.skipped === true;
-}
-
-/**
- * What the select mode's button does with the ticked rows (approval
- * "oq-skip-select", variant A): a ticked question not skipped yet is skipped,
- * a ticked skipped one is unskipped. Only questions that take a Skip count.
- */
-export function skipSelection(questions: readonly OpenQuestion[], ticked: ReadonlySet): { skip: OpenQuestion[]; unskip: OpenQuestion[] } {
- const skip: OpenQuestion[] = [];
- const unskip: OpenQuestion[] = [];
- for (const q of questions) {
- if (!ticked.has(q.id) || !openQuestionSkippable(q)) continue;
- (openQuestionSkipped(q) ? unskip : skip).push(q);
- }
- return { skip, unskip };
-}
-
-/**
- * What "Skip all N" covers: the open questions with nothing picked. A draft
- * pick, and a question already skipped, stay as they are.
- */
-export function skipAllCandidates(questions: readonly OpenQuestion[]): OpenQuestion[] {
- return questions.filter((q) => !q.picked && !openQuestionSkipped(q) && openQuestionSkippable(q));
-}
-
-/**
- * Whether a row can be skipped: exactly when its card offers Skip (an open or picked
- * question; a sent, closed or replaced one, and an action item, take none).
- * The list holds only such questions, so this is a guard, not a filter.
- */
-export function openQuestionSkippable(q: OpenQuestion): boolean {
- return isQuestion(q.question) && waits(q.question);
-}
-
-/**
- * What a Skip from the list saves: the answer the card's own Skip makes from
- * the same starting answer (ui's QuestionBlock `toggleSkip`, then its
- * `commit`). Skipped already: the skip comes off (Unskip), the note stays.
- * Otherwise the choices, Other and the text go and `skipped` is set, the note
- * staying. An answer left with nothing in it is null (the pick is cleared).
- * `base` is the card's: its saved or pending answer, else the question's
- * empty answer.
- */
-export function skipToggledAnswer(base: QuestionAnswer): QuestionAnswer | null {
- let next: QuestionAnswer;
- if (base.skipped) {
- const { skipped: _skipped, ...rest } = base;
- next = rest;
- } else {
- const { other: _other, text: _text, ...rest } = base;
- next = { ...rest, selected: [], skipped: true };
- }
- return isQuestionAnswerEmpty(next) ? null : next;
-}
+export * from '@plannotator/core/inbox-open-questions';
diff --git a/packages/ui/HANDOFF.md b/packages/ui/HANDOFF.md
index 61aeac0cd..2a82be362 100644
--- a/packages/ui/HANDOFF.md
+++ b/packages/ui/HANDOFF.md
@@ -2519,6 +2519,17 @@ Approval "inbox-decision-mode", agent contract lines (2026-10-24). All optional
- **Bridge** (additive, no `BRIDGE_PROTOCOL_VERSION` bump): the text-selection message gains `richCopy: true` and `textCut`; the parent posts `rich-copy-request` `{ id }` and the bridge answers `rich-copy` `{ id, html, text, truncated, tooLarge }`. An older bridge asset sends no `richCopy`, so its Copy stays plain text.
- **`BlockRenderer`** list items carry `data-rich-list-level`, `data-rich-list-kind`, `data-rich-list-index` and `data-rich-list-checked` (read by the list rebuild).
+## The Inbox thread's rules in core: `./inbox-folding`, `./inbox-delivery-line`, `./inbox-open-questions`, `./inbox-format` (next core release; additive, no ui change)
+
+Slice 1 of the Inbox thread extract (plan record, not in this repo). These pure modules moved from `packages/inbox` into core **unchanged**, so a host of the shared thread parts can use the Inbox's own rules. Only their import specifiers changed: `@plannotator/core/x` became `./x`, and `./format` became `./inbox-format`. Each is browser-safe and imports only core. The old paths (`packages/inbox/folding.ts`, `deliveryLine.ts`, `openQuestions.ts`, `format.ts`) are one-line `export *` shims, so every Inbox import and test runs unedited. No ui export changes yet. **Experimental** until Workspaces ships on them: the names and shapes follow the Inbox and may still change with it.
+
+- **`./inbox-folding`**: what folds when a thread opens and what a folded line says (`lookBoundary`, `planFolds`, `foldSnippet`, `holdingsOf` …).
+- **`./inbox-delivery-line`**: the words under the person's last reply (`deliveryLine`, `deliveryText`, `restartNote`, `RecipientConnection` …). It is not named `inbox-delivery` because `inbox-delivery.ts` already exists.
+- **`./inbox-open-questions`**: the open questions list's rules (`threadOpenQuestions`, `openQuestionsLabel`, `skipSelection` …). It needs `./inbox-format`'s `formatCount`.
+- **`./inbox-format`**: display helpers with fixed `en-US` `Intl` formats (`shortTime`, `whenTime`, `fullTime`, `formatCount`, `agentName` …).
+
+Publish core first, then ui, as always. These subpaths need no ui bump until ui imports them.
+
## The law (guardrails for anyone editing `@plannotator/ui`)
These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted.