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.

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 first if it's finished.
session_listList this server's sessions — handle, profile, status, exit code, timestamps.
session_statusOne session's state without its transcript — the cheap poll.
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. 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.
  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 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, and rmcp independently refuses to send a task to a peer that did not declare one.

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

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. Terminal states are immutable, so cancelling a task that already finished is a no-op — deliberately, because routing it through session_kill (which reaps an already-finished session) meant a spec-legal cancel of a completed task killed the running turn and deleted 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 is unimplemented (-32601): the harness never emits input_required, so no task can have outstanding inputRequests.

What this does not give you. notifications/tasks is pushed when a turn ends, but rmcp will not route task notifications through subscriptions/listen (SubscriptionFilter has no taskIds field yet), so a client that does not handle the method drops it silently — the same contract as the Claude channel. Polling tasks/get is the supported path today.

Which clients? None of the three vendor CLIs declares the extension as of claude 2.1.220, codex 0.146.0 and opencode 1.18.11 — measured against a real handshake. This exists so that the day one does, hyprpilot already speaks the standard protocol.

session_status

FieldTypeWhenWhat it means
statusstringalwaysrunning or exited. A session is exited after every turn, not only at the end.
exitCodeintonce exitedOmitted while running.
turnintalwaysWhich turn of the conversation this is, 1-based. Also the suffix of that turn's task id.
transcriptBytesintalwaysBytes written so far. A number that stops moving is a wedged agent.
hasResultboolalwaysWhether the agent's final answer has landed — see below.

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. Its stream ends step_finish(reason=stop), so the last {"type":"text"} part is the signal.

The session directory

Each session owns a 0700 temp directory. Every file in it is named on spawn / session_send / session_read results under sessionInfo.files, so nothing has to be derived from a sibling path:

KeyFileWhat it is
dirThe directory itself. Gone once the session is reaped, evicted, or the sidecar exits.
transcriptturns/<n>/turns.jsonlThe vendor's raw JSON event stream for THIS turn.
stderrstderr.logThe vendor's stderr. Surfaced in results only when non-empty.
donedone.jsonThe completion marker — see below.
breadcrumbsession.jsonCrash-recovery state (pid, pgid, owning sidecar, start ticks) read by the startup sweep.

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 resourcehyprpilot://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, hasResultwhen 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 turnhyprpilot://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.

resources/list names the two indexes and one entry per session, not one per view. The views are advertised as the template hyprpilot://sessions/{handle}/{view} instead — four views across 64 retained sessions would be 256 rows every client pays for on connect.

Both indexes carry ttlMs: 0. hyprpilot://sessions embeds each session's live status and nothing fires resources/updated for the index URI — list_changed invalidates resources/list, not this read. 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.

resources/list enumerates sessions as handle / profile / status. Useful for recovering a handle, but the point is subscribing to one you already hold.

Every older mechanism still works, unchanged. notifications/claude/channel still fires for Claude Code, notifications/tasks still pushes to clients that took a task handle, session_status is still the cheap poll, and done.json is still there for shell watchers. The subscription is an addition, not a replacement — a client that opts into nothing behaves exactly as before.

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: a turn starting or ending, a spawn or a reap moving the list.

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'sdir, 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… finished (exit 0). Read its output with session_read.
meta:    { session: "4670d5aa…", exit_code: "0" }

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.

Two things worth knowing:

  • Registering the channel is the client's job, not hyprpilot's. Claude Code only listens for channels it was launched with; that is your own launch configuration. hyprpilot declares the capability and pushes the event — where channels are unavailable, the push is dropped.
  • 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
promptstringThe instruction to send. Mutually exclusive with file.
filestringPath to a file whose contents become the prompt (~ / $VAR expanded). Mutually exclusive with prompt.
cwdstringprofile's cwdWorking directory for the agent.
modestringVendor 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_secondsinteger300Seconds to wait when wait is true. On timeout the agent keeps running; the result reports status running.

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 and timeout_seconds 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
sessionstringRequired. Handle from spawn or session_list.
tailinteger200Trailing lines to return when cursor is omitted.
cursorstringOpaque 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_secondsintegerCaps 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 — no vendor supports two concurrent turns on one conversation. Wait for it (poll session_status) or session_kill it first.
  • Refused if it cannot be resumed, because the vendor never reported a session id for it — its first turn likely failed before the agent even started; check session_read.
  • 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.

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