# harness-control-api — Design

audience: AI coding agents first. Contract-level: seams + decisions, NOT code bodies.
slug: `harness-control-api` · date: 2026-06-30

## Purpose

Give the mega-plan-harness a **control + observability surface**: watch any agent live, steer a running
agent (queue a next-turn message), kill it, and pause/resume a run — over a local API the eventual Web UI
(separate sub-project) consumes. This is sub-project **1 of 3** decomposed from the Mega Plan Harness brief
(2 = OKF 2nd-brain KB, 3 = Web UI). It layers on the harness core (Plan A): the `runstate/v1` journal, the
wrapper contract, and the standalone runner.

**Build is GATED: starts only after Plan A's wrappers land.** All wrapper edits here target the
**repo-vendored canonical copies `wrappers/na.sh`, `wrappers/ca.sh`, `wrappers/codex.sh`** — NOT the global
`~/.claude/skills/...` files. Plan A *creates* those canonical copies (vendors them from the global skills +
converges `~/.claude` to them in its final wave; design lines 63–69, 202–203); this sub-project then layers
the additive session/observability extension on top. Gating avoids two concurrent editors of the same
soon-to-be-canonical files (Plan A's own self-edit hazard). This spec is authored now; the plan + execution
fire once Plan A is committed.

Read first, do not duplicate: `spec/WRAPPER-CONTRACT.md`, `spec/FORK.md`, the Plan A design
(`docs/specs/2026-06-27-mega-plan-harness-design.md`).

## Ground truth (probed from installed CLIs, 2026-06-30 — do not re-derive from memory)

| adapter | wrapper | session id | live token stream today | kill |
|---|---|---|---|---|
| claude (north engine via CCR proxy) | `wrappers/na.sh` | **assignable** `--session-id <uuid>` | needs `--output-format json`→`stream-json` flip | SIGTERM pgid |
| cursor | `wrappers/ca.sh` | **assignable** via `cursor-agent create-chat` (returns id) | **already** `--output-format stream-json --stream-partial-output` | SIGTERM pgid |
| codex | `wrappers/codex.sh` | **stream-captured** — server-assigned UUID rides the `--json` stream as the FIRST event `{"type":"thread.started","thread_id":"<uuid>"}` (probed 2026-06-30); NO `--last`, NO filesystem race | `--json` emits JSONL events (probed); codex runs as a verdict/review seat, not a coding turn | SIGTERM pgid |

**Hard physical limit:** mid-turn stdin injection exists ONLY on claude (`--input-format stream-json`). The
other three cannot receive input while a turn runs. AND the batch wrapper contract mandates `stdin
</dev/null` — so during a plan run **every** agent (claude included) has stdin closed. Therefore:
- **"write to a running agent" for plan-dispatched agents = queue a next-turn message** (resume-steer) OR
  kill-then-resume. NEVER live mid-turn.
- **live mid-turn (claude `--input-format stream-json`) ships as a capability STUB** here — it applies only
  to future standalone interactive sessions, not plan dispatch. Pinned now, built later.

## Non-goals (YAGNI)

- **No Web UI** — sub-project 3. This ships the API the UI binds to, nothing rendered.
- **No OKF KB** — sub-project 2.
- **No remote / multi-user auth.** API binds `127.0.0.1` only. Remote exposure later = front it with
  `@platform-modules/auth`; out of scope.
- **No WebSocket.** Input is POST-queued steer; output is a one-way event stream → SSE suffices. WS is
  unjustified surface.
- **No live mid-turn input for batch adapters** — physical limitation, not a backlog item.
- **No new engines, no plan-grammar change.**

## Platform-modules (@platform-modules/* = `~/Projects/platform`)

**This sub-project imports NONE.** The API daemon inherits the standalone runner's invariant — "runs
ANYWHERE, anthropic-less, headless/cron" (FORK.md) — so it stays **zero-dep Node stdlib** (`http`, `fs`,
`child_process`), matching the harness's hand-rolled-validator / zero-per-repo-dep policy. The heavy
platform packages (`realtime-react`, `auth-react`, `ui-primitives`, `ui-tokens`, `ui-editor`) belong to the
**Web UI** (sub-project 3), which is a proper app with a build toolchain. `@platform-modules/auth` becomes
relevant ONLY if/when the API is exposed beyond localhost (a later, separate decision). Stated so the UI
sub-project knows what it owns.

