Skip to main content
Status:✏️ DRAFT
Date:📅 2026-07-18
Domain:📦 Daemon Protocol (Control Plane + Attach Stream)

SPEC-0002: Daemon Protocol (Control Plane + Attach Stream)

Overview​

One framed, length-prefixed message protocol over the local Unix socket ($XDG_RUNTIME_DIR/harness.sock) carrying two kinds of traffic multiplexed on a single connection: control (structured request/response plus a state-change subscription) and attach (opaque terminal byte streams). Remote clients never speak a separate wire protocol — the Wish/SSH session runs the TUI inside the daemon, and that TUI is a local client of this same contract (📝 ADR-0004). See the design notes for framing details and rationale.

Requirements​

Requirement: Message Framing​

Every message SHALL be framed as uint32 length (big-endian) + uint8 type + payload, with types HELLO, CONTROL_REQ, CONTROL_RESP, EVENT, ATTACH_OPEN, ATTACH_DATA, ATTACH_RESIZE, ATTACH_CLOSE, PING/PONG, and ERROR. Control payloads SHALL be JSON; attach payloads SHALL be raw bytes tagged with a session_id so multiple attach sessions can share one connection.

Scenario: Mixed traffic on one connection​

  • WHEN a client holds an attach session open and issues a control request
  • THEN both flow over the same connection without corrupting either stream

Requirement: Handshake And Versioning​

A client SHALL open with HELLO { proto_version, client_version, wants } and the daemon SHALL reply HELLO { proto_version, daemon_version, capabilities }. The same proto_version major is REQUIRED; on mismatch the daemon SHALL return a clear ERROR ("client too old/new; daemon proto vN") rather than garbling.

Scenario: Old client, upgraded daemon​

  • WHEN a client with an older proto major connects
  • THEN the daemon responds with a structured version-mismatch ERROR and closes cleanly

Requirement: Control Operations​

The control plane SHALL mirror the CLI verbs and the TUI 1:1 (📝 ADR-0002): list, describe, start, stop, restart, logs, profiles, use_profile, reload, daemon_info, the scheduled-run ops jobs, trigger and runs (SPEC-0008 REQ "Protocol Operations"), and the trigger source op triggers (SPEC-0014 REQ "Trigger Visibility"). trigger SHALL accept an optional event envelope (SPEC-0014 REQ "Manual Trigger With Event"). start SHALL accept an optional for duration carrying an after-hours lease (SPEC-0012 REQ "After-Hours Lease"). Operations SHALL be idempotent where that makes sense (double-start is a no-op). Errors SHALL come back as structured ERROR frames with a code and a human message. A response that carries raw terminal output SHALL also carry the viewport that output was drawn at, so a client replaying it through an emulator reconstructs the screen instead of reflowing it.

Scenario: Idempotent start​

  • WHEN start is issued for a harness already running
  • THEN the daemon replies success without disturbing the process

Scenario: Start with an after-hours lease​

  • WHEN start carries a for duration for a gated harness that is out of hours
  • THEN the daemon replies success and the harness's projection carries the lease end as lease_until

Scenario: A running harness reports its progress, not only its state​

  • WHEN a client requests list or describe for any harness
  • THEN its projection carries last_successful_call_at, the instant /metrics exports as harness_last_successful_call_timestamp (📝 ADR-0020), so an operator polling the socket can see an agent blocked inside one tool call instead of only state: running, and the two surfaces cannot give two answers about one harness (📝 ADR-0048)
  • AND for a running harness it also names that process's direct children with their pid, command, start time and cumulative CPU, so the hung child is visible without reaching for ps
  • AND a harness the daemon has never observed calling carries no timestamp rather than a zero an operator would read as 1970
  • AND a harness that is not running, or a daemon that cannot read the process table, carries no children at all rather than an empty list claiming none exist

Scenario: Structured failure​

  • WHEN a control request references an unknown harness
  • THEN the client receives an ERROR with a machine code and a human message the TUI/CLI can surface verbatim

