sloop-daemon 0.3.0

Agentic coding scheduler — a daemon that runs background coding agents autonomously in isolated git worktrees
Documentation
# Protocol

The CLI is a thin client. The real public API is a versioned JSON envelope
spoken over a Unix domain socket, one JSON object per line (NDJSON). A web
UI, a CI integration, or a higher-level orchestrator built on Sloop is just
another client of the same socket — it gets exactly what the CLI gets.

The quickest way to see the protocol in action is `--json` on any command:
the CLI prints the daemon's response envelope verbatim.

## Envelope

Request:

```json
{"v": 1, "id": "req-1", "verb": "show", "args": {}, "token": null}
```

- `v` — protocol version; currently `1`. A request with any other version
  is rejected with `unsupported_version` and the versions the daemon
  supports.
- `id` — caller-chosen request ID, echoed back so responses can be matched
  to requests.
- `verb` — the command name, the same set the CLI exposes.
- `args` — verb-specific arguments.
- `token` — the per-run worker token, or null on the operator socket.

Response:

```json
{"id": "req-1", "ok": true, "data": { ... }}
{"id": "req-1", "ok": false, "error": {"code": "not_found", "message": "...", "details": {}}}
```

Exactly one of `data` or `error` is present, matching `ok`. Error codes:
`invalid_arguments`, `invalid_request`, `unsupported_version`,
`unknown_verb`, `daemon_unavailable`, `unauthorized`, `not_found`,
`conflict`, `cooldown_active`, `internal`.

Malformed JSON, an unknown verb, or a wrong version get a structured error
back; none of them take the daemon down.

## Two sockets

The operator/worker split is enforced at the transport layer:

- **Operator socket** — a fixed per-repository path with mode `0600`,
  printed by `sloop daemon`. Connections on it may use every operator
  verb, plus the read-only `show`, which resolves any ticket, run, or
  project; worker tokens are rejected here.
- **Worker socket** — created fresh for each run and torn down with it,
  also mode `0600`. Its path and token reach the agent as the
  `SLOOP_SOCKET` and `SLOOP_TOKEN` environment variables. Only `brief`,
  `show`, `note`, and `verdict` are accepted, the token must match the run,
  and the token stops working when the run settles. `verdict` is accepted
  only for the currently executing stage when its snapshotted policy is
  `reported`; the first report for that stage wins.

Both rejections use the same `unauthorized` error, so a probing worker
learns nothing about what exists outside its scope.

## Writing an operator client

Connect to the operator socket, write one request envelope per line, read
one response envelope per line. There is no session state; each request
stands alone. A useful client can be a few lines of shell:

```sh
printf '%s\n' '{"v":1,"id":"1","verb":"show","args":{}}' \
  | nc -U "$OPERATOR_SOCKET" | head -1
```

Patterns that fall out of the verbs:

- **Gate CI on a run**`run` a ticket, then use
  `sloop show <run> --follow --quiet`; it exits `0` only for `merged`. Socket
  clients poll `show` and the scoped `events` feed themselves.
- **Drive a queue from your own tool**`post` tickets, `hold`/`ready` to
  sequence them, then `show` a ticket pattern to observe why work is not
  running.
- **Build a dashboard or search tickets**`show` takes optional `ref` and
  `limit` arguments. With neither, it returns the dashboard. The dashboard
  preserves the status fields (`daemon`, `gate`, `runs`,
  `queued_activations`, `tickets`, and optional `next_wake`) and adds
  `kind: "dashboard"`, `recent`, `recent_total`, and `recent_limit`.
  `recent` is newest first; `recent_total` is the untruncated count and
  `recent_limit` is the requested limit or the default `10`.

  With `ref`, the daemon first resolves exact ticket, run, ticket-name, and
  project reference forms. Exact references retain their existing detail
  response. If none matches, the final rung treats `ref` as a
  case-insensitive ticket-ID/name pattern: plain text is a substring, while
  text containing regex metacharacters is an unanchored regular expression.
  An invalid expression is `invalid_arguments`. Pattern responses retain the
  top-level `tickets` rows and add `kind: "matches"` and the original `ref`.
  Rows are newest first; `limit`, when present, must be at least `1`.
