feat(host-service,mobile): host sessions sync v1 — canonical sessions over tRPC snapshots + /sessions/sync, mobile rewired onto the sync client
Host sessions sync v1 — the canonical session plane (tRPC snapshots + /sessions/sync WS) with the mobile app fully rewired onto it, the legacy Mastra/ACP chat planes deleted from the phone, settings catalogs + pickers, and the ChatGPT-class chat scroll discipline with an on-device regression lab. Plan below, verbatim, including the shipped follow-ups.
Host Sessions Sync
Status: v1 — implemented on feat/sessions-v1-simplified-api, in team
review. This is the single source of truth for the host sessions surface:
the protocol spec (three review layers), rollout state, and the E2E user
story the suites assert against. It supersedes the previous wider protocol
draft and the separate rollout doc; everything cut was implemented and
tested first, so the cuts are deliberate removals of speculation, not
unbuilt wishes.
The spec is structured as three review layers. Layer 1 is the pitch — read
it alone to judge the approach. Layer 2 is the design — state ownership, the
exact surface, recovery, testing. Layer 3 is one realistic end-to-end
scenario with exact payloads. Rollout and the user story close the doc.
Layer 1 — Principles and approach
One host process per machine runs coding-agent sessions through vendor
harnesses (Claude Code today, more later). Phones and desktops are thin
replicas: they render state the host owns and submit intents. The protocol
between them is deliberately small:
Budgets (hard): 9 tRPC procedures · 12 socket packet types · 2 cursor
kinds · 1 automatic recovery path + 1 human hammer.
- The event log is the only truth in motion; everything else is a
projection. The host folds vendor output into canonical events; host
and client run the same pure reducer over them. No second state
machine to drift. - Mutations acknowledge admission, not completion. Every mutation
carries an idempotentrequestIdand returns a receipt; the outcome
arrives later as events whosecausationIdechoes the request. Retries,
double-taps, and races between devices are absorbed by receipts —
first write wins, losers get a clean stale error. - Big payloads ride tRPC; the socket carries only small frames.
Snapshots (session list, session state) are tRPC query responses. The
WebSocket carries individual events, acks, and control frames — nothing
that grows with session size. - Cursors are opaque and incarnation-tagged. Clients store and return
them verbatim, never parse or compare. A cursor from a dead log
generation fails loudly (reset) instead of silently misaligning. - Recovery is the cold path, not a special mode.
reseton a stream
triggers exactly what first-connect does: tRPC snapshot → subscribe from
its head. There is no separate resync state machine. When the host
itself is wedged, the existing desktop "restart host" button is the
answer — sessions re-hydrate from durable state; we add no intermediate
per-session recovery procedures. - Harness-agnostic entities. Canonical
Message/ContentBlock/
ToolCall/Threadshapes — not AI-SDK or ACP types — so the same wire
serves the ACP adapter today and a direct Claude SDK adapter next
without client changes.
Trajectory: one host control plane, terminals stay separate
This is host-level sync, not session-level sync — sessions are just the
first domain riding it.
- One control socket per host. The host stream on
/sessions/syncis
the host's control plane: small, ordered, cursor-replayable events for
everything the host owns. Today that is session/permission lifecycle;
workspace lifecycle (created/deleted/renamed), host status, and agent
availability join as new host-stream event types plus matching tRPC
snapshot procedures — never as new sockets or a second sync machine. - Terminal sync is a different animal and stays out. Terminals are a
throughput/data plane — raw PTY bytes, latency-sensitive, tolerant of
loss on reconnect — already served by the dedicated/terminal/*path.
Control and data planes never share a socket: a busy terminal must not
head-of-line-block a permission card, and PTY bytes must never pay the
event-log/cursor/replay overhead built for control events. - The client store extends the same way. The vanilla zustand store is
already host-scoped (one replica per host: connection, host stream,
sessionsById, per-session streams). New domains land as sibling slices
folded from the same host stream by the same reducer pattern — one
socket, one cursor discipline, one store per host.
Comparison at a glance
| Boundary choice | Strong at | Superset would still own | Best role |
|---|---|---|---|
| ACP | Agent ↔ editor interoperability, prompts, tools, permissions, capabilities, and agent-session lifecycle | Multi-device subscriptions, host-wide state, nested threads, workflow and subagent fidelity, cursor recovery, and non-chat domains | Host ↔ vendor harness |
| AI SDK UI | Streamed message parts, tool and approval state, typed UI state, chat hooks, and reusable components | Host multiplexing, persistence, multi-client arbitration, pagination and search, watcher events, and recovery | Canonical projection ↔ product UI |
| Superset-owned contract | Exact product entities, one host control plane, deterministic convergence, and additive product evolution | Schemas, reducer, client store, compatibility policy, and regression tests | Public host ↔ client boundary |
Why the host contract is product-owned
ACP and AI SDK solve valuable but narrower problems than this contract.
ACP standardizes communication between an editor/client and an agent. AI SDK
UI standardizes how one chat consumes and renders a model response stream.
Superset must synchronize an entire host across multiple clients: session
directory and metadata, nested threads, permissions, client tools, workflows,
host status, workspace lifecycle, and future host-owned domains.
Using ACP or AI SDK as the public host wire would therefore not remove a
Superset protocol. Superset-specific semantics would move into _meta, custom
data parts, extension methods, or a second side channel. The result would have
two nested envelopes, two versioning surfaces, and more intermediate state,
while the product contract would still be ours in practice.
The product-owned boundary gives us:
- explicit
subscribe/unsubscribesemantics for many streams over one
host connection; - multi-device arbitration, including idempotent intents and first-resolution
wins; - first-class
Thread, workflow, subagent, and future side-chat semantics even
when the active harness does not expose them; - one cursor, replay, reset, and cold-load discipline for every host control
domain; - room for search, pagination, file-watcher invalidations, workspace updates,
and other host capabilities without pretending they are chat messages; - one canonical reducer and one client replica instead of an upstream chat
state machine wrapped inside a second product state machine.
The transport split follows the data:
- tRPC carries snapshots, queries, and idempotent commands;
/sessions/synccarries small ordered control events and replay;/terminal/*remains a separate throughput-oriented data plane for raw PTY
bytes.
This is not a rejection of OSS. Upstream implementations are inputs, not the
source of product truth. We can vendor AI Elements, reuse AI SDK interaction
patterns, and align canonical shapes with ACP where the semantics match.
Upstream types terminate at adapters rather than leaking into the public
contract.
ACP remains useful at the vendor boundary today because the Claude and Codex
adapters reduce integration work and are already proven. Moving an adapter
in-process or driving a vendor SDK directly is a reversible optimization when
adapter lag, lost semantic fidelity, process complexity, or debugging cost
justifies it. SDK and adapter bumps run through the same real-model gym,
deterministic protocol suites, and Maestro regression story before shipping.
Testing approach
- Validation spine: one scripted "gym" user story (streaming, interrupt,
permission cards, always-allow, multi-select questions, subagent,
workflow, parallel sessions) replayed over every execution surface:
(1) the shipping ACP surface, (2) this protocol with a real model,
(3) the direct SDK adapter, (4) a phone via Maestro. Same story, four
wires — divergence is a bug by definition. - Deterministic fakes for the nasty parts: a scripted fake ACP adapter
(real JSON-RPC over stdio, no model) drives restart/crash/eviction
scenarios in milliseconds insidebun test. - Stress oracles: the sync hub runs a randomized multi-client
drop/reconnect/eviction storm; an oracle asserts byte-parity between what
the host holds and what every client converged to. - Adversarial review loop: every protocol invariant that survives review
(internal + external AI reviewers + second-opinion codex runs) becomes a
ZodsuperRefinethe host enforces at runtime on inputs and outputs —
the contract is executable, not prose.
Layer 2 — Design
Where real state lives
clients (N per user) host (1 per machine) vendor harness
┌─────────────────────┐ tRPC ┌────────────────────────────┐ ACP ┌──────────────────┐
│ sync client store │───────► │ sessions.* router │─────► │ adapter child │
│ (zustand replica, │ muts + │ CanonicalSessionsRuntime │ │ (1 per live │
│ DISPOSABLE) │ snaps │ · event log (RAM ring) │ ◄──── │ session) │
│ │ │ · projection │replay │ │
│ │ ◄────── │ SyncHub (cursors, streams) │ │ transcript JSONL │
└─────────────────────┘ WS │ SQLite registry (DURABLE) │ │ (DURABLE, vendor │
/sessions/sync events └────────────────────────────┘ │ owns it) │
└──────────────────┘
| Store | Durability | Owns | Rebuilt from |
|---|---|---|---|
| SQLite registry (host) | survives host death | which sessions exist: id → workspace, native session id, harness | — (truth) |
| SQLite canonical session meta (host) | survives host death | top-level host-side session edits: title override, archivedAt, closedAt | — (truth) |
Vendor transcript (~/.claude/... JSONL) | survives host death | conversation content — deliberately the ONLY place messages persist | — (truth) |
| Host RAM: canonical event log + projection | dies with host | live event stream, cursors, pending cards | registry + vendor replay (session/load), under a fresh log generation |
| Client store | disposable | rendered replica | tRPC snapshot + WS replay |
The invariant that keeps recovery simple: every layer is rebuildable from
the layers to its right. Nothing a client holds is ever authoritative;
nothing in host RAM is unrecoverable.
Control flow (high level)
COLD PATH (= first connect, = reconnect after reset, same code):
tRPC sessions.list() ──► sidebar state + host-stream head cursor
tRPC sessions.get(id) ──► session snapshot + session-stream head cursor
WS subscribe(after: head) ──► subscribed → replay gap → caughtUp → LIVE
LIVE:
user intent ──► tRPC mutation (requestId) ──► receipt (admission)
outcome ──► WS events (causationId = requestId) ──► reducer ──► UI
RECOVERY:
stream reset ──► re-run cold path for that one stream (others untouched)
reset loop ──► circuit breaker: stream status = error, stop, surface to UI
host wedged ──► human presses "restart host" (existing desktop feature)
registry survives → sessions listed as offline
first live mutation → spawn adapter → session/load replays
transcript from vendor disk → canonical log rebuilt
tRPC surface — 9 procedures
All wire identifiers (packet types, event types, enum values) are
camelCase. Every mutation input carries requestId; receipts are
admission-acks.
| Procedure | Shape |
|---|---|
list → host snapshot | {} → { sessions: Session[], pendingPermissions: PermissionRequest[], openClientToolCalls: ToolCall[], head: Cursor | null } — all non-archived sessions, no paging. Ungated capability probe: gated-off hosts return empty arrays, head: null. |
get → session snapshot | {sessionId} → { session, threads[], activeTurns[], pendingPermissions[], openToolCalls[], recentEvents[], hasOlderEvents, head } — recentEvents is a ≤ 50 tail ending exactly at head; hasOlderEvents tells the client whether getEvents paging has anything to fetch |
create | {requestId, workspaceId, agentId, title, settings} → {session, mainThread} |
update | {requestId, sessionId, title?, archived?, settings?} — one patch mutation for title/archive/model/mode/effort (absent = unchanged) |
getEvents | {sessionId, threadId?, beforeCursor?, limit ≤ 100} → EventsWindow — backwards-only scrollback; no cursor = newest window |
submitTurn | {requestId, sessionId, threadId, content: ContentBlock[]} → {status: "accepted", turnId} |
cancelTurn | {requestId, sessionId, turnId} → receipt |
resolvePermission | {requestId, sessionId, permissionId, outcome} — first write wins |
resolveToolCall | {requestId, sessionId, toolCallId, outcome} — client tools (ask_user); no claims: the card renders on every device, first resolve wins, others drain via events |
EventsWindow: { items ≤ 100 ascending, range: { oldest, newest, hasMoreBefore, truncatedBefore }, head }. Boundaries are full
{eventId, cursor, occurredAt} triples, schema-verified against the actual
first/last items. Pages come from the in-memory log (messages are
deliberately not persisted on the host); truncatedBefore: true = older
history existed but the source journal evicted it before the host observed
it.
Sync socket — 12 packet types, all small
client → hello (protocolVersion, clientInstanceId, toolResolvers),
subscribe (subscriptionId, stream, after: Cursor — required),
unsubscribe, toolResolversChanged, ping
server → helloAck (hostId, connectionId), subscribed, event,
caughtUp, unsubscribed, reset (code, recovery), error, pong
There are no snapshot packets. subscribe.after always comes from a
tRPC response (list/get/getEvents all return head). Subscribing with
an unservable cursor yields reset, and the client re-runs the cold path.
The hot-path frame is event: ~250–600 bytes typical. The only bounded
exception: tool-call rawInput/rawOutput up to the 256 KB cap (oversize
values are dropped to null at translation, never truncated mid-JSON).
Events:
- host stream (6):
sessionUpsert,sessionRemoved,
permissionAvailable,permissionResolved,clientToolCallAvailable,
clientToolCallResolved. All are idempotent upserts/removals keyed by
entity id — replay overlap after a tRPC snapshot is harmless by
construction. - session stream (16):
threadCreated/Updated,
turnStarted/Completed/Failed/Cancelled,
messageStarted/Delta/Completed,toolCallStarted/Updated,
permissionRequested/Resolved,planUpdated,settingsUpdated,
error. Envelope:{id, sessionId, threadId, cursor, occurredAt, causationId, payload}; dedup byid.
Cursors and incarnations — 2 kinds
| Cursor | Format | Minted by |
|---|---|---|
| host stream | h<boot-id>-<serial> | SyncHub, per host boot |
| session events | c<generation>-<serial> per session; same value in live stream, snapshot head, getEvents boundaries | CanonicalSessionsRuntime |
An incarnation is one lifetime of a log's owner. Host cursors embed the
boot id, so a restarted host deterministically rejects stale cursors
(CURSOR_INVALID). Session cursors embed the log generation, minted
fresh on every tracking: the canonical log is rebuilt from vendor replay
each time (content is deliberately not persisted host-side), so a cursor
from a previous tracking — usually a dead host process — fails the same
deterministic way (CURSOR_INVALID → snapshot rebuild) instead of
silently aliasing a serial in the new log. The bare zero cursor
c000000000000 stays generation-less: it is the head of a never-tracked
session's snapshot and means "from the very beginning". Session-list
paging cursors were deleted along with list pagination.
Recovery model — two paths, one guard
- Cold-load (automatic, per-stream, cheap). Triggers: ring eviction
(client slept), host restart, session resurrection, upstream adapter
resync.resetnames one stream; only it re-fetches. Handler = the
first-connect code path, literally. - Host restart (human hammer). Already a desktop button. The registry,
the canonical session meta (titles, archive/close state), and the vendor
transcripts survive; sessions come backoffline; the first live
mutation resurrects (spawn adapter →session/load→ vendor replays its
own disk → canonical log rebuilt under a fresh generation). Old session
cursors resetCURSOR_INVALIDinto the snapshot cold path. Reads never
resurrect — listing 50 dead sessions spawns 0 children.
Guard: reset circuit breaker — N resets on one stream within a window →
stream status error, stop auto-resubscribing, surface to UI. A reset loop
is by definition a host bug; clients must stop, not spin.
Read-consumption reality (retention design note)
Humans re-read user messages, attachments, and each turn's final assistant
response. Old turns' tool calls and deltas are write-only in practice.
Consequences: client eviction drops old-turn tool events/deltas first,
keeping a meaningful "skeleton"; tool payloads are already separable
(nullable, capped); a future condensed/skeleton getEvents mode is an
additive parameter. Not in v1 — plain backwards paging at 100
events/page is enough.
Decision (2026-07-12): the host SQLite persists no messages or events —
only top-level session metadata (title/archive/close, in
session_meta) plus the existing registry. Conversation content has
exactly one durable home, the vendor's own transcript, resumed via the
native session id. Delete semantics follow: metadata rows whose session
left the registry are swept at startup; archive/close never delete
anything — sessions reopen.
What was cut from the previous draft (and why)
| Cut | Why |
|---|---|
Claim/lease machinery (claimToolCall, claims, claimed state, claimId) | askUser pops on all devices by design; first resolve wins; receipts already make the loser's submit a clean stale error |
Session.attention[] | client-derived; the low-level pendingPermissions/openClientToolCalls in the host snapshot are the primitives |
WS snapshot packets (hostSnapshot, sessionSnapshot) | duplicated the tRPC list/get outputs; snapshots are big and belong on tRPC |
search | no consumer |
createSideChat + sideChat thread kind | no UI plans it; re-adding is additive |
getThread, listThreads | snapshots + thread events already deliver threads |
list pagination | session lists are small; deletes a whole cursor kind |
getEvents anchors (time/event) + forward paging | only backwards-from-latest is real usage |
separate updateSettings | one patch mutation |
Test evidence and work needed
Already proven on THIS surface (all automated, bun test, green on
feat/sessions-v1-simplified-api + feat/sessions-durable-store):
- Realistic-scenario suite (
sessions-sync-client.integration.test.ts,
11 tests, real host + real WebSocket, fake adapter): cold connect via
tRPC snapshots, streaming turns with causation receipts, permission
cards, offline replay across a mid-stream disconnect, a fresh client
converging from nothing, archiving, scrollback paging until the store
equals the host's full journal, session switching under load, and a
host reboot over the same host DB — canonical titles survive via the
metadata table, every cursor (host boot id, session log generation) is
rejected with exactly onereset(CURSOR_INVALID, refetchSnapshot)per
stream, the cold path re-seeds each replica from the rebuilt log, no
duplicated event ids, streaming resumes. The reboot test also pins the
causationId-loss trade-off of the no-messages-in-SQLite decision. - Session metadata store (
canonical-sessions.durable.test.ts):
title/archive/close overrides survive host reboots (archived rows stay
out of the host snapshot scope), createSession titles persist, rebuilt
logs mint a new generation whose foreign cursors reject
deterministically while the zero cursor stays servable, and the startup
orphan sweep drops metadata for sessions the registry no longer knows. - Circuit breaker: >3 resets in 30 s parks the stream in
error
(RESET_LOOP) and stops auto-resubscribe; cleared on reconnect. - Hub: foreign/evicted cursor → reset → cold path; two-hub restart
collision; send-failure drop; dispose idempotence; randomized stress
storm with a byte-parity oracle (required-aftergrammar). - Client store: reset handling, snapshot re-seed, paging never resurrects
resolved cards, mid-flight fetch races, never-subscribed packet drops,
reconnect backoff, socket loss downgradeslive→idle(data kept). - Runtime: offline sessions — passive reads synthesize (
head= zero
cursor, empty tail), live paths resurrect (fake adapter); backwards
paging withhasMoreBefore/truncatedBefore; mergedupdatereceipts. - Gym user story 10/10 with a real Opus instance over sessions.* +
/sessions/syncon this exact surface (tRPC-seeded client, claimless
question cards, mergedupdatefor the acceptEdits switch, final parity
oracle that pages the whole journal backwards and deep-equals it against
the client fold; zero drops, zero resets).
Work needed (details in the Rollout section below):
- React bindings package; mobile rewire (keep chat UI, kill
session-protocoldata plane); Maestro phone replay (execution 4). - Real OS-process host kill/respawn journey test.
Layer 3 — One realistic scenario, exact payloads
Phone opens the app, drives a turn with a permission card, loses the
network mid-stream, recovers, then the host is restarted and the session
resumes. (This is the shape of the automated scenario test; the gym E2E
runs the same surface with a real model.)
1 — Cold connect. Phone calls sessions.list:
// → response (host snapshot; also the capability probe)
{
"sessions": [ {
"id": "ses_01", "workspaceId": "ws_gym", "title": "Fix relay test",
"mainThreadId": "thr_main", "agent": {"id": "claude-code", "displayName": "Claude Code"},
"runState": "idle",
"capabilities": { "threadModel": "nested", "threadFidelity": "partial",
"canResume": true, "supportsPermissions": true,
"supportsModes": true, "supportsModels": true },
"settings": { "activeModel": "opus[1m]", "activeMode": "default", "effort": null,
"configuration": {} },
"eventHead": "c000000000512",
"createdAt": 1783532100000, "updatedAt": 1783532400000,
"lastActivityAt": 1783532400000, "archivedAt": null, "closedAt": null,
"error": null } ],
"pendingPermissions": [],
"openClientToolCalls": [],
"head": "h1734-000000000381"
}
Phone opens the socket and subscribes to the host stream from head; user
taps the session, phone calls sessions.get("ses_01") (snapshot above) and
subscribes the session stream from its head:
{ "type": "hello", "protocolVersion": 1, "requestId": "req_h1",
"clientInstanceId": "cli_phone", "clientVersion": "1.15.0",
"toolResolvers": [ { "toolName": "ui.ask_user", "version": 1 } ] }
{ "type": "helloAck", "requestId": "req_h1", "hostId": "org_7", "connectionId": "conn_a" }
{ "type": "subscribe", "requestId": "req_s1", "subscriptionId": "sub_host",
"stream": { "type": "host" }, "after": "h1734-000000000381" }
{ "type": "subscribed", "subscriptionId": "sub_host", "cursor": "h1734-000000000381" }
{ "type": "caughtUp", "subscriptionId": "sub_host", "cursor": "h1734-000000000381" }
{ "type": "subscribe", "requestId": "req_s2", "subscriptionId": "sub_ses1",
"stream": { "type": "session", "sessionId": "ses_01" }, "after": "c000000000512" }
{ "type": "subscribed", "subscriptionId": "sub_ses1", "cursor": "c000000000512" }
{ "type": "caughtUp", "subscriptionId": "sub_ses1", "cursor": "c000000000512" }
2 — A turn with a permission card. User sends a message:
// tRPC sessions.submitTurn
{ "requestId": "req_t9", "sessionId": "ses_01", "threadId": "thr_main",
"content": [ { "type": "text", "text": "run the failing test and fix it" } ] }
// receipt — admission only
{ "status": "accepted", "turnId": "trn_10" }
Events stream on sub_ses1 (envelope shown once, then payloads only):
{ "type": "event", "subscriptionId": "sub_ses1", "stream": "session",
"cursor": "c000000000513",
"event": { "id": "evt_513", "sessionId": "ses_01", "threadId": "thr_main",
"cursor": "c000000000513", "occurredAt": 1783532410000,
"causationId": "req_t9",
"payload": { "type": "turnStarted", "turn": { "id": "trn_10",
"sessionId": "ses_01", "threadId": "thr_main", "status": "accepted",
"originatingClientInstanceId": "cli_phone",
"createdAt": 1783532410000, "updatedAt": 1783532410000 } } } }
// … subsequent payloads:
{ "type": "messageStarted", "message": { "id": "msg_u1", "role": "user", "content": [ … ], … } }
{ "type": "messageStarted", "message": { "id": "msg_a1", "role": "assistant", "content": [], … } }
{ "type": "messageDelta", "messageId": "msg_a1", "content": { "type": "text", "text": "Running the test" } }
{ "type": "toolCallStarted", "toolCall": { "id": "tc_bash1", "tool": { "name": "bash", "version": 1 },
"title": "bun test relay.test.ts", "input": { "command": "bun test relay.test.ts" },
"resolver": { "type": "host" }, "state": "awaitingPermission",
"parentToolCallId": null, "turnId": "trn_10", … } }
{ "type": "permissionRequested", "permission": { "id": "perm_1", "toolCallId": "tc_bash1",
"multiSelect": false, "options": [
{ "id": "allow_once", "name": "Allow once", "kind": "allowOnce" },
{ "id": "allow_always", "name": "Always allow", "kind": "allowAlways" },
{ "id": "reject_once", "name": "Reject", "kind": "rejectOnce" } ],
"requestedAt": 1783532412000 } }
The card shows on the phone AND the desktop (no claims). Phone answers
first:
// tRPC sessions.resolvePermission (desktop's later attempt → NOT_FOUND, its card drains)
{ "requestId": "req_p1", "sessionId": "ses_01", "permissionId": "perm_1",
"outcome": { "type": "selected", "optionIds": ["allow_once"] } }
{ "type": "permissionResolved", "permissionId": "perm_1",
"outcome": { "type": "selected", "optionIds": ["allow_once"] } }
{ "type": "toolCallUpdated", "toolCallId": "tc_bash1",
"update": { "state": "running", "updatedAt": 1783532413000 } }
{ "type": "toolCallUpdated", "toolCallId": "tc_bash1",
"update": { "state": "succeeded", "output": { "exitCode": 0 }, "updatedAt": … } }
{ "type": "messageDelta", "messageId": "msg_a1", "content": { "type": "text", "text": " — passes now." } }
{ "type": "messageCompleted", "messageId": "msg_a1" }
{ "type": "turnCompleted", "turnId": "trn_10", "stopReason": "endTurn" }
3 — Mid-stream disconnect. Phone loses network after c…525; the agent
keeps streaming on the host. Phone reconnects (exponential backoff), replays
the gap, converges:
{ "type": "subscribe", "requestId": "req_s3", "subscriptionId": "sub_ses1b",
"stream": { "type": "session", "sessionId": "ses_01" }, "after": "c000000000525" }
{ "type": "subscribed", "subscriptionId": "sub_ses1b", "cursor": "c000000000525" }
// events c…526 … c…541 replay here; dedup by event.id absorbs any overlap
{ "type": "caughtUp", "subscriptionId": "sub_ses1b", "cursor": "c000000000541" }
4 — Long sleep → reset → cold path. Phone returns after hours; the ring
(5k events) evicted its cursor:
{ "type": "subscribe", "requestId": "req_s4", "subscriptionId": "sub_ses1c",
"stream": { "type": "session", "sessionId": "ses_01" }, "after": "c000000000525" }
{ "type": "reset", "subscriptionId": "sub_ses1c", "sessionId": "ses_01",
"code": "CURSOR_INVALID", "recovery": "refetchSnapshot" }
// phone re-runs the cold path: tRPC get → subscribe(after: new head). One stream,
// one refetch; every other stream on the socket is untouched.
If the host answered every such subscribe with another reset (a host bug),
the circuit breaker trips after N attempts: the stream goes error, the UI
shows a retry affordance, nothing spins.
5 — Host reboot. User restarts the host (desktop button). The socket
closes; the phone's store keeps rendering its replica. On reconnect,
sessions.list still shows ses_01 — the SQLite registry survived, and
its canonical title/archive state survived in session_meta — with
runState: "offline". The phone's cursors all belong to dead incarnations:
the host-stream cursor h1734-… fails parsing under boot h1802, and the
session cursor's log generation died with the old process (the canonical
log is rebuilt from the vendor's session/load replay, never from host
disk) → reset(CURSOR_INVALID) → cold path per stream. User types a new
message → submitTurn → the host spawns a fresh adapter child, issues
session/load with the stored native session id, the vendor replays its
own on-disk transcript, the canonical log is rebuilt (new generation, new
event ids, fresh cursors), and events flow again. A turn that was
mid-flight when the host died is terminalized (turnFailed), not silently
resumed — the user resubmits.
Known, deliberate limitation (pinned by the reboot test): the rebuilt
canonical log loses causationId attribution. Request→event linkage is
armed in the serving process; the vendor journal doesn't record it, so
every rebuilt event carries causationId: null. Receipts and UI still
work (they key on entities, not causation) — the trade for keeping
messages out of host SQLite entirely.
Open questions for review
- Snapshot
recentEventstail size (trade first-paint completeness vs
response size over the relay) — shipped default: 50. - Circuit-breaker N and window — shipped default: 3 resets / 30 s
(maxStreamResetsPerWindow/streamResetWindowMs, overridable per
client). - Session-cursor incarnation tag ships with the durable store (#10), or
block v1 on it? Resolved: shipped as the log generation tag
(c<generation>-<serial>), minted per tracking — no persistence
needed, since the log itself is never persisted. - Condensed/skeleton history mode: v1.1+ (taxonomy already supports it).
- Should a durable event log ever ship (restoring causationId across
reboots, scrollback beyond the vendor replay window)? Decided against
for now — no messages in host SQLite; revisit alongside the direct SDK
adapter if its native store changes the calculus.
Rollout
Where this stands (2026-07-12):
Done (on feat/sessions-v1-simplified-api, all suites green plus a
real-model E2E): the protocol package, the sessions.* router and
/sessions/sync hub, the shared projection reducer, the framework-free
client store with reset circuit breaker, the 11-test realistic-scenario
suite (disconnect, scrollback parity, session switching, host reboot), and
the gym story 10/10 with a real Opus instance.
Done (on feat/sessions-durable-store, stacked): rollout item 1,
rescoped by decision (2026-07-12) to durable session metadata, not a
durable event log — messages/events are deliberately kept OUT of host
SQLite; the vendor transcript stays the only content store, resumed via
the native session id. Shipped: the session_meta metadata table
(title/archive/close overrides survive host restarts, write-through,
loaded at construction, orphan-swept at startup) and generation-tagged
session cursors (c<generation>-<serial>, minted per tracking) so a
rebooted host deterministically rejects stale session cursors with
CURSOR_INVALID → cold path, exactly like host cursors. Deferred with
the event log: causationId parity across reboots and scrollback beyond
the vendor replay window (both pinned as for-now limitations in the
reboot test).
Direction: ACP and the Mastra chat stack are both being retired from
this path. The host serves this surface; the ACP bridge behind it gets
replaced by a direct Claude SDK adapter with no client-visible change, then
removed. Mastra chat stays in the tree only so existing desktop users keep
working — frozen, no new features, and never used by mobile.
Next, in order:
-
Durable history store — done as durable session metadata +
cursor generations, see above. -
React bindings and the mobile rewire — done (2026-07-12). Shipped:
packages/host-service-react(provider + zustand-backed hooks +
useSessionTimelinewith incremental fold), the framework-free
transcript fold athost-service-sync/timeline(canonical events →
message/tool-call/plan items, thought-splitting, subagent nesting,
pending-permission cards), andpackages/host-client'ssessions
module (command plane +syncApi/syncUrlfacades). Mobile's chat
screens keep their shipped UI but ridesessions.*+/sessions/sync
over the relay (verified live end-to-end against a dev host through the
relay tunnel — the relay WS proxy doesn't negotiate subprotocols, so
the mobile socket factory requests none). DELETED with the rewire: the
acpSessions.*router, the per-session/acp-sessions/:id/stream
route,host-client/acp, session-protocol'sclient/reactsubpaths,
and their surface tests.packages/session-protocolitself shrinks to
the ACP-bridge contract (acp/api/envelope/fold/state) still imported by
the AcpSessionManager + canonical translator — it goes with item 3, not
before.Follow-up shipped (2026-07-13): settings catalogs, pickers, and the
full mobile plane kill. The session entity gainedsettingOptions—
harness-reported catalogs ({id, name, kind: model|mode|effort|other, currentValue, options[{value,name,description}]}) composed live from
the adapter's config options (grouped selects flattened, mode
synthesized fromcurrentModewhen not mirrored, booleans skipped as a
v1 reduction). Catalogs ride the composed session (snapshots +
session_upsert), never the event log; the translator's settings
change-key includes the catalogs so a catalog-only rebuild still moves
the log and re-pushes the entity. The mobile composer's mode/model/
effort chips are back (genericOptionPickeroversettingOptions,
writes throughsessions.update), verified on-sim: chip → native sheet
→ live model switch reflected in the chip. The NewChatWidget's
new-workspace flow moved off the legacyworkspaces.createagents
sugar ontoworkspaces.create(no agents) +sessions.create+
submitTurn(image attachments ride the first turn as canonical image
blocks), with the widget's model pick passed as adapter aliases (the
shared "claude" preset: fable/opus/sonnet/haiku —SUPERSET_CHAT_MODELS
Mastra ids are gone from mobile). And the old planes are now fully
deleted from the phone: the Mastra chat thread screen
(chat/[sessionId]UI +useChatThread+ cloudchat_sessions
Electric collection + theHostChatClientchat facade) is gone, the
canonical thread now owns thechat/[sessionId]route (thechat/acp/
path segment is dead), and the session-row menu's rename/archive go
throughsessions.update. Verified end-to-end on-sim: widget → new
workspace → canonical thread → real agent reply, with the picked model
active.Follow-up shipped (2026-07-13): chat scroll discipline + thread
polish. TheConversationcomponent now implements the ChatGPT-class
scroll contract — the list moves only on user intent: drag-begin kills
following instantly; re-pinning requires a real gesture resting at the
true end (programmatic scrolls also emit momentum-end and must never
re-pin); send anchors the message to the top with whitespace for the
reply (bottom-relative target + corrective settle legs, exact even when
sent from deep in history); streaming, content re-measure dips, the
permission stack appearing/resolving, and the keyboard hiding never
shift an unpinned reader — trailing scroll range is pre-banked as a
nativecontentInset.bottomsized against the largest viewport seen
plus dip slack (insets fire no layout events, which kills the
spacer-view feedback loop), and every scroll target derives from the
native content height, never LegendList's estimates
(initialScrollAtEnd/maintainScrollAtEnd/scrollToEndare banned —
they chase estimated totals into blank space). Invariants are documented
inapps/mobile/components/ai-elements/AGENTS.mdand enforced by a
hidden regression lab (superset://debug/conversation-lab+
.maestro/flows/conversation-lab.yml) whose five scripted scenarios
(pin-follow, no-move-unpinned with dips, overlay resolve, send anchor,
send anchor from history) self-assert PASS/FAIL on-device. Thread
polish riding along: tool rows title with the model-written command
description(raw command stays in the detail sheet), GitHub-style
+added/−removed line badges on file-edit tool rows (input-derived,
tested), the running tool title breathes with a seam-free sine pulse,
the working indicator is a caret-sized boids flock (six dots milling in
an elliptical pen, speed-fade alpha, UI-threaduseFrameCallbackport
of the web caret), and the glass header title widens to 70% of the
screen before truncating. Cold thread load paints the FULL last page in
one frame:sessions.getnow resurrects on read so the snapshot always
carries the recent tail + real head (the old zero-head fallback made the
hub stream the entire replayed transcript event-by-event — the thread
visibly built up from the first message), the thread holds a spinner
until that one-shot page lands, and the list mounts invisible until the
page is measured and parked at the bottom (quiet-gate + hard cap) —
older history staysgetEventsscrollback territory. -
Direct Claude SDK adapter behind the same surface (prototype on the
claude-sdk-session-harnessbranch, PR #5582): run both adapters
through the same real-model story, switch, then remove the ACP bridge
(AcpSessionManager, translate-acp, and the rest of
packages/session-protocol). -
Maestro phone replay of the story (execution 4) as the final gate.
Gates: bun run lint and typecheck exit 0; the story passes on every
lane it applies to; teardown leaves no process, socket, DB handle, or temp
directory; the documented memory caps are measured with a transcript beyond
those caps.
E2E user story (the validation spine)
One realistic session, in user terms. It must pass with a real Claude
instance on each surface it migrates across: (1) the shipping ACP surface —
acp-user-story.integration.test.ts; (2) sessions.* + /sessions/sync —
sessions-user-story.integration.test.ts; both pass 10/10 with real Opus
today; then (3) the direct SDK adapter, and finally (4) replayed from a real
phone via Maestro.
The gym project
The story runs in a disposable "gym" repo the harness provisions per run
(packages/host-service/test/helpers/gym.ts):
notes.txt— known data (fixture_id,values=13,29,expected_sum=42)
that the subagent and workflow steps read, so results are assertable..claude/workflows/acp-e2e-dummy.js— the saved five-agent workflow
(inspect → analyze → audit ×2 → verify) overnotes.txt..claude/skills/gym-check/SKILL.md— a trivial skill that reads
notes.txtand prints a sentinel.scripts/ok.shandREADME.md— a runnable script and an editable file,
so two different tools (Bash vs file edit) can each trigger a
permission card.
Run the story with the model pinned to opus: the scripted prompts depend
on precise instruction-following.
The first message
Sent verbatim as the session's initial prompt (the story tests quote this
block):
You are a real Claude instance running inside an automated end-to-end test of
Superset's session harness. This workspace is a disposable "gym" repo created
for this run; nothing in it is real product code. The test exercises the
harness around you: streaming, interrupts, permissions, questions, subagents,
workflows, skills. Follow every instruction in this conversation literally and
minimally, use exactly the tools named, and reply with the exact sentinels
requested. Start now: write a continuous ~600-word tour of this repository in
plain prose, using no tools, and end with the line TOUR_DONE.
The steps
- Create the gym workspace — worktree provisioned, no agent process yet.
- Start a session with the first message, model pinned to
opus,
permission modedefault(never bypass). - The ~600-word tour streams incrementally — no gaps, no duplicates; other
connected devices see the same stream live. - Stop mid-stream — the turn is cancelled, partial text stays, session
returns to idle, nothing left pending. continueresumes the same conversation and reachesTOUR_DONE.- A Bash run raises a permission card (allow once / always allow /
reject); the session reads "needs attention", not "stuck". - Allow once — the card resolves on all devices at once, the script runs,
RUN_OK; a second tap gets "already answered", never a double
execution. - Always-allow on a rerun — the choice sticks for this session.
- Two more runs auto-approve with zero new cards.
- A README edit raises a fresh card — always-allow was scoped per tool.
- Stop, switch the session to acceptEdits,
continue— the edit applies
with no card; every tool call still lands in the transcript. - AskUserQuestion with two questions — cards arrive one at a time, and
the turn shows "waiting for you", not running. - Answer blue on the first, Skip the second — each answer registers
exactly once; the agent repliesANSWERS blue. - A Task subagent reads
notes.txt— its own partial-fidelity
subagentthread (never flattened), result flows back asSUB_OK 42. - The saved workflow launches exactly once, runs its five agents in the
background, and completes verified (WORKFLOW_VERIFIED). - The gym-check skill runs and reports its sentinel.
- A second session streams in parallel over the same socket — no bleed
between streams, cards, or attention. - Drop and reconnect — both streams catch up exactly; a too-old cursor
resyncs transparently via the cold path. - Host restart — sessions show offline, not gone; opening one loads its
transcript; a new message resumes the same native conversation. - Phone opens both sessions — identical transcripts, cards, and chips;
the first answer wins everywhere. Final gate: Maestro replays steps
2–19 from the phone against a real host.