Scenario: Replayable log tail​

  • WHEN a client requests logs for a harness whose PTY has an authoritative viewport (📝 ADR-0003 smallest-attached-wins)
  • THEN the reply carries that viewport (cols/rows) alongside the text, so the tail reconstructs at the geometry it was drawn at rather than the client's own

Scenario: Structured run activity​

  • WHEN a client requests logs with events set for a harness whose adapter records a native trajectory
  • THEN the reply describes one run — the latest, or the run bounded by the request's since/until — as the supervisor's lifecycle lines interleaved with the agent events SPEC-0006 REQ "Run Correlation" attributes to that run, lists the sessions correlation excluded, and works whether or not the harness is still running
  • AND when no agent activity is attributable the reply carries notices saying why, and points at --raw — it SHALL NOT carry the durable log, whose tail for a full-screen agent is a screenshot of its idle TUI
  • AND a harness whose adapter records no native trajectory answers with the durable log tail and no source, as a daemon predating the field would

Scenario: Raw log tail keeps its structure, minus credentials​

  • WHEN a client requests logs without events
  • THEN the reply is the durable log tail — the same lines, in the same order, which the TUI peek pane and harness logs --raw depend on
  • AND credential-shaped spans within those lines SHALL be masked, so --raw shows the shape of what ran without the secret inside it
  • AND the masking SHALL apply to every client of the op, --json included, rather than being a rendering choice made by one client

Scenario: Credentials are masked in what the daemon persists and serves​

  • WHEN a harnessed program prints a credential — a token-bearing remote URL, an Authorization: header, a secret-named assignment or flag
  • THEN the daemon SHALL mask it on a best-effort basis in the durable log as it is written, in the durable and run-log tails as they are served, and in the agent activity it renders
  • AND this is defence in depth, NOT a guarantee: the matcher is deliberately conservative, so a secret in an unrecognised shape passes through and env_file handling remains the actual control (📝 ADR-0008)
  • AND masking SHALL operate one log line at a time, carrying state only across the lines of a PEM private key block, so a credential split across lines by terminal or TUI wrapping is not recognised
  • AND a live attach SHALL be unaffected, since it carries the raw byte stream the terminal needs (📝 ADR-0003)

Scenario: Run-scoped logs​

  • WHEN a client requests logs with run set to a run id of a scheduled harness
  • THEN the raw reply is that run's own log, and the events reply describes exactly that run record's window
  • AND a run id the harness's history does not hold is an unknown_run ERROR

Scenario: A pinned harness carries its pin​

  • WHEN a client requests list or describe for a harness whose configuration declares a [harness.<name>.model_pin] table
  • THEN its projection carries the table as model_pin (SPEC-0020 REQ-1), an additive field a daemon without model pins omits and a client without them ignores, and the runs op's records can carry the outcomes model_mismatch and model_unattested, the skip reason model_hold, and the mismatch detail of the first mismatching call (REQ-13)
  • AND a project re-up that registers or edits a pinned harness round-trips the table unchanged and refuses a pin that would not load from a file (REQ-18)

Requirement: Event Subscription​

After a HELLO that includes wants: ["events"], the daemon SHALL push EVENT frames on state changes (harness_state_changed, harness_exited, harness_flapping, config_reloaded, profile_changed, the scheduled-run events job_run_started, job_run_finished and job_schedule_changed of SPEC-0008 REQ "Lifecycle Events", the operating-hours event harness_hours_changed of SPEC-0012 REQ "Operating Hours Visibility", the trigger-source event trigger_source_changed of SPEC-0014 REQ "Trigger Visibility", and the model-pinning surface of SPEC-0020: a job_run_finished with outcome model_mismatch or model_unattested (REQ-13), a harness_hold_changed whose hold_reasons carry model_hold (REQ-12, SPEC-0021 REQ-19), and the harness_state_changed a pinned resident harness makes to failed (REQ-10)) so the TUI re-renders reactively without polling. One-shot CLI invocations MAY skip the subscription entirely.

