Learn the family route's absence per connection where the data layer
already invalidates on disconnect. server.connected is delivered after
the connection reports connected, so route effects started family reads
before the old reset and kept using the fallback after reconnecting to
an upgraded server.
Key family membership by the client's own root. The server roots a
family whose top names a missing parent at the top, while resolveRoot
walks to the missing ID, so such a family read showed no members and
dropped its journaled events.
A fallback read invalidated in flight no longer starts member reads that
could complete with pre-reconnect data. Metadata updates and permission
replies now go through the journal, so an in-flight read cannot undo
them, and metadata updates raise time.updated like the projector does.
Test fixtures drop status values that were only needed while status was
required, and the TUI fixture's family route now roots orphan families
and forwards failed sub-reads as the real server does. The spec states
what is implemented and what is still to do.
A client from this branch talking to an older server got a 404 for every
family read, so the TUI closed the session it was opening.
The data layer now learns once per connection whether the server has the
family route. An older server answers it with a 404 that has no typed
error body; a missing Session still returns SessionNotFoundError and
still rejects. On the first untyped 404 the client falls back for the
rest of the connection and tries the route again after every reconnect.
The fallback restores the reads that preceded the family route: the
Session and its paged direct children, merged into the membership
already known, then pending permissions and forms for every known family
member, waiting only for the requested Session's. Status comes only from
/session/active and execution events, since older servers send none. A
stale read neither falls back nor marks the route missing. Delete the
fallback once the oldest supported server has the family route.
A client from this branch talking to an older server receives Sessions
without status and /session/active entries without rootID. The Promise
client never checks, but the Effect client decodes responses, so it
rejected every Session from such a server.
Both fields are now optional on the wire. Servers with this change
always send them; a missing value means the server predates them, not
that the Session is idle. The client data layer then keeps the status it
derives from /session/active and execution events instead of inventing
one.
New endpoints start experimental. The family read moves to
/api/experimental/session/:sessionID/family in the server.experimental
group, beside schedule and media.get, so the generated SDK exposes it
only as client.experimental.sessionFamily.get. In its own session group
the codegen would also emit a deprecated client.session.family alias for
a method that never shipped. The identifier experimental.session.family
collides with the session group's experimental methods in the codegen,
so it follows the persistentPty precedent for routes under
/api/experimental/session.
The spec records the open question of whether status on Session.Info and
rootID on /session/active should start experimental too.
With the data layer's family read carrying every member's status,
permissions, and forms, the TUI's own per-member reads are redundant.
Started alongside the family read, they still went out as separate
requests.
The session route now awaits only the family read and no longer syncs
pending requests for each descendant. Tab prefetch drops its permission
and form reads. The open dialog reads one family per distinct root of
running Sessions and of Sessions with pending requests, instead of two
pending-request reads per running Session and a single read per root.
Opening a root with subagents now costs /session/active once per
connection and one family read.
The shared data layer built a family by paging direct children, read
pending permissions and forms per member, re-read a Session after every
terminal and viewed event, and merged every read into what it already
held. A child deleted while disconnected stayed listed until restart,
and a permission.asked that arrived before a permission list response
which missed it was erased by that response.
session.sync(id, { children: true }) now reads /session/:id/family. It
replaces the family's membership, forgets members the server no longer
lists, and takes each member's status and pending requests from the same
response. It also marks every member's Session, family, and pending
request reads complete, so opening a child costs nothing more.
Reads and events travel on different connections, so a journal records
the events that arrive while a read is in flight and replays them on top
of its response. Session field patches, status, membership, and pending
requests all go through it, and every fold is idempotent because replay
reapplies events the response may already include. Terminal and viewed
events fold time.idle, outcome, and time.viewed, and time.updated
follows the events the projector stamps, so the per-event Session
re-reads are gone. A family read invalidated in flight applies nothing,
because a newer read supersedes it.
Reconnect hydration reads one family per running root from the rootID
on /session/active instead of one Session per running child.
Execution events carry only a Session ID, so a client that sees a child
start before it has loaded that child cannot tell which root to mark
busy. The iOS app walks parents one GET at a time to find out, and the
TUI loads the child to resolve its root.
Each /session/active entry now carries rootID, the root of the family
that contains the running Session, resolved for all running Sessions in
one query that shares the family read's root rule. It stays out of the
durable execution events, which record only irreducible facts;
/session/active is a read model and may carry derived state.
To draw one Session family, clients list children member by member and
read pending permissions and forms per member. Opening a root with about
a hundred subagents took about two hundred requests, and every client
rebuilt the tree its own way with its own cap on members.
GET /api/session/:sessionID/family returns the root and every descendant
(root first, then by creation time and ID) from one recursive query,
with status taken from one snapshot of the running set. Any member's ID
returns the same family. The response is complete; it is not paginated.
It also carries the family's pending permission requests and forms.
Those live in memory inside running Locations, so the read collects them
only from members whose Location has finished starting. A member whose
Location is not running contributes nothing and is neither started nor
waited on.
Every view that shows a Session today joins it against the
/session/active snapshot and the execution event stream to learn whether
it is running. Put the answer on the Session itself: Session.Info gains
status, "running" while SessionExecution in this process owns a busy
period for the Session and "idle" otherwise.
Core's Session facade attaches status wherever it returns a Session, so
HTTP responses and plugins both see it. It snapshots the running set
before reading rows, so a read never pairs "idle" with a row from before
the terminal commit.
Status is process-local and must not be persisted. The previous shape is
now Session.Persisted: the store, durable events, and Session export and
import keep using it, so exports written before this change still import
and new exports, including the app and desktop transcript exports, carry
no status. Session.Status has no schema identifier because SessionStatus
already names the session.status event union.
Session.list returned cursor.next for every non-empty page, including the
last one, so a list whose size is a multiple of the limit, or a short
final page, made clients request one more empty page. Read one extra row
to learn whether another page exists, and return the cursor in the
direction of travel only then. A backward page arrives reversed, so its
extra row is dropped from the front.
Pending permissions and forms live in memory inside a started Location,
so a Location that is not running cannot hold one. session.permission.list
still entered the Session's Location: it booted idle Locations (MCP
processes, watchers) just to return [], and failed with
LocationNotFoundError for Sessions whose worktree was deleted. Clients
read it for every member of a Session family, so one root with deleted
worktree children produced a burst of 404s on every open.
Instance.live, added for form listing, used RcMap.has, which is also true
while a Location is still starting, so a form list still waited on the
start and failed with it. Track readiness instead: LocationServiceMap
gains ready(ref), true only between a successful start and invalidation
or failure, read from the builds registry without waiting. The SDK's
embedded instance map tracks the same state for its own live.
session.permission.list now returns [] when the Location is not running,
like session.form.list.