The agent CLI becomes a three-level layered spawn knob, defaults resolve skip-layer against the effective catalog, and one searchable select replaces every spawn picker¶
Issue #66 names four operator-visible defects on the spawn surfaces and one
structural gap behind them. The composer's repo picker clips off-screen:
.composer-pop-panel hard-codes open-above (bottom: calc(100% + 6px), a
fixed max-height: 15rem in web/src/base.css) while the composer sits near
the top of the page, so with many repos the panel's scroll region is
unreachable above the viewport — worst on a phone. The start screen mixes a
custom repo popover with native <select> model/effort chips, no typeahead
anywhere. RepoSettings' base model/effort are free-text inputs under a stale
"provider catalogs arrive in M3" hint while the AFK section directly below
already uses CatalogSelect. And the agent CLI is not selectable at all:
repos.provider is stamped once from the reposvc.DefaultProvider const,
PATCH /repos/{id} rejects provider, and AddRepo never offers it.
The structural gap is that resolution is not agent-agnostic.
ResolveModelEffort (internal/instance/credential.go) hard-400s any
resolved value missing from the provider's catalog. The seeded global default
is opus[1m] — claude-shaped — so the moment a second provider exists, an
AFK run on its repo resolves the claude default and every launch 400s. A
provider with an empty efforts catalog can never spawn at all
(HasOption([], "") == false). The catalog seam itself is not the problem
and stays exactly as ADR-0021/D14 and ADR-0026 pinned it:
AgentProvider.Models()/Efforts() are provider-owned, statically pinned
lists served on GET /api/v1/providers, and the start screen already
re-catalogs by the selected repo's provider — Codex (#2) just declares its
lists.
This ADR amends ADR-0021 (provider joins model/effort as a layered,
AFK-symmetric spawn knob, and default-layer resolution semantics change from
hard-400 to skip-layer), touches ADR-0025 (the composer surface gains a
provider chip, and its repo-picker popover is replaced by the unified
select), and references ADR-0026 unchanged (the seam: catalogs stay
provider-owned pinned lists; GET /providers is untouched). The decisions,
pinned (settled with the operator 2026-07-09 — do not re-litigate):
- Three-level provider selection, D12d-style layering. The effective
provider of a spawn = per-spawn pick → repo override → global default.
repos.providerbecomes nullable (NULL = inherit); migration 0006 also NULLs every existing row — they were stamped from the now-deletedreposvc.DefaultProviderconst, never operator-chosen. A new seeded global settingprovider_defaultholds the first registered provider id (claude-codetoday). Symmetric AFK overrides mirror ADR-0021: nullablerepos.afk_provider_default+ globalspawn_provider_default_afk(empty = inherit), so the AFK chain is repo.afk_provider_default → global spawn_provider_default_afk → repo.provider → global provider_default. One migration (0006, sqlite + postgres).POST .../instancesaccepts an optionalprovider(the per-spawn pick, strict);PATCH /repos/{id}acceptsproviderandafk_provider_default;POST /reposaccepts an optionalprovider. Run rows already storeruns.provider, so an ended chat survives its repo switching agents. - Skip-layer resolution. A DEFAULT-layer value — model, effort, or
provider id — that is not in the effective provider's catalog (not in the
registry, for provider ids) is treated as unset: resolution falls
through to the next layer, and the final fallback is the catalog's first
entry (the first registered provider). An EMPTY catalog — a provider
without that knob — resolves to
"", omits the CLI flag, and skips validation, which fixes both the claude-shaped-global-default AFK 400 trap (the seededopus[1m]poisoning every launch on a second provider's repo) and the empty-efforts-can-never-spawn bug. EXPLICIT per-spawn request values stay strict: an unknown requested model/effort/provider is still a 400, the same discipline D12d always applied to a bad request. This is exactly what the SPA'sresolveSpawnOption(web/src/lib/spawn.ts) already did; the backend now mirrors it. Catalogs stay statically pinned per adapter (compat discipline, ADR-0026) — no live discovery from the CLI. - One select component. One hand-rolled Solid module — no new dependency
— used on the composer (chip trigger skin) AND the settings pages
(form-field trigger skin). It absorbs
CatalogSelect's inherit-entry and "(not in catalog)" semantics as props and the repo picker's disabled/status rows (cloning %, clone failed);CatalogSelectis deleted and every call site migrates — no legacy shim. A search input appears automatically at ≥8 options (a prop can force it on or off); the filter is a case-insensitive substring match over label + value. ARIA listbox pattern with Arrow/Enter/Escape navigation; the filter input sits at the top of the panel, the trigger stays a button. Positioning: prefer below, flip+clamp — free space is measured againstvisualViewport(on-screen-keyboard-aware), the panel flips above only when below lacks room and above has more, andmax-heightclamps to the free space so the whole panel (with internal scroll) always fits. This is the fix for the clipping composer repo panel. - UI consequences of the layering. The composer gets a provider chip ONLY when ≥2 providers are registered — today's single-provider UI stays pixel-identical. The per-spawn pick is ephemeral: it resets on repo change and page load; repo/global defaults are the durable levers. Model/effort picks, the auth/logged-out banner, and the catalogs all follow the EFFECTIVE provider, and model/effort picks reset when the effective provider changes — fixing a latent bug where a foreign pick survived a repo switch and 400'd on submit. The effort chip hides entirely when the effective provider's effort catalog is empty. AddRepo and RepoSettings get provider selects with an explicit inherit entry; global Settings gets base
- AFK provider selects, with base defaults rendered against the global
default provider and AFK defaults against the AFK-effective provider
(previously both hardcoded
providers[0]). A provider switch in RepoSettings re-catalogs the model/effort selects; stored foreign values stay, shown with the "(not in catalog)" marker, and NOTHING auto-clears — skip-layer makes a stale value harmless at spawn, and flipping the provider back restores the old defaults. The stale "provider catalogs arrive in M3" hint is gone.
Out of scope: the Codex adapter itself (#2 — this issue only makes lab ready for it), a per-run provider history UI, and live catalog discovery.
Status¶
Accepted. Amends ADR-0021: provider joins model/effort as a layered,
AFK-symmetric spawn knob (the same base + AFK-override apparatus, one rung
wider), and DEFAULT-layer resolution changes from hard-400 to skip-layer —
explicit request validation stays strict. Touches ADR-0025: the composer
surface gains the conditional provider chip, and the repo-picker popover is
replaced by the unified select (the composer's anatomy and behavior are
otherwise unchanged). References ADR-0026 without change: catalogs remain
provider-owned pinned lists and GET /providers keeps its shape. One
migration (0006, sqlite + postgres). Settled via issue #66. Non-breaking for
a single-provider deployment: the composer stays pixel-identical, and a repo
with no override resolves to the same claude-code it was stamped with —
now via provider_default instead of a const. Unblocks #2: registering a
second provider makes the chip appear and the selects populate, with zero
refactor.
Considered options¶
- Validate defaults strictly at write time everywhere instead of skip-layer at resolve time. Rejected: it turns every provider switch into forced data loss or a hard error — a repo whose stored model no longer matches the new provider's catalog could not even be saved. Skip-layer keeps stored values harmless and reversible; the write path stays honest by validating only what the operator explicitly sends.
- Auto-clear stored model/effort on provider switch. Rejected: it destroys operator data for no safety gain — skip-layer already makes a stale value inert at spawn — and flipping the provider back should restore the old defaults, which a cleared column cannot.
- Make the per-spawn provider pick sticky. Rejected: a sticky foreign pick silently poisons later spawns long after the operator forgot it. The pick is ephemeral by design; the repo override and the global default are the durable levers.
- Live catalog discovery from the CLI. Rejected: compat discipline — ADR-0026 pins catalogs statically per adapter version, verified with the pinned binary, and a runtime probe would trade that determinism for a moving target.
- Adopt a dependency for the select. Rejected: the repo's discipline is hand-rolled, dependency-free UI (ADR-0018/0019/0025), the component is small, and it now owns app-specific semantics (inherit entries, catalog markers, clone-status rows) no off-the-shelf listbox carries.
- Keep
CatalogSelectbeside the new select as a shim. Rejected: two select systems is precisely the defect this issue removes; every call site migrates in the same change.
Consequences¶
internal/store: migration 0006 (sqlite + postgres) makesrepos.providernullable and NULLs existing rows, adds nullablerepos.afk_provider_default, seeds theprovider_defaultsetting to the first registered provider id, and addsspawn_provider_default_afk(empty = inherit).reposvc.DefaultProvideris deleted — the seeded setting is the single source of the default.internal/instance: the resolver gains provider resolution besideResolveModelEffort/ResolveSpawnOptions, all three run-kind-aware and skip-layer for DEFAULT-layer values; explicit request values keep the 400 discipline. An empty catalog resolves the knob to""and the provider omits the flag.internal/httpapi:POST .../instancesgains optionalprovider;PATCH /repos/{id}gainsprovider+afk_provider_default;POST /reposgains optionalprovider.GET /providersis unchanged.- The SPA gains the one select module and loses
CatalogSelect.tsxand the composer's repo popover; NewRun, AddRepo, RepoSettings, and Settings all render it.resolveSpawnOptionstops being a frontend-only kindness — the backend now enforces the same skip-layer rule, so the two can no longer disagree about what a spawn will resolve to. - The seeded
opus[1m]global default stops being a foot-gun: on a repo whose effective provider cannot serve it, it reads as unset and resolution falls through instead of 400ing every unattended launch. - An ended run's chat keeps reading through
runs.provider, so switching a repo's agent never orphans its history.