Scenario: Reactive dashboard​

  • WHEN a harness crashes while a subscribed TUI is on the dashboard
  • THEN the TUI receives harness_exited and harness_state_changed without issuing any request

Scenario: A gated harness crosses its window​

  • WHEN a subscribed client is connected as a gated harness goes out of hours
  • THEN it receives harness_hours_changed carrying the new in_hours and the next transition

Requirement: Attach Session​

ATTACH_OPEN { name, cols, rows, mode } SHALL allocate a session_id and the daemon SHALL then send, in order: a screen snapshot (repaint of the current x/vt screen), a bounded tail of scrollback, and the live stream as ATTACH_DATA frames. Client keystrokes flow back as ATTACH_DATA and SHALL be ignored for ro sessions (📝 ADR-0008). ATTACH_RESIZE SHALL apply the smallest-attached-client-wins policy (📝 ADR-0003). ATTACH_CLOSE from either side SHALL tear down only that session — the harness and other attached sessions are untouched.

The scrollback tail SHALL be the contents of the harness's byte-bounded ring of raw PTY output (📝 ADR-0007), whole lines from the oldest retained, and SHALL be sent as frames no larger than one of the ring's storage chunks (about 1/64 of its budget), never as one frame holding the whole ring. The daemon SHALL NOT copy the ring to send it. A replay SHALL occupy at most 128 frames: a budget larger than 128 chunks replays its newest 128 chunks' worth, starting on a line boundary. No live byte SHALL reach a session before the last tail frame.

A harness whose runs have no terminal (a structured one-shot on pipes, SPEC-0017 REQ-18) SHALL be attached without an emulator. The session SHALL receive no screen snapshot: first its recent output lines, from a ring bounded as above, then the live lines, each CRLF-terminated, in frames of at most 32 KiB. Its input SHALL be discarded, since the process's stdin is /dev/null. ATTACH_RESIZE SHALL be recorded for describe and resize nothing, and describe SHALL report no viewport for it. A session too slow to drain its queue SHALL have its backlog replaced by a one-line notice, since there is no screen to repaint. The frames and their order are otherwise as above, so a client needs no change.

Scenario: Attaching to a structured one-shot​

  • WHEN a client attaches to a claude-code prompt one-shot while it runs
  • THEN it receives the run's recent stream-json and stderr lines, then the live ones, and the daemon builds no terminal emulator for the harness

Scenario: Instant repaint on attach​

  • WHEN a client attaches to a running harness
  • THEN it receives a full screen snapshot first, so the terminal is correct before any live bytes arrive

Scenario: Attach while the harness is writing​

  • WHEN a client attaches while the harness is producing output
  • THEN everything after the snapshot is one contiguous, line-aligned run of the harness's output: the tail, then live bytes, with nothing lost, repeated or reordered where they meet

Scenario: Scrollback larger than a frame​

  • WHEN a client attaches to a harness whose ring holds more than the 16 MiB frame limit
  • THEN it receives the ring as chunk-sized frames and then live output, and the session is not torn down

Scenario: Read-only attach​

  • WHEN a session opened with mode: "ro" sends keystrokes
  • THEN the daemon discards them and the PTY never sees the input

Requirement: Backpressure Isolation​

The daemon's PTY reader MUST NOT block on any client: output always reaches the emulator's screen, the scrollback ring and the on-disk log. Each attach session SHALL have a bounded outbound queue, sized to hold a snapshot and a full scrollback replay; when a slow client can't drain it, the daemon SHALL coalesce by dropping that session's queued incremental frames, replayed scrollback included, and sending a fresh snapshot instead. PING/PONG heartbeats SHALL detect dead clients so their sessions get reaped.

