Repository navigation
design phase 8 c5
Status: implemented (branch
phase-8-c5). Parent design:design-phase-8-client.md§ "ACP chat (C5)" + § "Realtime protocol" (the/app↔/ctlinteractive rows). Research:research-phase-8-c5-acp-web-demo.md— the web-demo study (permission round-trip, session-op pitfalls, fold/StreamBuffer) and the completed adapter matrix (claude-code/codex via official Zed ACP adapters, hermes nativeacp_adapter, zcode deferred). Verification: shared 49 / cli 13 / server 53 tests (chat integration 11: full round-trip incl. permission respond + timeout watchdog, busy gate, re-join, spawn-failed, ready-timeout, session cap, disconnect teardown, user/daemon close, gating matrix, existence hiding); smoke[8 C5]with the REAL daemon dist + fixture ACP agent — 266/266 overall; SQLite migration0010+ audit rows retained past machine deletion verified.
C5 makes a deployed AgentInstance conversational from the browser:
browser /app ──chat:*──▶ server (routing+gating+audit) ──chat:*──▶ daemon /ctl
│ spawn + ACP
▼
local agent subprocess (stdio)
The platform never learns agent protocols. The daemon is the adaptation edge:
it spawns a per-target ACP adapter subprocess (newline-delimited JSON-RPC
2.0 over stdio), drives the ACP client side (initialize → session/new →
session/prompt …), and maps ACP session/update notifications onto a
small, stable semantic stream the browser renders. Everything the server
does is: ownership/gating checks, room fan-out (chan:<sessionId>), AcSession
audit rows, and the permission-request timeout.
-
AcSessionmodel (core/domain/ac-session.ts, migration0010):{id, agentInstanceId, machineId, ownerId, openedAt, closedAt?, closeReason?}— one row per conversation channel, retained as audit (no FKs, rows survive machine deletion, closed withmachine-deletedinstead of cascade-deleted — the deliberate asymmetry vs jobs/inventory).AcSessionRepository:findById/listByAgentInstance/listOpenByMachine/save. UnitOfWork gainsacSessions. -
Chat wire protocol (
shared/src/realtime.ts, C5 section): ACP-dialect block schemas (content blocks for prompts, tool-call views, permission options with verbatimoptionId) + the semanticchatStreamEventSchemaunion + everychat:*event envelope (below). -
Server
ChatService(server/src/realtime/chat.ts, decoratedapp.realtime.chat, same pattern asJobService): session registry, gating, routing, permission watchdog, teardown on daemon disconnect / machine delete / revoke. -
Daemon session manager (
cli/src/daemon/chat.ts+daemon/acp/): minimal JSON-RPC stdio client, per-target adapter command table with env overrides, ACP↔semantic mapping, subprocess lifecycle (SIGTERM → SIGKILL on close/disconnect), permission + cancel handling. -
Web:
/chatpage (agent picker → session list → conversation pane with streaming, tool rows, permission cards, stop button); MachineDetail gains the remote-chat toggle (confirm-first; PATCH endpoint exists since C1). -
SDK:
updateMachine(PATCH) +listAgentSessions. -
Config:
CHAT_MAX_SESSIONS_PER_MACHINE(3),CHAT_PERMISSION_TIMEOUT_MS(60s),CHAT_READY_TIMEOUT_MS(30s). Daemon:DAEMON_CAPABILITIES += 'chat', version0.4.0-c5.
| Event | Dir | Payload | Ack |
|---|---|---|---|
chat:session.open |
B→S | {agentInstanceId, sessionId?} |
{sessionId}; errors: AGENT_INSTANCE_NOT_FOUND, SESSION_NOT_FOUND, REMOTE_CHAT_DISABLED, MACHINE_OFFLINE, DAEMON_NO_CHAT, SESSION_LIMIT_REACHED
|
chat:message.send |
B→S | {sessionId, content: string | PromptBlock[]} |
{accepted}; errors: SESSION_NOT_FOUND, SESSION_NOT_READY (pre-ready), SESSION_BUSY
|
chat:turn.cancel |
B→S | {sessionId} |
{accepted} (idempotent — cancelling an idle turn is a no-op) |
chat:permission.respond |
B→S | {sessionId, requestId, optionId?} |
{accepted}; absent optionId = cancelled |
chat:session.close |
B→S | {sessionId, reason?} |
{closed: true} |
chat:session.ready |
S→B |
{sessionId, agentInfo?} (to chan:<sid>) |
— |
chat:session.failed |
S→B | {sessionId, error} |
— |
chat:session.closed |
S→B | {sessionId, reason} |
— |
chat:event |
S→B |
{sessionId, event: ChatStreamEvent} (to chan:<sid>) |
— |
-
openwithoutsessionIdalways creates a new channel (new AcSession + daemon subprocess).openwith an opensessionIdis an idempotent re-join (page refresh path): returns the same id, joins the socket tochan:<sid>, spawns nothing. Only the session owner may join (admins are explicitly NOT granted chat — talking to an agent executes code on the user's machine; documented non-goal). -
SESSION_BUSYis a server-side gate on the last known turn state (session_statusevents feed it); the daemon independently drops prompts that race past the gate and re-emitsactiveto resync. One prompt in flight per session (the demo's follow-up queue is deferred).
| Event | Dir | Payload | Notes |
|---|---|---|---|
chat:session.start |
S→C | {sessionId, agentInstanceId, target, cwd} |
ack {accepted}; spawn + initialize + session/new, then ready or failed
|
chat:session.ready |
C→S | {sessionId, agentInfo?, error?} |
error ⇒ server closes the row (spawn-failed) + notifies the room |
chat:message.send |
S→C | {sessionId, prompt: PromptBlock[]} |
daemon sends session/prompt; string content normalized to a text block server-side |
chat:turn.cancel |
S→C | {sessionId} |
daemon sends session/cancel
|
chat:permission.respond |
S→C | {sessionId, requestId, optionId?} |
forwarded verbatim; absent optionId ⇒ ACP RequestPermissionOutcome::cancelled
|
chat:session.close |
S→C | {sessionId, reason?} |
daemon session/close (3s) then kill; room torn down |
chat:event |
C→S | {sessionId, event: ChatStreamEvent} |
validated, then fanned out to chan:<sid>; ack {accepted}
|
chat:session.closed |
C→S | {sessionId, reason} |
daemon-initiated (agent process exited / fatal) ⇒ server closes the row + notifies room |
{ kind: 'message_delta', delta } // ACP agent_message_chunk
{ kind: 'thought_delta', delta } // ACP agent_thought_chunk
{ kind: 'tool_call', call: { toolCallId, title?, kind?, status?, locations? } } // tool_call(+_update), upsert by id
{ kind: 'usage', inputTokens?, outputTokens? }
{ kind: 'permission_request', requestId, toolCall, options[] } // options carry verbatim optionId
{ kind: 'permission_resolved', requestId, outcome: 'selected'|'cancelled'|'timeout', optionId? } // server-emitted
{ kind: 'turn_result', stopReason: 'end_turn'|'cancelled'|'max_tokens'|'refusal' }
{ kind: 'session_status', state: 'active'|'idle' }
{ kind: 'raw', method, params } // escape hatch (unmapped ACP updates)Produced by the daemon (except permission_resolved, which the server emits
so every viewer's card settles). Live user_message_chunk updates are
dropped daemon-side (the browser echoes optimistically; there is no replay in
v1). This is the parent doc's semantic stream with the demo's pragmatic ACP
mapping: stable UI kinds, protocol churn confined to the edge.
-
Gating order on open (create path): AgentInstance exists + owned →
machine exists +
remoteChatEnabled(elseREMOTE_CHAT_DISABLED) → online (elseMACHINE_OFFLINE) → daemon capabilitychat(elseDAEMON_NO_CHAT) → open-session count < cap (elseSESSION_LIMIT_REACHED). Then: persist the AcSession row (closedAt: null), track it asstarting, join the opener's socket tochan:<sid>immediately (implementation refinement of the plan: failure pushes must reach the browser even though the agent never came up), emitchat:session.starttomachine:<id>, arm the ready watchdog (CHAT_READY_TIMEOUT_MS⇒ closespawn-timeout+ notify room). -
onReady: clears the watchdog;error⇒ closespawn-failed+chat:session.failedto the room; success ⇒ markready, verify the opener socket is still connected (if the opener left before the agent came up, nobody is watching — closeconnection-lostinstead of running a subprocess unattended), emitchat:session.ready. -
onChatEvent: session must belong to the reporting daemon's machine and be open → relay tochan:<sid>.permission_requestadditionally arms the permission watchdog (CHAT_PERMISSION_TIMEOUT_MS): on fire, forward a cancelled respond to the daemon and emitpermission_resolved {outcome:'timeout'}to the room.session_statusfeeds the busy gate. -
Message send: owner + open + not busy → normalize
string→[{type:'text', text}], forward to the machine room. (Machine offline ⇒SESSION_NOT_FOUND-style closure already happened via disconnect teardown, so no separate offline branch.) -
Teardown: daemon disconnect ⇒ close every open session of that machine
(
connection-lost, the "ACP has no resume in v1" rule), notify rooms; machine delete / revoke ⇒ same withmachine-deleted; daemon-reportedchat:session.closed⇒ close with its reason. Closing = row update + room notification +chat:session.closeto the daemon (skipped when the daemon is already gone). -
Audit: every open/close writes the AcSession row;
closeReason∈user | spawn-failed | spawn-timeout | connection-lost | machine-deleted | agent-exited | server-shutdown. Rows survive machine deletion (no FKs); note the listing route (GET /api/agent-instances/:id/sessions) 404s once the agent row itself is cascade-deleted — retention is for the record, and the rows remain queryable at the storage layer.
-
Adapter command table (
daemon/acp/adapters.ts):claude-code → npx -y @agentclientprotocol/claude-agent-acp(switched 2026-09 from@zed-industries/claude-agent-acp0.23.x — that wrapper never requested thinking on gateway/unknown models, so chat showed no thought stream; the official ACP-project wrapper streamsagent_thought_chunkby default, rig-verified),codex → npx -y @zed-industries/codex-acp,hermes → python3 -m acp_adapter,zcode → null. Env overrideHN_ACP_COMMAND_<TARGET>(-→_, upper) replaces the whole command line (split on whitespace) — the hook tests and the smoke fixture agent (a ~120-line Node script speaking ACP v1 over stdio) use exactly this. -
ACP client (
daemon/acp/agent-connection.ts): hand-rolled minimal JSON-RPC 2.0 client over the child's stdin/stdout — id correlation with per-request timeouts, notification fan-out, one request handler slot (session/request_permission). No new npm dependency (wire format is 3 message shapes; the fixture agent proves compatibility). -
Session manager (
daemon/chat.ts): per-session state{acpSessionId, proc, busy, permissionWaiters}.-
start→ spawn (cwd from the server payload, defaulting to the agent home) →initialize {protocolVersion: 1}→initialized→session/new {cwd}→chat:session.ready {agentInfo}. Spawn/initialize failure ⇒ready {error}(server closes the channel). -
message.send→session:prompt-map: emitsession_status active, sendsession/prompt, resolve ⇒turn_result+session_status idle. Busy race ⇒ drop + re-emitactive. -
session/request_permissionfrom the agent ⇒chat:event permission_request; the server's respond (or timeout-cancel) resolves it withselected {optionId}/cancelled—optionIdverbatim (demo pitfall #1). -
turn.cancel→session/cancel(idempotent; resolve ⇒cancelled). -
close/socket-disconnect/exit ⇒session/closebest-effort (3s), then SIGTERM, 3s SIGKILL; child exit ⇒chat:session.closed {reason: 'agent-exited'}. - Mapping (
mapAcpUpdate):agent_message_chunk→message_delta(text blocks concatenated per chunk),agent_thought_chunk→thought_delta,tool_call/tool_call_update→tool_call,usage_update→usage, everything else →raw {method:'session/update', params}.
-
-
/chatroute (nav "Chat"): left rail = machine picker → that machine's AgentInstances (only online + chat-enabled machines enable the "New session" action); session list per agent (REST + live status); conversation pane. Conversation state is a pureuseReducerfold (demo's fold.ts semantics, simplified: append deltas into the current block, upsert tool rows by id,turn_resultsweeps running steps,permission_resolvedsettles the card). No StreamBuffer in v1 — socket.io coalescing plus React batching held up in the demo's own load profile; revisit if chunk floods render poorly. - Permission cards render from the payload's options (never hardcoded
allow/deny), send
optionIdverbatim, and auto-settle onpermission_resolved/turn_result. - MachineDetail: agents card gains a "Chat" action (deep-links
/chat); remote-chat toggle with a confirm dialog (it is a remote-code-execution switch — the copy says so). - Signal system: agent text uses default foregrounds; the live-turn spinner
and the "agent active" status line use
--signal(that is the view's one live thing); status colors (bg-okidle etc.) are NOT used for chat liveness —--signalmarks the live stream, muted marks closed.
- Chat is owner-only (admins excluded by design — a chat turn executes
tools on the owner's machine), gated by
machine.remoteChatEnabled(default off, owner-toggled, confirm-first in the UI), machine online, and the daemon advertisingchat. - AcSession rows are the audit trail (who opened what channel when, why it closed) — retained past machine deletion (no FK, no cascade).
- The permission watchdog guarantees no dangling agent-side waiter: every
request_permissiongets an answer (selected / user-cancelled / timeout). - Payload bounds:
maxHttpBufferSize(8 MiB default) bounds frames; zod bounds prompt content (≤ 32k chars / ≤ 16 blocks), deltas ≤ 100k, options ≤ 8.raw.method≤ 64 chars. The daemon never trusts server-sent paths — it only ever spawns from its own command table / env overrides. - No fs/terminal/elicitation handlers are implemented daemon-side; agents that request them get a JSON-RPC method-not-found error and degrade.
-
agent:control.apply/agent:state(model, thinking level, permission mode): needs per-adapter capability matrices +session/set_mode/set_config_optionnegotiation — deferred to a C5.x/C6 slice; the parent doc's catalog row remains the target. - Session resume/replay (
session/load), session listing from the agent — v1 channels die with the daemon connection ("no resume" rule). SUPERSEDED by Phase 9 W7 (design-phase-9-w7-native-sessions.md): sessions are the agent's OWN — the platform persists nothing session-shaped, the rail lists the native store live, resume ridessession/load/session/resume, and history ships aschat:history. - Follow-up queue while a turn runs (server rejects with
SESSION_BUSY; the UI disables send while active). - Image/audio prompt blocks,
resourceblocks (schemas reserved, UI text-only), fs/terminal/elicitation channels, per-agent project cwd selection (v1 uses the agent home fromAgentInstance.directory), message history persistence (rows are audit, not transcripts — content is never stored server-side), rate limiting beyond the session cap.
-
core —
domain/ac-session.ts+AcSessionRepositoryport + UnitOfWork arm. -
shared — ACP dialect +
chat:*schemas inrealtime.ts; unit tests (valid/invalid shapes, block normalization). -
server storage — migration
0010(no FKs) + sqlite/memory repos. -
server chat —
ChatService+ realtime wiring (/apphandlers: open/send/cancel/respond/close;/ctlhandlers: ready/event/closed; disconnect teardown; machine-delete hook) + config keys. -
server routes —
GET /api/agent-instances/:id/sessions(owner-only, 404-hiding) + app.ts wiring. -
cli —
daemon/acp/(connection + adapter table) +daemon/chat.ts+ capability bump; fixture agentpackages/cli/test/fixtures/acp-agent.mjs; unit tests (mapping pure fns + full round-trip vs the fixture through a fake socket). -
sdk + web —
updateMachine/listAgentSessions+/chatpage + MachineDetail toggle/chat link + navItems. -
verify — server integration
chat.test.ts(fake daemon + fake browser): open→ready→send→delta→permission→respond→turn_result; re-open idempotent join; gating matrix (disabled/offline/no-cap/limit/ busy); permission timeout; ready timeout; disconnect teardown; daemon close; owner-only. Smoke[8 C5]: REAL daemon dist + fixture agent viaHN_ACP_COMMAND_HERMES— enroll → enable chat → deploy (C4 flow) → browser socket round-trip → close; gating assert first (disabled ⇒ refused).pnpm -r typecheck, per-package builds, full smoke suite.
Chat channels are bounded by CHAT_MAX_SESSIONS_PER_MACHINE (3) and a channel
dies with its daemon socket. Three defects around that budget surfaced while
verifying W8 on the rig — each compounded the next, and together they produced
the observed symptom ("I click a session and the pane is empty"):
-
The session page never released the channel it left. A row hop called
chat:session.openwithout closing the previous channel; the SPA socket survives navigation, and server teardown keys on the browser socket, so every hop leaked a slot. Three hops then filled the cap and every further open answeredSESSION_LIMIT_REACHED— the page rendered an error strip over an empty stream. Fix:openChannelnow leaves before entering (closes the previous channel before issuing the open, skipping the close when the target is a rejoin of the same channel), and the page closes its channel on unmount. Closing first also self-heals a full cap instead of deadlocking behind it — the old "close only after a successful open" shape skipped the close on the error path, so a rejected open left the previous channel live forever. -
A close that raced the establishment was dropped by the daemon. With the page fixed, hops were still leaking adapter processes:
chat:session.closelooked upsessions.get(id), found nothing (the spawn/establish takes seconds; the daemon had not registered the session yet), and did nothing — then the establishment finished and registered an orphan with no channel left to ever close it. Fix: the daemon records ids closed before registration (closedBeforeReady, consumed withSet.delete) and the start handler checks at two checkpoints (before the spawn, and immediately before registration — no await sits between that check and thesessions.set, so nothing can interleave). An aborted establishment kills the connection and emits noready. -
A daemon restart wedged chat on its machine until the server restarted. The server closes a machine's channels when it goes offline, but that reap is keyed on the offline→online transition, and a fast daemon restart registers the new socket before the old disconnect is processed — so the machine never "went offline" and the dead connection's channels kept their slots forever. Fix: the
/ctlconnection handler reaps the machine's live channels on every connection. The invariant that makes it correct: a newly connected daemon owns zero channels by construction (the daemon tears every session down in its socketdisconnecthandler), so any channel still live for that machine belongs to a connection that is gone.
Rig verification (post-fix): five rapid row hops (1.5s apart, well inside the
establishment window) hold the adapter count at 1 (a transient 2 during the
SIGTERM grace) and settle at exactly 1 = the live channel; the wire shows the
close, open alternation; a kill -9 daemon restart mid-channel leaves the
page honestly closed with no cap error and a fresh resume succeeds — no server
restart needed. Regression tests: server/test/chat.test.ts ("a daemon
RECONNECT reaps …"), cli/test/chat.test.ts ("a close arriving DURING
establishment aborts it and kills the adapter", via the fixture's new
FIXTURE_DELAY_NEW_MS knob + a pid-file liveness assert).
The hardening above made channel lifecycle correct but the budget itself was
hostile: a flat CHAT_MAX_SESSIONS_PER_MACHINE = 3 that could only REJECT,
with no way to see which sessions held slots. User-driven redesign (all three
complaints addressed — invisibility, blocking without remedy, and a cap too
small):
-
Two budgets instead of one flat cap. Total channels per machine:
CHAT_MAX_SESSIONS_PER_MACHINE(default 12, was 3). Of those, at mostCHAT_MAX_ACTIVE_SESSIONS_PER_MACHINE(default 5, new) may be mid-turn. A channel costs one total slot; a generating turn additionally costs one active slot. -
A full TOTAL budget evicts instead of rejecting. Opening past the
budget closes the OLDEST channel that is not mid-turn (viewers get
chat:session.closed {reason:'evicted'}; the web shows a dedicated "made room for a newer session" note).SESSION_LIMIT_REACHEDsurvives only for the corner where EVERY channel is mid-turn — unreachable in practice while the active budget (5) is well under the total (12). -
The active budget bites at prompt time, not open time:
chat:message.sendon an idle channel while the machine already hasmaxActiveturns generating answersMACHINE_BUSY(the web toasts it and restores the draft — a bounced prompt loses nothing). -
Open channels are VISIBLE.
GET /api/agent-instances/:id/sessionsrows carry server-computedopen: boolean+openChannelId(the live channel on that native session). The rail renders an已打开 — 点击重连marker on such rows and clicking REJOINS the channel (chat:session.open {sessionId: openChannelId}, falling back to a fresh resume if the channel died between listing and click). Corollary: the daemon's dsh listing no longer HIDES sessions with live channels (theliveNativeIdsexclusion and itsChatRegistryplumbing are deleted) — hiding was the old answer to "dsh refuses resuming an active session"; rejoin is the better one.
Rig-verified (2026-09-10): with a temporary cap of 3, the 4th open evicted the
oldest channel (reason:'evicted') and the listing showed exactly the 3
survivors as open; at the default budget, 4 concurrent channels all held. In
the UI, an open row shows the marker and clicking it rejoins WITHOUT spawning
(adapter count 2 → 1, the page's previous channel closed by leave-before-enter).
Tests: server/test/chat.test.ts (eviction, all-busy rejection, MACHINE_BUSY
gate), server/test/sessions-route.test.ts (open/openChannelId rows).
User report after the budget redesign: claude-code history sessions opened EMPTY, new claude-code sessions never appeared in the listing, and one new dsh session showed TWO unnamed rows. Four root causes, all fixed:
-
Channels never died with their viewers. The C5 "teardown on
disconnect" covered the daemon socket and explicit closes — but a /app
browser socket disconnecting (tab close, full page refresh) left the
channel LIVE forever with its adapter tree. Trees piled up on the machine,
every later wrapper spawn slowed past the 30s ready watchdog, and the
killed channels read as "sessions open empty". Now
onViewerGone(wired to the /app socketdisconnect) closes any of the user's channels whose room has no sockets left — a channel MID-TURN is spared and closes when the turn ends (closeWhenIdle), so an abandoned generation still finishes and persists. SPA navigation keeps the socket, so ordinary page-hopping still benefits from the rejoin rail. -
The official claude wrapper was misread as resume-only. Its initialize
result carries
loadSession: trueNESTED inagentCapabilities(the Zed adapters set it at the result ROOT), while itssessionCapabilitieshasresumebut noload. Our caps derivation read only the root flag → claude channels took dsh'ssession/resumepath → resumed with NO replay → the "history session is empty" pane.deriveSessionCaps(pure, unit- tested for the three adapter-family shapes) now reads both locations. -
New-but-unprompted sessions were invisible. claude-code materializes a
transcript only on the first message, so a freshly opened channel had no
native row. The listing route now SYNTHESIZES a row for every live channel
(
openChannelCwds+ cwd), so the rail shows the new session (已打开) the moment it exists — for both targets. -
dsh wrote empty sessions, twice sometimes. Every dsh spawn writes a
config-preamble transcript (session/permission-preset/sandbox-mode/
approval-policy) before any turn, and the
-32605startup-race retry leaves one orphan per lost race — the "two unnamed sessions". The dsh listing now requires a REAL user turn (agent/inbox/splicedinscanSummary→hasTurn); empty-but-live channels stay visible via the synthesized open row, matching claude-code's appear-on-content semantics.
Plus one hygiene fix found on the way: adapter kills now signal the whole
PROCESS GROUP (detached: true spawn + kill(-pid); the SIGKILL timer is
unref'd and not cleared on leader exit) — signaling only the direct child
orphaned the vendor binary (a bare claude, reparented to init) on every
teardown.
Rig-verified: claude-code new session → prompt → tab-drop leaves ZERO
processes (incl. the claude binary) and the session lists with its title;
resume replays full history (U, message_delta, turn_result…); one dsh new
session + one turn lists exactly ONE row (the orphan dir stays on disk but is
filtered). Tests: viewer-gone idle + mid-turn (server), synthesized rows
(sessions-route), deriveSessionCaps shapes (cli).
Rig finding (codex): the user answered two permission cards and the turn
never resumed. codex-acp asks with UUID STRING request ids, and the
daemon's inbound-id handling coerced them through Number() — so the reply
was encoded with "id":null (JSON.stringify(NaN) → null), the adapter
could not match it to its pending request, and the escalation waited forever
(our 75s backstop sent another id:null reply, equally unmatched). The claude
and dsh wrappers — and our fixture agent, which counted ints — all use integer
ids, which is why C5's permission tests were green while a real codex session
hung on the first write/escalation it requested.
Fix: agent-initiated request ids flow through verbatim as
JsonRpcId = string | number | null (acp/agent-connection.ts →
chat.ts session.permissions → respondPermission); only OUR OWN outgoing
request ids (numeric counters) keep the Number() matching. Fixture gained an
ask-permission string-id arm (UUID id) and a regression test that fails on
the old coercion.
Rig-verified end to end through /app + /ctl + the daemon: a
permission-heavy codex turn ("上海明天天气" in a read-only sandbox — 13
escalations) answers every card and settles with turn_end, where it used to
freeze after the second.
2026-09-14 revision (9 W11): the viewer-scoped rule above is ROOM-scoped liveness — superseded. Channels now close only when the user's LAST
/appsocket disconnects (any window showing the tab bar keeps them alive); seedesign-phase-9-w11-adapter-lifecycle.md§ "Post-ship fix — channel liveness is USER-scoped". The room still governs which window receives a channel's event stream.