Language-agnostic contract for the browser ↔ server terminal WebSocket, frozen
ahead of the Go rewrite (#210, #225). Source of truth in the Node backend:
src/server/ws-protocol.ts (message shapes)
and src/server/ws-message-router.ts
(routing + control semantics).
- Endpoint:
GET /ws(HTTP upgrade). - Auth scope:
ui. Browsers can't set anAuthorizationheader on a WS handshake, so the token is passed as the second subprotocol:Sec-WebSocket-Protocol: shellwatch.bearer, <token>. The server negotiates the sentinelshellwatch.bearerback (never the token). Non-browser clients that already sendAuthorization: Beareroffer no subprotocol. - Every frame is a JSON object with a
typediscriminator. Unparseable frames (parseClientMessagereturnsnull) are rejected with a reply:{ type: "error", message: "Invalid message format" }(ws-handler.ts) — the connection stays open. Handler exceptions similarly reply witherror. - All messages carry
sessionIdexcept the server→clientsessions:changedanderror.
Each WS connection tracks two per-session sets: attached and controlled.
terminal:attachis ownership-gated (session must belong to the caller's account). On attach, UI-sourced sessions (source === "ui") are auto-put into control mode;mcp/sshsessions attach as observer.terminal:inputandterminal:resizerequire both attached and control. Input on a session you don't control returns anerror; resize is silently dropped (fires on every layout change — see the asymmetry).- Multiple connections may observe one session; control is per-connection state, not a global lock. (See known issue #150: the UI "Release" button is shown for UI sessions where release is a no-op for meaningful purposes.)
type |
Fields | Effect |
|---|---|---|
terminal:attach |
sessionId, afterOffset?: number |
Subscribe. Replies with terminal:status, terminal:mode, and buffered terminal:output (delta from afterOffset, or full buffer with reset:true if the offset was evicted). Ownership-gated → error if not owned. |
terminal:detach |
sessionId |
Unsubscribe; clears attached + controlled. Silent. |
terminal:input |
sessionId, data: string |
Send input to the shell. Requires attached + control, else error. |
terminal:resize |
sessionId, cols: number, rows: number |
Resize PTY. Requires attached + control, else silently ignored. |
terminal:close |
sessionId |
Close the session (reason client.ws). Ownership-gated → error. |
terminal:take-control |
sessionId |
Enter control mode; replies terminal:mode {mode:"control"}. Ownership-gated → error. |
terminal:release-control |
sessionId |
Leave control mode; replies terminal:mode {mode:"observer"}. Silent if not attached. |
type |
Fields | Meaning |
|---|---|---|
terminal:output |
sessionId, data: string, offset: number, reset?: true |
Output chunk. offset is the absolute buffer offset after this chunk. reset:true means the client must clear its buffer first (offset was evicted). |
terminal:status |
sessionId, status |
The session's status, sent once in the terminal:attach reply (the only emitter, ws-message-router.ts). It is not pushed on subsequent transitions — runtime status changes propagate via sessions:changed. status ∈ {opening, open, closing, closed, error}. |
terminal:closed |
sessionId |
Session closed. (Redundant with terminal:status {status:"closed"} — see inconsistencies.) |
terminal:mode |
sessionId, mode |
Control-mode change. mode ∈ {control, observer}. |
sessions:changed |
sessions: SessionListEntry[] |
Full session-list snapshot for the account. Broadcast on any lifecycle change. |
error |
message: string |
Non-fatal error (bad attach, input without control, etc.). No code, no sessionId. |
{
sessionId: string;
endpointId: string;
status: TerminalStatus; // opening | open | closing | closed | error
createdAt: string; // ISO-8601
source: string; // "ui" | "mcp" | "ssh" — typed as bare string on the wire
mode: "control" | "observer"; // this connection's mode for the session
}Deliberate, but worth preserving explicitly in the Go port:
- Input errors loudly (deliberate user action → feedback); resize and release-control fail silently when not attached/controlled (they fire on layout churn / teardown and would spam the client).
modeinSessionListEntry(fromsessions:changed, built globally) reflects the broadcasting connection's controlled-set, computed inbuildSessionList. A connection observing someone else's control still sees its ownmode.
- Subprotocol negotiation returns the sentinel, never the token.
-
terminal:attachreplays buffered output with correctoffset/reset. - UI-source auto-control vs. mcp/ssh observer-on-attach.
- Silent vs. erroring paths preserved exactly (input loud; resize/release silent).
-
terminal:closedstill emitted alongsideterminal:status(or consciously dropped — see #225 normalization decision).