design phase 9 w13 multi model - sinrimin/harness-nexus GitHub Wiki
Design: Phase 9 W13 — multi-model picker (only usable models in the dropdown)
Status: in development (2026-09-16). Implements the feasibility verdict of
research-phase-9-w13-multi-model-picker.md(source-verified per adapter). Goal: the session-config model selector lists ONLY models the platform configured — the pushed default plus the W10modelsextras — instead of the agent's own built-in catalog, which is dead entries under a gateway route. Zero web changes, zero storage/entity changes; one optional wire field, one writer key, one daemon-side rewrite.
1. Per-target mechanism
| Target | Mechanism | Where |
|---|---|---|
| claude-code | W3 writer adds availableModels: unique([model, ...extras]) to ~/.claude/settings.json; the ACP wrapper applies the allowlist itself and synthesizes unknown ids verbatim (applyAvailableModelsAllowlist). |
cli/src/daemon/runtime-config.ts |
| codex | Daemon rewrites the model option list on OUR wire; session/set_config_option accepts raw ids (no list validation), so the set path is untouched. The model set rides chat:session.start (modelOptions), because the codex writer persists only the single root model. |
cli/src/daemon/model-options.ts + server/src/realtime/chat.ts |
| opencode | Same daemon rewrite; allowed values are modelOptions mapped to ${OPENCODE_PROVIDER_ID}/${id} — those exist in the provider registry (the W12 writer's models map), so set stays valid (parseSelectedModel requires registry existence). |
cli/src/daemon/model-options.ts |
| deepseek | Daemon FLATTENS dsh's nested grouped options (its options: [{group, name, options: […]}] shape failed the flat wire schema, so dsh showed NO model selector at all — rig-found) and intersects on ["harness-nexus","<id>"] JSON tuples, dropping the built-in deepseek-official group. Extras still come from the W10 models: catalog write. |
cli/src/daemon/chat.ts + model-options.ts |
2. Wire change (server → daemon)
chatSessionStartEventSchema (shared/realtime.ts) gains
modelOptions?: string[] (≤32 ids). ChatService.open resolves the agent's
stored RuntimeConfig (uow.runtimeConfigs.findByMachineAndTarget) and
sends unique([spec.model, ...(spec.models ?? [])]) — the stored models is
extras-only (PUT normalizes), the prepend is the caller's job, same as the
writers. Field omitted when no row exists (or the target is not
runtime-managed — hermes/zcode). Old daemons strip the unknown key
(zod non-strict) — no compatibility break.
3. Daemon rewrite (cli/src/daemon/model-options.ts)
Pure functions, applied at every point adapter options enter our wire:
- establishment responses (
takeSessionConfigresults, all three arms), - the live
config_option_updatepush (which REPLACES the whole list — the rewrite must be idempotent, and re-applying after a set is what keeps the noise from coming back), - the
session/loadcapture path's history items (belt-and-braces — the response snapshot is last-wins anyway, but a resync replay should not flash an unfiltered list).
Rules (only category === 'model' select rows are touched):
- claude-code / deepseek / everything else: identity.
- codex: BUILD the list from
modelOptions(bare ids) — keep the adapter's entry when a value exists in its list (preserves display names), else synthesize{value, name: value}. Building, not filtering, is load-bearing: codex only ever advertises its remote presets plus the CURRENT model as a verbatim entry, so configured extras never appear in its own list — an intersection filter silently dropped every extra beyond the current model (rig-found on 0.16.0). Raw ids are genuinely selectable on set (no list validation). NomodelOptions→ identity (old server / no stored config). - opencode: INTERSECT with
modelOptions.map(id =>${OPENCODE_PROVIDER_ID}/${id})— set validates against the provider registry, so only values the adapter itself offers are certain to be selectable. Empty intersection → the row stays untouched: a hand-managed install (or a provider the config no longer describes), where the full list is the honest state. NomodelOptions→ identity. currentValuenot represented in the resulting list is appended as a verbatim entry — mirrors the adapters' own out-of-picker semantics (e.g. a resumed session running a model outside our set).
chat:config.set is deliberately untouched: values forward verbatim and the
optimistic merge matches by option id; the rewrite only narrows entries and
never invents a VALUE the platform didn't configure (synthesized entries carry
exactly the configured ids).
OPENCODE_PROVIDER_ID = 'harness-nexus' is exported from
shared/src/schemas/runtime-config.ts (single source; the W12 writer's
module-private const adopts it).
4. Decisions & trade-offs
- claude
availableModelsis always written while a spec exists — even for a single model. Under a gateway the built-ins are dead entries, and this matches dsh/opencode behavior (their catalogs are exactly the pushed set). The key is platform-owned: re-apply overwrites a user's ownavailableModelsarray. Side effect (documented Claude Code semantics): the machine's TERMINAL/modelpicker narrows too — intended for a platform-managed machine, but an operator hand-running claude there loses the built-in rows while a spec is applied. The wrapper always keeps aDefaultrow (resolves to the pushedmodel). - Synthesized claude entries carry no SDK capability metadata → the effort selector stays hidden for non-claude ids (status quo for gateway models, no regression).
- Whether a model id is actually servable remains W10 semantics: extras come
from the endpoint's own
/modelsdiscovery and are user-picked. - Fallback matrix: old server + new daemon (no hint) → full list (honest fallback); new server + old daemon (field stripped) → full list (status quo). No new daemon capability — the soft-gates don't change.
5. Out of scope
- dsh (done in W10); web changes (selectors already render whatever arrives);
schema/storage migrations; per-PAT or per-profile model sets;
modelOverrides/ANTHROPIC_CUSTOM_MODEL_OPTIONarms of the wrapper; restricting codex's own terminal model picker (no config seam exists — daemon-side filtering is wire-only by design).
6. Verification
- Unit: shared schema (field presence/absence), server open payload
(
modelOptionsfrom a seeded row; omitted without one),model-options.tsmatrix, claude writer goldens (single/extras/idempotent). - Integration: chat.test.ts round-trip with the fixture agent driven as
codex+modelOptions— filtered ready snapshot, filtered re-push. - Rig E2E: apply configs with one extra per target; claude settings.json
shows
availableModels; all three pickers show only our models; page refresh (resync) keeps the filtered list.