Skip to content

Session Exclusivity ​

Clusto 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") while the holder is actively typing, and automatic once the holder has gone idle (an orphaned claim, see below).

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 }. 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.
  2. 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, the current holder is one of the reconnecting client's recent connection IDs, or the claim is orphaned.
  3. Daemon confirms - After accepting the claim, the daemon broadcasts SessionClaimed to everyone.
  4. 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 when claim_id is unclaimed, already matches the requesting connection, or matches a recent connection ID supplied by that same saved connection profile, or the current claim is orphaned. An unrelated holder that is actively typing is refused, including when the request arrives through another cluster node.

This makes "Take over" authoritative: a forced take-over starts a fresh input lease, so no other client's automatic claim can pull it back while you use it.

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.

This gate applies to background claims only (claim released, reconnect recovery). User-intent triggers skip it, see below.

Orphaned claims ​

A connection that stays open is not proof that anyone is using it: an app left running on another device keeps answering pings for hours, and a holder connected through a peer node that crashed never gets its disconnect cleanup. So the session owner tracks when the claim holder last showed intent - sent input, or explicitly took the session over - and treats a claim whose holder has done neither for 60 seconds as orphaned. An automatic claim from a client that only opened the session does not start that lease; it holds the claim for 15 seconds instead, so the device it came from cannot pull it straight back on its next focus or tap. Without that hold two open devices traded the claim on every interaction, reflowing the PTY between their widths each time.

An orphaned claim can be taken by any client's force: false claim. Clients send that claim on user intent, even when another connection holds the session:

  • opening the session, focusing its pane, or binding a pane to it
  • the app window regaining focus, or the app returning to the foreground
  • tapping or clicking into the read-only terminal (a press and release in place - scrolling or selecting text to read output does not count)

If the holder is idle, the claim moves silently and input is enabled; keys typed while the claim is in flight are held and sent once it lands. If the holder is actively typing, the daemon refuses and the viewer keeps the read-only banner with Take over. A refused probe is not retried.

Switching devices therefore needs no clicks: walk away from the desktop, open the session on the phone, and the phone takes it; return to the desktop and it takes the session back, because the phone only opened it (once its 15-second hold has passed; sooner than that, use Take over). Background triggers never probe a held claim, so two idle clients cannot trade it back and forth.

Touch/mobile clients apply one additional safeguard to background claims: an unclaimed session they did not previously own is not taken on reconnect or claim release, so the recorded desktop PTY width is not reflowed to the phone without the user touching it. Opening or tapping the session is user intent and claims it like any other client. A same-device reconnect still auto-reclaims through the prior-connection or last-holder cases above. 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. Raw PTY input means every byte source: keyboard input, paste, mobile keys, xterm binary events, and mouse reports such as a TUI wheel gesture. Resize and input therefore use the same owner check. The frontend also stops all of these sources before they reach the wire when the local client is not the claim holder, but the daemon check is authoritative.

A passive viewer can still use the wheel to read ordinary terminal scrollback. That scroll is entirely local and does not affect the shared PTY. When a full-screen application has switched to the alternate buffer or enabled mouse reporting, the same gesture is consumed locally and sends no remote mouse event. The viewer must claim or take over the session before interacting with that application.

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 as not_owner; it does not let the first viewer that reports dimensions set the shared PTY size.
  • Resize requests are correlated and serialized. Every ResizePty carries a request_id. The owner replies with PtyResizeResult only 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 PtyResizeBoundary immediately 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. Clusto 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 PeerResizePty fire-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 is actively using (the holder sent input within the last 60 seconds):

  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, paste, mobile keys, and TUI mouse input have no effect on the remote process. The viewer can scroll ordinary local scrollback, but cannot scroll or click inside the running full-screen application. If the local check were bypassed, the daemon would reject every input byte.

If the holder is idle, none of this appears: the claim is orphaned and moves to the opening client.

If instead the session is unclaimed but no claim reached it (for example a background trigger that canAutoReclaim refused), the same banner reads "Session unclaimed - read-only" with a [Claim] button (rather than "Take over"). Tapping the terminal or pressing Claim takes 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 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.

Neither release covers a client that is connected but unused, or a holder whose peer node died. The orphaned-claim lease above handles both lazily: the claim stays in place until another client asks for it.

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: focusing a pane is user intent and claims a free or orphaned session (see "Orphaned claims"), while background re-evaluation on claim changes stays behind canAutoReclaim. 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 ​

MessageDirectionPurpose
ClaimSession { session_id, client_name, connection_id, reclaim_connection_ids, force }Client → ServerRequest 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, supplied recent, or orphaned claim.
SendInput { session_id, input, connection_id } / SendPtyInput { session_id, data, connection_id }Client → ServerSend agent SDK or raw PTY input. SendPtyInput.data is an exact byte array so UTF-8 text and binary mouse reports cannot be changed by string conversion. 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 → ServerRequest 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 ClientConfirm 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 ClientsOrdered output-stream boundary at which clients change their terminal grid.
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): 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 ClaimSession against 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_id atomically. force: false can replace an unclaimed session, the current connection, a recent connection ID from the same saved profile, or an orphaned claim. Only an explicit force: true take-over can replace an unrelated holder that is actively typing. The input lease is node-local on the owner, which is also where every input byte is authorized.

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.

Clusto - remote AI coding agents over WebSocket.