harnessd
The daemon. One harnessd = one user. Everything stateful lives in
harness-core; this crate is a thin axum
transport shell (WebSocket + REST) plus the operator CLI. It binds loopback/Tailscale only,
never a public interface.
Where it sits
TUI / iOS ──WS + REST over Tailscale──► harnessd ──► harness-core
(this crate) runtime · store · providers
HTTP surface (server.rs)
All routes are versioned under /v1 so clients and daemon can evolve independently.
| Method | Route | Purpose |
|---|---|---|
GET |
/v1/health |
Per-subsystem status (providers build-checked; live probe is M2). |
GET |
/v1/version |
Daemon + protocol version. |
GET |
/v1/sessions |
List sessions, most recent first. |
POST |
/v1/sessions |
Create a session (provider optional; defaults to the main role). |
GET |
/v1/sessions/:id/events?from_seq=N |
Backfill the log without a WebSocket. |
GET |
/v1/providers |
Configured providers and their enabled state. |
POST |
/v1/pair/start, /v1/pair/complete |
Pairing — scaffolded, returns 501 until M4 (§8). |
GET |
/v1/ws |
The live event stream + client command channel. |
REST handlers live in rest.rs; CoreError is mapped onto stable HTTP status + {code, message} bodies there.
The WebSocket flow (ws.rs)
One task per connection, multiplexing inbound client frames and the runtime's broadcast bus
with tokio::select!:
- Handshake — the first frame must be
hello. The daemon negotiates the protocol version (accepts current and current − 1), checks auth, and replies withwelcome. - Subscribe → replay → live tail — on
Subscribe { session, from_seq }the daemon replays the durable log fromfrom_seq(viastore.events_since), then marks the session live so the select loop tails new broadcast events for it. Replay and live delivery use the identicalEventEnvelope, so reconnect-and-resume is seamless. - Commands —
SendMessagespawns the turn in the background (so the socket stays responsive for approvals/cancel while the model streams) and acks.Approve/Deny/CancelTurnack today and wire into the runtime's tool gates in M2.
A client that falls behind the broadcast buffer gets a Lagged warning; it recovers by
re-subscribing with a from_seq to catch up from the durable log.
Auth (server.rs::Auth)
M1 is permissive — it trusts the loopback/Tailscale boundary and accepts any client.
The handshake already threads an optional device token, so pairing (M4, design doc §8) can
tighten Auth::accepts to check a hashed token set without any protocol churn.
CLI (cli.rs)
Operability is a set of commands, not a README:
serve— run the daemon (the default if no subcommand is given).init— write a documented starter config (mock provider enabled, anthropic present but disabled). M4 turns this into an interactive wizard.doctor— a checklist as a command: config loads, each enabled provider builds, bind address. TCC/permissions and Tailscale checks arrive in M4.pair— scaffolded; prints that device tokens + QR land in M4.version— daemon semver + protocol version.
Config file
Loaded once at startup from --config <path>, else the platform config dir
(paths::default_config_path). A missing file yields the mock-only demo_default() so
the daemon boots with zero setup. Secrets are read from the env var named by
api_key_env and never written to the config or the event log.
Quick start (zero credentials)
# then drive it with the TUI, or headlessly:
Build & test