## Architecture

```
 DURABLE TIER (no daemon — reads artifacts both planes already write)
   runstate/v1 journal (git+JSONL)  +  per-run wrapper logfiles
                       │  read-only
            control-api  GET /runs, GET /runs/:id, GET .../tasks/:tid     → Overview & Job & Agent metadata

 LIVE TIER (requires the owning run's supervisor alive — needs the live PID)
   runner (supervised mode) ──spawns──> wrapper ──> engine
        │ registers {pid,pgid,session_id,logfile,state} → liveness/v1 (ephemeral, fast-path ONLY)
        │ control verbs: kill | steer | pause/resume
   control-api  GET .../stream (SSE)         ← tail logfile → normalize → events/v1
                POST .../steer | .../kill | /pause | /resume  → supervisor acts + journals
```

**Two tiers, stated honestly (deletion test passes for both):**
- **Durable observability** = pure function of artifacts on disk → works for ANY run (live or finished,
  either plane) with NO daemon. The Overview "all jobs" list is this tier.
- **Live control** = needs a handle to a live process → exists only for the lifetime of the owning run,
  and only the **standalone runner** can host the control socket (it owns the PIDs). The **DynWF plane**
  cannot host a socket (Workflow script body has no network/FS server) → DynWF runs are **observe +
  queue-steer-via-inbox-file**, NOT live-kill. This asymmetry is an allowed FORK drift (control mechanism),
  not leaked work — both planes still journal identically.

**FORK compliance:** the control-api is **NOT a third control plane** and is **NOT source of truth**. It
observes git+JSONL + logs (the durable record) and holds only ephemeral liveness — exactly the status the
Workflow `runId` cache holds ("fast path ONLY, never source of truth", FORK.md). Every control action
produces a journal record, so state stays reconcilable and resume-correct.

## Components & seams

### 1. Session-handle capture — wrapper extension (additive; built AFTER Plan A lands)

Each wrapper gains ONE additive responsibility: establish a stable session id and emit it. Rides the
status-line JSON, which `WRAPPER-CONTRACT.md` already declares "optional, additive". The fixed flag set and
exit-code map are UNCHANGED.

- **Status-line addition (all wrappers):** `{"ok":true,"session_id":"<id>","detail":...,"files":[...]}`.
  `session_id` MUST be present on a completed coding dispatch (absent ⇒ steer/resume unavailable for that
  task — fail-closed, never fabricated).
- **`na.sh` (claude/north):** caller passes/assigns `--session-id <uuid>` (harness generates the uuid at
  dispatch, so it is known BEFORE the turn → kill-then-resume is possible). Flip `--output-format json` →
  `--output-format stream-json` so the logfile carries a live event stream. Behavior delta only;
  foreground/timeout/stdin-closed/env-scoping unchanged.
  - **VERIFY-AT-BUILD (not yet probed):** north routes claude through the CCR proxy; the `json→stream-json`
    flip assumes the proxy **relays the stream-json frames unbuffered**. Confirm against `ccr-up.sh north`
    before shipping the `na.sh` flip — if the proxy buffers/normalizes to a single JSON blob, live `/stream`
    for the north engine degrades to turn-end-only (kill/steer/durable views unaffected). Probe, don't assume.
- **`ca.sh` (cursor):** add a `create-chat` pre-step → capture the returned chatId → dispatch bound to it
  (`--resume <chatId>` on a fresh chat, or pass at start) → emit it as `session_id`. Stream format already
  conformant.
- **`wrappers/codex.sh` (codex):** server-assigned, so NOT known pre-turn — but it rides the stream. Flip `exec` to
  `--json` and capture `thread_id` from the FIRST emitted event `{"type":"thread.started","thread_id":"<uuid>"}`
  (probed 2026-06-30), emit as `session_id`. **No `~/.codex/sessions/` filesystem snapshot, no `--last`, no
  start-time/cwd matching** — the id is unambiguous in this dispatch's own stream, eliminating the only race
  in the design. Same source the normalizer (component 2) already reads → one mechanism.
  - **stdout-contract delta (pin — mirrors the na.sh flip):** `--json` turns cdx's stdout from verdict-text
    into JSONL, so the verdict the seat returns must now be **reparsed from the final `item.completed`
    `agent_message` text** (probed shape above), not read raw. The wrapper's existing exit-code contract is
    unchanged; only stdout parsing moves.
  - **Scope (YAGNI):** codex is a verdict seat, NOT a coding turn. It is **observable** (normalized into the
    Agent-page event stream — component 2 already has a codex fixture, an explicit commitment) and resumable
    for audit. **`steer` is forbidden** on a verdict seat (steering a review verdict is meaningless → `409`),
    but **`kill` IS allowed** — kill is the universal SIGTERM-pgid escape hatch for a hung seat and must not be
    removed. Capturing `session_id` is the incidental free output of the flip the normalizer needs anyway.
- **One capture rule, two timings:** claude + cursor session ids are **assignable pre-turn** (uuid / `create-chat`)
  so kill-then-resume is possible before the first token; codex's is **stream-captured** from its first event
  (resume-able once that event is seen). All three read the id from the same json/stream-json output the
  normalizer consumes — no out-of-band discovery on any adapter.
- Document each addition in `spec/WRAPPER-CONTRACT.md` under a new "Optional: session continuity" section
  (additive; exit-code + flag contract untouched). Extend `wrappers/_contract-probe.sh` to assert the
  `session_id` field is emitted when the engine completes.

### 2. Event normalization — `spec/events.schema.json` + `lib/normalize-events`

The deepest seam: callers can't tell which engine produced an event. One schema, three input dialects
(claude stream-json, cursor stream-json, codex json-events) → `events/v1`.

`events/v1` record (pin verbatim — this is the contract the SSE channel and the UI bind to):
```json
{"v":"events/v1","run":"<slug>","task":"<id>","session":"<session_id>","seq":12,"ts":"<iso8601>","type":"<T>","data":{}}
```
`type` ∈ `turn_start | token_delta | tool_call | tool_result | turn_end | error | exit`. `data` shape
per type pinned in the schema file (e.g. `token_delta.text`, `tool_call.{name,args}`, `exit.rc`).

**`seq` MUST be the 1-based ordinal of the raw line that produced the event in the source logfile — a
deterministic function of position, NOT a runtime variable the normalizer increments.** (Logfiles are
append-only by construction — wrappers only append — so a line's ordinal never changes; that is the
foundation the resume contract rests on.)
Rationale (load-bearing): `/stream` is stateless re-tail (logfile → normalize → SSE), so a reconnect spawns a
FRESH normalizer process. A runtime counter would restart at 0 and make `Last-Event-ID` resume return wrong /
duplicate events. Deriving `seq` from line position makes it stable across reconnects and across processes:
the same raw line always yields the same `seq`. One source line that fans out to multiple events uses
`<line>.<subindex>` (still total-ordered, still deterministic). Lines the normalizer skips still consume an
ordinal (never renumber), so `seq` is gap-tolerant but never reassigned.

`lib/normalize-events` seam:
```
normalize-events <adapter> < raw-stream-chunk   → events/v1 JSON lines on stdout
  unknown/garbled line → emit one {type:"error",data:{raw,reason}} event, NEVER drop silently, NEVER crash
```
Pure, streaming, fully stateless per line — `seq` comes from line position, not retained state. A per-adapter parse table maps raw event
kinds → `events/v1.type`; an unmapped kind degrades to an `error` event (fail-visible). Single source for
both the SSE path and any future batch replay.

### 3. Liveness registry — `liveness/v1` (ephemeral, fast-path ONLY)

