feat(claude): discover models and context window from the CLI - #1605
feat(claude): discover models and context window from the CLI#1605junmo-kim wants to merge 34 commits into
Conversation
Adds a path that queries the claude CLI's list_models control request to read its model catalog, following the shape of the other launchers' *Models.ts modules with a 60-second TTL cache and single-flight coalescing keyed by cwd, and never caching a failed or empty response. No prompt is sent, so discovery carries no token cost. The catalog is exposed through a machine RPC and a GET /machines/:id/claude-models route on the hub. Nothing consumes it yet.
…talog Both the New Session form and the composer now prefer the live catalog when it's available and fall back to the constants when it isn't. The constants keep serving as a recognized-alias map for whatever a session may already have stored, including legacy [1m] suffixes, while the list the pickers actually offer no longer synthesizes bare/[1m] duplicate pairs. Wire values pass through unchanged -- opus[1m] is a real catalog value, and default maps onto the existing null sentinel the same way it always has. The effort control reflects each row's supportedEffortLevels. Since a single row's absence of that field can't say on its own whether the model lacks effort support or the running CLI simply doesn't report the field, that judgment is made once across the whole catalog rather than per row: if any row in the response carries the field, an absent field on another row is a real signal; if none do, nothing has been confirmed and the picker falls back to the static list.
Sends get_context_usage to a session's already-running process at init time to seed the turn-1 context window from an actual measurement rather than a guess. The authoritative result.modelUsage path stays exactly as it was and continues to take priority once a result arrives; this only fills the gap before the first one lands. The cache key used to store that seed is computed once at request time so a model switch in flight can't land the measurement on the wrong entry. The local-mode fallback normalizes a stored model id's suffix before judging its family, so the window estimate follows the model family rather than the suffix.
There was a problem hiding this comment.
Findings
- [Major] Isolate model discovery from project settings and startup hooks — the new
claude -pprobe runs in the selected repository and only disables project MCP configuration. Project.claude/settings*.jsoncan still registerSessionStartcommands, so merely loading the picker can execute repository-defined code before the user starts a session. Evidencecli/src/modules/common/claudeModels.ts:82.
Suggested fix:const args = [ "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--strict-mcp-config", "--setting-sources", "user" ]
- [Major] Do not skip the live catalog for controlled Claude sessions — Claude model and effort routes still permit changes while
controlledByUseris true, but this gate forces those sessions onto the static list. That list now offershaikuwhile still exposing every static effort level, and the reconciliation effect also exits for controlled sessions, allowing an invalidhaiku+highconfiguration. Evidenceweb/src/components/SessionChat.tsx:855.
Suggested fix:enabled: agentFlavor === "claude" && props.session.active // Also remove controlledByUser from the Claude effort reconciliation guard.
- [Major] Apply model and required effort reset in one config update — the post-render effect clears an unsupported effort only after the model RPC and refresh complete. A prompt sent in that gap, or a failed second RPC, leaves the CLI with the invalid combination the new catalog is intended to prevent. Evidence
web/src/components/SessionChat.tsx:1484.
Suggested fix:await api.setSessionConfig(sessionId, { model: nextModel, ...(mustClearClaudeEffort ? { effort: null } : {}) })
Summary
- Review mode: initial
- Three Major issues found: repository hook execution during discovery, missing live effort gating for controlled Claude sessions, and non-atomic model/effort reconciliation.
Testing
- Not run (review automation; PR code was not executed per security policy).
- Missing coverage for project-setting isolation,
controlledByUserClaude catalog behavior, and a model switch that must clear effort before the next turn.
HAPI Bot
The list_models probe can run against an arbitrary cwd before the user starts a session there, so it must not execute that directory's own .claude/settings*.json -- a project SessionStart hook would otherwise run just from opening the model picker. Restrict the probe to user-level settings only.
…atalog # Conflicts: # hub/src/sync/rpcGateway.ts # hub/src/sync/syncEngine.ts # web/src/components/NewSession/LaunchEffortSelector.tsx # web/src/components/NewSession/index.test.tsx # web/src/components/NewSession/index.tsx # web/src/components/SessionChat.tsx
getModelOptionsForFlavor's claude branch trusts a supplied catalog array as-is instead of merging it onto the static presets, which fixed a duplicate-row bug but dropped the guarantee that a null "Default" row is always present. Add withClaudeDefaultRow and apply it in both the options list and the Ctrl/Cmd+M cycler so a caller that omits the row (or omits the current model) still gets one, without reintroducing withCurrentModelOption's resolved-id dedup bug.
There was a problem hiding this comment.
Findings
- [Major] Load the live Claude catalog for controlled sessions — model/effort mutations remain enabled, but the catalog and reconciliation are disabled, allowing unsupported combinations. Evidence
web/src/components/SessionChat.tsx:968.
Suggested fix:enabled: agentFlavor === 'claude' && props.session.active // Also remove `controlledByUser` from the reconciliation guard.
- [Major] Clear incompatible effort in the same model update — the current effect repairs the invalid model/effort pair only after the model RPC and refresh, leaving a send/failure window. Evidence
web/src/components/SessionChat.tsx:1618.
Suggested fix:await api.setSessionConfig(sessionId, { model: nextModel, ...(mustClearClaudeEffort ? { effort: null } : {}) })
Summary
Review mode: follow-up after new commits
- The prior project-settings hook-execution finding is resolved by restricting the probe to user settings.
- Two Major findings remain: controlled Claude sessions bypass live capability gating, and model/effort updates are not applied together.
Testing
- Not run (automation; PR code was not executed per security policy).
- GitHub
testandintegrationchecks pass. - Missing coverage: controlled Claude sessions using live model/effort capabilities; model switches that must clear effort before any subsequent turn.
HAPI Bot
…ically The hub's model/effort routes for Claude sessions never gated on controlledByUser the way codex/cursor/grok do, so the composer's catalog query and effort reconciliation excluding controlled sessions left them on a static preset list with no reconciliation -- free to submit model/effort combinations the route itself keeps consistent for every other session. Drive both off the same session set the routes actually accept changes for instead. Also fold an effort clear into the same model-change request when the target model drops support for the currently pinned effort, so a prompt sent between a model RPC and a separate effort-clear RPC can't catch the CLI with an incompatible pair, and a failed second request can't leave the stale effort stranded.
There was a problem hiding this comment.
Findings
- [Major] Preserve Haiku's no-effort capability in fallback mode — the new fallback catalog offers Haiku, but both effort pickers expose every static effort whenever live discovery is unavailable or still loading. This permits
haiku+highbefore the probe completes, after a probe failure, or with an older CLI, even though this PR establishes that Haiku supports no effort levels. Evidenceshared/src/models.ts:31,web/src/components/NewSession/EffortField.tsx:72,web/src/components/AssistantChat/HappyComposer.tsx:228.
Suggested fix:export const CLAUDE_FALLBACK_EFFORT_LEVELS = { haiku: [] } satisfies Partial<Record<ClaudeModelPreset, ClaudeEffortLevel[]>> // In both fallback selector paths: const levels = CLAUDE_FALLBACK_EFFORT_LEVELS[selectedModel] ?? CLAUDE_EFFORT_LEVELS
Summary
Review mode: follow-up after new commits
- The two findings from the prior HAPI Bot review no longer reproduce at the fresh head.
- One Major fallback-path regression remains: Haiku can still be combined with an unsupported effort while capability discovery is unavailable or pending.
Testing
- Not run (automation; PR code was not executed per security policy).
- GitHub
testandintegrationchecks pass. - Missing coverage: fallback/loading/probe-error flows must keep Haiku at Auto-only in both New Session and the session composer.
HAPI Bot
CLAUDE_MODEL_FALLBACK_OPTIONS is a hand-maintained list -- we hardcoded haiku's identity there and this PR established that haiku doesn't support --effort, so leaving its capability as "unconfirmed" once the live catalog is unavailable was self-contradictory. Add supportedEffortLevels to each fallback entry (haiku: [], the rest: the full level list) and extend resolveClaudeSupportedEffortLevels to consult that list when the live catalog can't confirm a selection, so NewSession/SessionChat's effort pickers gate to Auto-only for haiku even before a live catalog loads.
There was a problem hiding this comment.
Findings
- [Major] Static fallback does not clear an incompatible Claude effort; see inline comment at
web/src/components/AssistantChat/claudeModelOptions.ts:166. - [Major] Combined model/effort requests bypass effort capability validation; see inline comment at
hub/src/web/routes/sessions.ts:663.
Questions
- None.
Summary
- Review mode: follow-up after new commits. The prior Haiku fallback UI finding is only partially resolved: fallback transitions can still persist an invalid effort, and the widened model endpoint accepts effort for unsupported flavors.
Testing
- Not run (automation); static review only, per untrusted-PR execution restriction.
HAPI Bot
resolveClaudeSupportedEffortLevels took the catalog row as an optional argument alongside the model identifier, so a call site could supply the identifier without the row and silently degrade to "unconfirmed" even when a live catalog was loaded -- resolveClaudeModelChangeEffortClear did exactly that, leaving the fallback (no-catalog) path unable to send an atomic effort clear on a model switch. Change the signature to take only the model identifier and derive the row internally, so every caller has to supply what it always has and the degraded call is no longer expressible. Also share /effort's two rejection guards (unsupported flavor, locally- controlled Grok) with /model's atomic model+effort request instead of letting the two routes' effort validation drift apart.
There was a problem hiding this comment.
Findings
- [Major] New Session can display Default while retaining and submitting a hidden Claude model; see inline comment at
web/src/components/NewSession/index.tsx:559.
Questions
- None.
Summary
Review mode: follow-up after new commits. The two prior Major findings are addressed at the latest head. One new Major regression remains in the New Session fallback path: catalog-only/current Claude values can disappear from the rendered options without being cleared from form state.
Testing
- Not run (automation); static review only, per untrusted-PR execution restriction. Add a NewSession test that preserves a catalog-only selection when the next cwd's catalog probe is unavailable and verifies the displayed option matches the submitted model.
HAPI Bot
… back NewSession's claudeModelOptions memo skipped the shared builder whenever the catalog was empty, falling back to ModelSelector's static MODEL_OPTIONS.claude list. That list has no row for catalog-only values (opus[1m], fable[1m]) or legacy aliases, so a model picked in one cwd could render as "Default" after moving to a cwd where discovery fails while the launch form still submitted the original model -- display and submission disagreed. Always delegating to getClaudeComposerModelOptions keeps its "rendered options always include the current selection" guarantee in effect in both modes.
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
Review mode: follow-up after new commits. No Blocker, Major, Minor, or Nit issues found in the latest full diff. The prior New Session fallback issue at web/src/components/NewSession/index.tsx:559 is addressed by always using the canonical Claude option builder, preserving catalog-only and legacy selections when discovery becomes unavailable. Residual risk is limited to automation still in progress and the newest regression test validating display state without also asserting the submitted spawn payload.
Testing
- Not run (automation); static review only, per the untrusted-PR execution restriction.
- GitHub checks at review time:
integrationpassed;testpending. - Suggested coverage: extend the fallback regression test to click Create and assert the same Claude model is passed to
spawnSession.
HAPI Bot
The fallback list kept the declaration order it inherited from the old preset constant (sonnet, opus, fable), while the live catalog returns opus, fable, sonnet. The picker therefore reshuffled whenever discovery dropped in or out, for no reason a user could see.
There was a problem hiding this comment.
Findings
- [Major] Keep fallback context budgets conservative across Claude tiers —
getContextBudgetTokensnow maps bare Sonnet/Opus/Fable aliases to near-1M whenever no explicit per-session context window is available. The same PR explicitly tests tier-dependent 200k plain aliases versus 1M[1m]aliases incli/src/claude/utils/sdkToLogConverter.test.ts:715andcli/src/claude/utils/sdkToLogConverter.test.ts:763. On those tiers, this fallback can under-report utilization by roughly 5× and suppress the near-limit warning. Evidenceweb/src/chat/modelConfig.ts:127.
Suggested fix:if (isClaudeModelPreset(trimmedModel) || trimmedModel.startsWith('claude-')) { return trimmedModel.endsWith('[1m]') ? LARGE_CLAUDE_CONTEXT_WINDOW_TOKENS : DEFAULT_CLAUDE_CONTEXT_WINDOW_TOKENS }
- [Major] Preserve
[1m]aliases when live discovery is unavailable — the fallback list now exposes only bare aliases, so an older CLI, transient probe failure, or unavailable machine prevents new sessions from selecting the 1M variant. This conflicts with the tier-separated behavior preserved by the new converter tests atcli/src/claude/utils/sdkToLogConverter.test.ts:715andcli/src/claude/utils/sdkToLogConverter.test.ts:763; keeping legacy aliases only for already-stored sessions does not help a new session. Evidenceshared/src/models.ts:44.
Suggested fix:{ value: 'opus[1m]', label: CLAUDE_MODEL_LABELS['opus[1m]'], supportedEffortLevels: [...CLAUDE_EFFORT_LEVELS] }, { value: 'fable[1m]', label: CLAUDE_MODEL_LABELS['fable[1m]'], supportedEffortLevels: [...CLAUDE_EFFORT_LEVELS] }, { value: 'sonnet[1m]', label: CLAUDE_MODEL_LABELS['sonnet[1m]'], supportedEffortLevels: [...CLAUDE_EFFORT_LEVELS] },
Summary
- Review mode: follow-up after new commits. Two Major fallback regressions remain. The newest commit only reorders the static options and does not resolve the cross-tier assumptions above.
Testing
- Not run (automation); static review only, per the untrusted-PR execution restriction.
- GitHub checks at review time:
integrationpassed;testpending. - Add regression coverage where live discovery is unavailable and plain aliases report 200k while
[1m]aliases report 1M; assert both picker availability and conservative fallback budgeting.
HAPI Bot
The comments asserted that plain aliases are 200k while their "[1m]" variants are 1M on some account tiers. Measured against claude 2.1.233 and 2.1.234 on both Pro and Max accounts, sonnet, opus and fable report the same window either way; only haiku differs, at 200k. The tests themselves are unchanged: keying per resolved model is what keeps a future divergence, or an account nobody has measured, from reading another variant's cached entry. The differing numbers are fixtures that make a key collision visible, so they now read that way.
There was a problem hiding this comment.
Findings
- [Major] Block sends and config changes while background effort reconciliation is in flight - the new effect starts the effort reset with
void, while the composer remains enabled bydisabled={props.isSending}atweb/src/components/SessionChat.tsx:2094. A prompt can therefore reach Claude with the unsupported model/effort pair before the RPC lands; a concurrent model change can also be followed by this stale clear and silently lose the user's effort selection. Evidenceweb/src/components/SessionChat.tsx:1703.
Suggested fix:const [isReconcilingClaudeEffort, setIsReconcilingClaudeEffort] = useState(false) if (!supportedLevels.includes(sessionEffort)) { setIsReconcilingClaudeEffort(true) void handleEffortChange(null, { silent: true }) .finally(() => setIsReconcilingClaudeEffort(false)) } // Keep sends and model/effort controls closed until the correction is applied. <HappyComposer disabled={props.isSending || isReconcilingClaudeEffort} />
Summary
- Review mode: follow-up after new commits. One Major reconciliation race remains. The latest commit only clarifies test comments and does not change this runtime path.
Testing
- Not run (automation); static review only, per the untrusted-PR execution restriction.
- GitHub checks at review time:
integrationpassed;testpassed. - Add a deferred-
setEffortcomponent test that verifies send/model controls stay disabled until the reconciliation request settles.
HAPI Bot
…ation Both sites encode a measurement or a trade-off that is not visible from the code alone, and reviewers have read them as oversights. The Claude context-window family lookup strips the '[1m]' suffix rather than treating it as a signal because the suffix carries no window information: measured against claude 2.1.233/2.1.234 on both a Pro and a Max account, sonnet reports 967,000 with and without it, opus and fable report 1,000,000 either way, and haiku reports 200,000. The value is also only the seed the first get_context_usage reading replaces. The effort reconciliation is fire-and-forget on purpose. A model change that starts while the clear is in flight can land first, but what is lost in that window is a level that was already invalid for the model it was stored against. Closing the window would need a server-side compare-and-set on model identity, which resolves to a catalog row on the client and would compare two different representations on the server, or freezing the composer for a correction the user never asked for.
…atalog The create-session form is the only conflicting surface: tiann#1626 added model-accurate thinking levels for Pi while this branch made Claude's launch-effort levels model-dependent. Both sides only added to the shared test mocks, so the resolution keeps both fields. EffortField's doc comment still described Claude as static, which stopped being true once both changes were in the same file.
There was a problem hiding this comment.
Findings
- [Major] Serialize background effort reconciliation — the new effect deliberately starts
setEffort(null)without awaiting it whileHappyComposerremains enabled atweb/src/components/SessionChat.tsx:2108. A prompt can therefore start with the unsupported model/effort pair before the correction lands; a concurrent switch to a supporting model can also be followed by this stale clear and silently discard the pinned effort. Evidenceweb/src/components/SessionChat.tsx:1717.
Suggested fix:const [isReconcilingClaudeEffort, setIsReconcilingClaudeEffort] = useState(false) setIsReconcilingClaudeEffort(true) void handleEffortChange(null, { silent: true }) .finally(() => setIsReconcilingClaudeEffort(false)) <HappyComposer disabled={props.isSending || isReconcilingClaudeEffort} />
Summary
- Review mode: follow-up after new commits. One Major reconciliation race remains; the new head documents accepting the race but does not serialize the conflicting interactions.
Testing
- Not run (automation); static review only, per the untrusted-PR execution restriction.
- GitHub checks at review time:
pr-reviewpending. - Add a deferred-effort component test proving send and model controls stay disabled until reconciliation settles.
HAPI Bot
… carry resolvePreferredLaunchSettings clamped the saved launch model against MODEL_OPTIONS, excluding only codex and copilot because their lists are dynamic. Claude's list is now the live CLI catalog too, and the static fallback lost the bare/[1m] pairs, so a saved 'sonnet[1m]' - valid on main until this branch - was silently replaced by Default on the next open, as was any model only the live catalog offers. The static fallback is not an allowlist. The effort value still clamps, since its levels come from the catalog row rather than the model id.
Both ports declare themselves ports of the shared Claude constants. Haiku is new to CLAUDE_MODEL_LABELS, so without it a session set to Haiku renders as the raw id in the native config sheets, and the create-session form cannot offer it. Nothing in CI compares these catalogs, so the drift is silent. The doc references also pointed at CLAUDE_MODEL_PRESETS, which no longer exists; they now name CLAUDE_MODEL_FALLBACK_OPTIONS.
…alog route The native client contract is the SSOT for the iOS and Android tracks. It still described POST /api/sessions/:id/model without the optional effort field that lets a model change clear an incompatible effort in the same write, and omitted GET /api/machines/:id/claude-models entirely.
MODEL_OPTIONS.claude spread the fallback rows wholesale, so supportedEffortLevels rode into objects that getClaudeComposerModelOptions deliberately projects it out of, and a test pinned the leaked shape as correct. Alongside it: getNextClaudeComposerModel landed on the null Default row for an unrecognized model while the sibling cycler was fixed to land on the first concrete row; setModel memoized on the whole mutation object rather than the stable mutateAsync; and the machines-route fixture asserted a supportsFastMode field normalizeClaudeModels strips.
…t wrong The native PRESETS lists are the key set of CLAUDE_MODEL_LABELS -- the recognition aliases, seven ids including the [1m] pairs. Annotating them as ports of CLAUDE_MODEL_FALLBACK_OPTIONS pointed at the offer list, which has four rows in a different order and no [1m], inverting the very role split this branch introduces. The reason recorded for keeping the Claude effort clamp was also wrong: the levels do come from the catalog row, and the probe passes CLI strings through unvalidated while both pickers render an unknown level. The clamp stays for a different reason, now stated. Claude leaving the model clamp took the only coverage of its true branch with it; the replacement pins it on agy and fails if the clamp is deleted.
…ve catalogs The native ports derived their picker from the key set of CLAUDE_MODEL_LABELS, which conflated the two roles this branch separates: the labels are recognition aliases and stay wide, while the picker offers one row per family. Adding Haiku to the labels therefore grew the picker as a side effect, and package-tests caught it against the pinned web option list. Both tracks now carry an explicit FALLBACK_PRESETS list mirroring CLAUDE_MODEL_FALLBACK_OPTIONS - opus, fable, sonnet, haiku - and both the create-session form and the composer picker read it, while the label maps keep resolving the [1m] ids for sessions created before they were dropped. This is the same shape as the web, where CLAUDE_MODEL_PRESETS was deleted rather than narrowed.
There was a problem hiding this comment.
Findings
- [Major] Serialize background effort reconciliation — the effect still starts
setEffort(null)without awaiting or disabling conflicting actions. A prompt can start with the unsupported model/effort pair before the correction lands, and a model switch to a supporting model can be followed by this stale clear, silently discarding the pinned effort. Evidenceweb/src/components/SessionChat.tsx:1717.
Suggested fix:const [isReconcilingClaudeEffort, setIsReconcilingClaudeEffort] = useState(false) setIsReconcilingClaudeEffort(true) void handleEffortChange(null, { silent: true }) .finally(() => setIsReconcilingClaudeEffort(false)) <HappyComposer disabled={props.isSending || isReconcilingClaudeEffort} />
- [Major] Reconcile capabilities known by the static fallback —
resolveClaudeSupportedEffortLevels()deliberately returns confirmed fallback capabilities when the live catalog is empty (for example,haiku -> []), and both selectors then render only Auto. However, the reconciliation guards exit while the query is loading/errored or no live row exists, so the storedhighvalue remains and is still submitted despite the UI displaying Auto. The same mismatch exists in New Session. Evidenceweb/src/components/SessionChat.tsx:1670,web/src/components/NewSession/index.tsx:633.
Suggested fix:Apply the same rule in New Session: gate onconst supportedLevels = resolveClaudeSupportedEffortLevels( claudeComposerModelValue, claudeModelsState.availableModels ) if ( agentFlavor !== 'claude' || !props.session.active || !props.session.effort || supportedLevels === undefined ) return
claudeEffortOptions !== undefined, not the live query's loading/error state. - [Major] Update the iOS catalog conformance expectation — adding Haiku to
ClaudeModels.presetschangesNewSessionCatalogs.claudeModels, but the expected array was not updated. The currentpackage-testscheck fails atCatalogTests.swift:244, so this head cannot pass required CI. Evidenceios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:22.
Suggested fix:NewSessionOption(value: "haiku", label: "Haiku"),
Summary
Review mode: follow-up after new commits. Three Major issues remain: two effort-reconciliation correctness gaps and one confirmed required-check failure.
Testing
- Not run locally (automation/security restriction); static review only.
git diff --checkpassed.- GitHub checks: build, app-build, integration, drift-gate, and main test pass;
package-testsfails innewSessionCatalogsMatchTheWebOptionLists(). - Add component tests covering a deferred reconciliation while send/model controls are disabled, plus catalog-error/fallback handling for a persisted
haiku+highstate in both Session Chat and New Session.
HAPI Bot
…ange race
The catalog effect clears an effort the current model does not support, and
the user can change model while that write is in flight. Landing it afterwards
silently dropped a level the new model does support.
The effort route now takes an optional expectedModel and skips the write when
the session has moved on, answering {ok: true, skipped: 'model-changed'}.
Senders that omit the field keep last-write-wins, so no other caller changes.
It carries session.model verbatim rather than the resolved wire value, so both
sides compare the same stored field and no catalog resolution is involved.
There was a problem hiding this comment.
Findings
- [Major] Make the model precondition atomic with the config mutation — the new
expectedModelcheck reads the hub cache beforeapplySessionConfig()awaits the CLI. A model RPC can already be in flight while the cache still contains the old model, so the stale effort clear is admitted and can execute after that model switch. Evidencehub/src/web/routes/sessions.ts:775.
Suggested fix:The precondition must be evaluated in the same serialized or CLI-side critical section as the model/effort mutation; a route-level cache check is not sufficient.const result = await engine.applySessionConfigIfModelMatches( sessionResult.sessionId, parsed.data.expectedModel, { effort: parsed.data.effort } ) if (result === "model-changed") { return c.json({ ok: true, skipped: "model-changed" }) }
Summary
Review mode: follow-up after new commits. The fallback reconciliation fix is present, but the stale-clear Major remains partially unresolved because the compare-and-set is not atomic with session configuration RPCs.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: package-tests, app-build, build, drift-gate, and integration passed; test was pending.
- Add a concurrency test that pauses an in-flight model
applySessionConfig, starts theexpectedModeleffort clear while the cache still has the old model, then verifies the clear is skipped after the model mutation wins.
HAPI Bot
Grok drives its composer effort options from a model-dependent list exactly like Claude now does - resolveComposerEffortOptions treats the two in the same branch - and it does not correct a stored level the current model no longer offers. The selector simply stops offering it. Claude was the only flavor issuing a corrective write from the live composer, and that write is what the compare-and-set, the reconciliation gate and its guards all existed to defend: a background mutation racing the user's own model changes, across a network boundary where the check and the write cannot be made atomic from the client. Removing it removes the race rather than narrowing it, and the invariant it was protecting was never load-bearing: the picker already refuses to offer an unsupported level, and the CLI accepts --effort alongside a model that does not advertise it. NewSession keeps reconciling because that is local form state before the session exists, which is where grok and pi reconcile too. This reverts the effort route's expectedModel precondition along with it.
There was a problem hiding this comment.
Findings
- [Major] Scope the combined model+effort write to Claude —
supportsEffort()also admits Pi, but Pi appliesset_modelandset_thinking_levelsequentially. If the second RPC fails, Pi has already committed the new model while this route returns 409 and the hub never persists the applied model. Evidencehub/src/web/routes/sessions.ts:699, with the sequential mutation atcli/src/pi/runPi.ts:790andcli/src/pi/runPi.ts:814.
Suggested fix:if (parsed.data.effort !== undefined && flavor !== 'claude') { return c.json({ error: 'Combined model and effort changes are only supported for Claude sessions' }, 400) }
Summary
Review mode: follow-up after new commits. The previous reconciliation race is removed, but its replacement broadens the combined request to Pi, where the operation is not atomic and can leave CLI and hub state inconsistent.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: app-build, build, drift-gate, integration, and package-tests passed; test and pr-review were pending.
- Add a Pi route test that rejects
{ model, effort }, or make the Pi handler transactional and test an effort failure after a successful model change.
HAPI Bot
The capability guard admits every flavor with supportsEffort, so the payload was reaching Pi as well. Pi does not apply the pair as one operation: its CLI awaits set_model and commits currentModel, then awaits set_thinking_level (cli/src/pi/runPi.ts). A failing thinking-level call leaves Pi on the new model while this route reports 409 and applySessionConfig never refreshes the hub cache, so the two disagree with no path back. Claude's session config RPC does apply them together, which is the only reason the combined payload exists. Other flavors get a 400 pointing them at the two routes; a plain model change is unaffected.
There was a problem hiding this comment.
Findings
- [Major] Validate restored Claude selections against the target cwd catalog — New Session now feeds a machine-wide saved model into the composer-style option builder, which deliberately re-adds models missing from the live catalog. A catalog-only model saved in cwd A therefore remains selectable and is submitted in cwd B even after cwd B's catalog omits it. Create is also not gated while the Claude query is waiting/loading, so restored model/effort values can be submitted before the reconciliation effect runs. Evidence
web/src/components/NewSession/index.tsx:578.
Suggested fix:const claudeSelectionValid = model === 'auto' || findCatalogRowFor(model, claudeModelsState.availableModels) || getClaudeModelLabel(model) !== null // recognized legacy aliases only useEffect(() => { if (agent !== 'claude' || claudeModelsState.isLoading || claudeModelsState.error) return if (claudeModelsState.availableModels.length > 0 && !claudeSelectionValid) { setModel('auto') setEffort('auto') } }, [agent, claudeModelsState, claudeSelectionValid]) // Add to isLaunchPreferenceValidationPending: || (agent === 'claude' && (model !== 'auto' || effort !== 'auto') && (deferredDirectoryExists === undefined || claudeModelsState.isLoading))
- [Major] Keep Haiku out of native offer lists until native effort handling is model-aware — Android and iOS now offer Haiku, but both native new-session/config sheets still expose every Claude effort and their model mutations send only
model, leaving an existinghigh/maxeffort pinned. The web path added in this PR treats Haiku as zero-effort and clears effort with the model change; native users can now create or switch to an unsupported pair. Evidenceandroid/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt:22, mirrored byios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:22.
Suggested fix:// Keep Haiku recognized, but do not offer it until native effort options // and model+effort clearing are derived from the selected model. val FALLBACK_PRESETS = listOf("opus", "fable", "sonnet")
Summary
- Review mode: follow-up after new commits. The previous Pi combined-write issue is addressed. Two remaining regressions allow a restored model to bypass the cwd-scoped catalog and allow native clients to select Haiku with unsupported effort.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: app-build, build, drift-gate, integration, package-tests, and test passed; pr-review was pending.
- Add a web test for a saved catalog-only Claude model across cwd changes/loading, plus iOS/Android tests asserting Haiku cannot retain or submit a non-auto effort.
HAPI Bot
New Session was building its Claude options with the composer builder, which deliberately folds a current value the catalog omits back in. That is right for an already-running session carrying a legacy id, but the create form's saved preference is machine-wide while the catalog is per-cwd, so a catalog-only model saved in one directory stayed selectable and submittable in another. Claude now follows what grok already does in this form: options come from the catalog alone, a restored model the loaded catalog does not list resets to Default, and Create waits while the catalog is still loading and a non-default model or effort is pinned. The probe-failure path is unchanged - there is no catalog to judge against, so the saved value stays visible and the reset is skipped in the same case.
There was a problem hiding this comment.
Findings
- [Major] Keep Haiku out of native offer lists until effort handling is model-aware — Android now offers Haiku while New Session still exposes the full static Claude effort list and chat model changes still post only the model. iOS mirrors both behaviors. A native user can therefore create or switch to Haiku while retaining
high/max, even though this PR defines Haiku as supporting no effort. Evidenceandroid/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt:22, related contextandroid/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt:638,android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt:1171, andios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:22.
Suggested fix:Mirror the temporary offer-list restriction in Android core and Swift until native model selection derives effort options and clears unsupported effort atomically.val CLAUDE_MODELS = listOf( OptionItem("auto", "Default"), OptionItem("opus", "Opus"), OptionItem("fable", "Fable"), OptionItem("sonnet", "Sonnet"), )
- [Minor] Map the catalog
defaultrow onto the existingautosentinel — the normal CLI catalog contains a literaldefaultrow, but this branch prependsautoand then maps every catalog row verbatim. New Session therefore renders two Default choices; choosing the second persists and submits the literaldefaultbecause onlyautois omitted at spawn. Evidenceweb/src/components/NewSession/index.tsx:592.
Suggested fix:const defaultRow = claudeModelsState.availableModels.find((candidate) => candidate.value === 'default') return [ { value: 'auto', label: defaultRow?.displayName ?? 'Default' }, ...claudeModelsState.availableModels .filter((candidate) => candidate.value !== 'default') .map((candidate) => ({ value: candidate.value, label: candidate.displayName })) ]
Summary
Review mode: follow-up after new commits. The saved-model/cwd finding from the prior review is fixed. The native Haiku/effort regression remains, and the follow-up introduced a duplicate/default-sentinel regression in New Session.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: drift-gate, package-tests, Android build, integration, and iOS app-build passed; test and pr-review were still in progress.
- Add a New Session test that asserts the live catalog
defaultrow produces exactly one Default option and submits no explicit model, plus native tests preventing Haiku with a non-auto effort.
HAPI Bot
The catalog carries its own `default` row and the form already prepends an 'auto' row labelled Default, so switching this picker to catalog-only rows in the previous commit rendered two Default choices. Picking the second submitted the literal string `default`, because handleCreate only translates 'auto' away. The composer builder maps the same row onto its null sentinel; this one filters it, since 'auto' is the sentinel here.
There was a problem hiding this comment.
Findings
- [Major] Keep Haiku out of native offer lists until effort handling is model-aware — Android New Session and both native chat pickers still expose the full static Claude effort list, while this PR adds Haiku and defines it as supporting no effort. Selecting or switching to Haiku can therefore preserve or submit
high/maxinstead of clearing effort atomically. Evidenceandroid/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionCatalogs.kt:22; related contextandroid/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt:638,android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt:1171,ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:22, andios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractor.swift:641.
Suggested fix:Mirror the temporary restriction in Android core and Swift until native clients derive effort options per model and sendval CLAUDE_MODELS: List<OptionItem> = listOf( OptionItem("auto", "Default"), OptionItem("opus", "Opus"), OptionItem("fable", "Fable"), OptionItem("sonnet", "Sonnet"), )
{ model, effort: null }atomically.
Summary
Review mode: follow-up after new commits. The new commit resolves the prior web duplicate/default-sentinel finding. The native Haiku/effort regression remains. No additional high-confidence issues found in the full latest diff. Residual risk: the Claude control-protocol behavior is mock-tested here; this review did not execute a real CLI probe.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: Android build, fixtures drift-gate, integration, iOS package tests, and iOS app build passed; the main test and PR-review jobs were still in progress.
- Missing coverage: native behavioral tests that selecting or switching to Haiku hides/clears effort and sends the model+effort change atomically.
HAPI Bot
Haiku is the first Claude model that supports no effort, and the native pickers offer a static effort list and post model and effort separately. So while the web can offer Haiku and narrow its effort options to Auto, offering it natively would let a session sit on Haiku with high or max pinned, or be created that way. It stays in both label maps, so a session already on Haiku still renders with its name rather than the raw id, and the create-form conformance test records why this one row diverges from the web offer list. Add it back once the native effort offers are model-aware.
There was a problem hiding this comment.
Findings
- None.
Summary
Review mode: follow-up after new commits. The prior Major finding is resolved by removing Haiku from the native offer lists while retaining recognition for existing sessions. No additional high-confidence issues found in the full latest diff. Residual risk: Claude control-protocol behavior and installed-version compatibility were reviewed statically; no real CLI probe was executed in this automation run.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: Android build, fixtures drift-gate, integration, iOS package tests, and iOS app build passed; the main test and PR-review jobs were still in progress.
- Coverage present for probe parsing/failure/cache/single-flight behavior, context-window seed ordering, picker mapping and effort capability, hub routes, and native catalog parity.
HAPI Bot
Several comments and test names cited the review rounds that produced them. Those identifiers name a process outside this repository, so a reader has no way to reach what they point at; the reasoning they introduced is kept, the citations are not.
There was a problem hiding this comment.
Findings
-
[Major] Worktree sessions lose the live Claude catalog --
SessionChatsendsmetadata.pathas the probe cwd, but HAPI-created worktrees live in a sibling<repo>-worktrees/directory while the machine RPC rejects paths outside configured workspace roots. Every active Claude worktree session therefore silently falls back to static models/efforts; that can expose values absent from the account/org catalog and miss values that are available. Evidence:web/src/components/SessionChat.tsx:986, with related contextcli/src/runner/worktree.ts:120andcli/src/api/apiMachine.ts:280.Suggested fix:
const claudeCatalogCwd = props.session.metadata?.worktree?.basePath ?? props.session.metadata?.path ?? null const claudeModelsState = useClaudeModelsForCwd({ api: props.api, machineId: props.session.metadata?.machineId ?? null, cwd: claudeCatalogCwd, enabled: shouldDriveClaudeCatalog(agentFlavor, props.session.active) })
Questions
- None.
Summary
- Review mode: follow-up after new commits. One Major issue found in the full latest diff: HAPI-managed worktree sessions cannot use the newly added live Claude catalog because the query targets the generated out-of-root worktree path.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: Android build, fixtures drift-gate, integration, iOS package tests/app build, and the main test job passed.
- Missing coverage: a Claude session with
metadata.worktree.basePathplus an out-of-rootmetadata.pathshould query the catalog using the base path.
HAPI Bot
A HAPI-created worktree lives in a sibling <repo>-worktrees/ directory, which falls outside a workspace root configured as the repo itself. The machine RPC rejects such a cwd, so every active Claude worktree session was silently dropping to the static model and effort lists - offering values the account may not have and hiding ones it does. The repo root the worktree was cut from is inside that root, and the probe runs with --setting-sources user, so the catalog does not depend on which of the two directories it ran in. Sessions without a worktree are unaffected.
There was a problem hiding this comment.
Findings
-
[Major] All-no-effort catalogs fall back to the full static effort list - capability reporting is inferred only when at least one row carries
supportedEffortLevels. A current account/org policy that exposes only Haiku can therefore return only rows without that field; this branch returnsundefined, so New Session and the composer expose the static effort levels and the model-change path does not carry the intended effort clear. That can submit a pinned effort such ashighfor a model the catalog says supports none. Evidence:web/src/components/AssistantChat/claudeModelOptions.ts:144.Suggested fix:
const row = findCatalogRowFor(modelValue, availableModels) if (!row) return undefined if (!catalogReportsEffortLevels(availableModels)) { const fallbackValue = row.value === 'default' ? availableModels.find((candidate) => candidate.value !== 'default' && candidate.resolvedModel === row.resolvedModel )?.value : row.value return resolveClaudeFallbackSupportedEffortLevels(fallbackValue) } return row.supportedEffortLevels ?? []
Questions
- None.
Summary
Review mode: follow-up after new commits. One Major issue remains in the full latest diff: policy-filtered catalogs containing only no-effort models are treated as unconfirmed and regain invalid static effort choices.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: main test, integration, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
- Missing coverage: a live catalog containing only
default+haikurows with nosupportedEffortLevelsfield should resolve Haiku/default to zero effort options and clear a pinned effort during a model change.
HAPI Bot
…alog is silent A catalog where no row carries supportedEffortLevels is ambiguous on the wire: it is either an older CLI that never reports the field, or an account whose only visible model happens to support none. Returning undefined for both meant an account seeing only Haiku got the full static effort list and no atomic clear on a model change. The measured static table already records Haiku as supporting no effort and the other families as supporting all five, so deferring to it lands on the right answer under either reading. A model the table does not carry stays undefined, since nothing has confirmed anything about it.
There was a problem hiding this comment.
Findings
-
[Major] The all-no-effort fallback still misses a Haiku-backed Default selection — when no catalog row reports
supportedEffortLevels, this branch falls back using the caller's rawmodelValue. New Session passes"auto"and the composer passesnullfor Default, so the static lookup returnsundefinedeven when the catalog'sdefault.resolvedModelmatches its only concretehaikurow. Both surfaces then restore the full static effort list, and switching to Default does not atomically clear a pinned effort; an account whose default/only model is Haiku can still submithigh. Evidence:web/src/components/AssistantChat/claudeModelOptions.ts:156.Suggested fix:
if (availableModels.length > 0 && !catalogReportsEffortLevels(availableModels)) { const row = findCatalogRowFor(modelValue, availableModels) if (!row) return undefined const fallbackValue = row.value === 'default' ? availableModels.find((candidate) => candidate.value !== 'default' && candidate.resolvedModel === row.resolvedModel )?.value : row.value return resolveClaudeFallbackSupportedEffortLevels(fallbackValue) } return resolveClaudeFallbackSupportedEffortLevels(modelValue)
Questions
- None.
Summary
Review mode: follow-up after new commits. One Major issue remains: the follow-up handles an explicitly selected Haiku, but not the common Default sentinel when that default resolves to Haiku.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: test, integration, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
- Missing coverage: an all-no-effort catalog containing only
default+haiku, sharing the sameresolvedModel, should resolvenull/autoto zero effort options and clear a pinned effort when switching to Default.
HAPI Bot
…he static lookup The static-table fallback used the caller's raw value, and both surfaces pass a sentinel for Default -- 'auto' from New Session, null from the composer. The table carries no such row, so an account whose Default resolves to Haiku got the full effort list back and lost the clear on a model change, even though the catalog held the identity: the concrete row sharing the default row's resolvedModel names the family it points at. Selections the catalog cannot place, and catalog-only families the table does not carry, still resolve to undefined.
There was a problem hiding this comment.
Findings
-
[Major] Native session settings still allow unsupported effort on explicit Haiku sessions — the new web catalog can switch a session to
haiku, but iOS and Android still build the session effort picker from the unconditional staticlow…maxlist. Opening that session on mobile can therefore pinhighagain through/effort, defeating the model-capability handling added by this PR. Evidenceios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:39,ios/Packages/HapiKit/Sources/HapiClient/Chat/ChatInteractionState.swift:248,android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Models.kt:20,android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatViewModel.kt:1289.Suggested fix:
public static func claudeEffortOptions( currentModel: String?, currentEffort: String? ) -> [CatalogOption] { if normalizeClaudeModel(currentModel) == "haiku" { return [CatalogOption(value: nil, label: "Auto")] } // existing option construction } effortOptions = ModelCatalog.claudeEffortOptions( currentModel: model, currentEffort: effort )
Mirror the model-aware signature/guard in Android.
Questions
- None.
Summary
Review mode: follow-up after new commits. The prior Default-to-Haiku effort-resolution finding is addressed. One Major cross-client gap remains: native session settings can reattach an unsupported effort to a Haiku session created or changed on the web.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at review time: test, integration, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
- Missing coverage: iOS and Android session-config tests should assert that an explicit
haikumodel exposes only Auto and cannot submit a non-null effort.
HAPI Bot
…ffort Both native config sheets built the Claude effort picker from the unconditional static level list. A session switched to Haiku in the web app could be opened on mobile and pinned back to high, so the capability the catalog reports was enforced on one surface only. Both tracks now carry the effortless-family set ported from CLAUDE_MODEL_FALLBACK_OPTIONS, and the effort builder takes the model. Unknown, Default and omitted selections stay permissive - nothing has said those lack effort - and a [1m] suffix resolves to its bare family.
There was a problem hiding this comment.
Findings
-
[Major] Resolved Haiku IDs bypass the no-effort guard - this PR explicitly supports existing sessions whose stored Claude model is a resolved SDK id, but the fallback capability lookup only strips
[1m]and exact-matches aliases. With no live catalog,claude-haiku-4-5-20251001therefore resolves to "unknown" and the web composer exposes the full effort list; the new iOS/Android guards have the same exact-haikulimitation. Those clients can reattachhighto a Haiku session. Evidenceweb/src/components/AssistantChat/claudeModelOptions.ts:98,ios/Packages/HapiKit/Sources/HapiProtocol/Catalog/ModelCatalog.swift:43,android/core/protocol/src/main/kotlin/app/hapi/protocol/catalog/Models.kt:28.Suggested fix:
function claudeFamily(model: string): string { const normalized = stripClaude1mSuffixForFallbackMatch(model.trim().toLowerCase()) if (normalized === 'haiku' || normalized.startsWith('claude-haiku-')) return 'haiku' return normalized } const family = claudeFamily(modelValue) return CLAUDE_MODEL_FALLBACK_OPTIONS.find( (option) => option.value === family )?.supportedEffortLevels
Mirror the resolved-id family normalization in Swift/Kotlin.
Questions
- None.
Summary
- Review mode: follow-up after new commits. The prior short-alias native finding is addressed, but resolved Haiku IDs still bypass effort capability handling when the live catalog is unavailable and in native clients.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at submission: test, integration, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
- Missing coverage: web fallback plus iOS/Android catalog tests for
claude-haiku-4-5-20251001should assert Auto-only effort.
HAPI Bot
A stored model can be a preset, a legacy [1m] alias, or a resolved SDK id, and the capability lookups only stripped [1m] and exact-matched the short aliases. A session carrying claude-haiku-4-5-20251001 therefore looked unknown, so the web composer offered the full effort list and the new native guards let it be pinned back to high. Resolved ids are claude-<family>-<generation>..., so shared/src/models.ts now exposes resolveClaudeModelFamily, which reads the family structurally rather than from an id-by-id table, and both native ports mirror it. Ids whose family the offer list does not carry still resolve to undefined.
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits. No issues found in the latest full diff. The prior resolved-Haiku effort-capability finding is addressed across web, iOS, and Android. Residual risk is limited to platform-specific regression coverage noted below.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at submission: test, integration, build, fixtures drift-gate, iOS package tests, and iOS app build passed.
- Missing coverage: Android has no targeted assertions for ClaudeModels.family / supportsEffort or ModelCatalog.claudeEffortOptions with a resolved Haiku id such as claude-haiku-4-5-20251001.
HAPI Bot
…atalog # Conflicts: # hub/src/sync/rpcGateway.ts # web/src/api/client.ts
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits. No issues found in the latest full diff. The update since the prior HAPI Bot pass is an upstream/main merge; the complete current diff was re-reviewed. Residual risk is limited to the testing gap noted below.
Testing
- Not run (automation/security restriction); static review only.
- GitHub checks at submission: unit tests, Android build, fixtures drift-gate, iOS package tests, and iOS app build passed.
- The integration job fails in four runner integration cases, but the current base commit 4948d23 fails in the same four cases, so this was not attributed to the PR diff.
- Missing coverage: Android has no targeted assertions for ClaudeModels.family / supportsEffort or the Auto-only Haiku effort options.
HAPI Bot
Claude is the last launcher whose model list is a compile-time constant. agy, codex, copilot, cursor, grok, opencode and pi all ask their CLI what it can run; this brings claude in line with them, and while the catalog is available it also lets the context-window indicator use a measured value instead of a guess.
This is larger than I'd like for one PR because both halves read the same catalog and splitting them would leave the picker offering a model whose window the status bar still guesses. It's arranged as three commits that each stand on their own and pass the suites independently, so it can be reviewed one at a time: the CLI-side catalog path, then the pickers that consume it, then the context-window seed.
Problem
shared/src/models.tslists six Claude presets, three of them bare/[1m]pairs. Measuring each alias againstclaude2.1.233 shows the pairs are the same model:--modelsonnet/sonnet[1m]opus/opus[1m]fable/fable[1m]haikuSo the picker offers three choices that do nothing, and
haiku— which the CLI does offer — is missing entirely. The list also can't follow account or organisation policy, or a new model generation, without a release.The same
[1m]suffix is used as the signal for the context-window budget, insdkToLogConverter's turn-1 seed and inweb/src/chat/modelConfig.ts's fallback. Since the suffix doesn't track the window, a session on baresonnetis measured against 200,000 rather than its real 967,000, which inflates the percentage the status bar shows for the whole first turn. The existing SDK-result path corrects this once a turn completes, and a recent fix already special-cased Fable's bare id in the local-mode fallback for the same reason; this generalises that.Solution
Catalog.
cli/src/modules/common/claudeModels.tssends alist_modelscontrol request to a headlessclaude -pand returns the rows verbatim, following the existing*Models.tsshape: a 60s TTL cache with single-flight coalescing, keyed by cwd, never caching failures. No prompt is sent, so the probe costs no tokens. It's reached through a machine RPC andGET /machines/:id/claude-models, matching the sibling routes.Picker. Both surfaces — the New Session form and the composer — render the catalog when it's available and fall back to constants when it isn't. The constants keep every alias they recognise, including the
[1m]forms that existing sessions may have stored, but the list offered to the picker no longer synthesises the duplicate pairs. Wire values pass through untouched:opus[1m]is a real catalog value anddefaultstill maps onto the existing null sentinel, so nothing changes in what gets stored or passed to--model.Effort. Rows carry
supportedEffortLevels, so the effort control now reflects what the selected model actually accepts;haikureports none and the control is hidden for it. Whether a row's absent field means "unsupported" is decided per catalog rather than per row: if no row in a response carries the field, the CLI isn't reporting it and the static list is kept.Context window. A
get_context_usagecontrol request on the session's existing process seeds the real window at init, so turn 1 starts from the measured number instead of a suffix guess. The authoritativeresult.modelUsagepath is unchanged and still wins; the seed only fills the gap before the first result lands, and the cache key is captured once at request time so a mid-flight model switch can't land the measurement on another entry. The local-mode fallback now keys on the model family after normalising the suffix away.Tests
Unit tests cover the probe (parsing, empty and failed responses, timeout, cache hits avoiding a spawn, single-flight coalescing, request-id matching, and a write to a closed stdin not escaping), the seed ordering including a mid-flight model switch and the existing anti-flicker behaviour, the catalog-to-picker mapping including stored resolved ids and legacy
[1m]aliases, effort resolution for both the reporting and non-reporting catalog shapes, and the new hub route.Manually verified end to end against a real
claude2.1.233 on an isolated hub: the endpoint returns the live catalog in ~0.9s cold and 0ms cached, five concurrent requests spawn one probe, the picker shows the catalog with no duplicate pairs, the effort control hides forhaiku, and a baresonnetsession reports52k / 967kfrom its first turn.Notes
Model discovery is scoped by cwd rather than to a running session, matching how the New Session form needs it before a session exists.
supportsFastModeis parsed by the CLI but not forwarded, since nothing reads it yet.AI disclosure per CONTRIBUTING: written with Claude Code (Opus 5 for design and review, Sonnet for implementation).