Skip to content

feat(host-service,mobile): host sessions sync v1 — canonical sessions over tRPC snapshots + /sessions/sync, mobile rewired onto the sync client - #5630

Closed
bra1nDump wants to merge 1 commit into
ParadevAI:mainfrom
bra1nDump:feat/sessions-v1-sync
Closed

bra1nDump wants to merge 1 commit into
ParadevAI:mainfrom
bra1nDump:feat/sessions-v1-sync

Conversation

@bra1nDump

@bra1nDump bra1nDump commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

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.

  1. 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.
  2. Mutations acknowledge admission, not completion. Every mutation
    carries an idempotent requestId and returns a receipt; the outcome
    arrives later as events whose causationId echoes the request. Retries,
    double-taps, and races between devices are absorbed by receipts —
    first write wins, losers get a clean stale error.
  3. 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.
  4. 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.
  5. Recovery is the cold path, not a special mode. reset on 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.
  6. Harness-agnostic entities. Canonical Message/ContentBlock/
    ToolCall/Thread shapes — 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/sync is
    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 / unsubscribe semantics 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/sync carries 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 inside bun 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
    Zod superRefine the 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 by id.

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

  1. Cold-load (automatic, per-stream, cheap). Triggers: ring eviction
    (client slept), host restart, session resurrection, upstream adapter
    resync. reset names one stream; only it re-fetches. Handler = the
    first-connect code path, literally.
  2. 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 back offline; the first live
    mutation resurrects (spawn adapter → session/load → vendor replays its
    own disk → canonical log rebuilt under a fresh generation). Old session
    cursors reset CURSOR_INVALID into 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 one reset(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-after grammar).
  • Client store: reset handling, snapshot re-seed, paging never resurrects
    resolved cards, mid-flight fetch races, never-subscribed packet drops,
    reconnect backoff, socket loss downgrades live → idle (data kept).
  • Runtime: offline sessions — passive reads synthesize (head = zero
    cursor, empty tail), live paths resurrect (fake adapter); backwards
    paging with hasMoreBefore/truncatedBefore; merged update receipts.
  • Gym user story 10/10 with a real Opus instance over sessions.* +
    /sessions/sync on this exact surface (tRPC-seeded client, claimless
    question cards, merged update for 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-protocol data 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

  1. Snapshot recentEvents tail size (trade first-paint completeness vs
    response size over the relay) — shipped default: 50.
  2. Circuit-breaker N and window — shipped default: 3 resets / 30 s
    (maxStreamResetsPerWindow / streamResetWindowMs, overridable per
    client).
  3. Session-cursor incarnation tag ships with the durable store (Bump electron from 37.8.0 to 39.0.0 #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.
  4. Condensed/skeleton history mode: v1.1+ (taxonomy already supports it).
  5. 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:

  1. Durable history store — done as durable session metadata +
    cursor generations, see above.

  2. React bindings and the mobile rewire — done (2026-07-12). Shipped:
    packages/host-service-react (provider + zustand-backed hooks +
    useSessionTimeline with incremental fold), the framework-free
    transcript fold at host-service-sync/timeline (canonical events →
    message/tool-call/plan items, thought-splitting, subagent nesting,
    pending-permission cards), and packages/host-client's sessions
    module (command plane + syncApi/syncUrl facades). Mobile's chat
    screens keep their shipped UI but ride sessions.* + /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's client/react subpaths,
    and their surface tests. packages/session-protocol itself 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 gained settingOptions —
    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 from currentMode when 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 (generic OptionPicker over settingOptions,
    writes through sessions.update), verified on-sim: chip → native sheet
    → live model switch reflected in the chip. The NewChatWidget's
    new-workspace flow moved off the legacy workspaces.create agents
    sugar onto workspaces.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 + cloud chat_sessions
    Electric collection + the HostChatClient chat facade) is gone, the
    canonical thread now owns the chat/[sessionId] route (the chat/acp/
    path segment is dead), and the session-row menu's rename/archive go
    through sessions.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.
    The Conversation component 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
    native contentInset.bottom sized 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/scrollToEnd are banned —
    they chase estimated totals into blank space). Invariants are documented
    in apps/mobile/components/ai-elements/AGENTS.md and 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-thread useFrameCallback port
    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.get now 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 stays getEvents scrollback territory.

  3. Direct Claude SDK adapter behind the same surface (prototype on the
    claude-sdk-session-harness branch, PR feat(host-service): prototype direct Claude SDK sessions #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).

  4. 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) over notes.txt.
  • .claude/skills/gym-check/SKILL.md — a trivial skill that reads
    notes.txt and prints a sentinel.
  • scripts/ok.sh and README.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

  1. Create the gym workspace — worktree provisioned, no agent process yet.
  2. Start a session with the first message, model pinned to opus,
    permission mode default (never bypass).
  3. The ~600-word tour streams incrementally — no gaps, no duplicates; other
    connected devices see the same stream live.
  4. Stop mid-stream — the turn is cancelled, partial text stays, session
    returns to idle, nothing left pending.
  5. continue resumes the same conversation and reaches TOUR_DONE.
  6. A Bash run raises a permission card (allow once / always allow /
    reject); the session reads "needs attention", not "stuck".
  7. 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.
  8. Always-allow on a rerun — the choice sticks for this session.
  9. Two more runs auto-approve with zero new cards.
  10. A README edit raises a fresh card — always-allow was scoped per tool.
  11. Stop, switch the session to acceptEdits, continue — the edit applies
    with no card; every tool call still lands in the transcript.
  12. AskUserQuestion with two questions — cards arrive one at a time, and
    the turn shows "waiting for you", not running.
  13. Answer blue on the first, Skip the second — each answer registers
    exactly once; the agent replies ANSWERS blue.
  14. A Task subagent reads notes.txt — its own partial-fidelity
    subagent thread (never flattened), result flows back as SUB_OK 42.
  15. The saved workflow launches exactly once, runs its five agents in the
    background, and completes verified (WORKFLOW_VERIFIED).
  16. The gym-check skill runs and reports its sentinel.
  17. A second session streams in parallel over the same socket — no bleed
    between streams, cards, or attention.
  18. Drop and reconnect — both streams catch up exactly; a too-old cursor
    resyncs transparently via the cold path.
  19. Host restart — sessions show offline, not gone; opening one loads its
    transcript; a new message resumes the same native conversation.
  20. 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.

https://claude.ai/code/session_016DKKd4kLa3Xd7kGsYfxsyP

@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

The PR adds a canonical host-session protocol, ACP-backed runtime, projection reducer, WebSocket synchronization hub, client-side Zustand replication, tRPC endpoints, host wiring, extensive unit/integration/stress coverage, and updated rollout documentation.

Canonical host sessions

