Appearance
Wire Protocol
Hive uses two WebSocket-based protocols:
- Client protocol - between
hiveclient andhiveddaemon (port 9178) - Peer protocol - between
hivedcluster nodes (port 9179)
All messages are JSON text frames using serde tagged enums ("type" field, snake_case) - except terminal output, which the daemon sends as a raw binary WebSocket frame (see Binary output frame). Terminal bytes are escape-dense, so JSON-encoding them dominated CPU on the session's busy owner node under load; the binary frame ships the payload verbatim. Every client decodes it back into the same logical Output message.
Client Protocol
Endpoint: ws://<host>:<port>/ws
Connection Lifecycle
Client Server
| |
|---- ClientMessage::Auth ----->|
| |
|<--- ServerMessage::AuthResult-| (success: true/false)
| |
|---- ClientMessage::* -------->| (commands)
|<--- ServerMessage::* --------| (responses/output)
| ... |
|---- WS Close ---------------->|The first message must be Auth. Any other message before auth results in an error and disconnect.
ClientMessage (client -> daemon)
json
// Authenticate
{"type": "auth", "token": "abc123"}
// Create a new session
// Optional run_as_user runs the session as that OS user (takes precedence over
// any run-as user derived from project_id); optional node_id targets a peer.
// provider selects the SDK agent CLI (default "claude"); hidden keeps the
// session out of the terminals listing (internal agent chats). tags assigns
// free-form tags at creation - a session tagged "dev-server" is launched hidden
// as a Run-panel dev server whose bound port is detected from output.
{"type": "new_session", "working_dir": "/path", "mode": "sdk", "model": "opus", "name": "my-session", "extra_args": ["--verbose"], "run_as_user": "alice", "node_id": null, "provider": "claude", "hidden": false, "tags": []}
// Attach to session output stream
{"type": "attach", "session_id": "550e8400-e29b-41d4-a716-446655440000"}
// Detach from current session
{"type": "detach"}
// Send prompt to a session
{"type": "send_input", "session_id": "550e8400-...", "input": "explain this code"}
// List all sessions
{"type": "list_sessions"}
// Kill a session
{"type": "kill_session", "session_id": "550e8400-..."}
// Get cluster status
{"type": "cluster_status"}
// Adopt a running Claude process
{"type": "adopt_session", "pid": 12345, "name": "my-session", "working_dir": "/path/to/project"}
// Rename a session
{"type": "rename_session", "session_id": "550e8400-...", "name": "new-name"}
// Rename a cluster node
{"type": "rename_node", "node_id": "a1b2c3d4-...", "name": "production-1"}
// List directory contents
{"type": "list_directory", "path": "/home/user"}
// Browse a project's working directory (M1 file explorer).
// path is interpreted relative to project.working_dir; null/empty = root.
// Path traversal (".." or absolute) is rejected with project_file_error.
{"type": "list_project_files", "project_id": "550e8400-...", "path": "src"}
// Read a UTF-8 text file inside a project's working directory.
// Returns project_file_content on success, project_file_error on missing /
// binary / oversized (>1 MB) / non-UTF-8 / path-traversal failures.
{"type": "read_project_file", "project_id": "550e8400-...", "path": "README.md"}
// Read one window of a binary file inside a project's working directory for
// streaming download (file explorer "Download"). Stateless: each request seeks
// to offset and returns up to len bytes (daemon caps len to its 8 MiB per-frame
// bound). No total-size cap - the client loops windows until eof, so a file of
// any size streams without buffering a whole copy in memory. Answered with
// project_file_chunk (data base64, plus offset / total_size / eof) or
// project_file_chunk_error, correlated by request_id.
{"type": "read_project_file_chunk", "request_id": "d1", "project_id": "550e8400-...",
"path": "build/artifact.bin", "offset": 0, "len": 8388608}
// Working-tree git status (M2 git changes panel).
// Daemon shells out to `git status --porcelain=v2 --branch -z` inside
// project.working_dir and parses the result. "Not a git repo" is NOT an
// error - git_status_result returns is_repo=false. git_error is reserved
// for transport / spawn failures (git missing, project unknown, peer down).
{"type": "git_status", "project_id": "550e8400-..."}
// Unified diff for a single project file.
// staged=false returns `git diff`; staged=true returns `git diff --cached`.
{"type": "git_diff", "project_id": "550e8400-...", "path": "src/lib.rs", "staged": false}
// Write a UTF-8 text file inside a project's working directory (M3 editor).
// base_sha256: the digest the editor last observed (from project_file_content).
// Daemon refuses with project_file_write_conflict when on-disk digest differs.
// Pass null to force-write (creating a new file or overwriting after conflict).
// 1 MiB cap; atomic temp+rename; preserves existing file mode on Unix.
{"type": "write_project_file", "project_id": "550e8400-...",
"path": "src/lib.rs", "content": "fn main() {}\n", "base_sha256": "abc..."}
// Project filesystem mutations (file explorer: create / rename / move / copy /
// delete). Each carries a client-generated request_id echoed back on
// project_fs_action_result / project_fs_action_error so the client can match
// the reply. All paths are project-relative; traversal is rejected. create /
// move / copy refuse to overwrite an existing destination (no clobber).
// Create a new empty file (exclusive; fails if the path already exists).
{"type": "create_project_file", "project_id": "550e8400-...", "path": "src/new.rs", "request_id": "r1"}
// Create a new directory (parent must already exist).
{"type": "create_project_directory", "project_id": "550e8400-...", "path": "src/sub", "request_id": "r2"}
// Move or rename an entry (one message serves both). from must exist; to must not.
{"type": "move_project_entry", "project_id": "550e8400-...", "from": "a.txt", "to": "b.txt", "request_id": "r3"}
// Copy an entry (directories copy recursively). from must exist; to must not.
{"type": "copy_project_entry", "project_id": "550e8400-...", "from": "src", "to": "src-copy", "request_id": "r4"}
// Delete an entry (directories delete recursively). The project root can't be deleted.
{"type": "delete_project_entry", "project_id": "550e8400-...", "path": "old.txt", "request_id": "r5"}
// Upload a host file (exclusive; data is base64-encoded bytes). The new file is
// chowned to the project's run-as user. Capped at 25 MiB.
{"type": "upload_project_file", "project_id": "550e8400-...", "path": "assets/logo.png", "data": "<base64>", "request_id": "r6"}
// Subscribe to filesystem changes under a project's working directory. While
// subscribed the daemon pushes a debounced project_files_changed whenever
// anything under the root changes on disk (terminals, git, builds, peers).
// The watch is per-connection; re-subscribing replaces the prior watch and it
// is dropped on unwatch_project_files or disconnect. In cluster mode the watch
// is routed to the project owner and change events stream back over the peer
// transport, so remote-owned projects auto-refresh too.
{"type": "watch_project_files", "project_id": "550e8400-..."}
// Cancel a watch_project_files subscription (no-op if not watching).
{"type": "unwatch_project_files", "project_id": "550e8400-..."}
// Application-level keepalive. Sent periodically by clients that cannot
// observe WS protocol pongs (the browser shim - browsers auto-pong
// invisibly); the daemon replies keepalive_ack so the client has guaranteed
// inbound traffic to drive its connection-staleness indicator. Liveness is
// observational on both sides: a widening gap only flips a UI indicator and
// a daemon log line, never a teardown - connection death is detected by the
// kernel (TCP keepalive / TCP_USER_TIMEOUT), not by application timers.
{"type": "keepalive"}
// Deprecated cluster-management messages (leaderless clustering has no
// election, leader, or manual sync - the daemon accepts these for wire
// compatibility but they are no-ops). Every connected node already serves
// writes locally and gossips them, and reconnect runs anti-entropy.
{"type": "force_election"}
{"type": "promote_node", "node_id": "a1b2c3d4-..."}
{"type": "trigger_sync"}
// Get cluster configuration
{"type": "get_cluster_config"}
// Update cluster configuration at runtime
{"type": "update_cluster_config", "peers": ["10.0.0.2:9179"], "heartbeat_interval_ms": 1000}
// ── Remote Terminals ───────────────────────────────────────────────────────
// Open a new PTY terminal running an arbitrary shell or command
// command: the shell/binary to launch (e.g. "bash", "powershell.exe", "zsh")
// args: optional extra arguments; name: optional display name
{"type": "open_terminal", "working_dir": "/home/user", "command": "bash", "args": null, "name": "my-shell"}
// ── Agent Teams ────────────────────────────────────────────────────────────
// Create a new team (daemon creates a leader session and returns the Team)
{"type": "create_team", "name": "research-team", "working_dir": "/path", "model": null, "extra_args": null, "project_id": null}
// Spawn a worker session under an existing team
{"type": "spawn_worker", "team_id": "550e8400-...", "working_dir": "/path", "model": null, "extra_args": null, "name": "worker-1"}
// Relay a message between team members
// to_session_id omitted: sends to leader (from worker) or broadcasts to all workers (from leader)
{"type": "relay_team_message", "team_id": "550e8400-...", "from_session_id": "uuid-a", "to_session_id": "uuid-b", "payload": "{\"task\":\"analyse\"}"}
// List all teams
{"type": "list_teams"}
// Get a specific team by ID
{"type": "get_team", "team_id": "550e8400-..."}
// Delete a team and kill all its sessions
{"type": "delete_team", "team_id": "550e8400-..."}
// ── Team autopilot ─────────────────────────────────────────────────────────
// Spawn a worker and block until it reports (or the timeout fires).
// On timeout, the daemon returns wait_id so the caller can resume via team_wait.
{"type": "handoff", "team_id": "550e8400-...", "prompt": "audit auth.rs", "worker_name": "explorer", "timeout_ms": 60000}
// Spawn a worker asynchronously; daemon replies with worker_assigned.
{"type": "assign", "team_id": "550e8400-...", "prompt": "fan-out task"}
// Bounded block on pending workers. Pass wait_id (from a prior timeout) to resume.
{"type": "team_wait", "team_id": "550e8400-...", "for_session_ids": null, "timeout_ms": 60000, "wait_id": null}
// At-least-once inbox replay for relayed messages. since_seq=null returns everything.
{"type": "get_team_inbox", "team_id": "550e8400-...", "session_id": "uuid-worker", "since_seq": 7}ServerMessage (daemon -> client)
json
// Auth result
{"type": "auth_result", "success": true, "message": "authenticated"}
// Reply to a client keepalive (see ClientMessage::keepalive) - gives clients
// without WS-pong visibility guaranteed inbound traffic for their staleness
// indicator.
{"type": "keepalive_ack"}
// Session created
{"type": "session_created", "session": {"id": "550e8400-...", "name": null, "mode": "sdk", "status": "idle", "working_dir": "/path", "model": "opus", "created_at": "2025-01-01T00:00:00Z", "last_active": "2025-01-01T00:00:00Z", "attached_clients": 0}}
// Session list
{"type": "session_list", "sessions": [{"id": "...", ...}]}
// Attached to session
{"type": "attached", "session_id": "550e8400-..."}
// Detached from session
{"type": "detached", "session_id": "550e8400-..."}
// Streamed output. `seq` is the per-session monotonic chunk counter assigned by
// the session owner; clients dedupe against `SessionHistory.last_seq` (drop any
// Output with seq <= last_seq). NOTE: on the wire this is NOT the JSON shown
// below - it is a binary frame (see "Binary output frame"). The JSON form is the
// logical message every client reconstructs after decoding.
{"type": "output", "session_id": "550e8400-...", "seq": 42, "data": "..."}
// Session status changed
{"type": "session_status_changed", "session": {"id": "...", "status": "running", ...}}
// Session killed
{"type": "session_killed", "session_id": "550e8400-..."}
// Session unknown - the addressed node has no live process for this session and
// cannot resolve an owner for it. In a cluster this is the owner's authoritative
// disclaim: a node that forwarded a request to the recorded owner treats this as
// proof the session is dead, prunes its stale local routing, and reports
// session_killed to its client. A client receiving it directly treats the
// session as ended.
{"type": "session_unknown", "session_id": "550e8400-..."}
// Cluster status. `role`, `term`, `leader_id`, and `leader_address` are
// vestigial under leaderless clustering: `role` is always "leader", `term` is
// always 0, and `leader_id`/`leader_address` always point at the connected node
// itself. They are retained in the payload for wire compatibility; clients
// treat every node as a co-equal authority and do not act on them.
{"type": "cluster_status", "cluster": {"node_id": "a1b2c3d4-...", "role": "leader", "term": 0, "leader_id": "a1b2c3d4-...", "leader_address": "10.0.0.1:9178", "peers": [...]}}
// Cluster config response
{"type": "cluster_config", "node_id": "a1b2c3d4-...", "peer_port": 9179, "peers": ["10.0.0.2:9179"], "heartbeat_interval_ms": 1000, "election_timeout_min_ms": 3000, "election_timeout_max_ms": 5000, "min_quorum": null, "advertise_addresses": ["10.0.0.1:9178"]}
// Cluster action acknowledgement
{"type": "cluster_action_ack", "action": "rename_node", "ok": true, "message": "Node renamed"}
// Non-fatal operational notice for the current client
{"type": "client_notice", "notice": {
"level": "warning",
"code": "output_lagged",
"message": "This client is falling behind on live session output.",
"detail": "Hive skipped 12 buffered output chunks while catching up.",
"session_id": "550e8400-e29b-41d4-a716-446655440000"
}}
// Directory listing response
{"type": "directory_listing", "path": "/home/user", "parent": "/home", "entries": ["projects/", "documents/", "file.txt"]}
// Project directory listing (response to list_project_files).
// Paths are project-relative; entries are sorted dirs-first then alphabetical.
// kind is "dir" | "file" | "symlink"; size is bytes (0 for directories).
{"type": "project_file_list", "project_id": "550e8400-...", "path": "src",
"entries": [
{"name": "components", "path": "src/components", "kind": "dir", "size": 0},
{"name": "lib.rs", "path": "src/lib.rs", "kind": "file", "size": 4096}
]}
// Project file content (response to read_project_file).
// sha256 is lowercase hex of the raw bytes (used by M3 for write conflicts).
{"type": "project_file_content", "project_id": "550e8400-...", "path": "README.md",
"content": "# Hi", "sha256": "abc..."}
// Project file error (list_project_files or read_project_file failed).
{"type": "project_file_error", "project_id": "550e8400-...", "path": "../etc/passwd",
"message": "path escapes project root"}
// One window of a streaming download (response to read_project_file_chunk).
// data is base64-encoded; offset echoes the window start; total_size is the
// file's full size; eof is true once this window reaches the end. A failed
// window answers with project_file_chunk_error { request_id, message }.
{"type": "project_file_chunk", "request_id": "d1", "data": "...",
"offset": 0, "total_size": 134217728, "eof": false}
// Git status result (response to git_status).
// is_repo=false means the working dir is not a git repository - empty change
// lists, no branch, not an error. status letters: M A D R C T U.
{"type": "git_status_result", "project_id": "550e8400-...",
"is_repo": true, "branch": "main", "upstream": "origin/main",
"ahead": 1, "behind": 0,
"staged": [{"path": "src/lib.rs", "status": "M"}],
"unstaged": [{"path": "README.md", "status": "M"}],
"untracked": ["new.txt"]}
// Git diff result (response to git_diff). patch is raw unified-diff text.
{"type": "git_diff_result", "project_id": "550e8400-...",
"path": "src/lib.rs", "staged": false,
"patch": "diff --git a/src/lib.rs b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n"}
// Git error (git_status / git_diff failed for a transport / spawn reason).
{"type": "git_error", "project_id": "550e8400-...", "message": "git not on PATH"}
// Project file saved (response to write_project_file). sha256 is the new base.
{"type": "project_file_saved", "project_id": "550e8400-...",
"path": "src/lib.rs", "sha256": "def456..."}
// Write conflict: on-disk sha256 differs from base_sha256 (or file is gone).
// The client typically prompts the user to reload, overwrite, or merge.
{"type": "project_file_write_conflict", "project_id": "550e8400-...",
"path": "src/lib.rs", "current_sha256": "ff00ff00..."}
// Project filesystem mutation succeeded (create / move / copy / delete).
// request_id echoes the originating request; path is the affected
// project-relative path (the new location for move/copy, the created path for
// create, the removed path for delete).
{"type": "project_fs_action_result", "project_id": "550e8400-...",
"request_id": "r3", "path": "b.txt"}
// Project filesystem mutation failed (path escapes root, destination exists,
// source missing, IO error, peer unreachable). request_id echoes the request.
{"type": "project_fs_action_error", "project_id": "550e8400-...",
"request_id": "r3", "message": "a file or directory with that name already exists"}
// Files changed under a watched project's working directory (pushed to clients
// with an active watch_project_files subscription). Debounced and coarse - no
// path detail; the client re-lists the directories it cares about (root plus
// expanded subfolders) in response.
{"type": "project_files_changed", "project_id": "550e8400-..."}
// Error
{"type": "error", "message": "session not found"}
// ── Remote Terminals ───────────────────────────────────────────────────────
// A new shell terminal was opened (response to open_terminal)
// The session uses mode "pty" and kind {"type": "shell", "command": "bash"}
{"type": "terminal_opened", "session": {"id": "550e8400-...", "mode": "pty", "kind": {"type": "shell", "command": "bash"}, "status": "running", ...}}
// ── Agent Teams ────────────────────────────────────────────────────────────
// A new team was created (response to create_team)
{"type": "team_created", "team": {"id": "...", "name": "research-team", "leader_session_id": "...", "worker_session_ids": [], "status": "active", "created_at": "...", "last_active": "...", "project_id": null}, "leader_session": {"id": "...", ...}}
// A worker was spawned and added to the team (response to spawn_worker)
{"type": "worker_spawned", "team": {"id": "...", "worker_session_ids": ["worker-uuid"], ...}, "worker_session": {"id": "worker-uuid", ...}}
// A relayed message from another team member
{"type": "team_message", "team_id": "550e8400-...", "from_session_id": "uuid-a", "payload": "{\"result\":\"done\"}"}
// Response to list_teams or get_team
{"type": "team_list", "teams": [{"id": "...", "name": "research-team", "status": "active", ...}]}
// A team was updated
{"type": "team_updated", "team": {"id": "...", ...}}
// A team was deleted
{"type": "team_deleted", "team_id": "550e8400-..."}
// ── Team autopilot ─────────────────────────────────────────────────────────
// Response to handoff. completed=true means result is populated. completed=false
// with wait_id set means the handoff timed out and may be resumed via team_wait.
{"type": "handoff_result", "team_id": "550e8400-...", "worker_session_id": "uuid-w", "success": true, "result": "done", "completed": true, "wait_id": null}
// Response to assign. Worker is running; collect its result via team_wait + the
// usual worker_result_received / team_completed flow.
{"type": "worker_assigned", "team_id": "550e8400-...", "worker_session_id": "uuid-w"}
// Response to team_wait. completed_workers reported within the window;
// pending_workers are still working. timed_out + wait_id signal resumption.
{"type": "team_wait_result", "team_id": "550e8400-...", "completed_workers": ["uuid-w"], "pending_workers": [], "timed_out": false, "wait_id": null}
// Response to get_team_inbox. messages are seq-ordered; next_seq is the cursor
// the caller stores and re-presents as since_seq on the next call.
{"type": "team_inbox", "team_id": "550e8400-...", "session_id": "uuid-w", "messages": [{"seq": 1, "from_session_id": "uuid-leader", "payload": "...", "delivered_at": "..."}], "next_seq": 2}client_notice is deliberately non-fatal. It exists for "something important is happening, but your request still worked" cases, and clients should surface it to the user without treating it like an error. Current notice codes are:
cluster_owner_lookup- Hive is still converging on the session owner, so the attach is being routed to the owning nodeoutput_lagged- the client fell behind on output and Hive skipped buffered chunks while catching upoutput_backpressure- the client could not keep up for an extended period, so Hive dropped that output stream to protect the rest of the connection
Binary output frame
Terminal output (ServerMessage::Output) is the only message sent as a binary WebSocket frame instead of JSON. Terminal bytes are dense with control/ESC sequences; JSON-escaping every one of them (ESC becomes ``) is a per-byte scan plus a large size inflation, on the highest-volume traffic and on the session's busy owner node - so it dominated CPU under load. The binary frame ships the payload verbatim.
Layout:
byte 0 : tag = 0x01 (Output v1)
bytes 1..17 : session_id (16-byte UUID)
bytes 17..25 : seq (u64, big-endian)
bytes 25.. : raw UTF-8 terminal payloadThe daemon's reader splits PTY output on UTF-8 boundaries, so each frame's payload is valid UTF-8. Every client (CLI, the app's native layer, and the browser/PWA shim) decodes the frame back into the logical Output message; nothing else in the protocol changes. Only terminal output uses this path - all control/lifecycle messages remain JSON text. Per the no-backward-compatibility policy the whole fleet upgrades together, so there is no JSON-output fallback.
Cluster note: a session owner still JSON-encodes output when forwarding to other nodes (
PeerMessage::SessionOutput); converting the peer path to the same binary frame is a planned fast-follow.
Write Handling in a Cluster
Leaderless clustering has no leader and no redirect. Every connected node serves writes locally: it applies the mutation to its own replicated state, gossips it to all peers (PeerMessage::SessionUpserted / SessionRemoved / ProjectUpserted / TeamUpserted, etc.), and answers the client directly.
The only request a non-owning node forwards is one that must reach a specific session's live process - interactive input, resize, and output subscription - which is routed to the recorded owner via PeerMessage::ForwardedRequest / SubscribeOutput (see Request Forwarding below). State mutations are never forwarded; the receiving node is its own authority.
SessionInfo Object
json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "my-session",
"mode": "sdk",
"status": "idle",
"working_dir": "/home/user/project",
"model": "opus",
"created_at": "2025-01-15T10:30:00Z",
"last_active": "2025-01-15T10:35:00Z",
"attached_clients": 1,
"adopted_pid": null,
"project_id": null,
"node_id": null,
"kind": {"type": "claude"},
"provider": "claude",
"hidden": false,
"team_id": null,
"is_team_leader": false
}mode: "sdk" | "pty" | "adopted"
"pty"- interactive terminal session; output is raw terminal data."sdk"- headless agent driven turn-by-turn. Each prompt (sent viasend_input) spawns the provider CLI in stream-json mode;Outputframes carry newline-delimited JSON the client parses into a chat transcript. The daemon threads the provider's own conversation id (--resume) across turns so context is preserved."adopted"- an external Claude process registered with the daemon.
provider: which agent CLI backs an SDK-mode session - currently "claude". Ignored for PTY/adopted sessions. Absent on daemons predating this field (treat as "claude").
hidden: when true, the session is kept out of the terminals/sessions listings. Used for internal agent chats (e.g. the Notes agent) that have their own UI. Absent/false for normal sessions.
node_id: UUID of the cluster node that owns the session, stamped by the daemon when the session list/created message is built for an app client. null when running standalone (no cluster). Lets the app label a session's node even when it has no explicit project.
status: "running" | "idle" | "detached" | "exited"
kind: describes what process the session runs.
{"type": "claude"}- a Claude Code CLI session (SDK or PTY mode){"type": "shell", "command": "bash", "args": null}- an arbitrary shell or command process
team_id: UUID of the team this session belongs to, or null.
is_team_leader: true if this session is the leader/coordinator of its team.
Team Object
json
{
"id": "660f9500-f30c-52e5-b827-557766551111",
"name": "research-team",
"leader_session_id": "550e8400-...",
"worker_session_ids": ["worker-uuid-1", "worker-uuid-2"],
"status": "active",
"created_at": "2025-01-15T10:30:00Z",
"last_active": "2025-01-15T10:35:00Z",
"project_id": null
}status: "active" | "inactive"
Note: teams work in both standalone and cluster modes. In standalone mode the daemon keeps teams in memory; in cluster mode they are replicated to all nodes via
PeerMessage::TeamUpserted/TeamRemovedand persisted in SQLite on every node.
Peer Protocol
Endpoint: ws://<host>:<peer_port>/peer (incoming) or direct TCP (outgoing).
Used for inter-node cluster communication. All messages are PeerMessage JSON.
Peer Connection Lifecycle
Connecting Node Accepting Node
| |
|---- PeerHello ------------------>|
| | (validate cluster_token)
|<--- PeerHelloAck ---------------|
| |
|---- StateSnapshot ------------->| (anti-entropy: both sides push their
|<--- StateSnapshot --------------| full state on connect, merged per-object)
| ... |Handshake
json
// Connecting node identifies itself
{"type": "peer_hello", "node_id": "a1b2c3d4-...", "cluster_token": "secret", "term": 0, "display_name": "node-a"}
// Accepting node responds
{"type": "peer_hello_ack", "node_id": "b2c3d4e5-...", "term": 0, "success": true, "message": null, "display_name": "node-b"}
// Rejected (bad token)
{"type": "peer_hello_ack", "node_id": "b2c3d4e5-...", "term": 0, "success": false, "message": "invalid cluster token"}Legacy consensus messages
The heartbeat / heartbeat_ack / request_vote / vote_response / leader_announce / start_election_now / clear_stickiness_for_election / promote_request / request_catchup variants are retained in the PeerMessage enum for wire compatibility but are ignored by handle_peer_message under leaderless clustering - there is no election, no heartbeat-driven leadership, and no leader-push catch-up. They carry a term field that is always 0.
State Replication
There is no leader push. Each node applies a local write to its own state and gossips it once to all peers (a full mesh means one hop reaches everyone); recipients apply it locally and never re-broadcast. Sessions carry a generation and projects/teams carry an updated_at so a recipient resolves conflicts last-writer-wins and rejects stale or reordered upserts. Removed sessions leave a time-bounded tombstone so a late upsert cannot resurrect them (a higher generation from the live owner can punch through - this self-heals an erroneous removal during cluster churn). The term and seq fields on these messages are vestigial (always 0) and ignored.
json
// Full snapshot - sent by both sides on connect (anti-entropy). The receiver
// merges it per-object (session/project/team union with tombstone + LWW
// rules); per-node `version` counters are NOT comparable, so a snapshot is
// never rejected wholesale.
{"type": "state_snapshot", "term": 0, "state": {"sessions": {...}, "projects": {...}, "version": 42}}
// Incremental: session created/updated (gossiped on local mutation)
{"type": "session_update", "term": 0, "session": {"info": {...}, "output_history": [...]}}
// Incremental: session removed (records a tombstone on every node)
{"type": "session_removed", "term": 0, "session_id": "550e8400-..."}
// Incremental: project created/updated
{"type": "project_upserted", "term": 0, "project": {"id": "550e8400-...", "updated_at": "...", ...}}
// Incremental: project deleted
{"type": "project_deleted", "term": 0, "project_id": "550e8400-..."}Node Management
json
// Request a peer to rename itself
{"type": "peer_rename_node", "name": "new-name"}
// Broadcast own display name change to all peers
{"type": "peer_display_name_update", "name": "new-name"}Request Forwarding
State writes are never forwarded - every node applies them locally. Forwarding exists only to reach a session's live process on its owning node (interactive input, resize, output subscription). The originating node sends a forwarded_request to the recorded owner; the owner runs it and replies with forwarded_response. A hops budget bounds re-forwarding so a misrouted or phantom-owner request cannot bounce around the mesh forever.
json
// Originating node -> session owner
{"type": "forwarded_request", "request_id": "uuid-...", "payload": "{\"type\":\"send_input\",...}", "hops": 4}
// Owner -> originating node
{"type": "forwarded_response", "request_id": "uuid-...", "payload": "{\"type\":\"...\",...}"}Project File Watching
Mirrors the session-output subscription model. A node that has a client watching a remote-owned project asks the owner to watch the working directory; the owner streams coarse, debounced change ticks back keyed by subscriber_id.
json
// Subscriber -> Owner: start watching a project's working directory
{"type": "subscribe_project_files", "subscriber_id": "uuid-...", "project_id": "550e8400-..."}
// Subscriber -> Owner: stop watching
{"type": "unsubscribe_project_files", "subscriber_id": "uuid-..."}
// Owner -> Subscriber: files changed (Lossy - idempotent, debounced)
{"type": "project_files_changed", "subscriber_id": "uuid-..."}Health Endpoint
A simple HTTP health check is available:
GET http://<host>:<port>/health
Response: "ok" (200)