feat(web): the developer's inbox on the service: items a plan's agents post, read and answered over two routes
Merged
DA
7d ago+2307+2307+0123456789012345678901234567890123456789-1-1-0123456789
Part 1 of 3 in the plan Inbox stack: #2161 (backend) ā #2162 (desktop Inbox tab) ā #2163 (agents post to it). This replaces #2103, which was too large to review well; its review threads are linked there.
Summary
Coding agents need decisions and manual steps from the developer, and those asks get lost in their transcripts. This PR adds the service side of a per-plan Inbox. It's inert on its own: no client calls the routes until #2162, and no agent posts until #2163.
- Storage: a new table,
inbox_item(migration0084), with one row per agent and key. Posting under a key the agent used before writes the same row again: the revision moves on and the item reopens if it was answered or closed. - Kinds: each item is a choice, an approval, a question, or a task the developer does by hand. Each says whether the agent needs to hear back. The vocabulary is declared once on the wire, in
@sidecar/hosted/inbox-wire, with JSON Schema goldens. GET /api/plans/{id}/inbox: a long-poll held open while nothing changes, like the transcript read.- It returns every open item, plus the latest settled ones up to a cap.
- Each poll inside the hold reads only each item's id, revision, status and update time. Full rows are read once something changes.
- The cursor is a digest of those fields, taken in id order. Every change moves it, even two within one millisecond, and it never moves while nothing changes.
POST /api/inbox/{id}: answers an item at the revision the developer saw.- The answer is recorded first, only if that revision still stands. Only then, if the item asks to be heard back, is it handed to the agent as one of the developer's messages, through the same steer path the composer uses, under the answer's own key.
- If the hand-off fails, is interrupted or dies, the recorded answer is undone. A retry under the same key hands it off again, and the steer's key makes it reach the agent once.
- An item whose agent's session has ended for good keeps its answer instead of waiting forever.
Evidence
./scripts/check.shpasses on this branch: knip, lint, discarded-effect, typecheck, all test shards, and the build with its bundle budget.- New:
tests/inbox-store.test.ts(5 tests) and 10 route tests intests/coding-agents-app.test.ts. They cover:- an answer recorded before it's handed off (this test fails on the old order);
- a cut-short hand-off finished by a retry;
- a failed hand-off undone, and an ended agent's item keeping its answer;
- an answer to a stale revision not recorded;
- the held read, the cursor's stability and same-millisecond changes;
- an open item kept in reads despite the settled cap.
Postgres migrations and storeruns these on real Postgres in CI.- No UI.
Invariants
-
./scripts/check.shgreen. - JSON Schema goldens: no existing golden changed; new ones recorded for
inbox-wire. - Test count: +15 tests, none removed.
- Every behavior change has, in this PR, a test that fails without it; no change-detector tests.
- Gateway envelope goldens unchanged.
- No new
asassertion outside the allowlisted files, noEffect.run*outside the runtime edges, no new raw async primitives outside the recorded files, and no Effect, Stream, or Layer dropped as a statement (anti-slop anddiscarded-effectpass). - No hand-rolled file replaced.
- No
CLAUDE.md/AGENTS.mdsentence names a changed part. - Package deps match imports; barrel/door rule kept (the renderer reaches only
@sidecar/hosted/inbox-wire, which is Schema only). -
PRIVACY.md: unchanged here. Nothing stores anything until #2163, which updates it.
š¤ Generated with Claude Code