Layer / File(s) Summary
Protocol contracts and package boundaries
packages/host-service-sync/...
Adds validated entities, API schemas, sync packets, cursors, errors, routes, exports, and package boundary checks.
Projection and ACP translation
packages/host-service-sync/src/projection/..., packages/host-service/src/runtime/sessions/translate-acp.ts
Translates ACP envelopes into canonical events and reduces them into validated session projections.
Canonical runtime
packages/host-service/src/runtime/sessions/canonical-sessions.ts
Provides session snapshots, event paging, mutations, permissions, replay, host snapshots, idempotency, and adapter integration.
Synchronization transport
packages/host-service/src/runtime/sessions/sync-hub.ts, sync-route.ts
Implements handshake, subscriptions, replay, cursor recovery, live fan-out, backpressure handling, and WebSocket route registration.
Client replication
packages/host-service-sync/src/client/...
Adds the client store, event-window management, retention, reconnects, reset handling, resolver updates, and defensive packet validation.
Host/API wiring
packages/host-service/src/app.ts, src/trpc/router/sessions/*, src/types.ts
Registers the runtime, tRPC router, authenticated sync route, lifecycle cleanup, and public runtime types.
Integration and rollout validation
packages/host-service/test/integration/*, sync-hub*.test.ts, plans/host-sessions-sync.md
Adds ACP user-story, parity, reboot, paging, churn, and protocol conformance coverage alongside the canonical rollout plan.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: saddlepaddle

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description is a long plan, but it omits required template sections like Related Issues, Type of Change, Testing, and Additional Notes. Rewrite it to match the repo template with Description, Related Issues, Type of Change, Testing, Screenshots, and Additional Notes sections.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is specific and matches the main change: host sessions sync plus mobile rewiring.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds the first canonical host sessions sync surface. The main changes are:

  • New @superset/host-service-sync protocol, projection, and client package.
  • New canonical sessions runtime over the existing ACP session manager.
  • New sessions.* tRPC router with shared Zod schemas.
  • New /sessions/sync WebSocket hub for host and session stream sync.
  • Integration, stress, protocol, projection, and client tests for the new surface.

Confidence Score: 4/5

The live sync subscribe path can miss events while reporting that clients are caught up.

  • Session stream subscriptions can miss events emitted during subscribe setup.
  • Host stream subscriptions can miss upserts, removals, or permission transitions during subscribe setup.
  • Event history can return an empty page for an anchor before the first event.

packages/host-service/src/runtime/sessions/sync-hub.ts and packages/host-service/src/runtime/sessions/canonical-sessions.ts

Important Files Changed

Filename Overview
packages/host-service/src/runtime/sessions/sync-hub.ts Adds the sessions WebSocket hub, but live subscriptions are registered after replay/snapshot delivery, leaving event-loss windows.
packages/host-service/src/runtime/sessions/canonical-sessions.ts Adds the canonical ACP-backed sessions runtime, with an event-window edge case for anchors before the first event.
packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts Adds the framework-free sync client and local store orchestration for session retention, history seeding, reconnects, and tool resolution.
packages/host-service/src/runtime/sessions/sync-route.ts Adds the /sessions/sync route adapter, but it does not enforce the expected WebSocket subprotocol.
packages/host-service/src/trpc/router/sessions/sessions.ts Adds the schema-bound sessions.* tRPC router over the canonical sessions runtime.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
  participant Client
  participant Hub as SessionsSyncHub
  participant Runtime as CanonicalSessionsRuntime
  Client->>Hub: hello
  Hub-->>Client: hello_ack
  Client->>Hub: subscribe host/session
  Hub->>Runtime: snapshot or replay from cursor
  Runtime-->>Hub: events or snapshot + head
  Hub-->>Client: subscribed
  Hub-->>Client: snapshot or replay
  Hub-->>Client: caught_up
  Hub->>Hub: register live subscription
  Runtime-->>Hub: later live events
  Hub-->>Client: event frames
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
  participant Client
  participant Hub as SessionsSyncHub
  participant Runtime as CanonicalSessionsRuntime
  Client->>Hub: hello
  Hub-->>Client: hello_ack
  Client->>Hub: subscribe host/session
  Hub->>Runtime: snapshot or replay from cursor
  Runtime-->>Hub: events or snapshot + head
  Hub-->>Client: subscribed
  Hub-->>Client: snapshot or replay
  Hub-->>Client: caught_up
  Hub->>Hub: register live subscription
  Runtime-->>Hub: later live events
  Hub-->>Client: event frames
Loading
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
packages/host-service/src/runtime/sessions/sync-hub.ts:525-530
**Session Subscribe Drops Events**

When a session event is emitted after `sessionReplay()` captures `replay.head` but before this subscription is added, `fanOutSessionEvent()` has no subscription to deliver to. The client still receives `caught_up` through the old cursor, so that event is missed until a full resync.

### Issue 2 of 4
packages/host-service/src/runtime/sessions/sync-hub.ts:596-601
**Host Subscribe Drops Changes**

When a host change happens after `head` is captured but before this subscription is registered, the change is not in the replay or snapshot and cannot be fanned out. Clients can be told they are caught up while missing a session upsert, removal, or permission transition.

### Issue 3 of 4
packages/host-service/src/runtime/sessions/canonical-sessions.ts:488-493
**Early Time Anchor Skips Events**

When `getEvents` is anchored at a time before the first event, `anchorIndex` stays `-1`, so this range calculation returns an empty page with `hasMoreAfter` instead of the first available events. Clients asking for context around an early timestamp cannot page into the existing log from that response.

### Issue 4 of 4
packages/host-service/src/runtime/sessions/sync-route.ts:36-38
**Subprotocol Gate Is Missing**

The route admits the socket without checking that the negotiated protocol is `superset.sessions.sync`. A client or proxy using the wrong subprotocol can still open the sessions endpoint and only fail later at the JSON handshake, which makes protocol misconfiguration look like a live sessions connection.

Reviews (1): Last reviewed commit: "fix(host-service): harden sync-hub again..." | Re-trigger Greptile

Comment on lines +525 to +530
connection.subscriptions.set(packet.subscriptionId, {
subscriptionId: packet.subscriptionId,
stream: "session",
sessionId,
lastCursor: replay.head,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Session Subscribe Drops Events

When a session event is emitted after sessionReplay() captures replay.head but before this subscription is added, fanOutSessionEvent() has no subscription to deliver to. The client still receives caught_up through the old cursor, so that event is missed until a full resync.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/runtime/sessions/sync-hub.ts
Line: 525-530

Comment:
**Session Subscribe Drops Events**

When a session event is emitted after `sessionReplay()` captures `replay.head` but before this subscription is added, `fanOutSessionEvent()` has no subscription to deliver to. The client still receives `caught_up` through the old cursor, so that event is missed until a full resync.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reproducible: the window you describe doesn't exist. subscribeSession runs synchronously from the sessionReplay() head capture through subscribed → snapshot/replay → caught_up → subscriptions.set(...) — there is no await in that block (the only async gap, warmSession, happens before the head is captured, so anything folded during warm-up is included in the replay). A runtime fold can only run on a later event-loop turn, at which point the subscription is registered and fanOutSessionEvent delivers it; the padded-cursor <= lastCursor check dedupes the overlap. This is pinned by the randomized churn stress suite (gapless-suffix oracle per subscription, sync-hub.stress.test.ts) and was independently re-verified this week by a cross-provider review that specifically targeted subscribe atomicity (verdict: sound).

Comment on lines +596 to +601
connection.subscriptions.set(packet.subscriptionId, {
subscriptionId: packet.subscriptionId,
stream: "host",
sessionId: null,
lastCursor: head,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Host Subscribe Drops Changes

When a host change happens after head is captured but before this subscription is registered, the change is not in the replay or snapshot and cannot be fanned out. Clients can be told they are caught up while missing a session upsert, removal, or permission transition.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/runtime/sessions/sync-hub.ts
Line: 596-601

Comment:
**Host Subscribe Drops Changes**

When a host change happens after `head` is captured but before this subscription is registered, the change is not in the replay or snapshot and cannot be fanned out. Clients can be told they are caught up while missing a session upsert, removal, or permission transition.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer as the session-stream twin of this comment: subscribeHost is synchronous from the head capture through registration — no await anywhere in the function — so a fanOutHostChange cannot interleave; it either lands before the capture (included in ring replay/snapshot) or after registration (delivered live, deduped by the padded-cursor check). Covered by the same stress oracle and the independent atomicity review.

Comment on lines +488 to +493
const start = Math.max(0, anchorIndex - input.before);
const endExclusive = Math.max(
start,
Math.min(filtered.length, anchorIndex + input.after + 1),
);
const items = filtered.slice(start, endExclusive);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Early Time Anchor Skips Events

When getEvents is anchored at a time before the first event, anchorIndex stays -1, so this range calculation returns an empty page with hasMoreAfter instead of the first available events. Clients asking for context around an early timestamp cannot page into the existing log from that response.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/runtime/sessions/canonical-sessions.ts
Line: 488-493

Comment:
**Early Time Anchor Skips Events**

When `getEvents` is anchored at a time before the first event, `anchorIndex` stays `-1`, so this range calculation returns an empty page with `hasMoreAfter` instead of the first available events. Clients asking for context around an early timestamp cannot page into the existing log from that response.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate semantics, documented at the -1 assignment: a time anchor earlier than every event means "anchored just before the first event". before-context is then correctly empty, and with any after > 0 the window returns the earliest events — so callers asking for context around an early timestamp do page into the log from that very response (start=0, endExclusive=after). The empty page only occurs for after: 0, where "no events at or before T, hasMoreAfter: true" is the accurate answer to the question asked, and the recovery is the same query with after > 0. No current client issues time anchors with after: 0.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — I traced through the math incorrectly. With anchorIndex = -1:

  • start = Math.max(0, -1 - before) → 0
  • endExclusive = Math.max(0, Math.min(length, -1 + after + 1)) → Math.min(length, after)

So after > 0 correctly yields the earliest events from filtered[0..after), and the after: 0 empty result is semantically accurate. The comment at the -1 assignment makes the intent clear. My original concern was wrong — withdrawing it.

Comment on lines +36 to +38
onOpen: (_event, ws) => {
connection = hub.connect(ws as SyncSocket);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Subprotocol Gate Is Missing

The route admits the socket without checking that the negotiated protocol is superset.sessions.sync. A client or proxy using the wrong subprotocol can still open the sessions endpoint and only fail later at the JSON handshake, which makes protocol misconfiguration look like a live sessions connection.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/runtime/sessions/sync-route.ts
Line: 36-38

Comment:
**Subprotocol Gate Is Missing**

The route admits the socket without checking that the negotiated protocol is `superset.sessions.sync`. A client or proxy using the wrong subprotocol can still open the sessions endpoint and only fail later at the JSON handshake, which makes protocol misconfiguration look like a live sessions connection.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly stale premise: the client offers superset.sessions.sync and the ws server (via @hono/node-ws, no handleProtocols configured) echoes the first offered subprotocol, so RFC-compliant clients (browser/React Native) negotiate successfully today — the route is not silently protocol-less. What's true is that a wrong subprotocol is also echoed and only dies at the hello gate (UNSUPPORTED_PROTOCOL_VERSION / INVALID_PACKET → close 1008), which is the designed authoritative gate. A strict upgrade-time check needs handleProtocols on the shared WebSocketServer that @hono/node-ws constructs internally and doesn't expose per-route; not worth forking the adapter for a fail-slightly-faster on a misconfigured proxy. Deferring — tracked with the transport hardening items in plans/host-sessions-rollout.md.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair correction — the original comment overstated the failure mode. Since @hono/node-ws echoes the first offered subprotocol, RFC-compliant clients do negotiate superset.sessions.sync successfully today, so "silently protocol-less" was inaccurate. And the hello gate (close 1008 on UNSUPPORTED_PROTOCOL_VERSION/INVALID_PACKET) is a well-defined authoritative failure path, not a silent one.

The architectural constraint — handleProtocols being on the shared internal WebSocketServer that @hono/node-ws doesn't expose per-route — makes a strict upgrade-time gate genuinely impractical without forking the adapter, which isn't a reasonable ask for fail-slightly-faster behavior on a misconfigured proxy. Tracking it in the rollout doc is the right call.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/host-service-sync/src/package-boundaries.test.ts (1)

22-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Boundary regexes miss dynamic and side-effect imports.

All patterns anchor on \bfrom, so import("node:crypto"), require("react"), and side-effect imports (import "expo") would slip past this guard while still pulling in a prohibited dependency. Consider matching the module specifier independently of the from keyword.

♻️ Sketch: match specifiers regardless of import form
 		const prohibited = [
-			/\bfrom\s+["']react(?:\/|["'])/,
-			/\bfrom\s+["']react-native(?:\/|["'])/,
-			/\bfrom\s+["']expo(?:\/|["'])/,
-			/\bfrom\s+["']node:/,
-			/\bfrom\s+["']`@superset`\/host-service(?:\/|["'])/,
-			/\bfrom\s+["']`@agentclientprotocol`\//,
+			/["']react(?:\/|["'])/,
+			/["']react-native(?:\/|["'])/,
+			/["']expo(?:\/|["'])/,
+			/["']node:/,
+			/["']`@superset`\/host-service(?:\/|["'])/,
+			/["']`@agentclientprotocol`\//,
 		];

Note: broadening to bare specifiers can match strings/comments; if that risk matters, prefer parsing import/require/import(...) forms explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service-sync/src/package-boundaries.test.ts` around lines 22 -
38, Update the prohibited patterns used by the package-boundary scan in the test
so they detect prohibited module specifiers across static imports, side-effect
imports, require calls, and dynamic import() expressions, not only `from`
imports. Preserve the existing package targets and violation reporting while
matching these forms explicitly enough to avoid unrelated strings or comments
where practical.
packages/host-service/src/runtime/sessions/translate-acp.ts (1)

704-715: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Turn-tracking caps reuse unrelated constants.

mintedTurnIds is bounded by MAX_TRACKED_TOOL_CALLS and turnIdsByRequest by MAX_TRACKED_SUBAGENT_THREADS. Functionally these bound memory fine, but the semantic mismatch is confusing. Consider a dedicated MAX_TRACKED_TURNS for clarity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service/src/runtime/sessions/translate-acp.ts` around lines 704
- 715, Introduce a dedicated MAX_TRACKED_TURNS constant and use it for both
mintedTurnIds and turnIdsByRequest size limits in the turn-tracking logic.
Replace the unrelated MAX_TRACKED_TOOL_CALLS and MAX_TRACKED_SUBAGENT_THREADS
references while preserving the existing eviction behavior.
packages/host-service-sync/src/protocol/primitives.ts (1)

11-51: 🚀 Performance & Scalability | 🔵 Trivial

Verify upstream size caps exist for jsonValueSchema.

Per-level bounds are generous (16MB strings, 10k array items, 10k object keys, 64 levels deep), and this schema validates toolCall.input/output and settings configuration maps flowing through the WS sync hub and tRPC mutations. Cost of validating a maximal conforming payload could be significant if there's no hard byte-size cap enforced before Zod parsing at the transport layer (not visible in this file set). Worth confirming sync-hub.ts/routes.ts enforce a message-size ceiling ahead of this schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service-sync/src/protocol/primitives.ts` around lines 11 - 51,
Verify the transport entry points that validate jsonValueSchema, especially
sync-hub.ts and routes.ts, enforce a hard message or request byte-size ceiling
before Zod parsing. If no upstream cap exists, add one at the earliest WS and
tRPC boundaries while preserving the existing jsonValueSchema validation for
payloads within that limit.
packages/host-service-sync/src/protocol/sync.ts (1)

26-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the deprecated, redundant .safe() refinement.

In Zod 4, z.number().int() already restricts to safe integers, and .safe() is deprecated (it now just aliases .int() and is slated for removal in the next major). Chaining .int().safe() is redundant today and a future-break risk.

♻️ Suggested cleanup
-	version: z.number().int().safe().positive(),
+	version: z.number().int().positive(),
-		maxSubscriptions: z.number().int().safe().positive(),
-		maxFrameBytes: z.number().int().safe().positive(),
+		maxSubscriptions: z.number().int().positive(),
+		maxFrameBytes: z.number().int().positive(),

Also applies to: 87-88

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service-sync/src/protocol/sync.ts` at line 26, Remove the
redundant deprecated .safe() refinement from the visible version schema and the
additionally affected schema occurrences, leaving z.number().int().positive()
and all other validation behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/host-service-sync/src/protocol/api.ts`:
- Around line 353-423: Update the eventsWindowSchema superRefine validation to
reject any window whose range.truncatedBefore is true while range.hasMoreBefore
is also true. Add a cross-field issue on the range metadata, preserving all
existing event identity, boundary, and empty-window checks.

---

Nitpick comments:
In `@packages/host-service-sync/src/package-boundaries.test.ts`:
- Around line 22-38: Update the prohibited patterns used by the package-boundary
scan in the test so they detect prohibited module specifiers across static
imports, side-effect imports, require calls, and dynamic import() expressions,
not only `from` imports. Preserve the existing package targets and violation
reporting while matching these forms explicitly enough to avoid unrelated
strings or comments where practical.

In `@packages/host-service-sync/src/protocol/primitives.ts`:
- Around line 11-51: Verify the transport entry points that validate
jsonValueSchema, especially sync-hub.ts and routes.ts, enforce a hard message or
request byte-size ceiling before Zod parsing. If no upstream cap exists, add one
at the earliest WS and tRPC boundaries while preserving the existing
jsonValueSchema validation for payloads within that limit.

In `@packages/host-service-sync/src/protocol/sync.ts`:
- Line 26: Remove the redundant deprecated .safe() refinement from the visible
version schema and the additionally affected schema occurrences, leaving
z.number().int().positive() and all other validation behavior unchanged.

In `@packages/host-service/src/runtime/sessions/translate-acp.ts`:
- Around line 704-715: Introduce a dedicated MAX_TRACKED_TURNS constant and use
it for both mintedTurnIds and turnIdsByRequest size limits in the turn-tracking
logic. Replace the unrelated MAX_TRACKED_TOOL_CALLS and
MAX_TRACKED_SUBAGENT_THREADS references while preserving the existing eviction
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b5d3ad9a-dc57-4523-89e9-6b0eeacc1dc2

📥 Commits

Reviewing files that changed from the base of the PR and between 20191d6 and 063ee6b.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (57)
  • apps/mobile/plans/20260709-1438-unified-home-workspaces-sessions.md
  • apps/mobile/plans/mobile-chat-runtime.md
  • packages/host-service-sync/package.json
  • packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/index.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/state.ts
  • packages/host-service-sync/src/client/index.ts
  • packages/host-service-sync/src/client/types.ts
  • packages/host-service-sync/src/index.ts
  • packages/host-service-sync/src/package-boundaries.test.ts
  • packages/host-service-sync/src/projection/fixtures/attention-and-settings.json
  • packages/host-service-sync/src/projection/fixtures/turn-lifecycle.json
  • packages/host-service-sync/src/projection/index.ts
  • packages/host-service-sync/src/projection/projection.ts
  • packages/host-service-sync/src/projection/reduceProjection.test.ts
  • packages/host-service-sync/src/projection/reduceProjection.ts
  • packages/host-service-sync/src/protocol/api.ts
  • packages/host-service-sync/src/protocol/entities.ts
  • packages/host-service-sync/src/protocol/events.ts
  • packages/host-service-sync/src/protocol/index.ts
  • packages/host-service-sync/src/protocol/primitives.ts
  • packages/host-service-sync/src/protocol/protocol.test.ts
  • packages/host-service-sync/src/protocol/routes.ts
  • packages/host-service-sync/src/protocol/sync.ts
  • packages/host-service-sync/tsconfig.json
  • packages/host-service/docs/acp-sessions.md
  • packages/host-service/package.json
  • packages/host-service/src/app.ts
  • packages/host-service/src/runtime/sessions/canonical-sessions.test.ts
  • packages/host-service/src/runtime/sessions/canonical-sessions.ts
  • packages/host-service/src/runtime/sessions/index.ts
  • packages/host-service/src/runtime/sessions/sync-hub.stress.test.ts
  • packages/host-service/src/runtime/sessions/sync-hub.test.ts
  • packages/host-service/src/runtime/sessions/sync-hub.ts
  • packages/host-service/src/runtime/sessions/sync-route.ts
  • packages/host-service/src/runtime/sessions/testing/fake-acp-port.ts
  • packages/host-service/src/runtime/sessions/testing/sync-harness.ts
  • packages/host-service/src/runtime/sessions/translate-acp.test.ts
  • packages/host-service/src/runtime/sessions/translate-acp.ts
  • packages/host-service/src/trpc/router/router.ts
  • packages/host-service/src/trpc/router/sessions/index.ts
  • packages/host-service/src/trpc/router/sessions/sessions.ts
  • packages/host-service/src/types.ts
  • packages/host-service/test/helpers/createTestHost.ts
  • packages/host-service/test/helpers/gym.ts
  • packages/host-service/test/integration/acp-user-story.integration.test.ts
  • packages/host-service/test/integration/sessions-sync-client.integration.test.ts
  • plans/acp-session-follow-ups.md
  • plans/cross-client-session-tab-sync.md
  • plans/done/20260710-session-harness-acp.md
  • plans/host-integration-test.md
  • plans/host-service-chat-architecture.md
  • plans/host-sessions-protocol.md
  • plans/host-sessions-rollout.md
  • plans/session-harness-claude-agent-sdk.md
  • plans/v2-chat-greenfield-architecture.md
💤 Files with no reviewable changes (4)
  • plans/cross-client-session-tab-sync.md
  • plans/session-harness-claude-agent-sdk.md
  • plans/acp-session-follow-ups.md
  • plans/host-integration-test.md

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/host-service/src/runtime/sessions/sync-hub.ts">

<violation number="1" location="packages/host-service/src/runtime/sessions/sync-hub.ts:764">
P1: Large valid host snapshots or event payloads make the bundled client close the socket as `frame_size_limit`, so hosts with enough sessions cannot complete initial sync. Bound/chunk outbound snapshots and events to the advertised `maxFrameBytes` before sending.</violation>
</file>

<file name="packages/host-service/src/runtime/sessions/canonical-sessions.ts">

<violation number="1" location="packages/host-service/src/runtime/sessions/canonical-sessions.ts:1081">
P2: Malformed or unsupported ACP frames disappear from the canonical stream with no observable failure because `foldError` is never consumed. Log or surface the fold failure while preserving journal fan-out isolation.

(Based on your team's feedback about handling async failures observably.) [FEEDBACK_USED]</violation>

<violation number="2" location="packages/host-service/src/runtime/sessions/canonical-sessions.ts:1094">
P2: When `reduceProjection` throws for one event in the fold loop, the error is recorded but processing continues for subsequent events in the same batch — all operating against the now-stale projection that never incorporated the failed event. This means events accumulate in the log (`tracked.events`) while the projection silently drifts from the ground truth.  The next batch of events then folds against the stale projection, compounding the error.  Callers reading the composed session state (e.g., `getSession`, `hostSnapshotData`) see a projection that diverges from what replaying the event log from scratch would produce.

Consider exiting the batch on projection failure so the drift is bounded to one event, or skipping just the failing event instead of continuing with the full remainder.</violation>

<violation number="3" location="packages/host-service/src/runtime/sessions/canonical-sessions.ts:1292">
P2: A batch with a later unsupported option can apply earlier settings before rejecting, despite the no-partial-application contract. Validate all requested option IDs/capabilities before issuing any mutation, or make the batch atomic.</violation>
</file>

<file name="packages/host-service-sync/src/protocol/api.ts">

<violation number="1" location="packages/host-service-sync/src/protocol/api.ts:114">
P3: The cross-field validation logic for session snapshots is duplicated across three `superRefine` blocks: `sessionSnapshotSchema` in api.ts, `hostSnapshotPacketSchema` in sync.ts, and `sessionSnapshotPacketSchema` in sync.ts. Each independently validates thread-parent consistency, permission/tool-call session-ID matching, claim-to-tool-call references, state invariants on claimed tools, duplicate detection, and orphan-claim detection — roughly 100 lines of structurally identical logic each.

If a new client tool call state is introduced or an invariant changes, all three blocks need coordinated updates. Consider extracting the shared validation into a helper function (e.g., `validateSnapshotIntegrity(sessionId, threads, pendingPermissions, clientToolCalls, toolCallClaims, context)`). The per-schema `superRefine` would then call the helper with context, keeping the integrity rules defined once.</violation>
</file>

<file name="packages/host-service-sync/src/protocol/primitives.ts">

<violation number="1" location="packages/host-service-sync/src/protocol/primitives.ts:46">
P1: A valid extension payload can consume unbounded practical memory/CPU despite these per-item limits: one array may hold 10,000 16-MiB strings, with the same fan-out recursively. Add a cumulative payload-size/node budget so hostile sync or tRPC inputs are rejected before validation allocates or traverses enormous structures.</violation>
</file>

<file name="packages/host-service/src/runtime/sessions/sync-route.ts">

<violation number="1" location="packages/host-service/src/runtime/sessions/sync-route.ts:43">
P3: Non-ASCII frames can bypass the advertised byte cap because `String()` produces UTF-16 text and the hub compares `.length`. Measure encoded UTF-8 bytes (or preserve the Buffer through intake) before parsing so `maxFrameBytes` limits actual WebSocket payload size.</violation>
</file>

<file name="packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts">

<violation number="1" location="packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts:764">
P2: `seedPromises` not cleared when the socket disconnects or the client disconnects, leaving stale in-flight seed-history HTTP promises that can apply events to the wrong session state after reconnection.

When a session is retained and has no cursor (`latestCursor === null`), `seedAndSubscribe` kicks off a `getEvents` HTTP call and stores the promise in the `seedPromises` map. If the socket drops while that promise is in flight, a new socket connects, and `hello_ack` triggers `seedAndSubscribe` for the same session, the `seedPromises.has(sessionId)` guard causes an early return — the new connection never sends a subscribe for that session. When the **old** seed promise eventually resolves, its `applyWindow` call writes events fetched against the old connection into the current store, which may already contain newer events from the new connection.

Clear `seedPromises` in both the `onclose` handler and the `disconnect()` method (similar to how `sentSessionSubscriptions` is already cleared there).</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const childSchema = boundedJsonValueSchema;
boundedJsonValueSchema = z.union([
jsonPrimitiveSchema,
z.array(childSchema).max(MAX_JSON_COLLECTION_ITEMS),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A valid extension payload can consume unbounded practical memory/CPU despite these per-item limits: one array may hold 10,000 16-MiB strings, with the same fan-out recursively. Add a cumulative payload-size/node budget so hostile sync or tRPC inputs are rejected before validation allocates or traverses enormous structures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service-sync/src/protocol/primitives.ts, line 46:

<comment>A valid extension payload can consume unbounded practical memory/CPU despite these per-item limits: one array may hold 10,000 16-MiB strings, with the same fan-out recursively. Add a cumulative payload-size/node budget so hostile sync or tRPC inputs are rejected before validation allocates or traverses enormous structures.</comment>

<file context>
@@ -0,0 +1,133 @@
+	const childSchema = boundedJsonValueSchema;
+	boundedJsonValueSchema = z.union([
+		jsonPrimitiveSchema,
+		z.array(childSchema).max(MAX_JSON_COLLECTION_ITEMS),
+		jsonObjectSchema(childSchema),
+	]);
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially mitigated, remainder deferred deliberately. The sync path is already bounded by maxFrameBytes (1MiB) before any Zod traversal, and e174f02 caps adapter-derived rawInput/rawOutput at 256KB at the normalization boundary — the two ingress points where hostile/degenerate payloads actually enter this surface. A cumulative node budget inside jsonValueSchema itself is the wrong layer for the remaining tRPC-body case: that wants a transport-level body limit host-wide (all routers share the risk), tracked with the strict-runtime-contracts backlog in plans/host-sessions-rollout.md.

return false;
}
try {
socket.send(JSON.stringify(packet));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Large valid host snapshots or event payloads make the bundled client close the socket as frame_size_limit, so hosts with enough sessions cannot complete initial sync. Bound/chunk outbound snapshots and events to the advertised maxFrameBytes before sending.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/runtime/sessions/sync-hub.ts, line 764:

<comment>Large valid host snapshots or event payloads make the bundled client close the socket as `frame_size_limit`, so hosts with enough sessions cannot complete initial sync. Bound/chunk outbound snapshots and events to the advertised `maxFrameBytes` before sending.</comment>

<file context>
@@ -0,0 +1,819 @@
+			return false;
+		}
+		try {
+			socket.send(JSON.stringify(packet));
+		} catch {
+			// A synchronous send throw (torn socket, ws internals) must not leave
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid at scale; consciously deferred. Today's host_snapshot scope is unarchived+unclosed sessions only (~2-4KB per entity ⇒ hundreds of sessions before the 1MiB inbound cap), per-event size is now bounded by the 256KB rawInput/rawOutput cap (e174f02), and a client that does hit the cap fails loudly (frame_size_limit close), not silently. Real fix is snapshot pagination in the wire protocol, which belongs with the durable-history/pagination slice tracked in plans/host-sessions-rollout.md rather than a bolt-on here.

// The protocol is JSON text frames; String() folds the rare
// Buffer delivery into the same parse path, where non-JSON is
// answered with INVALID_PACKET.
void connection?.handleMessage(String(event.data));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Non-ASCII frames can bypass the advertised byte cap because String() produces UTF-16 text and the hub compares .length. Measure encoded UTF-8 bytes (or preserve the Buffer through intake) before parsing so maxFrameBytes limits actual WebSocket payload size.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/runtime/sessions/sync-route.ts, line 43:

<comment>Non-ASCII frames can bypass the advertised byte cap because `String()` produces UTF-16 text and the hub compares `.length`. Measure encoded UTF-8 bytes (or preserve the Buffer through intake) before parsing so `maxFrameBytes` limits actual WebSocket payload size.</comment>

<file context>
@@ -0,0 +1,56 @@
+					// The protocol is JSON text frames; String() folds the rare
+					// Buffer delivery into the same parse path, where non-JSON is
+					// answered with INVALID_PACKET.
+					void connection?.handleMessage(String(event.data));
+				},
+				onClose: () => {
</file context>

clientToolCalls: z.array(toolCallSchema).max(10_000),
toolCallClaims: z.array(toolCallClaimSchema).max(10_000),
})
.superRefine((snapshot, context) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The cross-field validation logic for session snapshots is duplicated across three superRefine blocks: sessionSnapshotSchema in api.ts, hostSnapshotPacketSchema in sync.ts, and sessionSnapshotPacketSchema in sync.ts. Each independently validates thread-parent consistency, permission/tool-call session-ID matching, claim-to-tool-call references, state invariants on claimed tools, duplicate detection, and orphan-claim detection — roughly 100 lines of structurally identical logic each.

If a new client tool call state is introduced or an invariant changes, all three blocks need coordinated updates. Consider extracting the shared validation into a helper function (e.g., validateSnapshotIntegrity(sessionId, threads, pendingPermissions, clientToolCalls, toolCallClaims, context)). The per-schema superRefine would then call the helper with context, keeping the integrity rules defined once.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service-sync/src/protocol/api.ts, line 114:

<comment>The cross-field validation logic for session snapshots is duplicated across three `superRefine` blocks: `sessionSnapshotSchema` in api.ts, `hostSnapshotPacketSchema` in sync.ts, and `sessionSnapshotPacketSchema` in sync.ts. Each independently validates thread-parent consistency, permission/tool-call session-ID matching, claim-to-tool-call references, state invariants on claimed tools, duplicate detection, and orphan-claim detection — roughly 100 lines of structurally identical logic each.

If a new client tool call state is introduced or an invariant changes, all three blocks need coordinated updates. Consider extracting the shared validation into a helper function (e.g., `validateSnapshotIntegrity(sessionId, threads, pendingPermissions, clientToolCalls, toolCallClaims, context)`). The per-schema `superRefine` would then call the helper with context, keeping the integrity rules defined once.</comment>

<file context>
@@ -0,0 +1,569 @@
+		clientToolCalls: z.array(toolCallSchema).max(10_000),
+		toolCallClaims: z.array(toolCallClaimSchema).max(10_000),
+	})
+	.superRefine((snapshot, context) => {
+		const sessionId = snapshot.session.id;
+		const threadsById = new Map<string, (typeof snapshot.threads)[number]>();
</file context>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/host-service/test/integration/sessions-user-story.integration.test.ts (1)

848-861: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: parametrize the store lenses by sessionId to avoid re-implementing them for session B.

events()/agentText() are hardcoded to sessionA, so session B's stream reading and text extraction are re-implemented inline here. Extracting a sessionEvents(sessionId) lens (and letting events()/agentText() delegate to it) would let session B reuse the same folding logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/host-service/test/integration/sessions-user-story.integration.test.ts`
around lines 848 - 861, The session B assertions duplicate stream event and text
extraction logic because the existing events()/agentText() lenses are fixed to
sessionA. Add a sessionId-parameterized sessionEvents(sessionId) lens, update
events() and agentText() to delegate to it while preserving their current
sessionA behavior, and replace the inline streamB/textB folding with the shared
lenses for createdB.session.id.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/host-service/test/integration/sessions-user-story.integration.test.ts`:
- Around line 848-861: The session B assertions duplicate stream event and text
extraction logic because the existing events()/agentText() lenses are fixed to
sessionA. Add a sessionId-parameterized sessionEvents(sessionId) lens, update
events() and agentText() to delegate to it while preserving their current
sessionA behavior, and replace the inline streamB/textB folding with the shared
lenses for createdB.session.id.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a352e4fa-a562-41d4-93e0-0a1244357238

📥 Commits

Reviewing files that changed from the base of the PR and between 063ee6b and ff98dfd.

📒 Files selected for processing (3)
  • packages/host-service-sync/src/protocol/api.ts
  • packages/host-service-sync/src/protocol/protocol.test.ts
  • packages/host-service/test/integration/sessions-user-story.integration.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/host-service-sync/src/protocol/protocol.test.ts
  • packages/host-service-sync/src/protocol/api.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/host-service/test/integration/sessions-user-story.integration.test.ts">

<violation number="1" location="packages/host-service/test/integration/sessions-user-story.integration.test.ts:809">
P2: The `overlapWatch` background IIFE in the step-17 test polls until `secondDone` is set, but if any setup code throws before that line, the IIFE runs forever and can produce an unhandled rejection during `afterAll` cleanup. Consider wrapping the IIFE body in try/catch, or using an `AbortController` with a deadline so the loop always terminates regardless of the success/failure path.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic


let overlapped = false;
let secondDone = false;
const overlapWatch = (async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The overlapWatch background IIFE in the step-17 test polls until secondDone is set, but if any setup code throws before that line, the IIFE runs forever and can produce an unhandled rejection during afterAll cleanup. Consider wrapping the IIFE body in try/catch, or using an AbortController with a deadline so the loop always terminates regardless of the success/failure path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/test/integration/sessions-user-story.integration.test.ts, line 809:

<comment>The `overlapWatch` background IIFE in the step-17 test polls until `secondDone` is set, but if any setup code throws before that line, the IIFE runs forever and can produce an unhandled rejection during `afterAll` cleanup. Consider wrapping the IIFE body in try/catch, or using an `AbortController` with a deadline so the loop always terminates regardless of the success/failure path.</comment>

<file context>
@@ -0,0 +1,901 @@
+
+		let overlapped = false;
+		let secondDone = false;
+		const overlapWatch = (async () => {
+			while (!secondDone) {
+				if (sessionEntity().runState === "running") overlapped = true;
</file context>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts (1)

1214-1247: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Prefer Bun's fake timers over a real Bun.sleep(5) wait.

This test relies on a real wall-clock Bun.sleep(5) to let the reconnectDelayMs: 1 retry timer fire, which is inherently flaky under CI load (razor-thin margin between a 1ms scheduled delay and a 5ms real wait). Bun's test runner added jest.useFakeTimers() / advanceTimersByTime() support in v1.3.4, so a real-time-free alternative should be available given the project's declared @types/bun@1.3.14.

♻️ Suggested refactor using fake timers
-		client.connect();
-		expect(logs).toContainEqual({
-			event: "sessions_sync.connect_failed",
-			reason: "socket_factory_threw",
-		});
-		expect(client.store.getState().connection.error).toEqual({
-			code: "SOCKET_FACTORY_FAILED",
-			retryable: true,
-		});
-		expect(client.store.getState().connection.status).toBe("reconnecting");
-
-		// The retry loop still runs — and succeeds once the factory recovers.
-		await Bun.sleep(5);
-		expect(sockets).toHaveLength(1);
+		jest.useFakeTimers();
+		try {
+			client.connect();
+			expect(logs).toContainEqual({
+				event: "sessions_sync.connect_failed",
+				reason: "socket_factory_threw",
+			});
+			expect(client.store.getState().connection.error).toEqual({
+				code: "SOCKET_FACTORY_FAILED",
+				retryable: true,
+			});
+			expect(client.store.getState().connection.status).toBe("reconnecting");
+
+			// The retry loop still runs — and succeeds once the factory recovers.
+			jest.advanceTimersByTime(1);
+			expect(sockets).toHaveLength(1);
+		} finally {
+			jest.useRealTimers();
+		}

Please verify the reconnect scheduling actually uses setTimeout/clearTimeout (per the graph context on createSessionsSyncClient) so advanceTimersByTime deterministically triggers it before adopting this change.

Based on learnings: "test code should avoid real wall-clock timers... do not recommend changing... unless a safe alternative that doesn't use real time is available" — Bun 1.3.4+ fake timers appear to be that safe alternative here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts`
around lines 1214 - 1247, Replace the real-time Bun.sleep wait in the “socket
factory failures surface a log event and a connection error” test with Bun fake
timers, confirming the reconnect path in createSessionsSyncClient schedules
retries through setTimeout. Enable fake timers before connecting, advance them
by the configured reconnectDelayMs, assert the recovered socket, and restore
real timers during cleanup.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts`:
- Around line 1214-1247: Replace the real-time Bun.sleep wait in the “socket
factory failures surface a log event and a connection error” test with Bun fake
timers, confirming the reconnect path in createSessionsSyncClient schedules
retries through setTimeout. Enable fake timers before connecting, advance them
by the configured reconnectDelayMs, assert the recovered socket, and restore
real timers during cleanup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6aac6f08-1e70-4a9b-9f68-30b0b5583286

📥 Commits

Reviewing files that changed from the base of the PR and between e174f02 and db7eb6a.

📒 Files selected for processing (5)
  • packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/state.ts
  • packages/host-service-sync/src/client/types.ts
  • plans/host-sessions-rollout.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/host-service-sync/src/client/types.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/state.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts">

<violation number="1" location="packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts:609">
P2: A delayed `unsubscribed` acknowledgement after `session_removed` recreates an idle stream for the deleted session. Include unsubscribe acknowledgements in the stale-session filter so this race is dropped like other post-unsubscribe packets.</violation>

<violation number="2" location="packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts:735">
P1: A stale `syncUrl` rejection can mark a newer live connection as reconnecting and leave its status incorrect. Gate failure handling on the captured connection epoch (and stopped state), as `attachSocket` already does.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

}
void value.then(
(url) => attachSocket(url, epoch),
() => failSyncUrl(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A stale syncUrl rejection can mark a newer live connection as reconnecting and leave its status incorrect. Gate failure handling on the captured connection epoch (and stopped state), as attachSocket already does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts, line 735:

<comment>A stale `syncUrl` rejection can mark a newer live connection as reconnecting and leave its status incorrect. Gate failure handling on the captured connection epoch (and stopped state), as `attachSocket` already does.</comment>

<file context>
@@ -675,7 +732,7 @@ export function createSessionsSyncClient(
 		void value.then(
 			(url) => attachSocket(url, epoch),
-			() => scheduleReconnect(),
+			() => failSyncUrl(),
 		);
 	}
</file context>
Suggested change
() => failSyncUrl(),
() => {
if (stopped || epoch !== connectionEpoch) return;
failSyncUrl();
},

!sentSessionSubscriptions.has(packet.sessionId)
? packet.sessionId
: null;
case "subscribed":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A delayed unsubscribed acknowledgement after session_removed recreates an idle stream for the deleted session. Include unsubscribe acknowledgements in the stale-session filter so this race is dropped like other post-unsubscribe packets.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts, line 609:

<comment>A delayed `unsubscribed` acknowledgement after `session_removed` recreates an idle stream for the deleted session. Include unsubscribe acknowledgements in the stale-session filter so this race is dropped like other post-unsubscribe packets.</comment>

<file context>
@@ -585,6 +588,37 @@ export function createSessionsSyncClient(
+					!sentSessionSubscriptions.has(packet.sessionId)
+					? packet.sessionId
+					: null;
+			case "subscribed":
+			case "caught_up":
+			case "reset":
</file context>
Suggested change
case "subscribed":
case "subscribed":
case "unsubscribed":

@bra1nDump
bra1nDump force-pushed the feat/sessions-v1-sync branch from db7eb6a to 2fdbde0 Compare July 12, 2026 21:33
@bra1nDump bra1nDump changed the title feat(host-service): canonical sessions surface — sessions.* tRPC, /sessions/sync hub, framework-free sync client feat(host-service): host sessions sync v1 — canonical sessions over tRPC snapshots + /sessions/sync Jul 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/host-service-sync/src/protocol/sync.ts (1)

17-20: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the redundant .safe() calls
.safe() is deprecated in Zod 4 and duplicates .int(), so z.number().int().positive() is enough here; the same applies to the maxSubscriptions and maxFrameBytes schemas.

♻️ Optional simplification
 export const toolResolverDescriptorSchema = z.object({
 	name: z.string().min(1).max(256),
-	version: z.number().int().safe().positive(),
+	version: z.number().int().positive(),
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/host-service-sync/src/protocol/sync.ts` around lines 17 - 20, Remove
the redundant deprecated safe() calls from toolResolverDescriptorSchema and the
maxSubscriptions and maxFrameBytes schema definitions, leaving the
integer-positive validation as z.number().int().positive().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plans/done/20260709-1438-unified-home-workspaces-sessions.md`:
- Line 47: Update the stale reference in the observation about canonical live
status to point from plans/host-sessions-rollout.md to
plans/host-sessions-sync.md, leaving the surrounding explanation unchanged.

In `@plans/host-sessions-sync.md`:
- Line 25: The socket packet budget in the Budgets section is inconsistent with
the enumerated client and server packet types; update it from 12 to 13 to match
the packet lists under the client-side and server-side socket packet sections.

---

Nitpick comments:
In `@packages/host-service-sync/src/protocol/sync.ts`:
- Around line 17-20: Remove the redundant deprecated safe() calls from
toolResolverDescriptorSchema and the maxSubscriptions and maxFrameBytes schema
definitions, leaving the integer-positive validation as
z.number().int().positive().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e417ad98-9bc0-4c29-a5dc-9e4526521dcd

📥 Commits

Reviewing files that changed from the base of the PR and between db7eb6a and 2fdbde0.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (69)
  • apps/mobile/lib/host-service/client.ts
  • apps/mobile/lib/trpc/host-chat-types.ts
  • apps/mobile/plans/acp-vs-claude-sdk.html
  • apps/mobile/plans/mobile-chat-runtime.md
  • packages/host-service-sync/package.json
  • packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.test.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/createSessionsSyncClient.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/index.ts
  • packages/host-service-sync/src/client/createSessionsSyncClient/state.ts
  • packages/host-service-sync/src/client/index.ts
  • packages/host-service-sync/src/client/types.ts
  • packages/host-service-sync/src/index.ts
  • packages/host-service-sync/src/package-boundaries.test.ts
  • packages/host-service-sync/src/projection/fixtures/cards-and-settings.json
  • packages/host-service-sync/src/projection/fixtures/turn-lifecycle.json
  • packages/host-service-sync/src/projection/index.ts
  • packages/host-service-sync/src/projection/projection.ts
  • packages/host-service-sync/src/projection/reduceProjection.test.ts
  • packages/host-service-sync/src/projection/reduceProjection.ts
  • packages/host-service-sync/src/protocol/api.ts
  • packages/host-service-sync/src/protocol/entities.ts
  • packages/host-service-sync/src/protocol/events.ts
  • packages/host-service-sync/src/protocol/index.ts
  • packages/host-service-sync/src/protocol/primitives.ts
  • packages/host-service-sync/src/protocol/protocol.test.ts
  • packages/host-service-sync/src/protocol/routes.ts
  • packages/host-service-sync/src/protocol/sync.ts
  • packages/host-service-sync/tsconfig.json
  • packages/host-service/docs/acp-sessions.md
  • packages/host-service/docs/interim-router-types.md
  • packages/host-service/package.json
  • packages/host-service/src/app.ts
  • packages/host-service/src/db/schema.ts
  • packages/host-service/src/runtime/acp-sessions/stream.ts
  • packages/host-service/src/runtime/sessions/canonical-sessions.test.ts
  • packages/host-service/src/runtime/sessions/canonical-sessions.ts
  • packages/host-service/src/runtime/sessions/index.ts
  • packages/host-service/src/runtime/sessions/sync-hub.stress.test.ts
  • packages/host-service/src/runtime/sessions/sync-hub.test.ts
  • packages/host-service/src/runtime/sessions/sync-hub.ts
  • packages/host-service/src/runtime/sessions/sync-route.ts
  • packages/host-service/src/runtime/sessions/testing/fake-acp-port.ts
  • packages/host-service/src/runtime/sessions/testing/sync-harness.ts
  • packages/host-service/src/runtime/sessions/translate-acp.test.ts
  • packages/host-service/src/runtime/sessions/translate-acp.ts
  • packages/host-service/src/trpc/index.ts
  • packages/host-service/src/trpc/router/acp-sessions/acp-sessions.ts
  • packages/host-service/src/trpc/router/router.ts
  • packages/host-service/src/trpc/router/sessions/index.ts
  • packages/host-service/src/trpc/router/sessions/sessions.ts
  • packages/host-service/src/types.ts
  • packages/host-service/test/helpers/createTestHost.ts
  • packages/host-service/test/helpers/gym.ts
  • packages/host-service/test/integration/acp-user-story.integration.test.ts
  • packages/host-service/test/integration/sessions-sync-client.integration.test.ts
  • packages/host-service/test/integration/sessions-user-story.integration.test.ts
  • packages/session-protocol/src/acp.ts
  • plans/acp-session-follow-ups.md
  • plans/chat-mastra-rebuild-execplan.md
  • plans/chat-service-at-linking.md
  • plans/cross-client-session-tab-sync.md
  • plans/done/20260709-1438-unified-home-workspaces-sessions.md
  • plans/done/20260710-session-harness-acp.md
  • plans/host-integration-test.md
  • plans/host-service-chat-architecture.md
  • plans/host-sessions-sync.md
  • plans/session-harness-claude-agent-sdk.md
  • plans/v1-to-v2-fast-migration.md
  • plans/v2-chat-greenfield-architecture.md
💤 Files with no reviewable changes (10)
  • packages/host-service/docs/interim-router-types.md
  • plans/cross-client-session-tab-sync.md
  • plans/host-service-chat-architecture.md
  • plans/chat-service-at-linking.md
  • plans/host-integration-test.md
  • plans/chat-mastra-rebuild-execplan.md
  • plans/session-harness-claude-agent-sdk.md
  • plans/acp-session-follow-ups.md
  • packages/host-service/docs/acp-sessions.md
  • apps/mobile/plans/mobile-chat-runtime.md
🚧 Files skipped from review as they are similar to previous changes (24)
  • packages/host-service-sync/tsconfig.json
  • packages/host-service-sync/src/client/createSessionsSyncClient/index.ts
  • packages/host-service/src/trpc/router/sessions/index.ts
  • packages/host-service-sync/src/package-boundaries.test.ts
  • packages/host-service-sync/src/protocol/index.ts
  • packages/host-service-sync/src/protocol/routes.ts
  • packages/host-service-sync/src/client/index.ts
  • packages/host-service-sync/package.json
  • packages/host-service-sync/src/index.ts
  • packages/host-service-sync/src/projection/index.ts
  • packages/host-service/package.json
  • packages/host-service/test/helpers/createTestHost.ts
  • packages/host-service-sync/src/projection/fixtures/turn-lifecycle.json
  • packages/host-service/src/types.ts
  • packages/host-service/src/runtime/sessions/sync-route.ts
  • packages/host-service-sync/src/projection/projection.ts
  • packages/host-service/src/runtime/sessions/testing/sync-harness.ts
  • packages/host-service/src/app.ts
  • packages/host-service-sync/src/protocol/primitives.ts
  • packages/host-service/src/runtime/sessions/sync-hub.ts
  • packages/host-service/src/runtime/sessions/testing/fake-acp-port.ts
  • packages/host-service/src/runtime/sessions/translate-acp.test.ts
  • packages/host-service/src/runtime/sessions/sync-hub.test.ts
  • packages/host-service/test/integration/acp-user-story.integration.test.ts

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Socket packet budget likely off by one.

Line 25 states "12 socket packet types", but the enumerated packets total 13: client-side hello, subscribe, unsubscribe, toolResolversChanged, ping (5) plus server-side helloAck, subscribed, event, caughtUp, unsubscribed, reset, error, pong (8). Since this doc is the "single source of truth," reconcile the budget with the packet lists in Lines 174-179.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/host-sessions-sync.md` at line 25, The socket packet budget in the
Budgets section is inconsistent with the enumerated client and server packet
types; update it from 12 to 13 to match the packet lists under the client-side
and server-side socket packet sections.

@bra1nDump
bra1nDump force-pushed the feat/sessions-v1-sync branch from 2fdbde0 to 0a24557 Compare July 12, 2026 22:58
@bra1nDump
bra1nDump force-pushed the feat/sessions-v1-sync branch 2 times, most recently from a057c20 to a234df3 Compare July 13, 2026 00:32
@bra1nDump bra1nDump changed the title feat(host-service): host sessions sync v1 — canonical sessions over tRPC snapshots + /sessions/sync feat(host-service,mobile): host sessions sync v1 — canonical sessions over tRPC snapshots + /sessions/sync, mobile rewired onto the sync client Jul 13, 2026
@bra1nDump
bra1nDump force-pushed the feat/sessions-v1-sync branch 2 times, most recently from 0cd49eb to 9cc7465 Compare July 13, 2026 14:23
… over tRPC snapshots + /sessions/sync, mobile rewired onto the sync client
@saddlepaddle

Copy link
Copy Markdown
Collaborator

Picked this up and carried it to a merge-ready state in #5940 — your branch history is preserved there, rebased-via-merge onto main (the drizzle 0010 collision regenerated as 0012, FinishReviewSheet ported off the deleted ACP APIs), plus fixes from a four-track deep review (unbounded session log → real 5k ring, orphan-sweep data-loss guard, client cold-path retries + heartbeat, timeline fold determinism, mobile permission-card/failed-send fixes). Open follow-ups are listed in #5940's description. Closing this one in favor of it is your/team's call.

@Kitenite

Copy link
Copy Markdown
Member

Closing: main already ships this. Electric replaced by tRPC sync b671c27 (#6328) and ACP stack deleted f2f000b (#6434). Thanks for the PR; if you think the shipped version misses a case, open an issue and link this.

@Kitenite Kitenite closed this Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants