---
url: https://hyprpilot.kilic.dev/runtime/harness.md
---

# {{ $frontmatter.title }}

`hyprpilot mcp harness` is a control plane for hyprpilot itself: a connected agent can list your configured profiles, launch one as its own agent session, talk to it across turns, watch it work, and kill it. It is its own stdio server, alongside [Skills](./skills) and the general-tools server — separate process, separate catalogue entry, separate tool policy.

The harness can also be served over a port with [`--transport http`](./mcp-http) — read that page first, because an HTTP harness is shared by every client that can reach it and loses two of its notification channels.

## What it solves

Without the harness, an agent connected to hyprpilot's MCP servers can only read skills — it has no way to act as an orchestrator that spins up other hyprpilot sessions. The harness adds that: discover profiles, start one, send it follow-up turns, follow its output live, stop it. Every launch flows through the same `spawn::prepare` path `hyprpilot <profile>` itself uses, so profile resolution, the `-- <args>` escape hatch, and cwd precedence can't drift between a CLI launch and a harness-driven one — see [Launching](./launch).

## Gating the harness

```yaml
mcp:
  harness:
    enabled: true
```

**Off by default, deliberately.** hyprpilot auto-injects its in-tree servers into **every** launch (see [Skills → Auto-injection](./skills#auto-injection)), so an ungated spawn surface would let any agent session spawn nested sessions with no bound. A profile's `command` is an arbitrary binary — its `provider` only picks which native-flag projection applies, not a sandbox — so anything that reaches `spawn` executes commands as whoever runs the sidecar. Enable it only where that's intended, e.g. a gateway host that opts in explicitly.

The gate is **structural** within the served surface. The harness is its own subcommand and its own `ServerHandler`, so the skills server has no `spawn` implementation to reach even if a client guesses the name. (An earlier design put these tools on the skills server behind a `--with-harness` flag, which meant remembering to gate both `list_tools` and `call_tool` — dispatch is by name, so gating only the listing would have left every tool callable. A reviewer caught exactly that half-missing gate.)

::: warning What the gate is not

`mcp.harness.enabled` controls whether **hyprpilot** injects the entry — it bounds what a connected agent can *discover*, not what it can *do*. `hyprpilot mcp harness` is an ordinary subcommand: run it and it serves `spawn`, whatever your config says. That is deliberate — a hand-configured MCP entry (a gateway managing its own catalogue) must work without a hyprpilot config to consult, and `mcp` deliberately skips config validation so a broken config can't kill a sidecar the vendor keeps respawning.

So this is **exposure reduction, not a capability boundary**. Against an agent that can run shell commands it buys nothing: such an agent can start the harness itself, or skip it entirely and run `hyprpilot <profile>`. It is a real boundary only against a client whose sole reach is MCP — which is the case worth protecting, since that is what an MCP-only gateway looks like.

:::

Because it is a separate catalogue entry, it also gets its own tool policy — worth tightening, since `spawn` is the tool that runs arbitrary binaries:

```yaml
mcp:
  harness:
    enabled: true
    autoAcceptTools:
      - list_profiles
      - session_read
    autoRejectTools:
      - spawn
```

## Which profiles it can drive

The harness runs **only the profiles you nominate.** A profile is available when it declares a `harness` block; without one it is absent from `list_profiles` and refused by `spawn` / `session_send`:

```yaml
profiles:
  - id: personal/engineer
    agent: claude-code
    harness:
      enabled: true
```

Opt in a whole family with a `$match`ed [patch](../config/patches) rather than repeating it:

```yaml
patches:
  - $match:
      profile: 'personal/*'
    harness:
      enabled: true
```

Default-deny because `spawn` runs a profile's `command` as you. See [Profiles → Putting a profile on the harness](../config/profiles#putting-a-profile-on-the-harness).

### Scoping delegation per launch

Nomination above is **global** — it says a profile may be driven, not by whom. To narrow that per launch, scope the harness on the profile doing the orchestrating:

```yaml
patches:
  - $match:
      profile: 'personal/*'
    mcp:
      harness:
        enabled: true
        includeProfiles:
          - 'personal/*'
        excludeProfiles:
          - 'personal/codex/*'
```

A session launched as `personal/kilic/glm-5.2` now sees and can drive only `personal/*`, minus anything `excludeProfiles` catches — `work/*` is absent from `list_profiles` and refused by `spawn` / `session_send`.

* Both lists are globs over profile ids, matched with `globset`, so `*` **crosses `/`** exactly as `$match.profile` does — `personal/*` reaches `personal/kilic/glm-5.2`.
* `excludeProfiles` beats `includeProfiles` on overlap, mirroring `excludeTools` / `autoRejectTools`.
* Unset means no filter. `includeProfiles: []` means delegate to nothing — the server is still injected, it just has no candidates.
* The two gates **AND**. A glob here can never promote a profile that never declared `profiles.harness`; it can only narrow what is already nominated.

Like `enabled`, this bounds **discovery, not capability**: it decides what the auto-injected sidecar exposes. An agent that can run commands can start `hyprpilot mcp harness` itself, or skip it and run `hyprpilot <profile>` directly. It is a real boundary for an MCP-only client, and a guard against a work session reaching personal profiles by accident — not containment.

One corollary worth stating: a scoped-out id that **is** configured refuses differently from one that isn't (the latter falls through to the resolver's "unknown profile"), so an agent can probe which ids exist even when they are absent from `list_profiles`. That is the price of leaving "unknown profile" to the resolver instead of reporting a misleading scope refusal for a typo — the scope hides profiles from view, it does not make them unguessable.

## The tools

| Tool             | Purpose                                                                                                                        |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `list_profiles`  | Discover the profiles you can launch — vendor, model, effort, mode, cwd. Start here.                                           |
| `spawn`          | Start a new session from a profile and send it a prompt.                                                                       |
| `session_send`   | Send another message to an existing session — resuming it if it's finished, or, with `steer`, interrupting the turn in flight. |
| `session_list`   | List this server's sessions — handle, profile, status, exit code, timestamps, directory.                                       |
| `session_status` | One session's state and file paths without its transcript — the cheap poll. Takes an optional `turn`.                          |
| `session_read`   | Read, and optionally follow live, a session's transcript.                                                                      |
| `session_kill`   | Stop a running session and everything it started — or reap one that has already finished.                                      |

### Workflow

1. **`list_profiles`** to find an `id` — a row marked `!` failed to resolve; don't launch it.
2. **`spawn { profile, prompt }`** to start a session. It returns a `session` handle straight away and the agent keeps working — that handle is the session's identity for every later call. Pass `wait: true` to block instead, worth it only for a turn you expect to be short: past `timeout_seconds` the result comes back with status `running`, a `nextCursor` to resume reading from, and the agent still working.
3. **`session_status { session }`** until it reports `exited` — do **not** call `spawn` again for the same conversation. It reads no transcript, and its `transcriptBytes` tells you whether a running agent is progressing or wedged, which `status` alone cannot. It also carries `files`, so the poll that tells you the answer landed tells you where it landed. To watch the output as it arrives instead, follow with `session_read { session, wait: true }`.
4. **`session_read { session }`** for the transcript once it has finished.
5. **`session_send { session, prompt }`** for every follow-up turn, once the session has finished its previous one. Add `steer: true` to interrupt a turn that is still running and take the conversation somewhere else instead.
6. **`session_kill { session }`** to stop a runaway agent, or to free a slot when `spawn` reports a `max_live_sessions` ceiling. It is state-aware, like `session_send`: on a **running** session it terminates the agent and keeps the transcript, so you can still read why; on an **already-finished** one it reaps the session and its transcript. Calling it twice is the natural stop-then-clean-up, and the result's `action` says which happened.
7. **`session_list`** any time you need to recover a handle you lost.

### The handle is the session's id

`spawn` mints the `session` handle itself, before the vendor has produced a byte, and it is the only identifier any tool here accepts. It does not change across turns.

Vendors mint their own session ids too — and hyprpilot captures one, because it is what `session_send` hands back to the vendor to continue the conversation. It is deliberately **not** on the wire. It would be a second id that identifies the same thing while behaving worse: absent for the whole first turn, appearing only once the vendor emits it, and addressing nothing, since no tool takes it. A caller that saw both would have to learn which one to use and when the other becomes available. There is one id, and you have it from the first result.

### Tasks (SEP-2663) — the opt-in parallel path

The harness speaks the MCP Tasks extension **alongside** its own tools, never instead of them. A client that declares `io.modelcontextprotocol/tasks` **on protocol `2026-07-28` or later** gets a task handle from `spawn` / `session_send` and polls `tasks/get`; every other client gets exactly the result it always got. There is no config switch — the client's own declaration is the entire gate. A client on an older revision gets no task even if it declared the extension in `initialize`, as SEP-2663 requires, and its `tasks/*` calls are answered `-32021`, as for a client that never declared.

A task caller gets its task **when the turn starts**: `wait` is ignored in task mode, since blocking first would hand back a task for work already done. The task is minted from the turn's real state — a turn that already failed is minted `completed`, never `working`.

```jsonc
// spawn, from a declaring client
{
  "resultType": "task",
  "taskId": "1a3615c8-5dfa-4613-892b-fe27f25e0f9d:1",
  "status": "working",
  "statusMessage": "turn 1 running",
  "pollIntervalMs": 2000,
  "_meta": { "io.hyprpilot/session": "1a3615c8-5dfa-4613-892b-fe27f25e0f9d" }
}
```

`statusMessage` names the turn and its outcome — `turn 2 exited 0`, `turn 2 killed`, `turn 2 steered`. A completed task's result is what the same call returns synchronously, read from that turn's own record so it never changes once final, including the `answer` field described under [the launch parameters](#spawn--session_send-parameters).

**A task names one TURN, not the session.** The spec makes `completed` / `failed` / `cancelled` terminal — once reached, a task's state never changes — while a session handle is reused across turns and cycles `exited → running → exited`. Keyed by the handle alone, starting turn 2 would rewrite turn 1's finished task. So the id is `<session-handle>:<turn>`, and a completed turn keeps reporting `completed` however far the conversation moves on.

The session handle rides `_meta` rather than being parsed out of the task id: every other tool here takes the handle, and an id you have to take apart is not opaque.

`tasks/cancel` cancels **that turn**, not the session, checked under the same lock that stops it — so a cancel for turn 1 that lands after turn 2 started leaves turn 2 alone. Terminal states are immutable, so cancelling a task that already finished is a no-op; routing it through `session_kill` would reap the finished session and delete the transcript. An unknown handle is `-32602`, matching `tasks/get`.

**Task ids do not outlive the sidecar.** SEP-2663 presents a task id as a durable handle you can resume polling after a client restart; that assumption does not hold here. Sessions die with `hyprpilot mcp harness`, and finished ones are also dropped by `max_sessions` eviction and by `session_kill`. `ttl_ms` is `null` because retention is bounded by count and by process lifetime, not by a duration — any number would be a stronger promise than we can keep. `tasks/update` acknowledges a task that exists and answers `-32602` for one that does not: the harness never emits `input_required`, so there is never an outstanding `inputRequests` to apply.

**There is no task status push — poll `tasks/get`.** Under `2026-07-28` a server may push `notifications/tasks` only on a `subscriptions/listen` stream filtered by task id, and rmcp cannot express that filter yet. Tasks are served to no older revision, so there is no legal channel left; `pollIntervalMs` says how often to poll.

**Which clients?** As of October 2026 Claude Code 2.1.286 has a tasks client behind a feature flag that is off by default; opencode, Codex and Hermes do not implement the extension. This exists so that the day one does, hyprpilot already speaks the standard protocol.

### `session_status`

| Field             | Type   | When        | What it means                                                                                                                                           |
| ----------------- | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`          | string | always      | The **session's** state: `running` or `exited`. A session is `exited` after every **turn**.                                                             |
| `exitCode`        | int    | once exited | This turn's own code. Omitted while running, and for a killed turn — the `-1` a kill produces says nothing.                                             |
| `turn`            | int    | always      | Which turn this describes, 1-based. Also the suffix of that turn's task id.                                                                             |
| `turnFinished`    | bool   | always      | Whether **this turn** has ended. Not derivable from `hasResult`: a turn that ended with no answer and one still working both report `hasResult: false`. |
| `transcriptBytes` | int    | always      | Bytes this turn has written. A number that stops moving is a wedged agent.                                                                              |
| `hasResult`       | bool   | always      | Whether the agent's final answer has landed — see below.                                                                                                |
| `files`           | object | always      | This turn's paths — the same block `sessionInfo.files` carries. See [The session directory](#the-session-directory).                                    |
| `turns`           | array  | always      | Every turn of the conversation with its outcome and the URI that fetches it.                                                                            |

**`turn` is an argument too.** `session_status { session, turn }` describes that turn instead of the current one, and everything turn-scoped above follows it — the byte count, `exitCode`, `hasResult` and `files`. Omit it for the current turn; a turn the session never reached is an error rather than a silent fall back to the current one. This is what `hyprpilot://sessions/<handle>/turns/<n>/status` reads.

`hasResult` is `false` for any running session, and only then scanned per vendor. Both halves matter:

* opencode emits a `text` part for **every** completed sentence, not just the final answer, so content alone cannot say "done".
* The scan reads only the current turn's own file, so an earlier turn's marker cannot make a running turn read as finished.

The three vendors mark completion differently — all verified against the installed CLIs:

* **claude** — a terminal `{"type":"result"}` carrying the answer.
* **codex** — `{"type":"turn.completed"}` closes the turn; the text rode the `item.completed` before it, whose `item.type` is `agent_message`.
* **opencode** — emits **no** terminal marker at all. `opencode run --format json` may end without a closing `step_finish`, so the last `{"type":"text"}` part is the signal.

### The session directory

Each session owns a 0700 temp directory, and **every surface that names a session names its files**, so nothing has to be derived from a sibling path and no call has to be made twice to learn a path:

| Where                                     | How they arrive                                                                |
| ----------------------------------------- | ------------------------------------------------------------------------------ |
| `spawn` / `session_send` / `session_read` | `sessionInfo.files`                                                            |
| `session_status`                          | `files`, scoped to the turn you addressed                                      |
| `session_kill`                            | `files`, on a **terminate** — a reap deletes the directory, so it carries none |
| `session_list`                            | `dir` per row — everything else hangs off it at the names below                |
| Any session `resources/read`              | `_meta["io.hyprpilot/session"]` — `session`, `turn`, `files`                   |

| Key          | File                    | What it is                                                                               |
| ------------ | ----------------------- | ---------------------------------------------------------------------------------------- |
| `dir`        | —                       | The directory itself. Gone once the session is reaped, evicted, or the sidecar exits.    |
| `turnsDir`   | `turns/`                | Where every turn's directory lives.                                                      |
| `turn`       | —                       | Which turn the three paths below belong to.                                              |
| `turnDir`    | `turns/<n>/`            | THIS turn's directory.                                                                   |
| `transcript` | `turns/<n>/turns.jsonl` | The vendor's raw JSON event stream for THIS turn.                                        |
| `stderr`     | `turns/<n>/stderr.log`  | THIS turn's stderr. Surfaced in results only when non-empty.                             |
| `done`       | `turns/<n>/done.json`   | THIS turn's completion marker — see below.                                               |
| `breadcrumb` | `session.json`          | Crash-recovery state (pid, pgid, owning sidecar, start ticks) read by the startup sweep. |

A resource read is text alone, which is why the paths ride its `_meta`: a caller holding `/result` could otherwise not say which file the answer came from. Clients that render only the text block see the session directory on the `spawn` summary and the transcript path on the `session_status` summary instead.

**`transcript` is there so you can read it directly.** `session_read` pages it for you, but an agent with shell access is often better off with `jq` — the answer sits in a different event per vendor, and the `tool_use` events in between can be enormous:

```sh
T=$(…sessionInfo.files.transcript…)
jq -r 'select(.type=="result")     | .result'    "$T"   # claude
jq -r 'select(.type=="item.completed") | select(.item.type=="agent_message") | .item.text' "$T"   # codex
jq -r 'select(.type=="text")       | .part.text' "$T"   # opencode
```

Everything under `files` is a path into a directory that can disappear — treat a missing file as "the session was cleaned up", never as an error.

### Being woken instead of watching

Every session is also a **resource** — `hyprpilot://sessions/<handle>` — so a client can subscribe to one handle and be notified when its turn ends, with no polling and no shell watcher:

```jsonc
// once, after `spawn` returns the handle
{"method": "subscriptions/listen",
 "params": {"notifications": {"resourceSubscriptions": ["hyprpilot://sessions/<handle>"]}}}

// when the turn ends
{"method": "notifications/resources/updated",
 "params": {"uri": "hyprpilot://sessions/<handle>"}}
```

### The resource surface

| URI                                        | What it returns                                                        | Cacheable      |
| ------------------------------------------ | ---------------------------------------------------------------------- | -------------- |
| `hyprpilot://profiles`                     | What `list_profiles` returns, same delegate scope                      | no — see below |
| `hyprpilot://sessions`                     | What `session_list` returns                                            | no — see below |
| `hyprpilot://sessions/<handle>`            | What `session_status` returns — state, exit code, `hasResult`, `files` | when exited    |
| `hyprpilot://sessions/<handle>/result`     | **The latest turn's answer**, or why there isn't one                   | when exited    |
| `hyprpilot://sessions/<handle>/transcript` | The raw event stream, capped                                           | when exited    |
| `hyprpilot://sessions/<handle>/stderr`     | The vendor's stderr                                                    | when exited    |

Reading `hyprpilot://sessions/<handle>` also lists every turn and how it ended, each with the URI that fetches it:

```jsonc
{
  "turn": 2,
  "hasResult": true,
  "status": "exited",
  "turns": [
    { "turn": 1, "status": "exited", "exitCode": 0, "uri": "hyprpilot://sessions/…/turns/1/result" },
    { "turn": 2, "status": "exited", "exitCode": 0, "uri": "hyprpilot://sessions/…/turns/2/result" }
  ]
}
```

So one read answers "which turns exist and which is worth fetching", rather than walking `…/turns/<n>/status` until one errors. A killed turn reports `"status": "killed"` and no exit code — the `-1` a kill produces says nothing.

The **un-turned** forms are the shortcut to the current turn: `…/<handle>/result` is the latest answer with no turn number to look up. Reach for a turn-scoped URI only when you want an earlier one.

Every view is also addressable **per turn** — `hyprpilot://sessions/<handle>/turns/<n>/result` and the same for `status`, `transcript`, `stderr`. This is how an earlier turn's answer stays reachable once later turns have run. Turn numbers are 1-based; one the session never reached is an error, not an empty read. A turn-scoped `status` describes **that** turn, down to its `files` and its cacheability — an earlier turn is immutable however the session is doing now.

`resources/list` names the two indexes and **nothing else**. A row per session would make the listing change as a side effect of `spawn`, which `2026-07-28` forbids for `resources/list`, so sessions are found through the `hyprpilot://sessions` index or `session_list` and addressed through three templates:

| Template                                            | Reads                                                                    |
| --------------------------------------------------- | ------------------------------------------------------------------------ |
| `hyprpilot://sessions/{handle}`                     | the session's status                                                     |
| `hyprpilot://sessions/{handle}/{view}`              | one view of the current turn: `status`, `result`, `transcript`, `stderr` |
| `hyprpilot://sessions/{handle}/turns/{turn}/{view}` | the same views of one turn, by number                                    |

The listing never changes, so the server advertises `resources.listChanged: false`.

Both indexes carry `ttlMs: 0`. `hyprpilot://sessions` embeds each session's live status and nothing fires `resources/updated` for the index URI. `hyprpilot://profiles` comes from config re-read per call and **nothing watches that file**. A surface that cannot signal must not claim freshness.

`done.json` and the crash breadcrumb are deliberately not resources. The status view answers what `done.json` answers, and `done.json` exists precisely as the one signal a shell watcher can reach without MCP. The breadcrumb is orphan-debugging plumbing.

Each turn reads its own file, so no boundary has to be found. Nothing in a transcript marks where a turn begins, and a turn that *dies* emits no terminal event at all — when turns shared a file, two turns' output was indistinguishable, which is what the per-turn layout retires.

`/result` is the one that saves work. It does server-side what the `jq` recipes did by hand: finds the vendor's answer event, scopes it to the **latest** turn, and joins multi-block answers. It never comes back blank for a finished session — the three ways a run produces no answer each report themselves:

| What happened                           | What `/result` says                                 |
| --------------------------------------- | --------------------------------------------------- |
| Upstream failure (auth, quota, model)   | the `error` event's message                         |
| Launch failure (vendor rejected a flag) | `failed to launch`, plus the usage dump from stderr |
| Exited with nothing at all              | `exited with code N and produced no answer`         |

That matters because those land in *different places* — an upstream error is inside the transcript, a launch failure is in `stderr.log` with the transcript empty — so checking only one reports "the agent returned nothing" for a billing error. Two ways that goes wrong by hand, both silent, are structurally impossible here: it slices by event rather than by line (so a multi-line answer can't be truncated to its last line), and it never reports an earlier turn's answer as the reply to this one.

`/transcript` is capped and truncated from the front, since the answer is at the end. A resource read has no cursor, so **`session_read` remains the way to page a long one**.

An unknown view is refused rather than read as the status.

::: tip TTL depends on whether it's still moving

A finished session's views carry the 24-hour TTL. A **running** session's carry `ttlMs: 0` — its result and transcript change under you, and the notification that fires at turn end cannot retroactively correct a day-long cache taken a second earlier.

:::

`hyprpilot://sessions` lists sessions as handle / profile / status. Useful for recovering a handle, but the point is subscribing to one you already hold.

**The other mechanisms still work.** `notifications/claude/channel` still fires for Claude Code, `session_status` is still the cheap poll, and `done.json` is still there for shell watchers. The subscription is an addition, not a replacement.

Results carry `ttlMs` of 24 hours — longer than a sidecar lives — so a client that honours it re-fetches only when notified. Every change that invalidates a cached read is announced as `resources/updated` on the views it changed: a turn starting or ending, and a kill or a reap.

**Two delivery channels, chosen per notification.** With a `subscriptions/listen` stream open, notifications ride that stream — filtered to the URIs you subscribed to and tagged with `io.modelcontextprotocol/subscriptionId`, which is what a conforming client correlates on. With no stream, they are sent as plain unsolicited notifications, which is the only channel a client on an older revision has and exactly what it received before.

**Opening a stream replaces broadcasts entirely.** Anything outside your accepted filter is dropped, not delivered some other way — the filter is a declaration, and honouring it means honouring the parts you left out. So subscribe to `resourcesListChanged` too if you want to hear about sessions appearing and disappearing, not just the handle you named. Multiple streams may be open at once; each is filtered and tagged independently, so cancelling one never silences another.

### Watching from a shell

**Every turn owns a directory**, and the layout is what makes the rest of this page work:

```txt
/tmp/hyprpilot-session-XXXX/
├── session.json          crash-recovery breadcrumb (session-scoped)
└── turns/
    ├── 1/
    │   ├── turns.jsonl   this turn's transcript
    │   ├── stderr.log    this turn's stderr
    │   └── done.json     this turn's completion marker
    └── 2/ …
```

`sessionInfo.files` names the session's paths plus **this turn's** — `dir`, `turnsDir`, `turn`, `turnDir`, `transcript`, `stderr`, `done`, `breadcrumb`. Earlier turns are not listed: they are `<turnsDir>/<n>/` for every `n` up to `turn`, which is inferable, and enumerating them would grow the payload with every turn while saying nothing new.

A turn's output being its own file is why reading turn 1 cannot reach into turn 2, why "stderr is non-empty" means *this* turn wrote it, and why a fresh turn needs no marker cleared before it starts.

The rest of `sessionInfo` is turn-scoped for the same reason: `model` / `effort` / `mode` / `argv`, `pid` and `turnStartedAt` all come off the turn's own record, not off the session. SEP-2663 makes a completed task's result immutable and a task names a **turn**, so a caller that re-polls a finished task after a later turn started must get back the bytes it got the first time.

Each turn gets its `done.json` when its process exits, written by the same `child.wait()` task that owns the truth — so no recycled PID and no zombie can produce a false reading. This is the vendor-neutral completion signal, and the one a **shell** watcher can use, since a bash loop cannot call an MCP tool:

```bash
[ ! -d "$TURN_DIR" ] || [ -f "$TURN_DIR/done.json" ]
```

Both halves are required. The marker is advisory: reaping, eviction and shutdown remove the whole session directory, so a watcher that only tests for the file waits forever on a session that was cleaned up.

`{"handle": "…", "exitCode": 0, "finishedAt": 1785584247}`

Arm it on `turnDir` from the call that started the turn. A marker can no longer be stale: turn N+1 writes into its own directory, so nothing from turn N is in it.

### Completion notifications (Claude Code channels)

When a turn's process exits the harness pushes a `notifications/claude/channel` event, which Claude Code turns into a `<channel source="hyprpilot-harness">` block in the lead agent's next turn:

```txt
content: hyprpilot harness session 4670d5aa…: turn 2 exited 0. Read its output with session_read.
meta:    { session: "4670d5aa…", turn: "2", exit_code: "0" }
```

A killed or steered turn is named as one — `turn 2 killed`, `turn 2 steered` — from the same fixed vocabulary as a task's `statusMessage`.

On by default. It is safe to leave on — a client that has not registered the channel drops the notification silently, and unknown capabilities are ignored per the MCP spec, so nothing errors anywhere. The knob exists for **noise**: a session is `exited` after every *turn*, so a ten-turn conversation emits ten events.

```yaml
mcp:
  harness:
    notify_on_complete: false
```

Resolved by the **launcher**, from the profile it picked, and passed to the sidecar as a flag — the same way `max_sessions` arrives. A sidecar cannot work out which profile spawned it, so it cannot read this from config itself.

Three things worth knowing:

* **Registering the channel is the client's job, not hyprpilot's.** Claude Code only listens for channels it was launched with (`--channels`, or the development-channel flag); that is your own launch configuration. hyprpilot declares the capability — on stdio only, since over HTTP it can never be sent — and pushes the event; where channels are unavailable, the push is dropped.
* **It is a deliberate exception.** `2026-07-28` allows no notification a client did not request through a subscription, and this push arrives outside one. It stays because Claude Code opts into it through its own capability and it is the only completion push any client turns into model context.
* **The content is a fixed template.** Transcript bytes and agent output are never interpolated into it — that would let a spawned agent write into its parent's context through a path the parent never called. Everything variable rides `meta`, whose keys must be `[A-Za-z0-9_]` (a hyphen is silently dropped, which is why it is `exit_code`).

### `spawn` / `session_send` parameters

The two tools share one parameter set:

| Parameter         | Type             | Default       | What it does                                                                                                  |
| ----------------- | ---------------- | ------------- | ------------------------------------------------------------------------------------------------------------- |
| `prompt`          | string           | —             | The instruction to send. Mutually exclusive with `file`.                                                      |
| `file`            | string           | —             | Path to a file whose contents become the prompt (`~` / `$VAR` expanded). Mutually exclusive with `prompt`.    |
| `cwd`             | string           | profile's cwd | Working directory for the agent.                                                                              |
| `mode`            | string           | —             | Vendor mode override (e.g. claude's `plan`). Overrides the profile.                                           |
| `with_config`     | array of objects | `[]`          | Ad-hoc profile overlays. **Restricted to `model`, `effort` and `mode`** — see below.                          |
| `args`            | string\[]         | `[]`          | Extra arguments forwarded verbatim to the vendor CLI — the tool equivalent of the CLI's trailing `-- <args>`. |
| `wait`            | bool             | `false`       | Block until the turn finishes. Left off, the call returns as soon as the turn starts — poll `session_status`. |
| `timeout_seconds` | integer          | `45`          | Seconds to wait when `wait` is true. On timeout the agent keeps running; the result reports status `running`. |
| `steer`           | bool             | `false`       | **`session_send` only.** Interrupt the turn in flight and make this prompt the next turn — see below.         |

**Keep a `wait` under your client's own cut-off.** opencode abandons a tool call at 60 seconds and Codex and Hermes at 300, whatever the server is still doing — which is why the default is 45. Past it the call comes back `running` and you poll; the agent keeps working either way. Detached plus `session_status` is the only flow that works in every client.

A finished turn's result carries **`answer`**: the agent's final reply, extracted the way the `/result` view extracts it. `text` is the transcript from the turn's first byte, capped, so a long turn's answer — which comes last — can fall outside it; `answer` never does.

Exactly one of `prompt` / `file` is required on both — the same mutual exclusion the CLI's `-p`/`-f` enforce. `spawn` additionally requires `profile` (an id from `list_profiles`). `session_send` additionally requires `session` (a handle from `spawn` or `session_list`) and has **no** `profile` parameter — the profile is inherited from the original spawn, so a conversation can't switch profiles mid-stream.

`session_send` **replays the original launch** and does not let you change it. Only `prompt` / `file`, `mode`, `wait`, `timeout_seconds` and `steer` are per-turn; `cwd`, `args` and `with_config` are inherited from the `spawn` and are **rejected** if passed — start a new session to launch differently.

`mode` is the exception because a per-turn permission change is a real workflow (`mode: plan` for a read-only follow-up) and it does not affect how the vendor looks the conversation up.

How a conversation was launched is part of its **identity**, not a per-turn option — re-deriving a follow-up turn from defaults launched it differently from the first, silently. The visible failure was `cwd`: claude keys its conversation store by project directory, so a resume from elsewhere came back with a bare `No conversation found with session ID: …` for a perfectly healthy session, because it was looked up in the wrong place. A dropped `mode` or `args` is quieter and worse — it changes the agent's permissions or flags mid-conversation without saying anything.

::: warning `with_config` is restricted to `model`, `effort` and `mode`
Unlike the CLI's `--with-config`, the harness accepts only those three keys — an allow-list, not a block-list. A profile overlay can otherwise reach `command`, `args` and `env` (which replace the binary outright), `mcps` (whose inline `mcp_servers` entries carry their own `command`/`args`, which the vendor then spawns), `$deleteFromPrimitiveList/<field>` directives (which mutate a field without ever naming it, e.g. stripping a profile's `--sandbox`), and `system_prompt` (which reads an arbitrary file into the agent's context). Any of those turns `spawn` into arbitrary command execution as the sidecar's user.

Enumerating the ways *in* is a losing game against a config tree that grows; enumerating what's allowed is not. To run something else, add a profile for it in the hyprpilot config — that is the captain's decision to make, not the calling agent's.
:::

### `session_read` parameters

| Parameter         | Type    | Default | What it does                                                                                                                        |
| ----------------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `session`         | string  | —       | Required. Handle from `spawn` or `session_list`.                                                                                    |
| `tail`            | integer | `200`   | Trailing lines to return when `cursor` is omitted.                                                                                  |
| `cursor`          | string  | —       | Opaque pagination cursor — pass a previous result's `nextCursor` verbatim to continue where it stopped.                             |
| `wait`            | bool    | `false` | Follow the session live from `cursor` instead of returning immediately — the same knob, with the same meaning, as `spawn`'s `wait`. |
| `timeout_seconds` | integer | —       | Caps a `wait` follow, in seconds. Inert without `wait: true`. Omit to follow until the agent finishes or you cancel.                |

**Pagination follows the MCP idiom.** `cursor` in, `nextCursor` out, opaque both ways — pass one back verbatim, never parse or construct one. **An absent `nextCursor` means the session is finished and you have all of it**; a running session always returns one, so a poller never loses its place. An unrecognised cursor is an error rather than a silent reset. There is no `truncated` flag: the cursor's presence is the signal.

A follow streams each new chunk as an MCP `notifications/progress` message when the caller's request carries a `progressToken`; without one it degrades to a plain long poll and the caller still gets everything in the final result. It ends on whichever comes first: the agent finishing, the caller cancelling the request, or `timeout_seconds` elapsing — there's no other server-side time limit.

## `session_send` semantics

`session_send` doesn't require the target session to still be alive — it inspects the handle's status and does whatever's needed:

* **Refused** if the session is still `running` and `steer` was not set — no vendor supports two concurrent turns on one conversation. Poll `session_status` and send once it reports `exited`, or send again with `steer: true` to take the turn away from it.
* **Refused** if it cannot be resumed, because the vendor has not reported a session id for it — its first turn either failed before the agent started or is still too young to have emitted one; check `session_read`.
* **Steers**, when `steer: true` meets a running session: the turn in flight is interrupted and this prompt becomes the next turn. See below.
* Otherwise it **resumes**: the vendor's own session store continues the conversation in a new process, and the result's `delivery` field reports `"resumed"`. **The handle does not change.** It stays valid for the whole conversation however many turns you send, and each turn writes its own files — so an N-turn conversation costs one session, not N, and every turn's output stays separately addressable. A `session_read` cursor names its turn, so a resume reads the file it was taken from.

### Steering a turn in flight

`session_send { session, prompt, steer: true }` is how you redirect an agent that is working on the wrong thing. It:

1. terminates the turn in flight (`SIGTERM`, grace, then `SIGKILL`) and waits for the child to be reaped,
2. marks that turn's record `steered` — not `killed`, because the conversation did not end,
3. starts the next turn against the vendor's own session store with your prompt.

The handle does not change, the transcript keeps accumulating, and the new turn is an ordinary turn with its own `turns/<n>` directory and its own `done.json` — so a watcher armed on it works exactly as it does after a `spawn`. The result reports `delivery: "steered"` and the number of the turn it interrupted.

**It is opt-in because it discards work.** The interrupted agent keeps only what its vendor had already persisted; anything that turn had not finished is gone. A caller that merely raced a running turn wants the refusal, not a cancellation it did not ask for — which is why the default is to refuse.

**A steer against a turn whose vendor id has not landed yet is refused whole, and kills nothing.** The refusal is checked before the interrupt precisely so the agent is not terminated with nothing left to resume against.

`steer` is inert on a session that has already exited: there is no turn to interrupt, and the send is an ordinary resume.

Two concurrent steers do not both fire. The first claims the session from its interrupt until its replacement turn starts; the second is refused rather than terminating the turn the first just launched.

**`session_kill` is not the way to redirect an agent.** It stops a session whose work you do not want at all, and reaps a finished one — steering keeps the conversation, its transcript, and the context the agent has already loaded.

## Session lifetime

Every session is a **direct child** of `hyprpilot mcp harness`, not a daemon of its own: a `tokio::process::Child` waited on in-process, with its transcript streamed into a per-session, owner-only (`0700`) temp directory. That has one hard consequence — **sessions die with the server, and their transcripts die with them.** Restarting the sidecar, for any reason (the vendor restarting it, the host process exiting, a crash), doesn't preserve a single running or finished session: there's no persistence and no state across launches. Treat a `spawn`/`session_send` chain as living only as long as the MCP connection that started it; if a result needs to survive that boundary, capture it before the connection ends.

On a graceful transport close, or on `SIGTERM`/`SIGHUP`, the server kills every live session (SIGTERM the process group, a grace period, then SIGKILL if it didn't listen) and removes its temp directory before exiting — a clean shutdown never leaves an orphan behind.

## Orphan prevention

A crashed or forcibly-killed sidecar is a different story from a clean shutdown, so orphan prevention is layered — only the last layer is a guarantee:

1. **Graceful shutdown** — the path above. Userspace, so it only runs if the process gets a chance to run destructors at all.
2. **tokio's drop guard** (`kill_on_drop`) — without it, tokio's default behavior for a dropped, still-running child is to push it onto a global orphan queue rather than kill it, which is precisely the failure this exists to prevent. Still userspace.
3. **`PR_SET_PDEATHSIG`** (Linux only) — the kernel kills the child when the sidecar dies, *however* it dies. This is the only layer that survives a `SIGKILL` of the sidecar, or the release build's `panic = "abort"`, both of which run no destructor at all.

Each session also runs in its **own process group**, so a kill from `session_kill` or graceful shutdown signals the whole group — reaching everything the vendor itself spawned (its own MCP subprocesses, tool calls), not just the direct child.

**PDEATHSIG is the exception, and it matters.** It signals only the *direct* child, and is cleared across that child's own forks. So in exactly the case layer 3 exists for — the sidecar `SIGKILL`ed or aborted, with no chance to signal anything — the vendor dies but **its grandchildren can survive** until the next harness sidecar sweeps them. Layers 1 and 2 cover the group; layer 3 covers only the child.

Because PDEATHSIG is Linux-only, the guarantee degrades elsewhere to the first two layers, both of which a `SIGKILL` of the sidecar defeats.

A **startup sweep** (run once by `mcp harness` before it starts serving) covers what none of the three layers can: a machine crash, or the surviving grandchildren described above. It scans the temp directory for leftover session directories, kills any process group still alive (recorded in a crash-recovery breadcrumb written at spawn time), and removes the directory — logging a warning whenever it reclaims something, since a non-empty sweep means a previous sidecar died badly.

The sweep only reclaims sessions whose **owning sidecar is gone**. Each breadcrumb records the pid of the sidecar that created it, and the sweep skips any directory whose owner is still alive — or whose ownership it cannot establish. Running two harness sidecars at once is an ordinary setup, and without that check the newcomer's "recovery" would kill the other's live agents and delete transcripts still being written.

## Limits

| Limit                          | Value                         | Enforced by                                                                                                                                        |
| ------------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Concurrent running sessions    | none (`max_live_sessions: 0`) | Off by default. Set a number and `spawn` is refused past it; `session_kill` a finished or runaway session to free a slot.                          |
| Spawn nesting depth            | 1 (`max_depth`)               | `HYPRPILOT_SPAWN_DEPTH` env, stamped on every launch. At the cap no harness is injected, and `spawn` is refused.                                   |
| Transcript read per call       | 60,000 bytes                  | Caps `session_read` and an inline `spawn`/`session_send` result.                                                                                   |
| Default tail                   | 200 lines                     | `session_read`'s default when `cursor` is omitted.                                                                                                 |
| Default turn timeout           | 45 seconds                    | How long `spawn`/`session_send` block when asked to `wait: true`, before reporting status `running`. Inert by default.                             |
| Retained **finished** sessions | 64 (`max_sessions`)           | Past this, the oldest are evicted (with their transcripts) and logged. A running session is never evicted and never counted. `0` retains them all. |

Only distinct `spawn`s grow the table — a conversation reuses its session however many turns it runs — so the retention limit bounds a long-lived server's memory and temp directories without a tool you have to remember to call. Raise `max_sessions` on a busy gateway that wants deeper history; lower it where temp space is tight.

**Running sessions are outside the retention count, not merely spared by it.** A running session holds a live model connection rather than history, so counting one would spend the retention budget on work still in flight: with the ceiling off, enough concurrent agents would evict every transcript the cap exists to keep, and the busier the sidecar the less history survived it. `session_list` reflects the same priority — running sessions lead, then the most recent turn first — so what is happening right now is never buried among retained transcripts.

**The concurrency ceiling is off by default.** How many agents are worth running at once is a property of your machine and your work, so hyprpilot does not guess: `spawn` refuses nothing until you set `mcp.harness.max_live_sessions`. Set it on a shared or resource-tight host, where a profile's `command` being an arbitrary binary makes an unbounded fan-out something the host pays for. Leave it where fanning out wide is the point.

To free a session earlier than the limit would, call `session_kill` on it: on a finished session that reaps it and its transcript immediately.

The default depth ceiling of 1 means **the lead delegates and the delegate works**. It is enforced twice from one number, `mcp.harness.max_depth`:

* **At launch**, a session at or past the cap gets **no harness server injected at all**. This is absolute — no `enabled: true` re-opens it, because such a server could only ever refuse `spawn`. Without it a delegate paid for a long-lived sidecar and seven tools that existed to error.
* **At the tool**, a sidecar at or past the cap refuses `spawn`, with a message naming the knob that changes it.

Raise `max_depth` and both move together: at `2` your delegates get a harness and may delegate once more, and theirs may not.

**The raise has to reach the delegate's own block**, because the gate reads the cap from whichever block it is deciding on. An unscoped patch does that; so does `mcp.harness.mcp.harness.max_depth` on the lead. A raise scoped to the lead alone (`$match: { profile: gateway }`) leaves workers at the seeded `1`, so they still get no harness. The same rule read the other way: a worker profile carrying its own raised `max_depth` may delegate regardless of what the lead's cap says.

That is a resource decision, not a security one. A `max_live_sessions` ceiling — where you set one at all — is enforced by each sidecar over *its own* table and cannot see any other's, so allowing one more level lets N delegates each run N sessions: N² processes no single ceiling catches, none of them visible to the lead that started the tree. Keeping the default at 1 makes every session a direct child of the caller who can list and kill it, so one ceiling on that caller still describes the whole fan-out. Since a profile's `command` can be any binary, a tree that spawns without limit can exhaust the host — raise `max_depth` and a concurrency ceiling stops being a number you can reason about. There is no upper bound on `max_depth`; the trade is yours.

## What your delegates can reach

`mcp.harness.mcp` is an `mcp` block applied to every delegate this harness spawns, folded **per key** over whatever the delegate profile itself resolved — a key you set wins, a key you omit inherits.

```yaml
patches:
  - $match:
      profile: gateway
    mcp:
      harness:
        enabled: true
        mcp:
          serve:
            enabled: false
```

It answers a different question from `includeProfiles`: that one is *which* profiles you may launch, this is *what those agents can reach* once running.

`harness.enabled: true` in here does not hand a delegate a harness — the depth gate runs first and never consults `enabled`. `harness.max_depth` in here does, because the gate reads the cap from the block it is deciding on and the overlay is part of that block. Use it to grant one delegation an extra level without raising the cap globally.

Like `max_sessions` and `notify_on_complete`, the launcher resolves it from the picked profile and passes it to the sidecar as `--delegate-mcp=<json>`; a sidecar cannot work out which profile spawned it. A malformed block **fails the sidecar at startup** rather than degrading to "no overlay" — this block is what narrows a delegate's reach, so silently dropping it would widen it.

## Example config

Two independent switches, and you need **both**:

1. `mcp.harness.enabled` — whether the *server* is injected at all, for the profile doing the orchestrating.
2. `profiles.harness.enabled` — which profiles that server is allowed to *drive*.

```yaml
patches:
  # The gateway profile gets the harness server injected.
  - $match:
      profile: gateway
    mcp:
      harness:
        enabled: true
        max_sessions: 128

  # …and these are the profiles it may launch.
  - $match:
      profile: 'worker/*'
    harness:
      enabled: true

profiles:
  - id: gateway
    agent: claude-code
  - id: worker/engineer
    agent: claude-code
  - id: deploy # neither switch — invisible to the harness
    agent: claude-code
```

Setting only the first gives an agent the tools and an **empty** `list_profiles`; setting only the second nominates profiles nothing can reach. A profile's own `mcp` block works in place of the first patch, but it wholesale-replaces the global one, so you would have to restate `skills` alongside it.

`mcp.harness.includeProfiles` / `excludeProfiles` add a third, optional narrowing on top of switch 1 — see [Scoping delegation per launch](#scoping-delegation-per-launch).

To run it by hand (debugging, or a gateway that manages its own MCP config), the subcommand takes no catalogue:

```sh
hyprpilot mcp harness --max-sessions 64
```

The launcher resolves the picked profile's scope and passes it down as repeated flags — a sidecar cannot work out which profile spawned it, so it cannot read this from config itself:

```sh
hyprpilot mcp harness --include-profile 'personal/*' --exclude-profile 'personal/codex/*'
hyprpilot mcp harness --no-delegates   # includeProfiles: []
hyprpilot mcp harness --max-depth 2    # mcp.harness.max_depth
hyprpilot mcp harness --delegate-mcp '{"serve":{"enabled":false}}'
```

`--no-delegates` exists because zero `--include-profile` occurrences is exactly what an unset list looks like on the wire, and unset means unrestricted — the opposite of an empty list.
