Skip to content

Agent Harness ​

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 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 — 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.

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), 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.)

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 $matched patch 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.

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 ​

ToolPurpose
list_profilesDiscover the profiles you can launch — vendor, model, effort, mode, cwd. Start here.
spawnStart a new session from a profile and send it a prompt.
session_sendSend another message to an existing session — resuming it if it's finished, or, with steer, interrupting the turn in flight.
session_listList this server's sessions — handle, profile, status, exit code, timestamps, directory.
session_statusOne session's state and file paths without its transcript — the cheap poll. Takes an optional turn.
session_readRead, and optionally follow live, a session's transcript.
session_killStop 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.

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 ​

FieldTypeWhenWhat it means
statusstringalwaysThe session's state: running or exited. A session is exited after every turn.
exitCodeintonce exitedThis turn's own code. Omitted while running, and for a killed turn — the -1 a kill produces says nothing.
turnintalwaysWhich turn this describes, 1-based. Also the suffix of that turn's task id.
turnFinishedboolalwaysWhether this turn has ended. Not derivable from hasResult: a turn that ended with no answer and one still working both report hasResult: false.
transcriptBytesintalwaysBytes this turn has written. A number that stops moving is a wedged agent.
hasResultboolalwaysWhether the agent's final answer has landed — see below.
filesobjectalwaysThis turn's paths — the same block sessionInfo.files carries. See The session directory.
turnsarrayalwaysEvery 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:

WhereHow they arrive
spawn / session_send / session_readsessionInfo.files
session_statusfiles, scoped to the turn you addressed
session_killfiles, on a terminate — a reap deletes the directory, so it carries none
session_listdir per row — everything else hangs off it at the names below
Any session resources/read_meta["io.hyprpilot/session"] — session, turn, files
KeyFileWhat it is
dir—The directory itself. Gone once the session is reaped, evicted, or the sidecar exits.
turnsDirturns/Where every turn's directory lives.
turn—Which turn the three paths below belong to.
turnDirturns/<n>/THIS turn's directory.
transcriptturns/<n>/turns.jsonlThe vendor's raw JSON event stream for THIS turn.
stderrturns/<n>/stderr.logTHIS turn's stderr. Surfaced in results only when non-empty.
doneturns/<n>/done.jsonTHIS turn's completion marker — see below.
breadcrumbsession.jsonCrash-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 ​

URIWhat it returnsCacheable
hyprpilot://profilesWhat list_profiles returns, same delegate scopeno — see below
hyprpilot://sessionsWhat session_list returnsno — see below
hyprpilot://sessions/<handle>What session_status returns — state, exit code, hasResult, fileswhen exited
hyprpilot://sessions/<handle>/resultThe latest turn's answer, or why there isn't onewhen exited
hyprpilot://sessions/<handle>/transcriptThe raw event stream, cappedwhen exited
hyprpilot://sessions/<handle>/stderrThe vendor's stderrwhen 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:

TemplateReads
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 happenedWhat /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 allexited 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.

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:

ParameterTypeDefaultWhat it does
promptstring—The instruction to send. Mutually exclusive with file.
filestring—Path to a file whose contents become the prompt (~ / $VAR expanded). Mutually exclusive with prompt.
cwdstringprofile's cwdWorking directory for the agent.
modestring—Vendor mode override (e.g. claude's plan). Overrides the profile.
with_configarray of objects[]Ad-hoc profile overlays. Restricted to model, effort and mode — see below.
argsstring[][]Extra arguments forwarded verbatim to the vendor CLI — the tool equivalent of the CLI's trailing -- <args>.
waitboolfalseBlock until the turn finishes. Left off, the call returns as soon as the turn starts — poll session_status.
timeout_secondsinteger45Seconds to wait when wait is true. On timeout the agent keeps running; the result reports status running.
steerboolfalsesession_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.

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 ​

ParameterTypeDefaultWhat it does
sessionstring—Required. Handle from spawn or session_list.
tailinteger200Trailing lines to return when cursor is omitted.
cursorstring—Opaque pagination cursor — pass a previous result's nextCursor verbatim to continue where it stopped.
waitboolfalseFollow the session live from cursor instead of returning immediately — the same knob, with the same meaning, as spawn's wait.
timeout_secondsinteger—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 SIGKILLed 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 ​

LimitValueEnforced by
Concurrent running sessionsnone (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 depth1 (max_depth)HYPRPILOT_SPAWN_DEPTH env, stamped on every launch. At the cap no harness is injected, and spawn is refused.
Transcript read per call60,000 bytesCaps session_read and an inline spawn/session_send result.
Default tail200 linessession_read's default when cursor is omitted.
Default turn timeout45 secondsHow long spawn/session_send block when asked to wait: true, before reporting status running. Inert by default.
Retained finished sessions64 (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 spawns 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.

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.