- **Read a ticket's run history**`show` on a ticket returns `value.runs`,
  every run of the ticket newest attempt first. Each entry carries `id`,
  `alias`, `attempt`, `state`, `terminal`, `started_at_ms`,
  `finished_at_ms` (null while the run is in flight), the derived `reason`,
  and `stages`: one `{stage, state}` pair per flow stage, where `state` is
  `passed`, `failed`, `running`, or `pending`.
- **Explain one run**`show` on a run adds `attempt`, the timeline
  (`claimed_at_ms`, `started_at_ms`, `finished_at_ms`), `agent_exit_code`,
  and `stages`: per stage, its `state`, `attempts` (including `on_fail`
  repair retries), `started_at_ms`, `finished_at_ms`, `duration_ms`,
  `exit_code`, `verdict_source`, and `reason`. Stage names come from the
  run's admitted flow snapshot, so a run reports the stages it actually had
  even after the flow file changes.

  `value.reason` on a run is now populated for every non-merged terminal
  run. It is a classified vendor diagnostic where one exists, and otherwise
  a sentence derived from the stored stage and evidence rows — never from
  anything an agent reported about its own work. `exit_code` is unchanged
  and has always been the *agent stage's* exit; `agent_exit_code` is the
  same value under a name that cannot be misread as the run's outcome.
- **Read or stream one run's output**`logs` takes `{"run": <ref>}` plus
  optional `stage` (one flow stage name; an undefined one is
  `invalid_arguments`), `tail` (keep the last N matching entries), and
  `after` (a cursor). It returns the page along with `next_cursor`,
  `complete`, and `terminal`. Poll with `{"after": <cursor>}` to follow a
  live run and stop once a response is both `complete` and `terminal`;
  `sloop logs --follow` is exactly this loop. Filtering is server-side, so a
  dashboard tailing one stage of a large log transfers only that stage. Use
  `show` for the run's derived outcome and stage summary before reading its
  logs.
- **Stream activity**`events` returns one page of the append-only
  activity feed (`run_claimed`, `run_started`, `run_finished`,
  `run_aborted`) plus a `next_cursor`. Poll with `{"after": <cursor>}` to
  follow live, or `{"tail": N}` to start near the newest event; the daemon
  keeps no per-client state, so a websocket bridge or UI can fan the same
  feed out however it likes. `sloop show --follow` is this loop.

  Add `{"scope": "<ref-or-pattern>"}` to narrow the page. Resolution uses
  the same ladder as `show`, including exact-reference precedence and the
  final case-insensitive pattern rung. A ticket covers it and every run of it,
  a project or pattern covers its matching tickets and runs, and a run covers
  itself. A valid pattern may match nothing; an invalid regex is
  `invalid_arguments`. Events belonging to no ticket or run are
  repository-wide and appear only in an unscoped request. `next_cursor` still
  advances across rows the scope filtered out, so a scoped poller never
  rescans the feed.

A worker token can read its run-scoped brief and ticket, leave advisory notes,
and report the verdict of a stage explicitly configured to accept one. It
cannot claim, schedule, merge, or otherwise move operator-owned state. If your
integration needs those capabilities, it belongs on the operator socket.

## Stability

The envelope is versioned so clients can fail fast rather than misparse.
Within version `1`, verbs and their response fields may gain data but are
not repurposed. The daemon replies `unsupported_version` (listing what it
supports) rather than guessing at unknown versions.

That rule applies to the optional `show.ref` and `show.limit` arguments and to
the dashboard and match fields above: they are additive protocol-v1 changes.
The existing status fields and top-level ticket rows keep their meanings.
Existing v1 `status`, `list`, and `wait` requests remain accepted, although
the CLI names are hidden deprecated aliases for the `show` surface.

The same rule applies to `show`'s run and stage history: `value.runs`,
`value.stages`, `value.attempt`, `value.agent_exit_code`, and the timeline
fields are additive. `value.reason` on a run is the closest call — it was
previously null unless a vendor error was classified, and is now also
populated by a derived explanation — but it keeps its meaning ("why this run
ended where it did"). A client that tested it for null as a proxy for "no
vendor error" should read `value.classification` instead, which is unchanged.