Skip to content

Wire Protocol

Hive uses two WebSocket-based protocols:

  1. Client protocol - between hive client and hived daemon (port 9178)
  2. Peer protocol - between hived cluster 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 node
  • output_lagged - the client fell behind on output and Hive skipped buffered chunks while catching up
  • output_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 payload

The 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 via send_input) spawns the provider CLI in stream-json mode; Output frames 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 / TeamRemoved and 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)

Hive - remote AI coding agents over WebSocket.