# Runtime Adapters

How Minions abstracts over CLI runtimes (Claude Code, GitHub Copilot CLI, future
adapters). Engine code never special-cases a runtime by name — every CLI-specific
behavior is hidden behind an adapter object resolved through `resolveRuntime()`.

## What lives in `engine/runtimes/`

| File | Role |
|------|------|
| `engine/runtimes/index.js` | Adapter registry. `resolveRuntime(name)`, `listRuntimes()`, `registerRuntime()`. Engine code MUST go through these. |
| `engine/runtimes/claude.js` | Claude Code adapter. Owns binary probe, `--system-prompt-file`, JSONL parser, model shorthands, budget cap, bare mode. |
| `engine/runtimes/copilot.js` | GitHub Copilot CLI adapter. Owns standalone-vs-`gh-copilot` resolution, stdin-only prompt delivery, `https://api.githubcopilot.com/models` discovery, effort `'max' → 'xhigh'` mapping. |
| `engine/runtimes/codex.js` | OpenAI Codex CLI adapter. Owns `@openai/codex`/native binary resolution, `codex exec --json -` prompt delivery, `codex debug models --bundled` discovery, `.agents/skills` roots, and Codex JSONL parsing. |

`resolveRuntime(name)` throws when `name` is unknown so misconfigurations surface
at dispatch time instead of producing silent fallbacks deep inside spawn logic.
When `name` is `null`/omitted, `resolveRuntime()` falls back to `'claude'` for
parser-routing compatibility (Copilot's `parseOutput` cannot consume the Claude
JSONL `{type:"result",result:"..."}` shape — see W-mpmwxkk40007c995). The fleet
default that determines which runtime *new spawns* use is separate:
`ENGINE_DEFAULTS.defaultCli` (also in W-mpmwxkk40007c995) is now `'copilot'`, so
operators with no explicit `engine.defaultCli` get Copilot on dispatch.

## Adapter Interface

Every adapter exports the following surface. The Claude adapter is the canonical
implementation — copy it as the template for new runtimes and only override the
methods that genuinely differ.