A sidecar file per run, `runstate/<slug>.live.json` (NOT the journal — the journal is append-only truth;
this is mutable ephemeral state). Maps `task → {pid, pgid, session_id, logfile, state, started_ts}` and
carries a **run-level `sock`** field (the supervisor's control-socket path — see component 4) so the API can
locate the supervisor for live verbs.

```
liveness.set <slug> <task> <json>     # supervisor writes on spawn / state change
liveness.get <slug> [<task>]          # api reads for live tier
liveness.reap <slug>                  # drop dead pids (kill -0 check); a vanished pid ⇒ consult journal
```
NEVER source of truth: on any disagreement with git+JSONL, the journal wins (`journal.sh reconcile`).
Deleting this file mid-run loses only liveness (recoverable by re-deriving running pids), never run state.

### 4. Supervisor — `src/supervisor.js` (extends the standalone runner; control flow only)

Activated by `harness run … --supervised` (default off → today's batch behavior is unchanged). When on, the
runner ALSO: registers each spawned wrapper into `liveness/v1` (pid/pgid via `spawn` detached pgroup),
opens its control socket, and honors control verbs at the SAFE points below.

**Control-socket discovery (pin — the seam the API connects through).** The supervisor binds a Unix domain
socket at the **derived path `$HARNESS_HOME/sock/<runId>.sock`** (default `~/.harness/sock/`) and writes that
path into the liveness record's `sock` field. The control-api resolves a run's supervisor by that derived
path (the liveness `sock` field is the authoritative copy; the path is also re-derivable from `runId`
alone). If the socket is absent or `connect` fails → the run is not live → `409` (fail-closed). **DynWF plane
has no socket** — its target is the `runstate/<slug>.steer.jsonl` inbox reached via the pointer's
`runstateDir`; the API appends there instead of connecting (observe + queue-steer only). No judgment, no
model — pure control flow (FORK iron rule).

Control verbs (each ⇒ a journal record so it survives resume):
- **kill `<task>`** → `kill -TERM -<pgid>` (process group), `-KILL` after grace. Journals the task at its
  wrapper rc (124-class non-completion). The wave loop treats it as a halted task (existing fail-closed path).
- **steer `<task>` `<msg>`** → enqueue `msg`. Applied at the **task turn boundary**: re-dispatch the task
  via the adapter's session-resume (`claude --resume <id>` / `codex exec resume <id>` / `cursor --resume
  <chatId>`) with `msg` appended to the prompt. If the operator sets `interrupt:true`, kill the current
  turn first, then resume-with-msg. NEVER mid-turn for batch adapters.
  - **Journal state-machine contract (pin — names verified against `runstate/v1`, do not guess):** the
    journal states are `leased → implemented → gated → [reviewed] → committed` (`spec/FORK.md`,
    `lib/journal.sh` `valid_states`; `reviewed` is conditional on the binding requiring review). Steer is
    allowed ONLY on a task in a NON-terminal state (`leased | implemented | gated | reviewed`). A steered
    re-dispatch transitions the task **back to `leased`** (re-entering the normal lease→implement→gate→[review]
    path), so the appended turn's output **re-runs gate0/risk and review like any dispatch** — steer never
    bypasses gates. A steer targeting a **`committed`** (terminal) task is **rejected `409 {detail:"task
    already committed"}`** — reopening a closed task by piling commits onto it is out of scope (re-open = a
    new plan/task, not a steer). The steer enqueue itself journals; the resulting re-dispatch journals its
    own lease, so resume reconstructs the full history.
- **pause `<run>` / resume `<run>`** → set/clear a run-level hold flag the wave scheduler checks before
  dispatching the NEXT task/turn (cannot freeze an in-flight LLM call — documented limit). Journaled.
- **DynWF plane** has no socket: the same verbs are expressed as appends to a `runstate/<slug>.steer.jsonl`
  inbox that the DynWF dispatch agent reads at each task boundary (observe + queue-steer only; no live kill).

### 5. Control API server — `src/control-api.js` (Node stdlib `http`, binds 127.0.0.1)

Stateless over artifacts for the durable tier; proxies to the supervisor socket for the live tier.

Routes are keyed by **`runId`** (globally unique — see registry), NOT bare `slug` (which collides across
repos). `slug` stays the human-facing label in the response body.
```
GET  /runs                                    → [{runId,slug,repoRoot,project,repo,branch,worktree,status,wave,counts,base_branch}]  (enumerate the GLOBAL run registry — see below)
GET  /runs/:runId                             → job: meta + plan DAG (waves/tasks) + per-task last state
GET  /runs/:runId/tasks/:tid                  → agent: binding, session_id, pid/runtime/idle (liveness), log path
GET  /runs/:runId/tasks/:tid/stream           → SSE: events/v1 (tail logfile → normalize-events → text/event-stream)
POST /runs/:runId/tasks/:tid/steer  {msg,interrupt?}  → supervisor.steer | DynWF inbox append
POST /runs/:runId/tasks/:tid/kill                     → supervisor.kill
POST /runs/:runId/pause | /runs/:runId/resume         → supervisor.pause/resume
```
- **Global run registry (pin — `GET /runs` is cross-project, per the brief's Overview tiles).** A single
  localhost daemon cannot find runs by scanning one repo's `runstate/`; the Overview lists jobs across
  projects/repos/worktrees. So each run, at start, registers a small pointer record in a **machine-global
  index** under `~/.harness/runs/`. **The slug is NOT globally unique** — it is topic-derived per-project, so
  two repos (or two worktrees) can both run `api-refactor`; keying by bare slug would silently overwrite one
  job out of the Overview (exactly the silent-wrong to avoid). **`runId = <sha256(repoRoot + "\0" + worktree)[:12]>--<slug>`**
  is the globally-unique key — used as the pointer filename `~/.harness/runs/<runId>.json` AND as the API
  path key. Pointer record = `{runId, slug, repoRoot, project, repo, branch, worktree, runstateDir, created}`.
  `GET /runs` enumerates the dir (cheap pointer reads), then per pointer reads that run's per-repo
  journal/liveness for live status — runstate itself stays per-repo (in `<repoRoot>/runstate/`, next to git);
  only the **discovery index** is global. Registry-writing is a one-line additive step the runner/driver does
  at run start (both planes); a pointer whose `runstateDir` is gone is reaped on read (fail-soft, logged).
  Path override: `$HARNESS_HOME` (default `~/.harness`).
- Durable GETs need NO supervisor (read journals + plan + logs). Overview/job/agent metadata always works,
  live or finished, either plane.
- Live `/stream` + all POSTs require the owning run's supervisor reachable; if absent → `409 {ok:false,
  detail:"run not supervised / not live"}` (fail-closed, never a silent no-op).
- SSE supports `Last-Event-ID` → resume by re-tailing the logfile and replaying events whose position-derived
  `seq` is greater than the client's last id (deterministic, so the cut is exact across reconnects). Backpressure: bounded
  ring buffer per stream; on overflow emit one `error` event noting dropped range (never silently lose).
- Auth: none (localhost). A single `?token=` shared-secret hook is stubbed for the eventual non-local case.

### 6. live-claude capability stub

A declared, non-functional seam: `na.sh` may later accept `--interactive` to keep stdin open and bind
`--input-format stream-json` for true mid-turn injection. NOT wired to plan dispatch (stdin-closed contract).
Pinned so sub-project 3 / a later interactive-session feature has the seam, not so it runs now.

## Data flow

run executes (either plane) → wrappers emit `session_id` + stream-json logs + journal records → **durable
tier** reads those artifacts for Overview/Job/Agent views with no daemon → for a LIVE run, the supervisor
registers pids and serves `/stream` (tail→normalize→SSE) and control POSTs (steer/kill/pause→act→journal).

## Error handling

Fail-closed everywhere (repo invariant). Missing `session_id` ⇒ steer/resume unavailable for that task (no
fabrication). Supervisor unreachable ⇒ `409`, never a silent success. Normalizer garbled input ⇒ visible
`error` event, never a dropped/crashed stream. Liveness file disagreeing with journal ⇒ journal wins. Kill
of an already-dead task ⇒ idempotent no-op + current journal state returned.

## Testing strategy

- `lib/normalize-events`: fixture test per adapter — a captured raw stream (claude/cursor/codex) →
  asserts the exact `events/v1` sequence + position-derived `seq`; **re-running the same fixture yields
  identical `seq` values** (determinism guard for reconnect resume); a garbled line → asserts one `error`
  event, no crash. (Deepest seam → heaviest tests.)
- `liveness`: set/get/reap both branches; dead-pid reap consults journal.
- Supervisor integration: fixture plan + stub wrapper (echoes a stream + a `session_id`) → assert
  kill→journaled-halt, steer→resume-with-appended-prompt, pause→next-task-held. Stub exit 3 → fail-closed.
- API: durable GETs over a fixture run dir (no daemon) → correct Overview/Job/Agent JSON; `/stream` SSE
  smoke (events arrive, `Last-Event-ID` resumes at seq); POST to an unsupervised run → `409`.
- `wrappers/_contract-probe.sh`: asserts `session_id` emitted on completion (per extended wrapper).
- Drift guard: assert both planes journal identically and the durable tier reads a DynWF run's artifacts.

## Architecture Decisions

- **API daemon is zero-dep Node, NOT platform-modules.** Deletion test: the daemon must run wherever the
  standalone runner runs (headless/cron/codex box) where the platform turborepo + its build toolchain are
  absent. Heavy UI packages would break "runs anywhere". Platform-modules live in sub-project 3. Deep.
- **Two tiers (durable artifacts vs live supervisor).** Control needs a live PID → it cannot be durable;
  observability is a pure function of on-disk artifacts → it needs no daemon. Conflating them would force a
  daemon to be up just to list finished jobs. Splitting keeps Overview honest + always-available. Deep.
- **control-api is an observer, not a third plane; liveness is fast-path-only.** Same status as the Workflow
  `runId` cache. Source of truth stays git+JSONL; every verb journals. Preserves FORK + resume correctness.
  Deep.
- **SSE + REST, not WebSocket.** Input = POST-queued steer; output = one-way event stream. WS bidirectional
  is unused surface → YAGNI. Reversible (WS addable later if true bidirectional ever needed). Medium.
- **Wrapper changes additive + gated after Plan A.** Status-line `session_id` rides the already-optional
  status line; exit-code/flag contract untouched. Built only once Plan A's wrappers land → one editor at a
  time (Plan A's own self-edit-hazard rule). Medium.
- **live mid-turn = stub, not feature.** Physical limit (only claude; only with stdin open; batch contract
  closes stdin). Honestly pinned as a seam for later standalone-interactive sessions; plan-dispatched
  steering is resume/kill. Surfaced to the user at design time. Medium.
- **DynWF control asymmetry (observe + inbox-file steer, no live socket).** Allowed FORK drift (control
  mechanism), justified by the Workflow body's lack of a server; both planes still journal identically, so
  the durable tier is plane-symmetric. Medium.
- **Global discovery index, per-repo run state, `runId`-keyed.** `GET /runs` is cross-project (brief's
  Overview tiles span projects/repos/worktrees) → a localhost daemon needs ONE place to enumerate. Resolved
  by a thin global pointer index `~/.harness/runs/<runId>.json` (override `$HARNESS_HOME`); the authoritative
  journal/liveness stay per-repo next to git. **Keyed by `runId = sha256(repoRoot+worktree)[:12]--slug`, not
  bare slug** — slugs are topic-derived per-project and collide across repos/worktrees; a slug key would drop
  a job silently from the Overview. `runId` is also the API path key. Only discovery is centralized; run
  state never leaves its repo. Medium.
- **codex session id captured from its `--json` stream, NOT the filesystem.** Probed (2026-06-30): `thread_id`
  is the first `--json` event. Replaces an earlier `~/.codex/sessions/` snapshot-and-match design that raced
  under same-cwd concurrency and broke kill-then-resume if the rollout wasn't yet matchable. Stream-capture
  reads the same output the normalizer already consumes → one mechanism, zero races. `seq`-style "derive from
  the stream you already have" principle. Deep.
- **`seq` derived from logfile position, not a runtime counter.** `/stream` is stateless re-tail → a reconnect
  spawns a fresh normalizer; a runtime counter would restart at 0 and corrupt `Last-Event-ID` resume. Position
  (line ordinal) is process-independent and reconnect-stable. Deep — the whole resume contract rests on it.
- **Rejected — outer supervisor shim that re-invokes engines itself (no wrapper edits).** Would duplicate
  the wrapper rig (env, model-pin, timeout, stream-format) → violates the single-source-of-truth wrapper
  contract (DRY). Rejected on the contract, not preference.
- **Rejected — bake liveness into the runstate journal.** The journal is append-only truth; mutable pid
  liveness in it would corrupt reconcile semantics. Separate ephemeral sidecar instead. Deep.
