# sub-agent-dispatch.md — peaks sub-agent dispatch orchestrator contract

> **Slice**: 2026-06-07-sub-agent-dispatch-decouple (G3 + G6)
> **Audience**: SKILL.md authors and LLM readers of peaks-code / peaks-rd / peaks-qa
> **Status**: stable

This reference is the orchestrator-side contract for sub-agent dispatch.
It explains why the peaks family uses `peaks sub-agent dispatch <role>` as
the canonical primitive, how the dispatch envelope flows back to the LLM,
and how the new G6 heartbeat channel keeps the user informed during the
batch-sync wait. Read this before writing or extending any peaks-*
SKILL.md that dispatches sub-agents.

**LLM anti-collision note:** Do NOT guess flag names. The ONLY prompt flag is `--prompt <text>`. If unsure, run `peaks sub-agent dispatch <role> --help` first (see canonical signature below).

## Why a CLI primitive (skill-first / CLI-auxiliary)

The peaks skill family is the **product**. The CLI is a thin atomic
the skills compose. For sub-agent dispatch the relationship is:

| Surface | Who owns | What it does | Who calls it |
|---|---|---|---|
| SKILL.md (主面) | peaks-code, peaks-rd, peaks-qa | Tells the LLM when and why to dispatch a sub-agent; what prompt to pass; what artifacts to expect back | LLM (during normal Code / RD / QA flow) |
| CLI (副 / 原子) | `peaks sub-agent dispatch` | Validates the role, looks up the current IDE's `subAgentDispatcher`, returns a per-IDE tool-call descriptor + writes a dispatch record | LLM (read from the SKILL.md) |
| Dispatcher 抽象 (per-IDE) | `src/services/dispatch/sub-agent-dispatcher.ts` | Encapsulates the IDE-private tool name (claude-code: `Task`, trae: UNVERIFIED placeholder) and arg shape | CLI (called by the CLI) |

This is the **inverse** of the prior shape: SKILL.md used to hardcode
`Task(subagent_type="general-purpose", ...)`, which made peaks-loop
depend on Claude Code's specific tool name. Adding a new IDE meant
editing every SKILL.md that mentioned sub-agents. With the new shape,
the only per-IDE thing in the dispatch chain is the dispatcher — SKILL.md
stays IDE-agnostic.

> **Red line**: do not invoke `peaks sub-agent dispatch` from your own
> shell. The CLI is a primitive the LLM composes. Users do not need it.

## Dispatch contract

**Command**:

```
peaks sub-agent dispatch <role> --prompt <text> [--graph-node <nid>] [--workflow-id <wid>] [--request-id <rid>] [--session-id <sid>] [--project <repo>] [--batch-id <uuid>] --json
```

> **`--graph-node` is OPTIONAL (relaxed from the 4.0.8 RD §4 D4c requirement).**
> Slice 4.0.8 made it a `.requiredOption`; `provisionDispatchNode`
> (`src/services/workflow/provision-dispatch-node.ts`) relaxed it because the
> requirement was enforced but never validated and the recovery it prescribed
> was unwalkable: `peaks workflow node prepare` never calls `writeGraph`, so
> the node it reports is never persisted, and no CLI verb created the graph it
> reads. Its only observable effect was to block dispatch in every project
> without graph infrastructure.
>
> Omitting both flags now provisions a `dispatch-<role>-<stamp>` node and the
> graph on demand, so **plain `peaks sub-agent dispatch rd --prompt '<task>' --request-id <rid> --project . --json` succeeds**. Passing
> `--graph-node <nid> --workflow-id <wid>` binds to a node you control and
> persists it into `graphs/<wid>.json`.

