Skip to content

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 Output messages 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's SendInput / SendPtyInput messages 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:

  1. Auto-claim - If it is safe to auto-claim (see below), the client sends ClaimSession { force: false }.
  2. Daemon confirms - The daemon sets claim_id to the connection's UUID and broadcasts SessionClaimed to everyone.
  3. 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 daemon refuses it if the session is currently held by a still-live connection (one that is still registered in ConnectedClients). A holder that has actually dropped is released to None on disconnect, so a non-None claim_id held by a live connection means a genuine live owner - never something an auto-reclaim may steal.

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 daemon's live-holder guard double-checks this, so a prior id that turns out to belong to another live instance (e.g. leaked through shared localStorage) is still refused server-side.
  • Unclaimed and we were its last holder - our own blip; reclaim it.
  • Unclaimed and never claimed by anyone - a free session; take it.
  • 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.

Input Guard

The daemon blocks SendInput / SendPtyInput from any connection whose connection_id doesn't match the session's claim_id. Enforced server-side. The frontend additionally disables input in the terminal when the local client is not the claim holder (for UX; the server check is the ground truth).

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, viewers never report. The daemon also enforces this server-side: resize_pty ignores any ResizePty/PeerResizePty whose connection_id isn't the session's claim_id (an unclaimed session accepts any resize, for the brief pre-claim window).
  • The owning node publishes the size. It records last_pty_cols/rows, resizes the live PTY, and broadcasts the new size on SessionInfo.pty_cols/pty_rows (fast-pathed peer-to-peer as PeerSessionResized so viewers on other nodes update their mirror + clients).
  • Viewers render at the owner's size. Each viewer's xterm grid is set to the owner's cols/rows. A viewer whose window is larger is letterboxed (centered); a viewer whose window is smaller is CSS-downscaled to fit (text shrinks) so the whole canvas stays visible without horizontal scroll. The grid is never re-wrapped to the viewer's own width - the viewer always shows exactly the owner's screen, scaled. This replaces the older per-client reflow, where every client independently re-wrapped a shared session to its own width and cursor-addressed output drifted out of alignment.

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:

  1. The client attaches to the output stream and sees live output immediately.
  2. A read-only banner appears above the terminal: "In use by X - read-only · [Take over]".
  3. 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 }.

Taking Over

Clicking "Take over" sends a fresh ClaimSession { force: true }. The daemon:

  1. Updates claim_id to the new client's connection ID.
  2. Broadcasts SessionClaimed to all clients.
  3. Old owner sees claim_id !== myId and transitions to passive viewer (still attached, still sees output).
  4. New owner sees claim_id === myId, enables input, and calls GetSessionHistory once 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 on WebSocket close. The daemon's cleanup calls release_claim, which clears claim_id only if the disconnecting client currently holds it. There is no explicit "release" - closing the view or disconnecting is sufficient.

Claim State Machine

SessionDetailView tracks ownership with a state machine:

States: pending → claiming → owned
                ↘ other (read-only banner; terminal still visible)
StateBannerTerminalInput
pendingLoadingHiddenNo
claiming"Connecting…"HiddenNo
ownedNoneVisibleEnabled
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?"); when another connection holds the claim it shows a slim banner above the terminal with "Take over" and disables input in that pane. 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

MessageDirectionPurpose
ClaimSession { session_id, client_name, connection_id, force }Client → ServerRequest exclusive control. connection_id carries the originating client's id so cross-node forwarding can store the real per-WS id as claim_id (not the shared profile name). force: true is an explicit "Take over" (always granted); force: false is an auto-reclaim the daemon refuses if a still-live connection holds the claim.
SendInput { session_id, input, connection_id } / SendPtyInput { session_id, data, connection_id }Client → ServerSame purpose as before; connection_id is propagated so the owning node enforces the claim against the real client when the message arrives via a follower relay.
SessionClaimed { session_id, claimed_by, claim_id }Server → All ClientsBroadcast ownership change

Claim enforcement across nodes

The owner node is the source of truth for session claims:

  • Direct path (client → owner): check_claim runs in handle_client_message against the WS connection's daemon-assigned id.
  • Forwarded path (client → non-owner node → owner): the receiving node attaches the originating connection_id to the relayed SendInput/SendPtyInput/ClaimSession. The owner's dispatch_forwarded runs check_claim with that id.
  • Replicated mirrors (non-owner check_claim): when a session is present locally only as a replicated snapshot (no live PTY/SDK process), check_claim returns Ok and lets the owner enforce.
  • Claim routing: a non-owner node must not accept ClaimSession against a replicated mirror. It retries until the owner can be routed, then the owner applies the claim. This prevents two nodes from each marking their local mirror as claimed during simultaneous take-over attempts.

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 daemon also sends a peer-to-peer PeerSessionClaimed { session_id, claimed_by, claim_id } message in parallel with the slower replicated mutation. The claim_id field carries the originating client's connection_id so receiving peers can update their replicated mirror's claim_id immediately and broadcast SessionClaimed to their own clients without waiting for the next state-replication round-trip. Without this, frontends saw a transient SessionClaimed { claim_id: None } and triggered an auto-reclaim race.

Hive - remote AI coding agents over WebSocket.