harnessd 0.1.0

The harness daemon: API server (axum WS + REST), agent runtime host, and CLI (init/pair/doctor).
harnessd-0.1.0 is not a library.

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!:

  1. Handshake — the first frame must be hello. The daemon negotiates the protocol version (accepts current and current − 1), checks auth, and replies with welcome.
  2. Subscribe → replay → live tail — on Subscribe { session, from_seq } the daemon replays the durable log from from_seq (via store.events_since), then marks the session live so the select loop tails new broadcast events for it. Replay and live delivery use the identical EventEnvelope, so reconnect-and-resume is seamless.
  3. CommandsSendMessage spawns the turn in the background (so the socket stays responsive for approvals/cancel while the model streams) and acks. Approve / Deny / CancelTurn ack 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.
  • pairscaffolded; 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)

cargo run --bin harnessd -- init      # writes a starter config (mock provider)
cargo run --bin harnessd -- serve     # listens on 127.0.0.1:8787
# then drive it with the TUI, or headlessly:
cargo run --bin harness-tui -- --script "hello there"

Build & test

cargo build -p harnessd
cargo test  -p harnessd
bash scripts/e2e-smoke.sh             # boots the daemon + drives the TUI end-to-end