Compass Live Roster — the agent tree reads the live roster (SEA-2033)
Status: Draft
Problem / Intent
Section titled “Problem / Intent”The Compass UI’s agent surfaces (workspaces tree, board swimlanes, fleet
sidebar) still render the hardcoded STUB_AGENTS fixture even on a live
connection: LeftSidebar.tsx:400 renders <For each={agentTree(STUB_AGENTS)}>
directly from the fixture module, and the store pins a non-reactive
const agents = STUB_AGENTS (store.ts:669) with no live accessor. The
comms/board streams are already live (accounts, issues); this record designs
the missing source swap — the agent roster reads the live server (identity +
tree + presence + activity), and STUB_AGENTS is demoted from render source to
offline seed.
Approach
Section titled “Approach”Ground truth (what exists today)
Section titled “Ground truth (what exists today)”The live connection succeeds; only the agent source is fixture. Boot resolves the door and streams comms + board events:
-
store.ts:897-911runs the comms driver whenoptions.commsis set:if (options.comms) {const client = options.comms;...void runCommsStream({ client, callerId, mapMessage: adaptMessage,onState: adoptComms, signal: abort.signal, ... }) -
store.ts:935-942runs the board event stream (runEventStream({ client, onIssues: setIssues, ... })), replacing theSTUB_ISSUESseed — the pattern this record repeats for agents. The issues seam is explicitly documented as the template (store.ts:670-672): “Seeded from the fixture; the real @compass/client stream replaces the seed later (the accessor stays the seam).”
The agent source is a dead const. store.ts:669:
const agents = STUB_AGENTS;Three store reads resolve against it — agentById (store.ts:796-797),
selectedAgent (store.ts:987-989), and agentView (store.ts:993-994) —
and the store’s own comment names the owed migration (store.ts:793-795):
“agents is a static const today, so this cannot flip live→unreachable yet;
the live-agents migration owes converting agents to a signal/store read
flowing through this one seam (not a non-reactive snapshot).” The store
interface documents the join seam (store.ts:281-286): “The pure seam
(joinAgents in the real era) the workspace WILL read once the
SubscribeComms/SubscribeEvents join lands.”
Two components bypass the store entirely:
LeftSidebar.tsx:400:<For each={agentTree(STUB_AGENTS)}>— the workspaces tree.Bridge.tsx:110(prRowGroups(STUB_AGENTS, store.issues())) andBridge.tsx:121(boardAgentsOf(STUB_AGENTS, store.issues())) — the board’s swimlane/PR groupings, with the comment “STUB_AGENTS stays direct — agents aren’t mutated here” (Bridge.tsx:120).
The derivation is already live-ready. agentTree(agents: readonly Agent[]): AgentTreeNode[] (stub-data.ts:387-426) is a pure, total,
cycle/dangling-safe derivation over parentAgentId — source-agnostic. The
board helpers are likewise pure over an injected agent list (board.ts:53-56
boardAgents(agents, all), board.ts:87 treeOrder(agents),
board.ts:139-142 prRowGroups(agents, all)). Only the INPUT is hardcoded.
The agent-trees record designed exactly this derivation
(docs/designs/product/compass-agent-trees/design.md:383-417, §T4) against
the fixture carrying parent_agent_id; the fixture→live source swap is the
layer it left open — this record. Its derivation contract (stable input order,
dangling-promotes-to-root) is untouched here.
Live identity already flows. CommsState.accounts is live
(comms-state.ts:54-59), snapshot-reduced via adaptAccount
(comms-state.ts:97) and event-updated via the accountChanged case
(stream.ts:209-214). adaptAccount (adapt.ts:118-143) lifts the agent
arm’s ownerUserId / homeChannelId / parentAgentId onto the flat domain
Account, normalizing empty-string parent to undefined “so the domain’s
‘absent = a root’ contract holds and agentTree derives it as top-level”
(adapt.ts:133-136).
The live presence source exists server-side but is unconsumed:
GetRosterRPC (comms.proto:113-114: “the live presence projection with the durable activity string”; generated clientcomms_pb.ts:2567-2573getRoster: { methodKind: "unary"; ... }).RosterEntry(comms.proto:705-713):agent_account_id,handle,display_name,parent_agent_id,presence,activity,activity_at_unix_ms.RosterScope(comms.proto:690-697):NEIGHBORHOOD/SUBTREE/OWNER(“the whole agent set owned by the vantage agent’s owner”);GetRosterRequest.agent_account_idis “Optional for human/UI callers naming a vantage” (comms.proto:684-686). Ratified as DL-135 (“Agent roster is a pull … reading the DL-074 in-memory presence enum joined with the agent tree; the activity string is DURABLE”).AgentPresence(comms.proto:548-554) is the DL-074 four-state enum:UNSPECIFIED / IDLE / WORKING / WAITING / OFFLINE.AgentPresenceChanged(comms.proto:536-544:agent_account_id,presence,activity) flows on SubscribeComms (field 17,comms.proto:471-472, “PUBLIC — UI board state consumes it”) but the UI driver drops it today indecodeEvent’s default arm (stream.ts:224-227).AgentSessionStatuson SubscribeEvents is the richer session lifecycle; the events driver deliberately ignores it (events.ts:34-37: “gated on other lands and are safely ignored”).
The view-model. Agent (stub-data.ts:344-361) = durable account: Account + optional lifecycle?: AgentLifecycle (= AgentState,
stub-data.ts:336) + optional activity?: string (already documented as
“AgentPresenceChanged.activity”, stub-data.ts:347-352) + UI-only role: AgentRole / model: string / cwd: string / terminals: Terminal[]
(stub-data.ts:353-360: “UI-only roster config” / “Fixture-only”).
AgentState (stub-data.ts:47-55) is the eight-value union working | idle | waiting | done | paused | stopped | error | disconnected the StateDot
renders (StateDot.tsx:16, AGENT_STATE_LABEL at constants.ts:32), per the
ux-foundation D3 vocabulary (“the eight AgentState values, keyed 1:1 against
the frozen brand vocabulary”, design/components.md:90-92).
The chosen approach
Section titled “The chosen approach”Live agents = a reactive join of the already-live accounts with a new
presence map; GetRoster seeds the map; AgentPresenceChanged updates it;
STUB_AGENTS becomes the offline seed only.
Concretely (each is a settled fork; the full rationale is in Resolved decisions):
- Roster source — join, not replace.
RosterEntrycarries identity fields (handle,display_name,parent_agent_id) but NOThomeChannelId/ownerUserId/kind— and the store’sopenAgentpath and the comms rail depend onhomeChannelId(e.g.store.test.ts:350-352resolvesaccount.homeChannelIdto open the home DM). SoAccount(from the liveSubscribeCommsaccounts, already wired) stays the single identity + tree source, andGetRosteris consumed only for what accounts don’t carry: the initial presence + activity join. Live updates ride theAgentPresenceChangedevent the stream already receives and drops. This avoids a second identity source that could drift fromaccountChanged, and reuses the existing snapshot+tail reducer (comms-state.ts/stream.ts) rather than adding a parallel driver. - Presence→AgentState mapping (total over the 4-state enum):
WORKING → "working",IDLE → "idle",WAITING → "waiting",OFFLINE → "stopped",UNSPECIFIED → undefined. TheOFFLINE → "stopped"arm is a ruled decision (Resolved decision R2): the server defaults EVERY agent absent from its in-memory presence source toOFFLINE(roster.go:79-82“Live presence enum (absent → OFFLINE)”, pinned byroster_pgtest_test.go:188-190), soUNSPECIFIEDis unreachable on theGetRosterpath andOFFLINEcovers BOTH “deliberately stopped” and “never started” — the 4-state enum cannot split them client-side. MappingOFFLINE → "stopped"renders such an agent with the hollow-ring “stopped” dot (agent-state.ts:41“terminated; distinct from live idle”,StateDot.tsx); the known day-one cost is that a freshly-seeded-but- unstarted agent (the DL-192 root supervisor, or a whole fleet just after a runner restart) reads “stopped” rather than today’s grey idle dot (AgentLeafdefaultslifecycle ?? "idle",LeftSidebar.tsx:47) — and because the activity string is durable (roster_pgtest_test.go:254-258), such an agent can even carry a live-looking activity string beside a “stopped” dot. Splitting “terminated” from “never started” is owed to the deferredAgentSessionStatuslane; the 4-state enum alone cannot.UNSPECIFIED → undefinedstays in the total mapping as a defensive arm only (unreachable from GetRoster, matchingagent-state.ts:43-44). Reachable dot subset from the roster stream:working / idle / waiting / stopped. The remaining four (done,paused,error,disconnected) stay reachable only viaAgentSessionStatus(agent-state.ts:47-56agentDotState(...): AgentStatealready implements that projection) and the spawn phase machine (spawn.ts:142bindingDotState), both separate lands — consumingAgentSessionStatusin the events driver stays deferred (ignored today,events.ts:34-37; wiring it is a SubscribeEvents-lane change, not a roster change). - UI-only fields. Grounded consumers:
role→ only the LeftSidebar role-pip (LeftSidebar.tsx:49-53, shown whenrole !== "worker");model+cwd→ only the AgentView header (AgentView.tsx:219-220);terminals→ only AgentView panes (AgentView.tsx:22-23,181; the zero-terminal path is already proven —acc-supervisorhasterminals: [],stub-data.ts:481, andAgentView.test.tsx:193-195pins it). So:role,model,cwdbecome OPTIONAL onAgent(role?: AgentRole,model?: string,cwd?: string); a live agent carries none of them; the pip and the header spans render only when present (<Show>).terminalsstays required but a live agent gets[](the already-proven empty path). Derivingrolefrom tree position is rejected: “has children” ≠ supervisor (any worker can spawn), and no server field exists — an honest absent beats a guessed pip. - Empty roster. A live connection with zero agent accounts renders the
tree-empty state the surfaces spec already defines
(
design/surfaces.md:129-131: “Tree-empty (no agents yet) renders a real empty-state row set: a one-line explanation and the palette hint … not a blank column”). No client-side fake supervisor: SEA-1820 / DL-192 seed the root manager server-side (seedRootSupervisor, DL-192), so a live empty tree is a legitimate transient, not a state to paper over. (The empty-state VISUALS are the disjoint compass-ux styling lane; this record owes only the conditional render seam.) - Stub story.
STUB_AGENTSsurvives as the OFFLINE seed, exactly theSTUB_ISSUESpattern (store.ts:670-673): withoptions.commsabsent the store’sagentsaccessor returns the fixture; with it present, the live join (which starts empty —EMPTY_COMMS_STATE,comms-state.ts:63-69— until the first snapshot, so no fixture flash on a live boot). The ~7 test files that construct offline stores or read the fixture (store.test.ts,identity.test.ts,App.test.tsx,LeftSidebar.test.tsx,AgentView.test.tsx,RightSidebar.test.ts, pluscomms-stub.tsderivingSTUB_ACCOUNTSfrom it,comms-stub.ts:283-285) keep working unchanged: offline stores keep resolving fixture agents through the SAME accessor the components now read. Thevite devwalking skeleton (App.tsx:20-25) is preserved by the same arm.
Why not the alternatives
Section titled “Why not the alternatives”GetRosteras the whole roster source (poll or snapshot+events): duplicates identity already streaming viaaccountChanged, lackshomeChannelId/kindthe UI requires, and adds a second source that can drift from the accounts the comms rail renders. Rejected.- A separate roster driver/signal outside
CommsState: the presence event arrives ON the comms stream, and the reducer already owns snapshot-boundary + tail-overlap semantics (comms-state.ts:47-53). A parallel driver would re-implement resync/backoff for one map. Rejected — presence joinsCommsStateas a fifth collection. - Consume
AgentSessionStatusnow for the full 8-state dot: a SubscribeEvents-lane change with its own attribution/precedence design (spawn-binding vs live-state precedence,spawn.ts:13-14); the roster swap neither needs it nor blocks it. Deferred. - Presence/activity on
Account+accountChanged(server-side), eliminating the client join: structurally the cleanest long-term shape — one identity source, no client join — but a proto + comms-server lane far outside SEA-2033’s UI scope. Noted as the rejected long-term direction, not taken now.
No new abstraction is introduced: the change is one new CommsState
collection, one adapter, one mapping function, one store memo behind the
already-documented joinAgents seam, and component cutovers to the accessor.
Global Constraints
Section titled “Global Constraints”- Domain shapes above the adapt seam — components and the store never see
wire types; all wire→domain mapping lives in
apps/ui/src/live/adapt.ts(the existing convention,adapt.tsheader +stub-data.ts:338-339“NEVER a wire shape”). - Offline-constructible store —
createAppStorewith nooptions.comms/options.compassmust keep working with fixture data and no network (the walking skeleton,App.tsx:20-25; every existing happy-dom test constructs it this way). agentTree/treeOrder/boardAgents/prRowGroupscontracts are frozen (agent-trees record §T4/T5) — this record changes their INPUT plumbing only, never their semantics.- No styling work —
app.css/ token/empty-state visuals are the compass-ux lane; this record adds render seams only. - Mixed liveness is out of scope — a store constructed with one client but
not the other (comms live, board not, or vice versa) renders per-arm: live
agents join
store.agents()behindoptions.commswhile issues stay live behindoptions.compass(store.ts:895-921), so Bridge’s agent×issue join (Bridge.tsx:110) can show live agents against fixture issues. A degenerate dev configuration, deliberately not reconciled here. - Repo:
RigelBuild/compass; UI atapps/ui; tests are happy-dom bun/vitest-style suites beside their modules; Biome formatting. - Every task lands with its own tests (rule://red-green-testing) and keeps the full existing suite green.
T1 — presence mapping + adapter (pure, no store)
Section titled “T1 — presence mapping + adapter (pure, no store)”Add the wire→domain presence pieces in apps/ui/src/live/adapt.ts.
- Interfaces:
export function presenceLifecycle(p: AgentPresence): AgentState | undefined— total over the generatedAgentPresenceenum (@compass/client):WORKING → "working",IDLE → "idle",WAITING → "waiting",OFFLINE → "stopped",UNSPECIFIED → undefined; default arm throws on an unmodeled numeric (theagent-state.ts:75-78exhaustiveness convention).export interface AgentPresenceInfo { readonly lifecycle?: AgentState; readonly activity?: string; }— the domain presence value, keyed by agent account id (empty-string activity normalizes toundefined, matching theAgent.activitycontractstub-data.ts:350-351).export function adaptRosterEntry(w: RosterEntry): [string, AgentPresenceInfo]— maps one wire entry to its map entry (id + info). Identity fields (handle,displayName,parentAgentId) are deliberately DROPPED — accounts own identity (Approach fork 1).
- Tests: a sibling block in the existing adapt tests — mapping totality, empty-activity normalization, unmodeled-enum throw.
T2 — presence joins CommsState (reducer + stream driver)
Section titled “T2 — presence joins CommsState (reducer + stream driver)”Extend the comms reduction so presence is a fifth reduced collection, seeded
by GetRoster and tailed by AgentPresenceChanged.
- Interfaces:
CommsState(apps/ui/src/live/comms-state.ts:54-60) gainsreadonly presence: ReadonlyMap<string, AgentPresenceInfo>;(andEMPTY_COMMS_STATEgainspresence: new Map()).CommsSnapshot(comms-state.ts:79-85) gainsreadonly roster: readonly RosterEntry[];;reduceSnapshot(comms-state.ts:93-104) reduces it viaadaptRosterEntry.- The snapshot fetch lives in
fetchSnapshot(stream.ts:87-129), which runs the existinglistAccounts/listChannelGroups/listChannelsreads in onePromise.all(stream.ts:94-98). Addclient.getRoster({ scope: RosterScope.OWNER })to that SAME failure domain: the existing reads are NOT best-effort — a rejection throws, is caught inrunCommsStream(stream.ts:357-361), and retries the whole snapshot with backoff. Roster joins them so a failed read gets retry-with-backoff for free, rather than a swallow that would leave presence permanently empty (nothing re-readsGetRosteron a tail resubscribe — the snapshot only runs whentailSeq === 0n,stream.ts:293,315-326). - Consistency:
GetRosterRequestcarries onlyscope+agent_account_id(comms.proto:683-688), nosnapshotSequnlike the other reads (stream.ts:95-97). So the presence seed is unversioned — it races the accounts snapshot boundary and converges via the seq’d tail replay (last-write-wins,stream.ts:322-333), by design. decodeEvent(stream.ts:173-227) gains acase "agentPresenceChanged"arm producing a domain event{ kind: "presenceChanged"; accountId: string; info: AgentPresenceInfo }; the reducer applies it as a map upsert (a fresh Map instance — the “every transition returns a fresh object” contract,comms-state.ts:43-45).GetRosteris called with noagent_account_id: the server defaults the vantage to the caller and resolves a user caller to its own owned set (roster.go:34-36+ownerOfroster.go:144-153) — see R6 (confirmed).
- Tests: reducer tests beside the existing comms-state suite — snapshot seeds
the map; a presence event upserts; structural sharing (untouched collections
keep identity); a roster-read rejection aborts the snapshot like its siblings
(surfaces to
onError+ backoff), not a silent empty map.
T3 — the store’s reactive agents seam (joinAgents)
Section titled “T3 — the store’s reactive agents seam (joinAgents)”Convert the dead const to the documented reactive seam.
- Interfaces:
export function joinAgents(accounts: readonly Account[], presence: ReadonlyMap<string, AgentPresenceInfo>): Agent[]— pure, in a new small moduleapps/ui/src/roster.ts(sibling ofboard.ts, same pure-over-injected-inputs shape,board.ts:6): filterskind === "agent", preserves account order (theagentTreestable-order contract,stub-data.ts:372-377), and composes{ account, lifecycle: info ? info.lifecycle : "stopped", activity: info?.activity, terminals: [] }. A presence-map MISS maps to"stopped", mirroring the server’s absent→OFFLINE→stopped default at the client seam (R2 / DL-194): an account present inaccountsbut absent from the presence seed — a snapshot- boundary race, or a post-snapshotaccountChangedarrival never re-seeded (GetRosterruns only attailSeq === 0n,stream.ts:293, andAgentPresenceChangedfires only on a real transition,presence.go:36-38, so the miss is durable, not a boot flicker) — is an at-rest/unstarted agent, so it MUST render the “stopped” dot, never the false-live grey idle dot the components’lifecycle ?? "idle"fallback (LeftSidebar.tsx:47,AgentView.tsx:217) would otherwise show; the first realAgentPresenceChangedupserts the map and flips the dot. A present-but-UNSPECIFIEDentry keepslifecycle: undefined→ the defensive idle arm (unreachable on the GetRoster path).Agent(stub-data.ts:344-361):role,model,cwdbecome optional (role?: AgentRole; model?: string; cwd?: string).terminals: Terminal[]stays required (live =[]).store.ts:669becomes a memo pair. First an intermediate presence memo beside the existing per-collection memos (store.ts:841-845):const presence = createMemo(() => comms().presence);— so it re-notifies only when the presence map’s identity actually changes, not on every comms event (each posted message replaces the wholeCommsStateviaadoptComms,store.ts:869-871; the per-collection memos lean oncreateMemo’s===equality + the reducer’s structural sharing to absorb that). Then:const agents = createMemo<readonly Agent[]>(() => options.comms ? joinAgents(accounts(), presence()) : STUB_AGENTS);— the roster re-joins only when accounts or presence change, not on every chat message. (On a genuine presence tick the join builds freshAgentobjects and Solid’s keyed<For>rebuilds the tree DOM; that residual churn is bounded and matches the existing Bridge-over-issues behavior,Bridge.tsx:110/121.)agentById(store.ts:796-797),selectedAgent(store.ts:987-989),agentView(store.ts:993-994) and the fixture-derived clones accessor readagents()instead of the const, discharging the owed migration note (store.ts:793-795).AppStoreinterface gainsagents: Accessor<readonly Agent[]>;(besideaccounts,store.ts:368-370), the accessor the components cut over to in T4.
- Tests:
roster.tsunit tests —joinAgentsover accounts + a presence map: a present entry projects its lifecycle/activity; a map MISS projectslifecycle: "stopped"(NOT idle) so an unseeded / just-arrived account renders the stopped dot; account order is preserved. Store tests — offline store returns the fixture throughagents(); a store with a fake comms state joins live accounts + presence;agentByIdreacts to an agent-set change (the SEA-1645 reactivity the comment atstore.ts:790-795owes).
T4 — component cutover (retire STUB_AGENTS as render source)
Section titled “T4 — component cutover (retire STUB_AGENTS as render source)”- Interfaces:
LeftSidebar.tsx:400:<For each={agentTree(store.agents())}>; drop theSTUB_AGENTSimport (LeftSidebar.tsx:20).Bridge.tsx:110/121:prRowGroups(store.agents(), store.issues())/boardAgentsOf(store.agents(), store.issues()); drop the import (Bridge.tsx:20) and the stale ”:STUB_AGENTS stays direct” comment (Bridge.tsx:119-120).LeftSidebar.tsx:49-53: the role-pipShowcondition becomesa().role !== undefined && a().role !== "worker"(equivalently: pip only for a present, non-worker role).AgentView.tsx:219-220: wrapav-model/av-cwdspans in<Show when={...}>so an absent field renders nothing.comms-stub.tsis UNCHANGED (it derives fixture accounts for the offline arm, which survives).
- Tests: existing suites keep passing via the offline store (they exercise
the same accessor). Add: a LeftSidebar test with a live-shaped store
(agents from accounts+presence) renders the joined tree + activity; an
AgentView test that an agent without
model/cwdrenders noav-model/av-cwdspan.
T5 — tree-empty state seam
Section titled “T5 — tree-empty state seam”- Interfaces:
AgentsSection(LeftSidebar.tsx:382-407): render a.tree-emptyrow — one-line explanation + palette hint copy perdesign/surfaces.md:129-131, instead of the bare<For>— ONLY when the roster is genuinely empty, NOT merely not-yet-loaded. Gate onstore.firstSnapshotArrived() && agentTree(store.agents()).length === 0: on a live boot the join is empty until the first snapshot lands (EMPTY_COMMS_STATE), so a barelength === 0would flash “no agents yet” during every connect window for a fleet that HAS agents.firstSnapshotArrivedalready exists internally (store.ts:860, set inadoptCommsstore.ts:871) but is not yet onAppStore— T5 exposes it asfirstSnapshotArrived: Accessor<boolean>. Offline stores are unaffected (the fixture is never empty andfirstSnapshotArrivedstays false). Class name only; visual styling is the compass-ux lane (Global Constraints).
- Tests: an empty live roster PAST the first snapshot renders
.tree-empty; a pre-first-snapshot live store renders no.tree-empty; a non-empty roster renders none.
Task dependency order
Section titled “Task dependency order”T1 → T2 → T3 → T4 → T5 (T5 depends only on T3/T4’s accessor; T4 and T5 can land together if the driver prefers).
- T1 —
presenceLifecycle+AgentPresenceInfo+adaptRosterEntryinlive/adapt.ts, with mapping/normalization tests. - T2 —
CommsState.presencemap:GetRoster(OWNER)in the snapshot fetch,agentPresenceChangedindecodeEvent, reducer upsert + tests. - T3 —
joinAgents(roster.ts) +store.agentsreactive accessor;Agent.role/model/cwdoptional;agentById/selectedAgent/agentViewflow through the memo. - T4 — LeftSidebar + Bridge cut over to
store.agents(); role-pip and model/cwd renders gated on presence of the optional fields;STUB_AGENTSimports removed from components. - T5 — tree-empty render seam in
AgentsSection.
Ledger delta
Section titled “Ledger delta”Appended to docs/designs/product/DECISIONS.md (UI shell section) in this PR
as DL-193/194/195; no existing row flips — DL-095 (tree primitive), DL-074
(4-state presence), DL-135 (GetRoster pull), DL-111 (WhoAmI), DL-077 (accounts
persist) are all composed with, not superseded:
- DL-193 — The UI’s live agent roster is a reactive JOIN: identity +
tree from the live
SubscribeCommsaccounts (the single identity source), presence + activity from aCommsStatepresence map seeded by oneGetRoster(OWNER)per snapshot boundary and tailed byAgentPresenceChanged;STUB_AGENTSis demoted to the offline-store seed (theSTUB_ISSUESpattern), never a live render source. - DL-194 — The presence→dot projection is the total 4-state mapping
WORKING→working, IDLE→idle, WAITING→waiting, OFFLINE→stopped, plus a defensiveUNSPECIFIED→undefined(unreachable on the GetRoster path, which defaults every absent/unstarted agent toOFFLINE,roster.go:79-82; the clientjoinAgentsmirrors this, mapping a presence-map miss tostoppedso the absent→stopped invariant holds end-to-end). The 4-state enum cannot distinguish a deliberately-stopped agent from a never-started one; splitting them is owed to the deferredAgentSessionStatuslane, which is also the only source for the remaining fourAgentStatevalues (done/paused/error/disconnected) — its consumption stays deferred to its own lane. - DL-195 —
Agent.role/model/cwdare optional view-model fields with no server source: a live agent renders without them (no derived/guessed role pip), andterminalsis[]for live agents until a terminal stream exists.
Resolved decisions
Section titled “Resolved decisions”Every fork below is settled: R2 (OFFLINE dot) by Matt’s ruling; R1/R3/R4/R5
survived the design-critic’s attack as the recommended choice; R6 is confirmed
by server source. None is a live open question — the record carries these as
the frozen contract.
- R1 (roster source) — join, not replace. Join the live
accounts(identity/tree) with a presence map seeded byGetRoster(OWNER)+ tailed byAgentPresenceChanged, rather thanGetRosteras the whole roster source:RosterEntrylackshomeChannelId/kindthe UI requires (store.test.ts:350-352opens the home DM;adapt.ts:124-137lifts them from accounts), accounts already stream live, and a single identity source can’t drift. - R2 (presence→AgentState) — RULED by Matt:
OFFLINE → "stopped"now, split later. Two parts. (a) DeferAgentSessionStatusand map only the 4-state presence enum for now — the richer 8-state projection already exists asagentDotState(agent-state.ts:47-56) for the session-events lane, a separate land (events.ts:34-37). (b)OFFLINE → "stopped"(offline = no live session, the lifecycle’s terminal rest state;disconnectedwould wrongly connote a transport fault, which OFFLINE is not, per DL-074). Because the server defaults every agent absent from its presence source toOFFLINE(roster.go:79-82, pinnedroster_pgtest_test.go:188-190), a freshly-seeded-but-unstarted agent (the DL-192 root supervisor; a whole fleet after a runner restart) renders the “stopped/terminated” dot rather than today’s grey idle dot — a known, named day-one cost. Splitting “terminated” from “never started” is owed to the deferredAgentSessionStatuslane; the 4-state enum alone cannot. The clientjoinAgentspreserves this invariant at its own seam: an account absent from the presence map (a snapshot race or a post-snapshotaccountChangedarrival) also maps to"stopped", not the components’?? "idle"fallback, so “not live → stopped” holds end-to-end (see T3). - R3 (UI-only fields) — optional and render-gated.
role/model/cwdbecome optional; live agents getterminals: []; the role pip is NOT derived from tree position (“has children” is not “supervisor” and a guessed pip is worse than none). A liverole/model/cwdlater is a server-field lane (proto addition), parked. - R4 (empty roster) — surfaces-spec tree-empty row, no client-seeded fake
supervisor. Render the tree-empty row (
surfaces.md:129-131); SEA-1820/ DL-192 seed the root manager server-side, so a live-empty tree is transient and honest. (Gated onfirstSnapshotArrived()so the connect-window is not a false empty — see T5.) - R5 (stub story) —
STUB_AGENTSis the offline seed behind the same accessor. TheSTUB_ISSUESpattern (store.ts:670-673); all existing offline tests keep constructing stores unchanged; the live arm starts empty (no fixture flash). Full retirement would kill thevite devwalking skeleton (App.tsx:20-25) and force a rewrite of every store-constructing suite for zero product value. - R6 (GetRoster vantage for a UI caller) — confirmed by server source.
GetRoster(scope: OWNER)with noagent_account_idfrom a user-credential caller returns exactly the caller’s owned agent set: the handler defaults the vantage to the caller (roster.go:34-36) andownerOftreats a user vantage as its own owner (roster.go:144-153—AgentOwner’s not-found signals “use vantage directly”). No server accommodation or events-only fallback is needed. One residue for the comms lane (not this record): no pgtest pins the empty-vantage + user-caller + OWNER path — every existing OWNER test names an explicitAgentAccountId(roster_pgtest_test.go:142); a one-line server test is owed.