**Envelope** (AC-8) — **2.1.0** (slice 2026-06-23-audit-4th #E1):

> **Audit-3rd #4 + audit-4th #E1**: the `data.prompt` field was
> deliberately removed from the CLI envelope. Prompts can carry user
> content (sometimes test credentials / internal URLs) that has no
> business landing in shell history, log aggregators, or tmux
> scrollback. The dispatch record on disk (gitignored under
> `.peaks/_sub_agents/`) keeps the prompt for the sub-agent to read;
> CLI stdout stays metadata-only. Surface `originalPromptSize` +
> `promptSize` so the LLM-side runner can still reason about the size
> delta without seeing the content. The prompt content the LLM should pass
> through lives in `data.toolCall.args.prompt` (the IDE-arg the
> consumer passes to the tool), NOT in the outer envelope.

```json
{
  "ok": true,
  "command": "sub-agent.dispatch",
  "data": {
    "envelopeVersion": "2.1.0",
    "role": "rd",
    "ide": "claude-code",
    "originalPromptSize": 4321,
    "promptSize": 4321,
    "toolCall": {
      "name": "Task",
      "args": {
        "subagent_type": "general-purpose",
        "description": "rd for rid=002-2026-06-07-...",
        "prompt": "<complete prompt the LLM should pass through to the tool>"
      }
    },
    "dispatchRecordPath": ".peaks/_sub_agents/2026-06-06-session-5b1095/dispatch-002-2026-06-07-...-...json",
    "batchId": "<uuid>",
    "dispatchedInBatch": 3,
    "forcedAt": null,
    "contextImpact": {
      "promptBytes": 4321,
      "artifactBytes": 0,
      "totalBytes": 4321
    },
    "artifactMetas": [],
    "orchestratorVisibleHint": "⏳ Spawning sub-agent via Task tool: rd for rid=002-2026-06-07, batch-id=… (ETA ~45s)",
    "artifactsPublicPaths": [],
    "expectedCompletionSeconds": 45
  },
  "warnings": [],
  "nextActions": [
    "Tool call is dry-run; LLM must execute the tool to actually dispatch the sub-agent.",
    "After dispatching, the sub-agent should call `peaks sub-agent heartbeat --record <dispatchRecordPath>` periodically."
  ]
}
```

**LLM side**: read `data.toolCall` (the `{name, args}` descriptor), look
up the tool by `name` in the current environment, and invoke it with
`args`. The CLI does not spawn anything. The IDE (Claude Code, Trae,
etc.) is the one that actually runs the sub-agent.

> **Slice 2026-06-23-audit-4th #F2 callout**: the prompt content the
> LLM should pass through lives in `data.toolCall.args.prompt` (the
> IDE-arg the consumer passes to the tool), NOT in the outer envelope
> (`data.prompt` was removed in slice 2026-06-23-audit-3rd #4; see the
> `envelopeVersion: '2.1.0'` marker and the `envelope-contract.md`
> reference for the full contract). A reader skimming the example
> should not conflate the two fields.

**Side effect**: the CLI writes a dispatch record to
`.peaks/_sub_agents/<sid>/dispatch-<rid>-<ts>.json` (R-2 path-guarded).
The record starts with `heartbeats: []`, `lastBeatAt: null`, `status: 'queued'`
and is updated by the heartbeat flow (§G6 below).

## Role model

`SubAgentRole` is a free-form string. The CLI **does not** enforce a
hard whitelist. Recommended roles:

- Top-level: `rd` | `qa` | `ui` | `txt` | `general-purpose`
- QA sub-roles: `qa-business` | `qa-perf` | `qa-security`
- Business sub-divisions: `qa-business-api` | `qa-business-frontend` |
  `qa-business-regression` | ... (≤ 2 levels deep per RL-4)
- Promotable Worker (architecture预留, slice #009 不实际提升): `prd-business` |
  `prd-technical` | `prd-ux` | `ui-visual` | `ui-flow` | `ui-component`

The only hard validation is: non-empty, ≤ 256 chars, no control
characters. Any other string is a convention, not a contract.

## Per-IDE dispatchers (G1)

| IDE | Tool name | Args shape | Status |
|---|---|---|---|
| `claude-code` | `Task` | `{subagent_type: "general-purpose", description, prompt}` | verified |
| `trae` | `Task` | byte-identical to claude-code by design | UNVERIFIED — pending real Trae dogfood |
| `null` (no IDE detected) | — | throws `IDE_NOT_SUPPORTED` | fallback |

Adding a new IDE means:

1. Add a new adapter to `src/services/ide/adapters/<id>-adapter.ts`.
2. Fill in `subAgentDispatcher: <yourDispatcher>` (implements
   `SubAgentDispatcher`).
3. Register the adapter in `ide-registry.ts`.

SKILL.md and existing dispatcher implementations are untouched.

## G6 — heartbeat + dynamic progress (RL-13..RL-16)

Sub-agents run for 30 s to 2 min. During that wait, the LLM platform
provides no progress signal to the Dispatcher. Without a heartbeat
channel, the user sees a frozen terminal and reasonably assumes the
system is dead. G6 is the rule that says: **no**.

### The three-layer contract

| Layer | Who | What | Cadence |
|---|---|---|---|
| **Sub-agent writes** | The sub-agent itself | Calls `peaks sub-agent heartbeat --record <dispatchRecordPath> --status <state> --progress <pct> --note "<text>"` to append a heartbeat | 30 s default; SKILL.md can override via `heartbeatIntervalSec: 15` |
| **Dispatcher reads** | peaks-code main loop, during the batch-sync wait | In-process async poller (`BatchHeartbeatPoller`) reads `heartbeats[]` + `lastBeatAt` from each record, emits a single-line status per G6.5 | 10 s (offset from 30 s to avoid jitter) |
| **User / CLI reads** | Anyone, anytime | `peaks sub-agent list --session-id <sid> --json` (G5 RL-10, future slice) | manual |

### Sub-agent prompt template (heartbeat-aware + MCP-decoupled)

Every sub-agent prompt dispatched via `peaks sub-agent dispatch` should
include the heartbeat instruction so the LLM knows when and how to
report progress. The recommended paragraph (auto-generated by the CLI
and the SKILL.md heart of each Dispatcher) is:

```
While running, call `peaks sub-agent heartbeat --record <dispatchRecordPath>
--status <state> --progress <pct> --note "<text>"` at least every 30 seconds
(the Dispatcher expects 30s cadence). On completion call
`--status done --progress 100 --note "completed"`. On failure,
`--status failed`. Do not skip heartbeats; the parent Dispatcher uses them
to keep the user informed during the wait.
```

**Slice #016 retirement (G3 prompt template — MCP subsystem removed)**:
The MCP-decouple paragraph from slice #007-007 no longer applies.
peaks-loop no longer manages MCP install or invocation. Sub-agents
check their own tool list for `mcp__<server>__*` entries and invoke
the tool by name directly. The only prompt-template addition that
remains is the tool-list self-check:

```
This task may need an MCP server (playwright, chrome-devtools, figma,
or context7). Check your tool list for any `mcp__<server>__*` entry.
If present, invoke the tool by name directly. If absent, tell the user
the install command for their IDE (Claude Code:
`claude mcp add <server> -- <npx-command>`) and stop the sub-task until
the user installs the MCP. Do NOT hand-edit `.claude/settings.json` or
auto-install on the user's behalf.
```

The tool-list self-check is required for any sub-agent that needs a
browser, design, or docs-lookup capability. The CLI auto-generates it
for `role in (rd, qa, ui, txt)`.
when the active IDE is not `claude-code`; for `role = general-purpose`
or unknown roles, the caller (the SKILL.md heart of the Dispatcher) must
add it explicitly.

`heartbeatIntervalSec` is overridable per SKILL.md (5..600). Default 30.

### Status line shape (G6.5)

Single line, 80-120 chars:

```
[peaks-code] swarm 3/3 running | rd-planning 45% (12s ago) | qa-test-cases 30% (5s ago) | ui-design 20% (2s ago)
[peaks-code] swarm 3/3 running | rd-planning 70% (8s ago) | qa-test-cases 50% (3s ago) | ui-design 30% (6s ago)
...
[peaks-code] swarm 3/3 done
```

If a sub-agent has not written a heartbeat in 5 minutes, the poller
appends `⚠ stale` to that sub-agent's segment and marks
`status: 'stale'` on the record. **It does not cancel, kill, or send
SIGTERM** (RL-15). Stale is a user-visible warning, not a failure.
The user decides.

### Truncation (RL-16)

`heartbeats[]` is append-only for audit, but capped at 100 entries.
Past 100, the oldest are dropped and `truncated: true` is set. The
record JSON can otherwise balloon to MB on long-running sub-agents.
100 is the LLM-friendly limit — stale heartbeats are not informative
once the poller has read them.

### Acceptance criteria covered

- AC-33 `peaks sub-agent heartbeat` CLI primitive (implemented)
- AC-34 Dispatch record schema upgrade (`heartbeats[]` + `lastBeatAt` + `status` aggregate, backward-compat defaults)
- AC-35 peaks-code main loop batch-sync poller (10 s cadence, 5 min stale)
- AC-36 SKILL.md fan-out sections include heartbeat instruction
- AC-37 G6 E2E dogfood (3 heartbeats, 101 → truncated, stale simulation)

## Acceptance criteria (overall G1-G6)

- AC-1..AC-5: dispatcher interface + 3 adapter impls (`claudeCodeSubAgentDispatcher`, `traeSubAgentDispatcher`, `nullSubAgentDispatcher`)
- AC-6: dispatcher unit tests
- AC-7..AC-9: dispatch CLI signature + envelope + steps
- AC-10: dispatch CLI unit tests
- AC-11 / AC-11b / AC-11c: E2E dogfood on the live repo
- AC-12: `peaks --help` byte-level delta is `+1` line (`sub-agent <cmd>`)
- AC-13..AC-19: SKILL.md fan-out rewrites (peaks-code 5 spots, peaks-rd 2, peaks-qa 3-way, peaks-ui 1; this reference is the canonical contract)
- AC-22..AC-24: R-2 path guard, prompt size limit, atomic write
- AC-25..AC-32: G5 resource lifecycle (RL-1 batch counter, dispatch record schema, reducer dispose, slice archive, cancel dispose)
- AC-33..AC-37: G6 heartbeat CLI, schema, poller, status line, E2E

## G11.5 — visibility contract (slice 2026-07-28-sub-agent-visibility)

The dispatch CLI is a `dry-run` primitive — it never spawns the Task
tool itself. The LLM orchestrator (peaks-code / peaks-rd / peaks-qa)
executes the returned `data.toolCall` via the IDE's `Task` agent. The
harness does not always render the spawned agent's progress in the
user's terminal, so the user frequently sees "peaks dispatched" but
no Task UI feedback. This rule closes that gap with three
machine-readable + one prose obligation.

**Envelope additions (single dispatch and `--from-dag`):**

- `data.orchestratorVisibleHint: string` — a single-line prose string
  the orchestrator MUST echo verbatim in the user-facing turn
  (i.e. in the orchestrator's response, NOT just the tool call).
  Format: `⏳ Spawning sub-agent via Task tool: <role> for rid=<rid>, batch-id=<batchId> (ETA ~<seconds>s)`.
- `data.artifactsPublicPaths: readonly string[]` — public artifact
  paths the sub-agent will own. Empty when `--write-artifact` is not
  passed; otherwise contains the single user-supplied path. The
  orchestrator surfaces this list before fan-out so the user can see
  what files will appear on disk.
- `data.expectedCompletionSeconds: number` — ETA budget. Single
  dispatch defaults to 45s; `--from-dag` defaults to 60s. Use as a
  hint, not a hard deadline (the heartbeat poller at §G6 still owns
  the 5-min stale threshold).

**Orchestrator obligations (text-locked, no skip):**

1. Before every `Agent` tool call that materializes a `data.toolCall`
   returned by `peaks sub-agent dispatch`, the orchestrator MUST emit
   one line of prose to the user containing
   `⏳ Spawning sub-agent via Task tool: <description>`. `<description>`
   MUST be ≥ 6 chars and MUST include the rid. (`description` is the
   short label rendered by the IDE; the prose line is the user-visible
   counter-part.) The line is rendered in the assistant's reply text,
   not in the tool result block.
2. When the CLI returns a fan-out envelope (`dispatchCount > 1`),
   the orchestrator MUST emit the prose line once for the whole fan-out
   (not once per leaf) and then issue all `Agent` tool calls in the
   same turn. Repeating the line N times defeats the point of fan-out.
3. If the orchestrator cannot satisfy (1) (e.g. an inline tool call
   failed and the orchestrator wants to retry), it MUST explain the
   retry in the next user-visible message — silent retry is a
   visibility regression.

**Why the prose, not just the envelope field**: the
`orchestratorVisibleHint` field is machine-readable, but a terminal
user reads prose. The LLM MUST transcribe the hint into the visible
reply. The two surfaces are not redundant — the field is for
downstream tooling, the prose is for the human. Same payload,
different readers.

**Regression guard**: none. The unit test that parsed the same SKILL.md
text and asserted the G11.5 paragraph is present was deleted in
`f17aa377`; do not delete the heading — convention is the only thing
holding it now.

---

## D21 — LLM-side completion signal (mandatory)

`peaks sub-agent dispatch` writes the record in `queued` state. The
dispatcher has no other way to learn that the spawned Task tool
actually returned. **The LLM runner MUST call `peaks sub-agent finalize`
once per dispatched Task, in the post-completion branch** (after the
Task tool's `tool_result` has been received). Without it, the record
stays `queued, no-execution` forever.

```sh
# After each Task tool returns:
peaks sub-agent finalize --request-id <rid> --outcome done

# On Task failure:
peaks sub-agent finalize --request-id <rid> --outcome failed --error "<msg>"

# Crash-recovery sweep (only if the runner was interrupted and many
# records are stuck in queued):
peaks sub-agent finalize --all-stale --outcome done
```

`finalize` calls `markCompleted` (in dispatch-record-writer.ts), which
transitions the record to the chosen status and unregisters the path
from `active-dispatches.json`. The next runner does not see a stale
ghost.

This was added in slice 2026-07-17-D21 after 13 stale records were
observed in the peaks-loop repo (`active-dispatches.json` would have
grown unboundedly without this fix).

## How to apply

When writing a SKILL.md that fans out sub-agents:

1. Use `peaks sub-agent dispatch <role>` (never `Task(...)`).
2. Graph binding is OPTIONAL — omit `--graph-node` and the CLI provisions a
   node on demand. Pass `--graph-node <nid> --workflow-id <wid>` only when you
   need the dispatch bound to a node you control (and note the node is
   persisted by `provisionDispatchNode`, not by `peaks workflow node prepare`).
3. Issue all dispatches in a single message; the LLM will fire all
   returned toolCalls in parallel.
4. Pass `--request-id` and `--session-id` (or omit and let the CLI resolve the
   active session).
5. The sub-agent prompt **must** include the heartbeat instruction
   (30 s cadence; override via `heartbeatIntervalSec` if needed).
6. After the fan-out returns, the Dispatcher reducer reads the
   dispatch record + the artifacts the sub-agents wrote, marks
   `disposed: true` on each record, and advances the state machine.
7. The poller handles the 5-min stale case as a warning, never as a
   failure. The user is the one who decides to cancel.

## Cross-reference

- PRD #009 G1-G6 (this slice's source of truth)
- RD request #009 in-scope files (implementation contract)
- `.peaks/memory/sub-agent-resource-lifecycle-red-line.md` (G5 red line)
- `.peaks/memory/sub-agent-heartbeat-progress-red-line.md` (G6 red line)
- `skills/peaks-code/references/swarm-dispatch-contract.md` (predecessor contract)
- `skills/bee/peaks-qa/references/qa-fanout-contract.md` (QA-specific fan-out)

---

### Sub-agent mechanism (IDE-agnostic dispatch, NOT Skill tool)

> Body of `### Sub-agent mechanism`. **Code is itself a skill running in the current session. To invoke a role in the Swarm, Code MUST call the IDE-agnostic dispatch primitive `peaks sub-agent dispatch <role>` — NOT the `Skill` tool, NOT any IDE-private sub-agent literal.** The `Skill` tool is single-stack and blocking; using it for "parallel" work was the v1.x illusion of concurrency. The dispatch CLI is the only mechanism that keeps SKILL.md free of IDE-private tool names and lets the same prompt work on every registered IDE.

Each sub-agent dispatch call looks like:

```
peaks sub-agent dispatch <role> \
  --prompt "<paste peaks-<role>/SKILL.md body, minus the self-presence / Step 0 blocks,
            plus the runtime arguments: project=<repo>, session-id=<session-id>, request-id=<rid>, mode=<mode>,
            plus the explicit output contract: 'Write your artefacts to the paths listed below and
            return only the list of paths. Do not call Skill(...). Do not set presence. Do not
            hand back prose.', plus the heartbeat instruction: 'While running, call
            peaks sub-agent heartbeat --record <dispatchRecordPath> --status <state> --progress <pct> --note \"<text>\"
            at least every 30 seconds.'>" \
  --graph-node <nid> --workflow-id <wid> \
  --request-id <rid> --session-id <session-id> --project <repo> --json
```

Then the LLM takes `data.toolCall` from the envelope (a `{name, args}` descriptor), looks up the tool by `name` in its environment, and invokes it with `args` — IDE-private, no SKILL.md hardcoding.

The role's required artefact paths (also see peaks-ui/rd/qa SKILL.md and `swarm-dispatch-contract.md`):

| Role | Writes | Reads (PRD-side) |
|---|---|---|
| `ui` | `.peaks/_runtime/<sessionId>/ui/design-draft.md`, `.peaks/_runtime/<sessionId>/ui/requests/<rid>.md` | PRD body, project-scan, archetype |
| `rd-planning` | `.peaks/_runtime/<sessionId>/rd/tech-doc.md` (feature/refactor) or `.peaks/_runtime/<sessionId>/rd/bug-analysis-<rid>.md` (bugfix) | PRD body, project-scan, existing-system, codegraph |
| `qa-test-cases` | `.peaks/_runtime/<sessionId>/qa/test-cases/<rid>.md` | PRD body, RD planning artefact, project-scan, codegraph |

**Code launches all sub-agents in the swarm plan in a single message (multiple `peaks sub-agent dispatch` calls in parallel, each followed by execution of the returned toolCall)** — this is what gives real concurrency. Do not sequentialize them. The CLI returns N toolCall descriptors; the LLM fires all N in the same message; the IDE dispatches them concurrently; Code then waits for all to return, runs `ls` checks against the paths above (Peaks-Loop Gate B), and only then advances to RD implementation.

**Hard prohibitions on sub-agents** (also passed in each dispatch prompt):

- Do NOT call `Skill(skill="...")` — sub-agents must not recursively activate skills, that defeats the fan-out.
- Do NOT call `peaks skill presence:set` — only the main Code loop owns the presence lease. Sub-agents write to a per-agent marker file `.peaks/_runtime/<sessionId>/system/sub-agent-<role>.json` if they need to record state, but never the main presence lease.
- Do NOT open interactive user prompts. If a sub-agent needs clarification, it must return a `blocked` verdict in its return string and let Code handle the user message.
- Do NOT commit, push, install hooks, or apply settings.json mutations. Only Code holds those permissions.
  - **Reinforced (2026-06-28 incident):** an RD sub-agent silently auto-committed its own diff during a fix-callerid-leak dispatch (commit `bff4dff`). The orchestrator caught the unauthorized commit on the next turn but the user had to manually re-approve. **Every dispatch prompt MUST copy the verbatim block below into its `--prompt` argument** so the sub-agent sees the rule with full weight:

    ```
    ## Commit / push policy (verbatim, mandatory)
    - Do NOT run `git commit`, `git push`, `git tag`, `git checkout -b`, or any
      state-mutating git command. Leave the diff on disk and return the
      diff summary in your final message. The orchestrator (Code) owns the
      commit / push decision; it will run `git status` and `git diff` after
      your return and decide whether to commit.
    - If you believe a commit is urgent (e.g. emergency hotfix), return
      `verdict: blocked-by-orchestrator-policy` and explain. Do NOT commit
      yourself even in emergencies.
    ```
- **Do write heartbeats** — call `peaks sub-agent heartbeat --record <dispatchRecordPath> --status running --progress <pct> --note "<text>"` at least every 30s (see `sub-agent-dispatch.md` §G6 for the full contract). The parent Dispatcher uses these to render the live status line during the wait.

After every sub-agent dispatch returns, Code **restores presence** once (not per-agent), then continues to Gate B verification:

```bash
peaks skill presence:set peaks-code --project <repo> --mode <mode> --gate swarm-converged
```

---

## Detached Mode (Phase A, slice 2026-08-10)

`peaks sub-agent dispatch <role> --prompt <text> --graph-node <nid> --workflow-id <wid> --request-id <rid> --mode detached --vendor claude|codex|copilot [--no-throttle --max-concurrent <N>] --json` spawns a real OS process independent of the orchestrator IDE session. `--graph-node` is optional here too — the relaxation is on the shared option table, not on the in-process path only.

- **Cross-platform spawn**: Windows uses `DETACHED_PROCESS` + `CREATE_NEW_PROCESS_GROUP`; POSIX uses `setsid` + `nohup`. Implementation: `packages/peaks-loop-internal-runtime/src/process-supervisor.ts`.
- **Minimum-context prompt**: `PromptBuilder` emits a 5–8KB slice `{rid, role, vendor, files, refs}` plus the verbatim `<peaks-auto-compact>` marker. The forbidden marker `@@@ORCHESTRATOR_SESSION_HISTORY_BOUNDARY@@@` MUST NOT appear in any prompt (unit-tested; regression fails vitest).
- **G8 infinite context (corrected 2026-09-13, rid `2026-09-13-defects-e`)**: the child does **not** and **cannot** compact its own session. `/compact` is not model-invokable, hooks may only observe or veto, there is no `--compact` flag, and no external process can reach a running session's memory — compaction is the child's own harness acting on its own window. What the `<peaks-auto-compact>` marker actually asks for is the reachable half: write the durable state (summary / task state / decisions kept / options discarded) to `.peaks/_runtime/<sid>/detached/<rid>/compact/<n>.json` and heartbeat to `status.json`, so nothing load-bearing lives only in a conversation that will be summarised. Nothing flows back into the child's context — its prompt is the `-p` argv string fixed at spawn. Token cost: peaks runtime has no token-cost throttle to relax (the only guard, `ResourceBudgetGuard`, throttles RSS / CPU / fan-out, never spend), so there is no cost cap — but that is an absence of throttling, not a granted permission with a flag behind it.
- **Lifecycle closure invariant**: `pid` / `log.txt` / `status.json` / `owner-session` 100% cleaned on every exit path (success / crash / OOM / SIGTERM). `peaks sub-agent cleanup --orphan` is the only orphan-killing path (RL-15: user-only decision; default is "do nothing").
- **Resource budget guard**: `ResourceBudgetGuard` enforces `runtime RSS ≤ 200MB / idle CPU ≤ 5% / fan-out ≤ 8 (default)`. `--no-throttle` bypasses (user accepts risk; adds warning to envelope). `--max-concurrent <N>` overrides the 8 default.
- **Vendor-neutral**: `VendorAdapterRegistry` registers `ClaudeAdapter` (Phase A) / `CodexAdapter` + `CopilotAdapter` (Phase B). Missing vendor CLI → fallback to default with `dispatchRecord.warning = 'vendor fallback: <id>→claude'`.
- **Orchestrator prose obligation (G11.5)**: before each detached dispatch, emit `⏳ Spawning detached sub-agent via <vendor>: rid=<rid> (ETA ~60s)`. Envelope field `data.orchestratorVisibleHint` carries the same string for tooling.

Default mode remains `in-process` (backward compat). The dispatch CLI accepts `--mode in-process|detached`; existing 106+ tests dispatching without `--mode` continue to use the in-process IDE-internal Task path.
