docs: re-sync CUJ walkthrough with the rebased tree

Co-authored-by: Isaac
This commit is contained in:
Bryan Qiu
2026-07-30 23:11:32 -07:00
parent 7247de4cf2
commit bc4b6c0604
+128 -97
View File
@@ -6,7 +6,13 @@ journeys on `routing-mvp`. Companion documents:
deltas" — the per-fix narratives this doc expands into full chains),
`designs/CUJ_STATUS.md` (evidence layer per CUJ and the 14/14 matrix run) and
`designs/LIVE_MODEL_STATE.md` (codex model-state mechanics in protocol detail).
Commit shas are cited inline, as in §12.
Commit shas are cited inline, as in §12. They name the original per-fix commits,
which is the granularity the narratives need; the branch has since been rebased
onto `origin/main` and reconciled with main's catalog-routing work, so those shas
are no longer reachable. The shipped series is `git log --oneline
origin/main..HEAD` — eleven commits led by `80d3bcc7` "feat(routing):
session-start smart routing core", which is where the reconciliation lives. Line
numbers below are HEAD's.
The three journeys:
@@ -36,15 +42,15 @@ behaviour all three share.
### 1.1 The route-options seam
All knowledge of the router's contract lives in `omnigent/server/smart_routing.py`
behind one concrete route-options source, `TaskV1RouteOptionSource` (`:859`):
`build_route_options` (`:886`) turns a harness set plus a catalog into the option
list the router requires, and `resolve_selection` (`:920`) turns the router's
behind one concrete route-options source, `TaskV1RouteOptionSource` (`:931`):
`build_route_options` (`:958`) turns a harness set plus a catalog into the option
list the router requires, and `resolve_selection` (`:992`) turns the router's
pick back into a `(harness, servable id)` pair. It injects the frozen task_v1 arm
menus (`TASK_V1_MENUS`, `:527`) even when the workspace serves no endpoint for
menus (`TASK_V1_MENUS`, `:599`) even when the workspace serves no endpoint for
them, because task_v1 400s on a partial menu and two of its arms are unservable
on eng-ml-inference (plan §1). Callers — `route_session_harness` (`:1354`),
`route_turn` (`:1525`), and the runner's subagent endpoint — never see router
vocabulary; they reach the source through `route_option_source` (`:1090`).
on eng-ml-inference (plan §1). Callers — `route_session_harness` (`:1467`),
`route_turn` (`:1655`), and the runner's subagent endpoint — never see router
vocabulary; they reach the source through `route_option_source` (`:1162`).
It was originally a `RouteOptionSource` Protocol with one implementor, and the
menus were nested one level deeper under a single `router_name` key. Both
@@ -72,11 +78,11 @@ model. So the client now *owns* resolution: `route_session_harness` applies its
`route_turn` no longer re-resolves at all (`36a17c65`).
**One prefix list, configurable, with an honest empty case.**
`strip_catalog_prefix` (`:502`) drops a leftover leading separator, because a
`strip_catalog_prefix` (`:574`) drops a leftover leading separator, because a
prefix configured without its trailing dot (`system.ai`) produced router ids like
`.claude-opus-5` (`972dea9d`). The hardcoded `_BARE_ID_PREFIXES` /
configurable-`model_prefixes` split then collapsed onto one `MODEL_ID_PREFIXES`
(`:499`), the default for `RoutingSettings`, the seam and `ExternalRoutingClient`
(`:571`), the default for `RoutingSettings`, the seam and `ExternalRoutingClient`
alike, so the two ends can no longer disagree (`36a17c65`). Prefix comparisons
honour `routing.model_prefix` throughout, and an explicit `model_prefix: []` now
means bare catalog ids rather than silently falling back to the defaults
@@ -88,14 +94,14 @@ list order: an alphabetical live catalog substituted `gpt-5-nano` for the codex
anchor arm `gpt-5-6-sol`. The first attempt was a ~170-line capability-ranking
engine (`_capability_key`, `_size_class`, `_version_key`, `_listed_rank`, arm
tiers) — deleted in `36a17c65` for a reviewable `{arm: (preferred, fallback, …)}`
table, `_ARM_SUBSTITUTES` (`:539`), read by `substitute_model` (`:711`). The
table, `_ARM_SUBSTITUTES` (`:611`), read by `substitute_model` (`:783`). The
ranking engine had a `_listed_rank == -1` hole (a current-generation model absent
from `MODEL_LISTS` ranked below everything); the table has none. When the chain
names nothing on offer, `substitute_model` takes the same-family candidate
*nearest the pick's own cost position* (`_cost_position`, `:685`), biasing
*nearest the pick's own cost position* (`_cost_position`, `:757`), biasing
cheaper on a tie — the earlier "most capable same-family" fallback inverted cost
outright, escalating every SIMPLE pi turn to opus because haiku is barred on pi
(`46a50556`). Ids compare with dots read as dashes (`_bare_id`, `:660`), so a
(`46a50556`). Ids compare with dots read as dashes (`_bare_id`, `:732`), so a
picker's `gpt-5.6-sol` matches the router's `gpt-5-6-sol` instead of missing
every chain and collapsing onto the priciest row, and the offered menu carries
one row per model rather than two. With live pre-session catalogs (§1.3) exact
@@ -103,8 +109,8 @@ matches are the common case and substitution is the exception, which is what the
matrix's "no fallback arrows" bar requires.
**Harness bars were unenforced on the turn path.** `_redirect_incompatible_pick`
(`:826`) stays in the seam as post-verdict harness correction:
`_HARNESS_EXCLUDED_MODELS` (`:639`) pairs are *not* pruned from the offered menu
(`:898`) stays in the seam as post-verdict harness correction:
`_HARNESS_EXCLUDED_MODELS` (`:711`) pairs are *not* pruned from the offered menu
when the harness is itself in play (the router needs its full menu or it 400s),
so an incompatible pick is moved to a harness that can run it instead. Two
corrections landed in `46a50556`: the redirect now takes the *offered* harness
@@ -113,19 +119,31 @@ restricted to its parent's family can no longer escape onto `codex` or
`claude-sdk` — and a turn, which cannot change harness at all, prunes the models
its own gateway bars before offering them and swaps the *model* via
`substitute_model` when an injected arm comes back barred. `harness_bars_model`
(`:812`) is the shared predicate. `designs/LIVE_MODEL_STATE.md` documents why
(`:884`) is the shared predicate. `designs/LIVE_MODEL_STATE.md` documents why
each `pi` exclusion exists.
Post-verdict harness correction is **two layers, in order**, and only on the
session path. Ours runs first: `_redirect_incompatible_pick` over the static
`_HARNESS_EXCLUDED_MODELS` pairs, which is the one that can also swap the model
rather than the harness. `_redirect_wire_incompatible_pick` (`:1428`) runs second,
on whatever survived — the catalog-driven companion, reading the `_RunnerModel`
wire APIs kept off the live catalog (§1.3): a `pi` pick on a Claude-family
endpoint the catalog reports as not speaking Anthropic Messages moves to
`claude-sdk`. It carries the same on-offer guard, so a family-restricted child
cannot escape through it either, and unknown metadata from an older runner is
never read as incompatible. `route_turn` runs only the first layer, since a turn
cannot change harness at all.
### 1.2 RoutingSettings on RuntimeCaps
`RoutingSettings` (`smart_routing.py:581`) is the frozen deployment record, and
`RoutingSettings` (`smart_routing.py:653`) is the frozen deployment record, and
it is down to three fields: `router_name`, `selection_model` (passed through as
`route_selector.config.model` so a deployment can pin an extraction model it has
query access to) and `model_prefixes`. `scenario_menus` went with the flattened
menu tables (§1.1), and `subagent_fail_mode` / `subagent_cache_ttl_s` went with
the knob and the cache they configured (§1.5) — all three in `36a17c65` /
`6112e6cb`. Everything reads the record through one accessor,
`routing_settings(caps)` (`:1057`), which returns all-defaults when caps carry
`routing_settings(caps)` (`:1129`), which returns all-defaults when caps carry
none — the de-scarring pass collapsed several ad-hoc re-parses into it, and fixed
routing settings being dropped from Docker's `RuntimeCaps` construction entirely
(`d181cbd5`).
@@ -133,10 +151,10 @@ routing settings being dropped from Docker's `RuntimeCaps` construction entirely
The router client itself is chosen at *build* time — `cli.py` constructs exactly
one client into `RuntimeCaps.routing_client` — so there is no runtime fallback
chain. A router failure returns `None` with `last_error` set, surfaced through
`routing_last_error` (`:1077`), and callers proceed unrouted with the reason
`routing_last_error` (`:1149`), and callers proceed unrouted with the reason
attached (plan §2). This is what made the task_v1 rollback incident a logged
degradation rather than an outage. `last_error` is not part of the
`RoutingClient` Protocol (`:190`) — the accessor was always `getattr`-defensive,
`RoutingClient` Protocol (`:192`) — the accessor was always `getattr`-defensive,
so declaring it only implied a contract clients did not have (`36a17c65`).
Two logging-posture corrections belong here. The external router request body
@@ -150,11 +168,14 @@ points log it at DEBUG and keep model/harness at INFO (`46a50556`).
Three catalog sources, in order of preference:
- **Live per-session:** `fetch_runner_models` (`:219`) hits the runner's
`/v1/sessions/{id}/models`; `catalog_models_for_harness` (`:122`) extracts the
harness's slice.
- **Live per-session:** `fetch_runner_models` (`:323`) is a thin id-only
adapter over `_fetch_runner_catalog` (`:246`), which hits the runner's
`/v1/sessions/{id}/models` and keeps each row's wire APIs and cost tier on a
`_RunnerModel` (`:222`) — cost-tier ordering with catalog order as tie-break,
and the retained wire metadata is what the post-verdict wire check reads.
`catalog_models_for_harness` (`:124`) extracts the harness's slice.
- **Pre-session:** `_pre_session_model_catalog`
(`server/routes/_sessions/orchestration.py:5620`) fans out to the host's
(`server/routes/_sessions/orchestration.py:5678`) fans out to the host's
pre-launch model options for each candidate harness. A create has no session,
so the live catalog is out of reach; the host holds the CLIs and already
resolves their picker options. Introduced with `158042a3` because create-time
@@ -162,11 +183,14 @@ Three catalog sources, in order of preference:
session got offered models it could not run. One helper now owns the host
model-options round trip for both callers, and both readers accept picker rows
spelled `model` *or* `id` (`3b00d101`).
- **Static:** `infer_models` (`:87`) as last resort, for harnesses the host
cannot answer for.
- **Static:** `infer_models` (`:89`) as last resort, for harnesses the host
cannot answer for. The table behind it (`MODEL_LISTS`, `:39`, plus
`_CURRENT_GENERATION_MODELS`, `:68`) is a deliberate fork: main deleted its
other uses, and it is kept here because substitution needs a cost ordering on
the catalog-less paths.
Whatever the source, the candidate set is family-filtered by `models_in_family`
(`:105`), and family compatibility for codex is one shared authority:
(`:107`), and family compatibility for codex is one shared authority:
`is_codex_compatible_model` (`model_override.py:126`) matches per id segment with
an optional trailing generation number, so `system.ai.glm-5-2` and
`kimi-k2-instruct` pass while a lookalike endpoint name (`glmqlfit-eval`) does
@@ -197,11 +221,11 @@ Every routing decision is a transcript item. `RoutingDecisionData` gained
`harness`, `scope` (`session` | `turn` | `child_session` | `native_subagent`),
`decision_id`, `raw_model` and `attempted_override`, all defaulted for legacy
rows (plan §5.2). `_emit_server_routing_decision`
(`server/routes/_sessions/helpers.py:5451`) writes it — after the decision
(`server/routes/_sessions/helpers.py:5499`) writes it — after the decision
validates, not before a parse failure that produces no chip (`3b00d101`) — and
`_stamp_routing_decision_label` (`orchestration.py:4089`) records the decision id
`_stamp_routing_decision_label` (`orchestration.py:4131`) records the decision id
on the session so a persisted `model_override` can be joined back to the decision
that produced it (`ROUTING_DECISION_LABEL_KEY`, `subagent_routing.py:87`). The
that produced it (`ROUTING_DECISION_LABEL_KEY`, `subagent_routing.py:88`). The
`routed_model` field on a child-session row is gated on that label, so a
user-pinned model no longer reports as routed with a null decision id
(`3b00d101`).
@@ -220,7 +244,7 @@ different:
- **The chip renders below the user message it routed.** Native terminal
sessions persist the decision *before* the message, so order-faithful
rendering put the chip at the top of the chat. `deferredRoutingChips`
(`web/src/lib/renderItems.ts:640`) pairs a session/turn-scoped chip with the
(`web/src/lib/renderItems.ts:639`) pairs a session/turn-scoped chip with the
adjacent user message and defers it below; already-correct orders are
untouched, subagent chips never move, and streaming rebuilds the pair
atomically in both arrival orders (`8fa280ea`). On claude only, the injected
@@ -233,8 +257,8 @@ different:
cache hardcoded two bubbles per region, so it dropped one too few and
re-emitted the echo bubble on every later frame of any turn past the first
(duplicate React keys included, until a full rebuild). The region now records
`regionBubbleStart` (`:448`) and reports
`lastBubbleCount = bubbles.length - regionBubbleStart` (`:484`), with a
`regionBubbleStart` (`:467`) and reports
`lastBubbleCount = bubbles.length - regionBubbleStart` (`:483`), with a
frame-by-frame test at a non-zero block offset — where the cache actually
reuses; every earlier test started at block 0, where reuse bails out
(`2245f57d`). The fix also split "renders nothing" from "may sit between chip
@@ -249,25 +273,25 @@ Native in-harness spawns never reach the server, so routing them needs a
runner-local endpoint the harness's own hook subprocess can call.
`omnigent/runner/subagent_routing.py` serves it:
- `start_subagent_router` (`:797`) binds an HTTP server on `127.0.0.1:0` and
- `start_subagent_router` (`:803`) binds an HTTP server on `127.0.0.1:0` and
writes `subagent_router.json` (`{url, token, pid, session_id, updated_at}`)
into the session's bridge dir — the same advertisement pattern as
`tool_relay.json`. `SubagentRouter.close` (`:777`) removes it.
`ensure_session_router` / `ensure_session_router_quietly` (`:997`, `:1040`)
`ensure_session_router` / `ensure_session_router_quietly` (`:1013`, `:1056`)
install it whenever a server client exists, *not* only for sessions that
started routed, so a mid-session toggle-on has something to talk to (§5).
- `resolve_subagent_route` (`:475`) is the policy: it builds the candidate set
with `candidate_models` (`:396`), calls the router, and returns a
`SubagentRouteDecision` (`:235`) of `allow` / `rewrite` / `redirect` / `deny`
- `resolve_subagent_route` (`:476`) is the policy: it builds the candidate set
with `candidate_models` (`:397`), calls the router, and returns a
`SubagentRouteDecision` (`:236`) of `allow` / `rewrite` / `redirect` / `deny`
with `model`, `harness`, `raw_model`, `rationale`, `decision_id` (plan §5.1).
Unoffered picks are denied outright — "didn't spawn" beats "wrong model" — and
that is the *only* remaining `deny`. The enablement gate is read **per call**
one hop out, by the server relay route
(`server/routes/sessions/routes_hooks.py:1246`) and the child-session path
(`orchestration.py:680`), both through `subagent_routing_enabled` (`:156`),
(`server/routes/sessions/routes_hooks.py:1388`) and the child-session path
(`orchestration.py:686`), both through `subagent_routing_enabled` (`:157`),
which layers the per-session override over the own/parent cost-control state.
- Family rules live here too: `harness_family` (`:337`), `model_in_family`
(`:377`), and `auto_harness_session` (`:354`), which is what allows a
- Family rules live here too: `harness_family` (`:338`), `model_in_family`
(`:378`), and `auto_harness_session` (`:355`), which is what allows a
cross-family pick *only* under the Smart Routing harness (§4.7).
Two pieces of this layer were built and then deliberately deleted (`6112e6cb`):
@@ -278,7 +302,7 @@ Two pieces of this layer were built and then deliberately deleted (`6112e6cb`):
router exception, empty verdict, transport error, hook timeout — already fell
through to allow, so `closed` could not deliver what it promised. The gate is
documented as **advisory** in the module docstring instead, `_unavailable_decision`
(`:455`) is the single allow-and-say-why path, and the knob, its plumbing and
(`:456`) is the single allow-and-say-why path, and the knob, its plumbing and
the deny-on-failure branch are gone.
- **The per-`(session, task)` decision cache.** It saved a task_v1 extraction
round-trip on identical spawns, but a cache hit re-emitted a `decision_id` that
@@ -286,10 +310,10 @@ Two pieces of this layer were built and then deliberately deleted (`6112e6cb`):
telemetry for one decision. Correctness won.
**Hardening.** The advertisement carries a bearer token, so `write_advertisement`
(`:702`) writes it through `os.open(..., 0o600)` into a temp file and
(`:703`) writes it through `os.open(..., 0o600)` into a temp file and
`os.replace`s it into place — never world-readable, not even for the instant
between a `write_text` and a follow-up `chmod`. The SDK harnesses have no bridge
dir of their own, so `router_dir_for_session` (`:1120`) gets them a private one
dir of their own, so `router_dir_for_session` (`:1161`) gets them a private one
through the shared bridge-dir ancestor check (`ensure_secure_dir`,
`claude_native_bridge.py:740`) rather than `mkdir(mode=0o700, parents=True)`,
which applies the mode to the leaf only and trusts pre-existing ancestors on the
@@ -303,16 +327,16 @@ request (`6112e6cb`).
**Lifecycle.** The router used to leak on two of three launch paths — only
claude-native tore it down — costing a `ThreadingHTTPServer`, a daemon thread, a
loopback socket, ledger entries and a live token file per session. Teardown is
now unconditional and idempotent: `shutdown_session_router` (`:1086`) is called
now unconditional and idempotent: `shutdown_session_router` (`:1116`) is called
from both codex-native forwarder exits and the claude-native `finally`
(`runner/native/orchestration.py:4034`, `:4081`, `:6127`, all via
`_shutdown_session_router_async` (`:452`) because the close joins the serving
(`runner/native/orchestration.py:4073`, `:4124`, `:6157`, all via
`_shutdown_session_router_async` (`:442`) because the close joins the serving
thread) and from the runner's session-delete path for SDK harnesses
(`runner/app.py:3484`). `close()` only unlinks advertisements still naming its own
(`runner/app.py:3111`). `close()` only unlinks advertisements still naming its own
url — sessions that fork/clear/resume keep the same bridge dir, so a newer router
may own the file — and every advertised dir is tracked and pruned
(`c46ef54d`, `6112e6cb`). Router env vars are scoped to the launching harness
(`session_router_env`, `:1142`), so a codex executor beneath a claude session no
(`session_router_env`, `:1183`), so a codex executor beneath a claude session no
longer inherits the parent's session id (`6112e6cb`, `de2acfdb`).
**Timeout budget.** Four hops wait on each other, so each is strictly larger than
@@ -333,7 +357,7 @@ vocabulary out of `bridge.json` (`resolve_parent_model`,
`resolve_model_vocabulary_env`), builds the request (`build_route_request`),
calls the endpoint (`request_decision`) and renders the harness's hook output
(`decision_to_hook_output`, `route_pre_tool_use`). `run_route_subagent_main`
(`:620`) always exits `0`: routing must never be the reason a spawn fails. Fork
(`:642`) always exits `0`: routing must never be the reason a spawn fails. Fork
spawns are exempt in v1 (`FORK_SUBAGENT_TYPES`, `_FORK_SUFFIXES`).
De-scarring collapsed the per-harness duplicates into this one module and fixed
@@ -347,7 +371,7 @@ Hooks that silently do not run are the worst failure mode available: the UI show
routing on, the spawns are unrouted, and nothing complains. The canary is the
detector — a `SessionStart` hook writing a file into the bridge dir, plus a
watcher that posts the session-scoped warning `subagent_routing_unenforced`
(`runtime/session_warnings.py:31`) when the file is absent. Its arming logic had
(`runtime/session_warnings.py:34`) when the file is absent. Its arming logic had
to be inverted before it worked; see §3.7, where it caught both codex apply-layer
bugs. The warning is retractable as well as postable — see §5.3.
@@ -405,10 +429,10 @@ the absence of a pin is what arms it. Evidence for this layer is the
### 2.3 The turn gate and the routing call
`_forward_event_to_runner` (`server/routes/_sessions/orchestration.py:3641`)
computes `effective_runner_override` (`:3794` — per-event override, else the
`_forward_event_to_runner` (`server/routes/_sessions/orchestration.py:3675`)
computes `effective_runner_override` (`:3833` — per-event override, else the
persisted column, `is not None` and never `or`, per the no-invented-defaults rule)
and then the `_should_route` gate (`:3890`): routing enabled, event is a
and then the `_should_route` gate (`:3929`): routing enabled, event is a
`message`, the auto-harness block did not already route this turn, and no model is
pinned. In practice that fires on the **session's first message only** — the
routed turn persists its pick as `model_override`, which is itself a pin, so turn
@@ -423,7 +447,7 @@ remain live one level down, in `route_turn` itself, where the reason is *not*
inferable from the caller: no routing client configured, no candidate models for
the harness, and the harness bars every candidate.
`route_turn` (`smart_routing.py:1525`) scores the raw user text — 4000-char cap,
`route_turn` (`smart_routing.py:1655`) scores the raw user text — 4000-char cap,
no wrapper or summary, because `task.prompt` is the entire routing signal
(plan §1.1) — against the `cc` scenario menu, with candidates filtered to the
Claude family.
@@ -431,7 +455,7 @@ Claude family.
### 2.4 Decision persistence and the chip
"Can this pane actually apply the pick?" is answered *before* anything is
persisted, by `_routed_turn_model_spelling` (`orchestration.py:3551`). A
persisted, by `_routed_turn_model_spelling` (`orchestration.py:3580`). A
mid-session switch on a Claude pane is typed as `/model`, which accepts only that
session's own picker vocabulary; a routed id outside it is skipped by the executor
(fail open, the turn runs on the current model). So the server runs the *same*
@@ -448,7 +472,7 @@ because any `model_override` blocks routing (§5.1), one unapplicable pick
permanently disabled routing for that session and misattributed its usage to a
model it never ran. `3b00d101` collapsed the marker into this pre-persist check:
no spelling means no `model_override`, no in-band switch, and
`_unapplied_routed_verdict` (`:3600`) appends the reason to the rationale and
`_unapplied_routed_verdict` (`:3629`) appends the reason to the rationale and
clears `applied`. Honest `applied=false` beats a silent lie, and it is what makes
the matrix's no-arrows bar meaningful.
@@ -462,22 +486,22 @@ This is where nearly all the work was. Four separate problems, in launch order.
**Launch env pins.** claude-native launches its terminal *before* any turn
decision exists, and `/model` can only reach ids the launch env spells. So when
`launch_metadata.routing_enabled`, `runner/native/orchestration.py:5895-5898` pins
`launch_metadata.routing_enabled`, `runner/native/orchestration.py:5941` pins
the family aliases at the router's frozen arms via
`claude_config_with_routed_arms_pinned(claude_config, task_v1_claude_arms())`
the arm list is read from `_TASK_V1_CLAUDE_ARMS` through that one accessor
(`smart_routing.py:568`) rather than duplicated. Without this, `/model
(`task_v1_claude_arms`, `smart_routing.py:640`) rather than duplicated. Without this, `/model
opus` landed on whatever the workspace's newest opus was (`claude-opus-5`) while
the chip claimed the routed arm (`claude-opus-4-8`) — the workspace moving ahead
of the frozen router (`972dea9d`, §12 delta 3).
**The custom picker slot.** Claude Code has exactly one extra picker slot that
takes an *exact* id. `claude_config_with_launch_model_pinned`
(`claude_native.py:435`) parks the launch model there when no alias spells it —
(`claude_native.py:437`) parks the launch model there when no alias spells it —
the case a Smart Routing create hits, since the harness CUJ resolves an exact
model before the terminal exists (§4). It also gives the user a picker row to
return to. Both pin sets are persisted into the bridge config as `model_env`
(`claude_native_bridge.py:889-895`, keys `MODEL_VOCABULARY_ENV_VARS`, read back by
(`claude_native_bridge.py:889-895`, keys `MODEL_VOCABULARY_ENV_VARS`, `:891`, read back by
`read_model_env` at `:1067`) because the executor and the server do not share the
terminal's environment and both need to know its vocabulary.
@@ -541,13 +565,14 @@ MVP because the harness config modal's Model picker already behaves that way
### 2.7 Subagent routing
`build_hook_settings` (`claude_native_bridge.py:1188`) registers
`claude_router_hook` as a `PreToolUse` hook on the `Task|Agent` matcher (`:1425`);
`claude_router_hook` as a `PreToolUse` hook on the agent-tool matcher (`AGENT_TOOL_MATCHER`,
`hook_scripts/subagent_router.py:59`, registered at `:1441`);
settings-level hooks recurse into nested subagents. The hook rewrites
`tool_input.model` via `hookSpecificOutput.updatedInput` with
`permissionDecision: "allow"`, or denies. Because the Agent tool's `model` is a
closed enum, the hook translates through `claude_model_alias` with the vocabulary
read out of `bridge.json` (`claude_model_translator`,
`hook_scripts/subagent_router.py:403`) — this is what turned a 7 ms schema failure into a
`hook_scripts/subagent_router.py:425`) — this is what turned a 7 ms schema failure into a
spawn that ran to completion on the routed arm (`CUJ_STATUS.md` §4). Candidates
are family-filtered, so a `cc` session can never spawn a Codex arm. Mid-session
toggling is §5.2.
@@ -589,7 +614,7 @@ Applying a model to codex has **three writers**, which neither the plan's §2 no
`/model` writes, and what omnigent's own readers use — the forwarder's mirror
and the cost-gate hook.
3. The launch pin `_pin_codex_config_model`
(`codex_native_app_server.py:203`) seeds that key, and the TUI is launched
(`codex_native_app_server.py:204`) seeds that key, and the TUI is launched
with the same value as `-c model="…"`.
The observed symptom was that the routed model survived exactly one turn. Turn N
@@ -611,7 +636,7 @@ What shipped (`0fcc313f`, `51801530`):
the first message, so no re-read timing could help, but every turn re-applies
`ExecutorConfig.model` and the thread converges on the routed model at turn 1.
- **A `config.toml` mirror on a successful switch** (`write_codex_config_model`,
`codex_native_bridge.py:315`), writing the same key the TUI's `/model` writes, so
`codex_native_bridge.py:345`), writing the same key the TUI's `/model` writes, so
the cost gate and the mirror agree instead of diverging.
- **Forwarder precedence** (`codex_native_forwarder.py:2737`,
`_refresh_model_from_config`): the state tracks `settings_model` (last live
@@ -623,7 +648,7 @@ What shipped (`0fcc313f`, `51801530`):
`external_model_change` only on a real difference, and the server dedupes
against `conv.model_override`, so no echo loop.
- **A `session.model` SSE at routing persist time** (`_publish_routed_model`,
`orchestration.py:3617`) so the web dropdown tracks live state instead of
`orchestration.py:3646`) so the web dropdown tracks live state instead of
waiting for a reload. It carries the spelling the session's picker uses — a tier
alias, not a catalog id — because that is what the dropdown matches against, and
the native path publishes picker vocabulary too (`3b00d101`).
@@ -645,21 +670,21 @@ Codex needs the most machinery of the three, and every piece of it was forced by
a live failure.
**Hook generation and merge.** `codex_router_hooks_settings`
(`inner/codex_executor.py:917`) builds the Omnigent half of a `hooks.json`:
(`inner/codex_executor.py:876`) builds the Omnigent half of a `hooks.json`:
a `PreToolUse` gate on the spawn tool, a `SessionStart` canary, and a
`SubagentStart` audit writer. The spawn matcher is the regex `.*spawn_agent`
(`_CODEX_SPAWN_AGENT_MATCHER`, `:865`) because codex flattens the tool name
(`collaborationspawn_agent` on 0.145.x). `write_codex_router_hooks_file` (`:1067`)
(`_CODEX_SPAWN_AGENT_MATCHER`, `:824`) because codex flattens the tool name
(`collaborationspawn_agent` on 0.145.x). `write_codex_router_hooks_file` (`:1026`)
merges it with the user's hooks for the SDK executor path; the app-server path
merges policy + routing + user hooks in `_write_codex_policy_hooks_file`
(`codex_native_app_server.py:1007`).
(`codex_native_app_server.py:1008`).
**One writer, one file, and probe the version first.** Arming subagent routing on
codex < 0.129 used to *delete the user's hooks*: `_populate_codex_home_config`
dropped the symlink to `~/.codex/hooks.json` because the generated file was going
to own that name, and only afterwards did the version gate decide not to write one
— leaving the private `CODEX_HOME` with no `hooks.json` at all. The version is now
probed before the home is populated (`app_server.py:629-641`), so an unsupported
probed before the home is populated (`app_server.py:628-631`), so an unsupported
codex keeps the symlink. The root cause was two divergent `hooks.json` writers,
whichever ran last erasing the other's contribution; they collapse onto one shared
`write_codex_hooks_file` taking a *list* of payloads — policy, routing and the
@@ -672,14 +697,15 @@ untrusted and were *silently skipped* while the policy hooks worked — only the
policy module's hashes had ever been persisted. Both app-server launch paths now
run a persisted trust handshake for the router hook module: `hooks/list`
`config/batchWrite` of `hooks.state.<key>.trusted_hash = currentHash`
(`_persist_hook_trust`, `:1156`) → re-list to verify
(`trust_codex_router_hooks`, `:1193`; policy equivalent
`trust_native_policy_hooks`, `:1256`), both driven immediately after the
app-server connects (`:773-780`) and filtered by hook module so the trust step
(`_persist_hook_trust`, `:1157`) → re-list to verify
(`trust_codex_router_hooks`, `:1194`; policy equivalent
`trust_native_policy_hooks`, `:1257`), both driven immediately after the
app-server connects (`:774-781`) and filtered by hook module so the trust step
never touches hooks the user's own file contributed. It is best-effort and
isolated, so a routing-trust failure can never disable the policy gate
(`e32c4925`). The flag itself survives only where it actually works — the
interactive TUI launch (`runner/native/orchestration.py:3809`) — and nothing on
interactive TUI launch (`_CODEX_BYPASS_HOOK_TRUST_FLAG`,
`codex_native_app_server.py:1997`) — and nothing on
the app-server path relies on it. A codex version we cannot parse is treated as
*supported* on both paths, so a flaky probe can never wedge a terminal on a prompt
no subagent can answer (`c46ef54d`).
@@ -690,7 +716,7 @@ with the *session workspace* as cwd, and `python -m` puts cwd first on
`sys.path` — so a workspace containing an `omnigent/` directory (this repo being
the single most likely workspace) shadowed the installed package and every
generated hook died on import, silently: routing gate, canary, spawn audit and
the policy hook alike. `_codex_router_hook_command` (`:874`) now runs
the policy hook alike. `_codex_router_hook_command` (`:833`) now runs
`python -I -m …`, matching the bridge MCP command's posture, with a subprocess
regression test that runs the real canary from a workspace containing a decoy
package (`518376ba`).
@@ -706,7 +732,7 @@ first turn (codex fires `sessionStart` at first turn, not thread start), and
once cwd shadowing proved the second mode existed (`518376ba`), and the watcher
task is cancelled on teardown so a session that never takes a turn cannot leak it
(`c46ef54d`). This watcher is what caught both codex bugs;
`reconcile_spawn_audit` (`codex_executor.py:1194`) additionally compares the
`reconcile_spawn_audit` (`codex_executor.py:1153`) additionally compares the
`SubagentStart` audit's actual `model` against the models we routed to — through
`normalized_model_id`, because codex reports its own spelling and a prefix or case
difference is not a different model (`c46ef54d`).
@@ -725,8 +751,8 @@ the rewrite in the TUI via a `systemMessage` (`with_system_message`,
signal-free spawns to allow-with-parent-model, since the `SubagentStart` audit
proves spawns inherit the routed thread model — keeping both the chip and the audit
reconciliation truthful. `6112e6cb` then deleted the placeholder task and its
disclosure marker outright: `_routing_task` (`subagent_routing.py:440`) returns
`None` when there is no signal, and `_decide` (`:557`) allows the spawn unchanged
disclosure marker outright: `_routing_task` (`subagent_routing.py:441`) returns
`None` when there is no signal, and `_decide` (`:533`) allows the spawn unchanged
on `req.parent_model` with the rationale "No routable signal (encrypted prompt, no
task name); subagent inherits the session model". Nothing is scored on a synthetic
prompt any more, so nothing has to be disclosed.
@@ -793,16 +819,16 @@ active so it does not look like a Claude Code pick.
A native session's harness cannot wait for the first message the way the
bundle-agent auto path does — the terminal launches as soon as the session row
exists. So `_resolve_native_smart_routing` (`orchestration.py:5649`) routes at
create: it authorizes the caller's `host_id` (`resolve_host_owner`, `:5684`), reads
the host, filters `AUTO_NATIVE_ROUTING_HARNESSES` (`smart_routing.py:1341`) to the
CLIs actually installed (`_installed_native_harnesses`, `:5537`), calls
exists. So `_resolve_native_smart_routing` (`orchestration.py:5707`) routes at
create: it authorizes the caller's `host_id` (`resolve_host_owner`, `_host_launch.py:49`, called at `:5752`), reads
the host, filters `AUTO_NATIVE_ROUTING_HARNESSES` (`smart_routing.py:1413`) to the
CLIs actually installed (`_installed_native_harnesses`, `:5595`), calls
`route_session_harness` over the `both` five-arm menu with candidates from
`_pre_session_model_catalog` (§1.3), and returns the chosen native **wrapper
agent name**. The caller rebinds `agent` to that wrapper
(`:5798-5820`), and from there the create is byte-identical to a normal native
(`:5856-5880`), and from there the create is byte-identical to a normal native
create, terminal launch and all — nothing is launched twice. The routed model is
threaded into `validate_session_model_metadata` (`:5841`) as the session's
threaded into `validate_session_model_metadata` (`:5899`) as the session's
`model_override`, so it reaches the CLI as a `--model` argv element at launch,
which is a different (and more permissive) contract than `/model`: `--model`
takes any string verbatim. That is why the harness CUJ needs the custom picker
@@ -818,7 +844,7 @@ another user's host connection. `resolve_host_owner` runs first now (`3b00d101`)
wrapper rejects a harness override, and leaving the sentinel behind would make the
first message re-route an already-running terminal. Auto-ness is instead recorded
as a durable label, `omnigent.routing.auto_harness`
(`subagent_routing.py:93`) — the sentinel is consumed at first message, so
(`AUTO_HARNESS_LABEL_KEY`, `subagent_routing.py:94`) — the sentinel is consumed at first message, so
nothing else would survive to answer "was this session genuinely Smart Routing?"
(`0fb7ea95`).
@@ -864,9 +890,9 @@ child of a plain codex or claude session got `harness_override: "auto"`, was
routed over a family-mixed catalog, and inherited the cross-family escape hatch —
found live as a codex parent with nine forced-auto children, some on
claude-opus. Now the auto treatment requires the parent to actually be in auto
mode (`auto_harness_session`, checked at `orchestration.py:5877`), child routing
mode (`auto_harness_session`, checked at `orchestration.py:5935`), child routing
passes the parent's family as a candidate filter (`allowed_family`,
`:3914-3926`), and `route_turn` drops out-of-family models from the self catalog
`:3951-3965`), and `route_turn` drops out-of-family models from the self catalog
(`5a397d6f`). `46a50556` closed the last escape: the post-verdict harness redirect
(§1.1) used to hand back `codex` or `claude-sdk` whether or not they were on offer,
so a family-restricted child could still land outside its family. It now declines
@@ -906,8 +932,8 @@ for Claude Code, Codex (native and SDK) and Smart Routing sessions, toggleable a
any time and effective on the next spawn (`0fb7ea95`, web `1d030f22`, sticky
per-harness default `2a415cf4`).
**"Inherit" is its own option** in that row (`web/src/pages/ChatPage.tsx:5615`,
`:5778`). It used to collapse onto the effective `on`/`off`, which broke twice over:
**"Inherit" is its own option** in that row (`web/src/pages/ChatPage.tsx:5673`,
`:5781`). It used to collapse onto the effective `on`/`off`, which broke twice over:
Radix fires no `onValueChange` for the value already displayed, so re-picking the
inherited value silently persisted nothing, and the row labelled it "Default" for
sessions the spec routes by default (`2245f57d`).
@@ -928,16 +954,21 @@ Once hooks install unconditionally, the canary warning fires on sessions with
routing *off* — a direct consequence of the previous change. The recorded
observation stays durable, but visibility is re-derived per session-snapshot
build using the same effective gate the relay applies (override, else own/parent
cost-control state) — `orchestration.py:677-689`. So a mid-session toggle-on
cost-control state) — `orchestration.py:686-696`. So a mid-session toggle-on
reveals the warning and toggle-off clears it, without re-posting anything
(`5444a1a4`).
Three follow-ons made the banner behave:
- **Warnings are clearable.** `session_warnings.clear(session_id, code=None)`
(`runtime/session_warnings.py:93`) is called when a relayed spawn proves the hook
did fire (`routes_hooks.py:1235`), when a publisher posts an empty list
(`routes_events.py:742`), and on session delete (`:1688`). Codes are allowlisted
- **Warnings are clearable.** `session_warnings.clear(session_id, codes=None)`
(`runtime/session_warnings.py:123`) is called when a publisher posts an empty
list — scoped to `EXTERNAL_WARNING_CODES`, the codes that publisher's own check
covers (`routes_events.py:757`) and unscoped on session delete (`:1704`),
where the session is gone so every code goes with it. The relay path does
*not* clear: a relayed spawn only proves one hook fired, and the blanket clear
there wiped exactly the warnings the publisher had just raised
(`routes_hooks.py:1371-1377`), so the canary watcher owns the repair and posts
it on its next check. Codes are allowlisted
and payloads reduced to known string fields, so the index cannot grow arbitrary
shapes (`3b00d101`, empty-list clearing `c46ef54d`).
- **The banner can appear without a reload.** Warnings are recorded server-side