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
startis issued for a harness alreadyrunning - THEN the daemon replies success without disturbing the process
Scenario: Start with an after-hours lease
- WHEN
startcarries aforduration 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
listordescribefor any harness - THEN its projection carries
last_successful_call_at, the instant/metricsexports asharness_last_successful_call_timestamp(📝 ADR-0020), so an operator polling the socket can see an agent blocked inside one tool call instead of onlystate: 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 forps - 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
childrenat 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
ERRORwith a machine code and a human message the TUI/CLI can surface verbatim
Scenario: Replayable log tail
- WHEN a client requests
logsfor 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
logswitheventsset 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
logswithoutevents - THEN the reply is the durable log tail — the same lines, in the same
order, which the TUI peek pane and
harness logs --rawdepend on - AND credential-shaped spans within those lines SHALL be masked, so
--rawshows the shape of what ran without the secret inside it - AND the masking SHALL apply to every client of the op,
--jsonincluded, 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_filehandling 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
logswithrunset to a run id of a scheduled harness - THEN the raw reply is that run's own log, and the
eventsreply describes exactly that run record's window - AND a run id the harness's history does not hold is an
unknown_runERROR
Scenario: A pinned harness carries its pin
- WHEN a client requests
listordescribefor 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 therunsop's records can carry the outcomesmodel_mismatchandmodel_unattested, the skip reasonmodel_hold, and themismatchdetail 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_exitedandharness_state_changedwithout 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_changedcarrying the newin_hoursand 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-codeprompt 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
PINGis 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 daemonstarts 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).
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain SPEC-0002 for the transitive view.