Spawn state: when Downshift works and when it switches
Downshift by Tiago Vilas Boas. Updated . Source on GitHub
Downshift by Tiago Vilas Boas https://github.com/tiagovilasboas/downshift
Every subagent spawn the hook sees gets exactly one state, computed once in internal/telemetry/spawn_state.go from the decision event. Every harness gets it through the shared runner (runHookAdapter); no adapter holds any of it.
| State | Meaning |
|---|---|
switched |
Downshift changed the spawn's model or effort |
held |
A change was considered; a guardrail (HELD_*), the credit gate or a guessed baseline kept the spawn as it was. Doubt never spends money |
observed |
Downshift looked and had nothing to change |
paused |
downshift session --pause: classified and logged, never changed |
control |
Control group or DOWNSHIFT_NO_ROUTE=1: classified, never changed |
blocked |
A policy-mode harness (KiroCrew) denied the spawn for a respawn |
Held wins over switched: a hold means the change was not applied.
Where it shows
| Surface | What you see | Harnesses |
|---|---|---|
| stderr, one line per spawn | downshift: spawn switched opus → haiku, effort high → low · TRIVIAL, spawn held on opus (would be haiku) [HELD_UNCONFIDENT_DOWNSHIFT], spawn observed, spawn unchanged on opus |
all (whether the harness UI shows hook stderr is harness-dependent; see the matrix) |
downshift watch |
first line: ● downshift active · last spawn 12s ago: ... or ○ downshift idle · ... (idle after 5 min without a spawn); then the tree |
all |
downshift session / --json |
header N switched, M held; each row's reason starts switched from <model>; JSON rows carry state, from_model, reason_codes, and the view switched, held |
all |
| statusline | ◆ downshift · 3 subagents · 2 switched · 1 held · ... |
harnesses with a statusline bridge |
live.json |
the record below, for a panel | all |
Idle means no recent spawn. The hook only runs when a subagent is spawned, so it cannot show a heartbeat between spawns.
live.json (for panels and mods)
Written atomically next to the event log (~/.downshift/live.json, or beside DOWNSHIFT_EVENT_LOG) on every spawn. Best effort and fail-open: a write failure never delays or blocks a spawn. No prompt, task text, file path, raw session or agent id is stored; session is the first 8 hex of the hashed id.
{
"updated": "2026-10-19T12:00:00.123Z",
"session": "a1b2c3d4",
"counts": {"switched": 2, "held": 1, "observed": 4},
"last": {
"state": "switched",
"harness": "claude-code",
"session": "a1b2c3d4",
"time": "2026-10-19T12:00:00.123Z",
"from_model": "claude-opus-5-5",
"to_model": "claude-haiku-4-5",
"from_effort": "high",
"to_effort": "low",
"recommended_model": "",
"complexity": "TRIVIAL",
"reason_codes": ["CLASSIFIER_CONFIDENT"]
}
}
counts reset when the session changes. A reader treats updated older than 5 minutes as idle. For held, to_model is what the child runs on and recommended_model is what Downshift would have chosen.
downshift session --panel (what the agents panel reads)
Prompt-free JSON for the selected session (latest, or --session), built in telemetry.BuildPanel. Ready-made labels, so a panel only draws:
{"session":"a1b2c3d4","switched":2,"held":1,"observed":4,
"tokens":{"children":4,"saved_children":2,"work_tokens":5200,"work_saved":3200,
"cache_tokens":180000,"cache_saved":104000},
"tokens_line":"tokens saved: work 62% · cache 58% (107k of 185k)",
"by_agent_type":[{"agent_type":"general-purpose","tokens":{...},
"line":"general-purpose: work 70% · cache 64% (2 of 3 agents)"}],
"spawns":[{"tool_use":"<sha256 hex>","state":"switched",
"from_model":"claude-opus-5-5","to_model":"claude-sonnet-5-5",
"label":"opus → sonnet (mid)",
"tokens":{...},"tokens_label":"48k tokens · saved from opus"}]}
Labels: opus → sonnet (mid), held: original model kept, kept, control (unrouted), paused (unrouted), blocked: respawn on another model. tool_use is telemetry.ToolUseKey(tool_use_id): sha256 of harness-downshift-session-v1: + the harness tool call id.
observed_model is optional and per spawn (telemetry.PanelSpawn): the model the child was observed to run on, taken from its resolved record or else its usage record. It is set only when that model is known and differs from to_model; an equal, dated-equal, unknown or missing observation leaves the key out. It holds model ids only, never prompts.
Tokens, never dollars (owner decision 2026-10-19). "Saved" means a child's tokens ran on a cheaper model than the one requested (telemetry.TokenSavings, the same buckets as stats --export tokens_rerouted); work (input+output) and cache (read+write) are counted apart. tokens_line and tokens_label are "" until a child's tokens are known; a reader then draws no figure. A child on a pricier model reads pricier than <model> and adds to work_pricier / cache_pricier, never to the saved share. Control, held, kept and refuted children count in the totals. Harnesses with no proven token source (Cursor, Codex, OpenCode today) have no token figures, never zeros. No dollars: downshift report is tokens only.
The same figures appear in downshift session (header, a tokens column per row, a by agent type line), the statusline (tokens saved work N% · cache M%), downshift watch, dsmon, the child-end line (48k tokens (work 2.1k, cache 46k) · saved from opus) and the stats headline (downshift stats; stats --detail for more). downshift try has no tokens for one decision and says only where they would run: → small: opus → haiku · its tokens would run on a cheaper model than requested, or (not applied, tokens stay on opus) when the hook would leave the spawn alone.
Compactor in the panel JSON
session --panel adds an optional compactor block for the session: {commands, compacted, raw_bytes, saved_bytes, estimated_tokens_saved, token_estimate}. Bytes are measured; estimated_tokens_saved is saved_bytes / 4 and is never added to the routing tokens (different units, different certainty). The hook puts the hashed session on the wrapped command (downshift compact --session=<hash> -- …, telemetry.HashSessionID, never the raw id) and the runner logs it in exec-events.jsonl. No wrapped command in the session, or records written before this field existed, means no block, never zeros.
The session comes from the hook payload's session_id. When that is absent (some live Bash payloads carry none), it is the uuid file name of transcript_path, since Claude Code names each transcript <uuid>.jsonl. A name that is not a uuid gives no session. session_id always wins when present. Without a session the command is still wrapped, but the runner logs no session hash, so no block appears for it.
mods/savvy-progress
Vendored, so it is patched only through its documented local patch (mods/savvy-progress/downshift-local.patch, README section "Downshift local patch"; mod version 1.2.6). The patch runs downshift session --panel and shows two tiles in the panel. The first reads Downshifted and draws the tokens that ran on a smaller model. A Compactor tile, with ↓, draws the bytes the Compactor cut in the session, only when the compactor block is present. The two are never added together: one is measured tokens, the other measured bytes. The header and the band show one line, Downshift · N switched · M held, and each agent row shows a one-word status, ↓ and the tokens that agent moved to a smaller model (work_saved + cache_saved). tokens_line, tokens_label, by_agent_type and observed_model stay in the contract for the CLI; the mod does not draw them. With no binary or bad output it draws what upstream draws. No routing logic lives in the mod.
What a harness cannot honor
Computing the state is not conditional on the harness. What varies is only display: the child-end line and statusline need CanShowChildLine / a statusline bridge (see harness-matrix.md), and a harness may not show hook stderr in its UI. live.json, downshift watch and downshift session work for every harness that logs spawns.
More docs
- Install Downshift: Claude Code, Codex and Cursor hook setupGuide
- Downshift configuration: state directory and local settingsGuide
- Downshift and Claude Code subagents: when routing can actGuide
- Downshift session view: subagent tree, reasons and pauseGuide
- Downshift report: routed versus control tokens per completed taskGuide
- Downshift evidence ladder: what each saving figure meansGuide
- Downshift companions: Headroom and Claude Code mods to pairGuide
- Downshift capability matrix: status and evidence per featureReference
- Downshift capability matrix, part 2: context and packagingReference
- Downshift harness matrix: Claude Code, Codex, Cursor statusReference
- Downshift value per harness: observed tokens vs list priceReference
- Downshift offline simulation: routing without model callsGuide
- Downshift session models: which model IDs a hook may writeReference
- Downshift complexity weights: how a task becomes a modelReference
- Downshift architecture: deterministic hook and offline CLIDesign
- Downshift routed vs control: first Claude Code A/B resultsEvidence
- Downshift real usage: a Claude Code day and the last 30 daysEvidence
- Downshift Code Intelligence MVP: evidence for Claude CodeEvidence