| Member | Type / Returns | Purpose |
|--------|----------------|---------|
| `name` | string | Adapter identifier (matches the registry key). |
| `capabilities` | flag object (see below) | Feature flags consumed by engine code. |
| `resolveBinary({ env, config? })` | `{ bin, native, leadingArgs }` or null | Probe PATH, npm-globals, package overrides; cached to `engine/<name>-caps.json`. `leadingArgs` is `[]` for direct binaries and `['copilot']` for the `gh copilot` extension fallback. |
| `capsFile` | absolute path | Cache file for `resolveBinary()` results. |
| `installHint` | string | Surfaced when `resolveBinary()` returns null. Consumed by `engine/preflight.js` and `engine/spawn-agent.js`. |
| `listModels()` | `Promise<{id,name,provider}[] | null>` | Model catalog or null when discovery isn't supported. |
| `modelsCache` | absolute path | Cache file for `listModels()` results. |
| `spawnScript` | absolute path | Path to the runtime-agnostic spawn wrapper (currently `engine/spawn-agent.js` for both runtimes). |
| `buildArgs(opts)` | `string[]` | CLI args excluding the binary itself. |
| `buildSpawnFlags(opts)` | `string[]` | Optional; engine-side flags consumed by `engine/spawn-agent.js` (overrides the default `_buildAgentSpawnFlags`). |
| `buildPrompt(promptText, sysPromptText)` | string | Final prompt delivered to the CLI. Adapters that lack `--system-prompt-file` (Copilot) prepend a `<system>` block here. |
| `getUserAssetDirs({ homeDir })` | `string[]` | Runtime-native global asset roots passed to spawn as `--add-dir` so worktrees still see them. |
| `getSkillRoots({ homeDir, project? })` | `[{ scope, dir, projectName? }]` | Where `collectSkillFiles` looks for native + project skill markdown. |
| `getSkillWriteTargets({ homeDir, project? })` | `{ personal, project }` | Where `extractSkillsFromOutput` writes auto-extracted skills. |
| `getResumeSessionId(...)` | string or null | Pre-spawn resume probe; runtime-agnostic stamp prevents cross-runtime session-ID confusion. |
| `saveSession(...)` | void | Writes `agents/<id>/session.json` after a successful turn. |
| `resolveModel(input)` | string or undefined | Shorthand expansion / passthrough. Returning undefined causes the adapter to omit `--model` and the CLI uses its own default. |
| `modelLooksFamiliar(model)` | boolean | Heuristic powering the preflight "stale model after CLI switch" warning. |
| `parseOutput(raw)` | `{ text, usage, sessionId, model }` | Final-event parser. |
| `parseStreamChunk(line)` | event object or null | Single JSONL line → typed event. |
| `parseError(rawOutput)` | `{ message, code, retriable }` | Codes: `auth-failure`, `context-limit`, `budget-exceeded`, `model-unavailable` (retriable=true for upstream overload/503; retriable=false for invalid/typo'd model id — Copilot enriches the message via `_warmModelCache()` so it lists the available models), `crash`, null. |
| `createStreamConsumer(ctx)` | consumer object | Stream accumulator used by `engine/llm.js`. |
| `detectPermissionGate`, `getPromptDeliveryMode`, `usesSystemPromptFile`, `classifyFailure` | misc | Adapter-owned policy that engine code reads through accessors instead of branching on `runtime.name`. |

The contract surface is documented inline at the top of `engine/runtimes/claude.js`
(see the JSDoc block at the start of that file). Keep that block authoritative —
this table is the human-readable mirror.

## Capability Flags

Engine code branches on flags, never on runtime names. Add a flag whenever a
real behavioral split appears between adapters.

| Flag | Claude | Copilot | Codex | Gates |
|------|--------|---------|-------|-------|
| `streaming` | ✓ | ✓ | ✓ | JSONL events on stdout. |
| `sessionResume` | ✓ | ✓ | ✓ | Runtime-specific resume args. |
| `midRunSessionId` | ✓ | ✗ | ✗ | Resumable session ID emitted before the terminal `result` event; when false, steering waits for a checkpoint. |
| `systemPromptFile` | ✓ | ✗ | ✗ | sysprompt via `--system-prompt-file` (else inlined into stdin by `buildPrompt`). |
| `effortLevels` | ✓ | ✓ | ✓ | Runtime reasoning-effort controls. Codex maps through `--config model_reasoning_effort=...`; Copilot maps `'max'` to `'xhigh'`; Claude leaves `'max'` alone. |
| `costTracking` | ✓ | ✗ | ✗ | USD + token counts in the result event. Copilot only emits `premiumRequests`; Codex accounting is not assumed. |
| `modelShorthands` | ✓ | ✗ | ✗ | Bare `sonnet`/`opus`/`haiku` accepted by Claude only. |
| `modelDiscovery` | ✗ | ✓ | ✓ | `listModels()` returns a real catalog when supported (Copilot API, Codex `debug models --bundled`). |
| `promptViaArg` | ✗ | ✗ | ✗ | If true, prompt goes via `--prompt <text>` instead of stdin. False for all current runtimes. |
| `budgetCap` | ✓ | ✗ | ✗ | `--max-budget-usd <n>`. |
| `bareMode` | ✓ | ✗ | ✗ | `--bare` (suppresses CLAUDE.md auto-discovery). Other runtimes use their own config/suppression knobs. |
| `fallbackModel` | ✓ | ✗ | ✗ | `--fallback-model <id>` on rate-limit. |
| `sessionPersistenceControl` | ✓ | ✗ | ✗ | Engine writes `session.json`; Copilot and Codex own their runtime stores, while Minions records IDs only for safe resume. |
| `resumePromptCarryover` | ✗ | ✓ | ✓ | CC resume turns prepend recent visible Q&A in stdin when the runtime session store is opaque to Minions. |
| `resumeBookkeepingTurn` | ✓ | — | — | Claude CLI injects a synthetic "Continue from where you left off." meta turn on `--resume`; CC prompts must tell the model not to treat it as user intent. |
| `streamConsumer` | ✓ | ✓ | ✓ | Adapter implements `createStreamConsumer(ctx)` — required by `engine/llm.js` accumulator. |

When a behavior is genuinely uniform across all current adapters, it still gets
a flag if a future runtime might disagree — flags are cheap.

## CC vs Agent Runtime Independence

Command Center (CC) / doc-chat and the agent fleet are two **independent**
runtime paths. Setting `engine.ccCli: copilot` for CC alone must NOT switch the
agent fleet to Copilot, and vice versa. Both paths fall through to
`engine.defaultCli` (the fleet-wide default), but they never see each other's
overrides.

Six helpers in `engine/shared.js` are the single source of truth for runtime/model
resolution. Engine code MUST go through them instead of reading config keys
directly.

| Helper | Chain |
|--------|-------|
| `resolveAgentCli(agent, engine)` | `agent.cli` → `engine.defaultCli` → `'copilot'` |
| `resolveCcCli(engine)` | `engine.ccCli` → `engine.defaultCli` → `'copilot'` |
| `resolveAgentModel(agent, engine)` | `agent.model` → `engine.defaultModel` → undefined |
| `resolveCcModel(engine)` | `engine.ccModel` → `engine.defaultModel` → undefined |
| `resolveAgentMaxBudget(agent, engine)` | `agent.maxBudgetUsd` → `engine.maxBudgetUsd`. Honors literal `0`. |
| `resolveAgentBareMode(agent, engine)` | `agent.bareMode` → `engine.claudeBareMode` → false. Strict null check so per-agent `false` overrides engine `true`. |

Agent dispatch resolves the runtime once at spawn time:

```js
// engine.js spawnAgent (~line 1866)
const runtime = resolveRuntime(shared.resolveAgentCli(agentConfig, engineConfig));
```

CC / doc-chat resolves the runtime via `engine/llm.js` `_resolveRuntimeFor()`:

```js
// engine/llm.js (~line 247)
if (!runtimeName && callOpts.engineConfig) runtimeName = resolveCcCli(callOpts.engineConfig);
```

Tests in `test/unit/runtime-fleet-helpers.test.js` enforce that
`resolveAgentCli` does NOT fall through to `engine.ccCli` and that `resolveCcCli`
does NOT inspect any agent settings.

## `--cli` / `--model` / `--effort` Knobs

Three CLI flags switch the fleet without dashboard interaction. They forward
through `engine/cli.js` `parseRuntimeFleetFlags()` and persist into `config.json`.

| Flag | Persists to | Notes |
|------|-------------|-------|
| `--cli <runtime>` | `engine.defaultCli` | Validated against the runtime registry; unknown runtimes exit non-zero with the registered list. Implicitly clears any explicit `engine.ccCli` (with a warning naming the prior value). |
| `--model <id>` | `engine.defaultModel` | Empty-string sentinel (`--model ''`) **deletes** `engine.defaultModel` — never pin to a literal empty string. Compatibility with `defaultCli` is checked; mismatch emits an incompatibility warning suggesting `--model ''`. |
| `--effort <level>` | dispatched as `opts.effort` | One of `low|medium|high|xhigh`. Copilot maps `'max' → 'xhigh'`; Claude passes `'max'` through unchanged. Capability-gated by `effortLevels`. |

Examples:

```bash
minions start --cli copilot --model claude-sonnet-4.5
minions restart --cli claude --model ''     # '' DELETES defaultModel
minions config set-cli copilot --model gpt-5.4
```

Inside the adapters, `buildArgs(opts)` translates the resolved values into the
CLI-specific argv:

```js
// engine/runtimes/copilot.js buildArgs
if (model) args.push('--model', String(model));
if (mappedEffort) args.push('--effort', mappedEffort);
```

When `resolveAgentModel` returns undefined the adapter omits `--model` from
`buildArgs` entirely and the underlying CLI uses its own default.

## Per-Runtime Skill Roots

Skills live in runtime-native directories so a skill placed there is genuinely
visible to that runtime when invoked outside Minions. The adapter's
`getSkillRoots()` and `getSkillWriteTargets()` are the discovery and write
contracts.

| Adapter | Personal skill root | Project skill roots | Auto-extraction write targets |
|---------|---------------------|---------------------|-------------------------------|
| Claude  | `~/.claude/skills`  | `<repo>/.claude/skills` | personal: `~/.claude/skills`; project: `<repo>/.claude/skills` |
| Copilot | `~/.copilot/skills` (+ `~/.agents/skills`) | `<repo>/.github/skills`, `<repo>/.agents/skills` | personal: `~/.copilot/skills`; project: `<repo>/.github/skills` |
| Codex | `~/.agents/skills` (+ `/etc/codex/skills`) | `<repo>/.agents/skills` | personal: `~/.agents/skills`; project: `<repo>/.agents/skills` |

`getUserAssetDirs()` returns the union of runtime-native global roots and is
passed to spawn as `--add-dir` so spawned agents read the same global assets the
user sees outside Minions. The cross-runtime portable location
`~/.agents/skills` is included by every adapter that opts into it (Copilot today;
add it to new adapters when the runtime can read directly from there).

## The "No Runtime-Name Branching" Rule

The whole point of this layer: **engine code MUST gate behavior on
`runtime.capabilities.*` flags, not on `runtime.name === 'claude'` comparisons.**

If you find yourself wanting to write `if (runtime.name === 'copilot')` in
engine code, the correct response is one of:

1. Add a capability flag to the contract and set it on every adapter.
2. Add a method to the adapter and call it through the runtime object.
3. Move the special case into the adapter's `buildArgs` / `buildPrompt` /
   `parseOutput` where it belongs.

Tests in `test/unit/runtime-adapters.test.js` and `test/unit/copilot-adapter.test.js`
enforce the contract; the source-inspection tests in `test/unit.test.js` look
for `resolveRuntime(...)` call sites and reject runtime-name comparisons in new
code.

## Adding a New Runtime

1. Create `engine/runtimes/<name>.js` implementing the full adapter contract.
   Copy `claude.js` as the template and override only the members that differ.
   Set a useful `installHint`.
2. Register it in `engine/runtimes/index.js`:
   ```js
   registry.set('<name>', require('./<name>'));
   ```
3. Add unit coverage modeled on `test/unit/runtime-adapters.test.js`.

Once registered, the dashboard `/api/runtimes` endpoint, the `--cli` flag
validator, the agent CLI dropdown, the `engine/preflight.js` per-runtime binary
check, and the model discovery cache all light up automatically. Use capability
flags — never special-case in engine code.

## See Also

- `CLAUDE.md` → "Runtime Adapters" section — short reference table that mirrors
  this doc and is enforced by `test/unit.test.js`.
- `docs/copilot-cli-schema.md` — empirical Copilot CLI behavior captured during
  the spike that produced the Copilot adapter; cite it when changing
  Copilot-specific capability flags.
- `engine/runtimes/claude.js` JSDoc header — authoritative adapter contract.
- `engine/shared.js` `resolveAgentCli` / `resolveCcCli` etc. — the only
  supported way to read runtime / model selection from config.
