Appearance
Session Exclusivity
Hive enforces single-writer control of terminal sessions: only one client at a time can send input, but all attached clients see live output (passive viewers). Switching the active writer is explicit ("Take over") and immediate.
How It Works
Two Orthogonal Concepts
- Attach (output subscription) - any number of clients can attach and receive
Outputmessages for a session. This is independent of the claim. - Claim (input lock) - exactly one connection at a time holds the
claim_id. Only that connection'sSendInput/SendPtyInputmessages are accepted by the daemon.
Attach and claim are intentionally separate: viewers stay attached to output even when they do not own input.
Claim Protocol
When a client opens a session:
- Auto-claim - If it is safe to auto-claim (see below), the client sends
ClaimSession { force: false }. Safe automatic claims are fire-and-forget. After an app restart, the client retries them a bounded number of times while cluster owner routing converges. It stops retrying if another client owns the claim, the connection drops, the session disappears, or the session's node is offline. Explicit Take over remains a manual, forced claim and does not use this retry path. - Session owner decides - The node that owns the live session atomically checks and updates
claim_id. An automatic claim is accepted only when the session is unclaimed, the same connection already holds it, or the current holder is one of the reconnecting client's recent connection IDs. - Daemon confirms - After accepting the claim, the daemon broadcasts
SessionClaimedto everyone. - Attach is unconditional - The client attaches to the output stream regardless of who holds the claim. Passive viewers also attach.
Forced vs. automatic claims
ClaimSession carries a force flag that separates an explicit user action from a background auto-reclaim:
force: true- an explicit "Take over" / "Adopt" button press. Always granted by the daemon.force: false- every automatic claim (on focus, on reconnect recovery, when a claim is released). The session owner accepts it only whenclaim_idis unclaimed, already matches the requesting connection, or matches a recent connection ID supplied by that same saved connection profile. Any unrelated holder is refused, including when the request arrives through another cluster node.
This makes "Take over" authoritative: once you take a session, no other client's background auto-reclaim can pull it back.
When is an auto-claim safe? (client-side gate)
Before sending force: false, the client checks canAutoReclaim(session):
- Held by us (
claim_id === our connectionId) - idempotent refresh, safe. - Held by one of our own prior connection ids - a zombie WS from a suspend/relaunch (same device, fresh
connection_id). The reconnect claim carries recent IDs scoped to that saved connection profile. The session owner atomically transfers the claim from the prior socket to the new one without waiting for disconnect cleanup. The same owner-side decision applies when the request is routed across the cluster. - Unclaimed and we were its last holder - our own blip; reclaim it.
- Unclaimed and never claimed by anyone - a free session; desktop clients take it automatically. Touch/mobile clients stay passive unless they can establish prior ownership.
- Otherwise (a claim another client just released by blipping) - left alone. That holder may reconnect; seizing it requires an explicit "Take over".
The client tracks the last non-null claim holder per session (lastClaimHolder) so a claim_id that just dropped from another client is distinguishable from a session that was genuinely never claimed.
Touch/mobile clients apply one additional safeguard: opening an unclaimed session they did not previously own attaches as a passive, read-only viewer. This preserves the session's recorded desktop PTY width instead of making the phone viewport authoritative and reflowing the terminal. The user can still press Claim to take control explicitly. A same-device reconnect still auto-reclaims through the prior-connection or last-holder cases above. Desktop auto-claim behavior is unchanged. For sessions created on the current mobile device, the client keeps a session UUID marker in localStorage for up to 10 minutes. This lets an Android WebView reload after brief backgrounding safely auto-reclaim the session even if the daemon cleared the disconnected claim and the new JavaScript process lost its in-memory lastClaimHolder state.
Input Guard
The daemon blocks SendInput / SendPtyInput from any connection whose connection_id does not match the session's claim_id. This applies to both agent SDK prompts and raw PTY input, including PTY input delivered directly between cluster peers. Resize and input therefore use the same owner check. The frontend also disables terminal input when the local client is not the claim holder, but the daemon check is authoritative.
PTY Sizing - Owner-Driven
Terminal size is owner-authoritative: the claim holder drives the PTY size, and every other viewer renders at that size. This keeps cursor-addressed output (status spinners, TUI menus) aligned with the PTY instead of wrapping at a mismatched width and leaving duplicated/"shown twice" debris rows - without letting a passive viewer shrink the owner's terminal.
- Only the claim holder reports a size. The frontend gates resize reporting on the claim (
resizeEnabled); the owner fills its window and reports that size, while viewers never report. The daemon enforces the same claim check at the session owner. An unclaimed session rejects resize requests asnot_owner; it does not let the first viewer that reports dimensions set the shared PTY size. - Resize requests are correlated and serialized. Every
ResizePtycarries arequest_id. The owner replies withPtyResizeResultonly after it has handled that exact request. The app keeps at most one request in flight, retains only the latest dimensions from a resize burst, and sends them after a 120 ms trailing settle. This avoids overlapping TUI repaint storms while a window edge or pane divider is moving. - Geometry changes at an ordered stream boundary. The owner inserts
PtyResizeBoundaryimmediately before output produced at the requested geometry. xterm keeps parsing earlier output at the old rows and columns, then changes its grid only when that boundary arrives. This prevents cursor-up, erase-line, and menu redraw sequences from being interpreted against the wrong grid. - The local pane remains responsive while waiting. Hive CSS-scales the existing grid to the changing pane while the request and boundary are in flight. Scaling changes presentation only, so it cannot reflow or corrupt the terminal buffer.
- Viewers render at the owner's size. Each viewer applies the same ordered boundary and uses CSS scaling or letterboxing to fit its local pane. The grid is never re-wrapped to the viewer's own width.
- Remote sessions use the regular forwarded request path. The request, result, and output boundary all pass through the owner-authoritative session flow. There is no separate
PeerResizePtyfire-and-forget path that can report success before the remote PTY has handled the resize.
PtyResizeResult.status is applied when the PTY changed, unchanged when the requested geometry was already current, not_owner when the claim check rejects the sender, not_found when the live session cannot be resolved, and failed for another PTY resize error. A failed resize leaves the published canonical size unchanged.
Requesting the current size publishes an unchanged boundary without changing the real PTY geometry. Resume and output-gap repaint nudges send SIGWINCH without the old hidden one-row shrink/restore cycle, so connected clients never receive output generated at an unpublished intermediate size.
On mobile, native keyboard and safe-area insets define the visible terminal band, but keyboard changes never resize the remote PTY. The app updates only the local presentation during the IME animation: Move above keyboard translates the natural-size grid upward to keep its bottom visible, Fit to screen scales the grid locally, and Scroll keeps it natural-size and top-aligned. No mode sends ResizePty, triggers SIGWINCH, or waits for an ordered boundary when the keyboard opens or closes. Actual pane and window geometry changes still use the owner-authoritative resize flow.
Take-over overrides the current owner. Because the size check keys on claim_id, claiming a session (Take over) immediately makes the new owner's size win - the daemon swaps claim_id, the new owner's resyncSize pushes its dimensions, and the old owner's now-stale resizes are rejected. The old owner transitions to a viewer and re-renders at the new authoritative size.
What the Second Client Sees
When another client opens a session someone else already holds:
- The client attaches to the output stream and sees live output immediately.
- A read-only banner appears above the terminal: "In use by X - read-only · [Take over]".
- Typing in the xterm has no effect locally (input is disabled). If the local check were bypassed, the daemon would reject the input.
If instead the session is unclaimed but the client did not auto-claim it (it was not safe to under canAutoReclaim), the same banner reads "Session unclaimed - read-only" with a [Claim] button (rather than "Take over"). The view stays read-only until the user claims it. Claiming an unclaimed session sends ClaimSession { force: false }; taking over a held session sends ClaimSession { force: true }. This is also what a touch/mobile client sees when it opens an unclaimed session last owned elsewhere: opening the session does not change its recorded PTY width.
Taking Over
Clicking "Take over" sends a fresh ClaimSession { force: true }. The daemon:
- Updates
claim_idto the new client's connection ID. - Broadcasts
SessionClaimedto all clients. - Old owner sees
claim_id !== myIdand transitions to passive viewer (still attached, still sees output). - New owner sees
claim_id === myId, enables input, and callsGetSessionHistoryonce to catch up on any bytes produced while the broadcast subscriber was fast-forwarded past them.
On mobile, the button enters a pending state immediately after tap so duplicate taps do not fire competing claim requests while the daemon broadcast is in flight.
Why GetSessionHistory on Take Over
tokio::sync::broadcast receivers only deliver messages produced after subscribe() is called. A client that was a passive viewer saw everything via its existing receiver, but a client that just subscribed (e.g., after reconnect) will miss pre-subscribe output. Calling GetSessionHistory against the session's live_output buffer fills the gap.
Claim Release
Claims are released automatically when the WebSocket disconnects. Cleanup clears only claims still held by that connection, so an old socket closing after a reconnect transfer cannot release the new socket's claim. There is no explicit release action.
The daemon also releases every terminal input claim held by a connection after five minutes without either a Pong or any client message. This does not close the socket or detach its output subscriptions: the inactive connection remains a passive viewer and continues receiving terminal output. Time lost because the daemon was delayed by host scheduling is credited and does not count as client inactivity.
Claim State Machine
SessionDetailView tracks ownership with a state machine:
States: pending → claiming → owned
↘ other (read-only banner; terminal still visible)| State | Banner | Terminal | Input |
|---|---|---|---|
pending | Loading | Hidden | No |
claiming | "Connecting…" | Hidden | No |
owned | None | Visible | Enabled |
other | "In use by X · Take over" (or "Session unclaimed · Claim") | Visible (read-only) | Disabled |
TerminalPanel (workspace panes) uses the same passive-viewer model: auto-claims sessions on focus only when canAutoReclaim allows it (see "When is an auto-claim safe?"). The read-only banner stays hidden for the full automatic claim attempt, including slower remote-node startup, so a newly created session does not briefly appear unclaimed while ownership is still being established. If the attempt ends without ownership, the pane shows the genuine "Take over" or "Session unclaimed" state and keeps input disabled. Output subscriptions are maintained for every session currently shown in the workspace layout, not just the focused pane, so split panes keep streaming live output across layout restores and reconnects.
Sticky subscriptions
Once a session has been loaded into a pane during the current app run it becomes sticky: the client keeps subscribing to its output even after the pane is closed or replaced with another session. The local scrollback buffer therefore stays in sync with the daemon's live_output, and re-opening the session in a pane is instant - no GetSessionHistory round-trip is needed. The sticky set is released only when the session is killed or the client app exits; on reconnect, the workspace re-subscribes every sticky session whose id is still in the daemon's authoritative SessionList.
The claim check compares session.claim_id against connectionStore.connectionId (UUID per connection).
Protocol Messages
| Message | Direction | Purpose |
|---|---|---|
ClaimSession { session_id, client_name, connection_id, reclaim_connection_ids, force } | Client → Server | Request exclusive control. connection_id identifies the originating socket. Recent, profile-scoped reclaim_connection_ids let the owner transfer the same client's prior claim atomically, including across cluster routing. force: true is an explicit "Take over"; force: false can replace only an unclaimed, current, or supplied recent claim. |
SendInput { session_id, input, connection_id } / SendPtyInput { session_id, data, connection_id } | Client → Server | Send agent SDK or raw PTY input. The session owner accepts it only when the originating connection matches claim_id, whether delivery is local, forwarded, or direct between peers. |
ResizePty { session_id, request_id, rows, cols, connection_id } | Client → Server | Request an owner-authoritative PTY geometry. request_id correlates the owner-generated result across local and remote sessions. |
PtyResizeResult { session_id, request_id, status, cols, rows, message } | Server → Requesting Client | Confirm that the session owner handled the resize or return not_owner, not_found, or failed. |
PtyResizeBoundary { session_id, seq, cols, rows, request_id } | Server → Attached Clients | Ordered output-stream boundary at which clients change their terminal grid. |
SessionClaimed { session_id, claimed_by, claim_id } | Server → All Clients | Broadcast ownership change |
Claim enforcement across nodes
The owner node is the source of truth for session claims:
- Direct path (client → owner): the owner checks the socket's assigned connection ID against
claim_id. - Forwarded path (client → non-owner node → owner): the originating connection ID travels with input and claim requests so the owner makes the same check.
- Direct peer PTY path: raw PTY input sent directly to the owner carries the originating connection ID and is rejected unless it matches
claim_id. - Replicated mirrors: a node that has only a replicated session snapshot leaves the authorization decision to the live session owner.
- Claim routing: a non-owner node must not accept
ClaimSessionagainst a replicated mirror. Automatic client claims retry a bounded number of times while the owner route converges, then the owner applies the claim. The retry stops when another client owns the session, the connection drops, the session disappears, or its node is offline. This prevents two nodes from each marking their local mirror as claimed during simultaneous take-over attempts. - Claim decision: the owner compares and updates
claim_idatomically.force: falsecan replace an unclaimed session, the current connection, or a recent connection ID from the same saved profile. Only an explicitforce: truetake-over can replace an unrelated holder.
Cluster Behavior
Claims are replicated across the cluster via StateMutation::SessionUpdated. If a session is created on Node A but the client connects through Node B, the claim still works - every node holds the replicated session state, and the owning node (Node A) enforces claims authoritatively.
For low-latency claim notifications across nodes, the session owner sends a peer-to-peer PeerSessionClaimed { session_id, claimed_by, claim_id } message only after it has accepted and applied the claim. Receiving peers then update their replicated mirror's claim_id and broadcast SessionClaimed to their own clients without waiting for the slower state-replication round-trip. A rejected automatic claim never emits this fast notification, so another node cannot overwrite the live owner's decision. The claim_id field carries the originating client's connection ID.