Scenario: Slow SSH client​

  • WHEN a remote client stalls mid-stream
  • THEN the harness and all other clients continue at full speed, and the slow client eventually receives a snapshot repaint instead of the backlog

Scenario: Slow client during replay​

  • WHEN a client stops reading while its scrollback tail is still queued and the harness keeps writing
  • THEN the PTY reader is not blocked, and once the client drains it receives a fresh snapshot rather than the whole tail and backlog

Scenario: Wedged client pinning the viewport​

  • WHEN an attached client stays connected but stops reading its socket, so the daemon's writes keep succeeding into its receive buffer and its PONGs stop
  • THEN the daemon reaps that connection once its silence passes the liveness timeout, tears down its attach sessions, and recomputes smallest-attached-wins over the survivors so the guest PTY is no longer clamped by it — a client that has never answered a PING is left alone

Requirement: Emulator Memory​

Every x/vt emulator the daemon creates — the attach mux's per harness and the log sanitizer's per spawn — MUST keep no scrollback the daemon does not read, and MUST have its reply pump released when its owner is done with it: the sanitizer's when that spawn's output stream ends, the mux's when its harness is removed. Release SHALL close the emulator's input pipe rather than call Emulator.Close, whose unsynchronised flag races the pump's parked Read (📝 ADR-0003). After release a query reply SHALL fail rather than block the PTY reader, and a session still attached to a removed mux SHALL keep working: snapshots still render, and any output still written reaches it. The scrollback an attach replays SHALL come from the per-harness ring (📝 ADR-0007), never from emulator scrollback.

Scenario: A restarting harness does not accumulate memory​

  • WHEN a resident harness prints a long stream, exits and is respawned repeatedly
  • THEN once each spawn's output has ended, no reply pump from it remains and none of its emulator stays reachable; after the harness shuts down the daemon is back to its baseline goroutines and heap

Scenario: A removed harness releases its mux​

  • WHEN a project harness is deregistered while a client is still attached
  • THEN its mux's reply pump exits, the attached client keeps its session (output still written reaches it, a snapshot still renders) until it detaches, and a query in that output does not block the PTY reader

Requirement: Transport Bindings​

Locally, the daemon SHALL serve this protocol on a Unix socket with 0600 permissions (📝 ADR-0008). Remotely, the daemon MAY run a Wish SSH server whose sessions host the TUI in-process as a local client of the same protocol — no separate remote protocol exists (📝 ADR-0004). SSH provides auth, encryption, and host-key verification.

Scenario: Remote parity​

  • WHEN a user attaches over SSH
  • THEN snapshot-on-attach, backpressure, and resize policy behave identically to a local attach

Requirement: Socket Lifecycle​

The socket file SHALL keep naming the listener of the daemon that bound it for as long as that daemon serves. A daemon SHALL NOT unlink a socket that another process answers on — at startup or at shutdown — and SHALL refuse to start when one does, naming the holder. A serving daemon SHALL detect that its socket path no longer resolves to its own listener and re-bind it, except when another live listener has taken the path, which it SHALL report and leave alone.

A client SHALL distinguish a missing socket file from a socket nobody is listening on and from a daemon that accepted the connection without answering; it SHALL NOT report a daemon as not running on evidence that only shows the socket is unreachable.

Scenario: Second daemon on a live socket​

  • WHEN a second harness daemon starts against a socket a live daemon is serving
  • THEN it refuses to start, leaves the socket file untouched, and the first daemon stays reachable

Scenario: Socket removed under a live daemon​

  • WHEN the socket file is deleted while the daemon is serving
  • THEN the daemon re-binds it (mode 0600) within a tick, logs that it did, and clients can reach it again without a restart

Non-goals (v1)​

  • A stable public API for third-party clients (the CLI is the supported programmatic surface).
  • Session recording/replay as first-class protocol messages (📝 ADR-0007 defers the full event store).
  • Multi-daemon federation (one daemon per host).

Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain SPEC-0002 for the transitive view.