Capability-aware routing — cyberine route
Cyberine routes each task by marginal cost so it works standalone (a Claude Code / Codex
replacement) on whatever you already pay for. Every worker carries a costModel and a
priorityRank (src/core/workerProfiles.ts):
subscription— a CLI on a flat sub (claude,codex): $0 marginal, top priority.metered— pay-per-token (opencode-*, allloop-*key workers,gemini).free-local— $0 local compute (loop-ollama-build).
To keep a subscription CLI actually free, Cyberine scrubs the metered API-key env var from the spawned
worker when a sub is present — ANTHROPIC_API_KEY for claude, OPENAI_API_KEY for codex —
so the worker bills the flat sub (claude.ai OAuth / ChatGPT sign-in), not per-token. The strip is
conditional: a machine with only an API key (no sub) keeps it as the sole auth.
The preferred pool = subscription CLIs + the cheap metered OpenCode agents
(opencode-deepseek, opencode-kimi) — always eligible, never excluded for cost. The router
(src/core/router.ts) picks:
- QA / question -> a SINGLE highest-priority available pool worker (
claude). No race for a question. - Code task (small or large) -> RACE every available pool worker
(
claude, codex, opencode-deepseek, opencode-kimi), DiffJudge picks the winner. - Metered native-API (
loop-*,gemini) is the last resort — auto-selected only when NO pool worker is available (so Cyberine still runs key-only), otherwise opt-in viaCYBERINE_BUILD_FLEET.
The candidate list is filtered to the workers actually usable on THIS machine right now
(availableBuildWorkerIds() in src/orchestrator/buildAdapters.ts — logged-in CLIs + key-configured
loop workers), so the fleet cyberine route prints is what would really dispatch.
The failover: block lists eligible backup workers in order, with each worker's resolved billing class and placement reason. Free lanes lead, followed by readable Command Code credits, other credit backends, codex/muse subscriptions, metered API-key workers, and Claude seats. The selected fleet is excluded from its own chain; cooldown and disabled workers are excluded. Use cyberine route --json "fix the off-by-one bug in pagination" | jq .failover for the same data. Routed background runs record it in run.json under routing.failover.
For an isolated workflow step, a worker that stops on a rate limit, exhausted credits, or a CLI usage limit hands its worktree to the next eligible built worker. The replacement reviews the partial edits and finishes the task in that same worktree. Each original candidate slot keeps one final result for judging, including in compete mode. The run records a worker:failover event and a separate worker row with failoverOf. The hop comes from the run's failover chain (routing.failover, see cyberine route); a run with an explicit --fleet pin has no routed snapshot, so the chain is computed at the first quota failure from the workers available on this machine, and a backup the run did not build is built on demand. A step whose fleet.json routing.steps pin is strict fails over only among its pinned workers. Workers already used by this step or in cooldown are skipped. Set CYBERINE_FAILOVER=off to disable handoff, or CYBERINE_FAILOVER_MAX_HOPS=2 to limit replacements per slot (default: 2; 0 permits none). Every billing class is eligible, subject to the existing daily and USD spend caps.
A worker killed by the CLI timeout (CLI agent timed out after <n>ms) fails over the same way, with reason=timeout on the worker:failover event. Decision (k3apk): a timeout is failover-eligible, not a terminal loss of the slot, because the next hop continues the killed worker's partial tree in the same worktree instead of re-running its minutes from zero, and CYBERINE_FAILOVER_MAX_HOPS plus the spend caps bound the extra wall clock and spend. The handoff prompt names the files already changed. Whether or not a hop follows, any isolated worker that ends in error or cancelled with a non-empty worktree diff has that diff saved before its worktree is removed, at <runDir>/<step>__<worker>.partial.diff (mode 600), and its worker row in run.json records partialDiffPath and partialDiffLines. Continue from it by hand:
jq -r '.steps[].workerRuns[] | select(.partialDiffPath) | "\(.agentId) \(.partialDiffLines) \(.partialDiffPath)"' .local/runs/<runId>/run.json
git apply "$(jq -r '.steps[0].workerRuns[0].partialDiffPath' .local/runs/<runId>/run.json)"CYBERINE_FAILOVER_MAX_HOPS=2 cyberine run --bg "fix the off-by-one bug in pagination"Known Limitations and Deferred Work: this handoff applies to isolated WorkflowEngine steps. The non-isolated Orchestrator.dispatch path does not retry a worker mid-run. An explicit --fleet pin records no routed snapshot, so its chain is computed on demand and is not shown in run.json until a hop happens (the worker:failover event names it). Every hop spends through the same reservation as the first worker: the daily cap and CYBERINE_SPEND_CAP_USD are the only stops, by operator ruling (2026-09-23), so a hop may reach a metered API worker without asking; CYBERINE_FAILOVER=off turns the behaviour off. A timeout hop gets the same CLI timeout as the worker it replaces, so a task too large for one timeout window can spend up to 1 + CYBERINE_FAILOVER_MAX_HOPS windows before the slot ends. A saved partial diff is never judged or landed: it is a half-finished edit, so it stays an error row the operator continues from, and under failover it is cumulative (every hop shares one worktree). The diff file is written verbatim, not redacted, so git apply still takes it, the same as a winner diff in run.json.
cyberine route "what is the capital of Australia" # -> qa: claude (single sub, no race)
cyberine route "fix the off-by-one bug in pagination" # -> code: race claude, codex, opencode-deepseek, opencode-kimi
cyberine route "refactor the auth module across all files" # -> code-large: same preferred-pool raceBackground dispatches (cyberine run --bg, chat /run, auto-dispatch) consult the router automatically
when CYBERINE_ROUTE=auto (the default) and you did NOT set CYBERINE_BUILD_FLEET yourself — an explicit
fleet always wins, and CYBERINE_ROUTE=off disables routing entirely. The launch log prints the
decision as [route] tier=… kind=… fleet=… recommendedMode=…. The router is deterministic (no
LLM call): question patterns (English + Vietnamese, diacritics optional) map to QA, edit verbs +
size signals split code-small vs code-large. An explicitly named worker in the task text is always
appended. Quota exhaustion on a sub surfaces as a runtime error and the fleet degrades to the
remaining workers.
Gotcha — a non-code task can still get classified as code and misrouted into the implementer fleet. Classification is purely content-based (classifyKind, src/launchBg.ts:152): a bare QA/comms dispatch whose prompt happens to mention code-shaped terms (tool names like bus_send, file paths, etc.) can classify as code-small even though nothing about the task calls for a diff. When that happens the router's picked fleet is injected as CYBERINE_BUILD_FLEET into the child run's env (src/launchBg.ts:366), which is exactly the opt-in signal implementTaskWorkflow.ts watches for — so the run silently gets the isolated-worktree + DiffJudge treatment (expecting a git diff) instead of the bare single-step cyberine-run flow, even with no CYBERINE_BUILD_FLEET set by hand. Export CYBERINE_ROUTE=off before dispatching a task you know is QA/comms-only (no file edits expected) to force the bare workflow and skip auto-routing entirely (the short-circuit condition is config.routeMode === 'auto' + CYBERINE_BUILD_FLEET unset, src/launchBg.ts:147-150, backed by CYBERINE_ROUTE's auto default at src/core/config.ts:872).
The router also emits a recommendedMode (M5 auto-routing): complex tasks get compete (full
fleet + BehaviorJudge gate), all others get swarm (single cheap worker + DiffJudge gate).
cyberine route "<task>" now shows mode: in its dry-run output. A --mode flag or CYBERINE_MODE env
overrides the auto recommendation.
The routed fleet carries every band the cost class needs (kk34x)
A task's cost class picks a BAND per workflow step — the thinking band (the top strength band of the
enabled fleet) or the cheap band (its cost-class-1 half). See "A task's cost class picks its step
profile" for the class and the table it drives. Until kk34x the router picked its fleet
independently of that, so a routed fleet could contain no worker of a band a step needs;
selectAdapters then printed
[engine] WARN: step "implement" restricts workers to [...] but none were built on this machine — degrading to all available adapters [...]and ran the step on whatever band WAS routed. The classic shape is a fleet of subscription seats plus
one metered cost-class-1 worker: the seats are the preferred pool, the metered worker is excluded for
cost, and the implement step's cheap band ends up empty.
routeTaskSmart (src/core/router.ts) now closes that from the routing side. After the deterministic
(or LLM) pick and before the smart-spawn budget gate it:
- resolves the step profile for the task's cost class exactly as
implementTaskWorkflowdoes,fleet.jsonrouting.profilesoverrides included, and collects the distinct bands the UNPINNED steps need — a step pinned byrouting.stepsneeds no band, because it dispatches its own workers and refuses to widen; - computes each band from the ENABLED fleet through the same
src/core/strengthFleet.tssplit the workflow itself reads, then intersects it with the router's own candidate pool, so a band worker that is retired, cooling down or missing its credential is never named; - appends the top-ranked worker of any needed band the pick does not already carry — preferred pool first, then the free lane, then the metered fallback, and by strength inside each;
- for a cheap-only class, keeps only the cheap band. A worker the task text named outright is never dropped by that step.
The class comes from CYBERINE_TASK_COST_CLASS when it is set, otherwise from the board row a
background dispatch carries (src/launchBg.ts classifies it BEFORE routing and hands it to the
router), otherwise the standing expensive default — the same precedence the workflow uses.
Run recipe. Every decision is a cost-class bands: reason line, so the dry run shows the bands
without dispatching anything:
cyberine route "Refactor the session store across all files in the auth module"
# reasons:
# - cost-class bands: class=expensive — design=thinking, implement=cheap, review=thinking
# - cost-class bands: thinking band already routed (claude-kt)
# - cost-class bands: added loop-deepseek-build (metered) — the routed fleet carried no cheap-band worker and it is the top-ranked one the enabled fleet has
# The same task as a cheap-class one keeps only the cheap band
CYBERINE_TASK_COST_CLASS=cheap cyberine route "Refactor the session store across all files in the auth module"
# - cost-class bands: class=cheap — design=cheap, implement=cheap, review=cheap
# - cost-class bands: class=cheap runs every step on the cheap band — dropped [claude-kt, claude-nf, claude-xg]
# Machine-readable: the lines are not tied to one worker, so they land in generalReasons
cyberine route --json "Refactor the session store across all files in the auth module" \
| jq -r '.routing.generalReasons[] | select(startswith("cost-class bands"))'Extend recipe. There is no new switch. The pass reads what already decides the bands:
fleet.jsonrouting.profilesmoves a step between bands for a class — the pass covers whatever that ends up naming.fleet.jsonrouting.stepspins a step outright; a pinned step is skipped by the pass, so pinning all three turns it off.cyberine fleet disable <provider>takes a worker out of the enabled fleet and therefore out of both bands — that is the lever for "never route this one", including for a metered cheap-class worker you do not want admitted.
Known Limitations and Deferred Work.
- An explicit pin is dispatched verbatim.
--fleet/CYBERINE_BUILD_FLEETskips the router entirely (buildFleetRoutingApplies), and the pass refuses to run even if it is reached, so a pinned fleet that misses a band still degrades throughselectAdapters. That is the point of a pin — it names exactly what may run. - A declared
routing.<kind>route is not covered either.fleet.jsonrouting.code-largeand friends return before the heuristic router runs, so those fleets are dispatched as declared. Name a worker of each band there yourself. - It can admit a metered worker. When the only cost-class-1 worker on the fleet is metered, the cheap band is covered by that worker and the run spends real money where the degraded path would have burned subscription quota instead. The reason line names the cost model; disable the provider if that trade is wrong for you.
- One worker per missing band. The pass adds exactly one, not the whole band — enough to stop the degrade, not a second race.
recommendedModeis not recomputed. A cheap-class task narrowed to a single cheap worker keeps thecompeterecommendation its tier earned; pass--mode swarmif that matters.- qa routes are untouched. A question is answered once, by design, and never runs the design/implement/review steps — the pass records that and changes nothing.
- The bands themselves are unchanged. What is IN each band is still
src/core/strengthFleet.ts's shipped strength table plus cost class 1; this pass only makes the routed fleet cover them.
Durable worker transcripts — cyberine transcript
A loop worker's events.jsonl is telemetry, not a transcript: each entry's detail is a 120-character slice of the prompt, so when a fleet worker fails there was previously no way to see what it was...
Smart (LLM-as-router) for low-confidence tasks (T61)
When the deterministic router is unsure about a task (confidence < 0.7 — the "default fallback" bucket), you can opt in to a secondary LLM call that refines the worker ordering and per-worker...