refactor(run-menu): one table-driven launcher for every external CLI (PR B2) - #458
Conversation
…e (PR B2) PR Ark0N#380 (PR B) held back the frontend half of the CLI registry refactor, explicitly deferring window.__codemanCliCatalog and making session-ui.js / mobile-overview.js catalogue-driven as "PR B2". - Inject window.__codemanCliCatalog in renderIndexHtml(), following the existing __codemanCustomModelClis pattern (escapeScriptJson-guarded, resolved per-request). Reading CliEntry.shortBadge here is what makes it genuinely read, so it drops out of types.ts's DECLARED_FOR_LATER list. - Consolidate session-ui.js's 8 near-duplicate run<Mode>() launch functions (opencode/codex/gemini/antigravity/pi/omp/grok/deepseek) into one shared _runCliMode() plus a local RUN_MODE_LAUNCH config table. The 8 method names stay as thin wrappers (index.html calls them by name; tests assert on the name). Also collapses a duplicated 8-way isAltMode/isExternalCli OR-chain (same expression, copy-pasted twice in openSessionOptions) into one EXTERNAL_CLI_MODES check. - Add test/frontend-cli-no-id-branching.test.ts, a guard scoped to session-ui.js/mobile-overview.js only (not the rest of src/web/public/, which stays explicitly out of scope per CLAUDE.md), mirroring the backend's own no-id-branching guard. mobile-overview.js and the wiring of accent/echo/wheelForward/ keyboardAccessory were investigated and deliberately left alone: the first is already a single, tested, gated table (not duplicated logic); the second set belongs to terminal-ui.js/keyboard-accessory.js/styles.css, files outside this PR's mandate. Verified on a tmux-capable devbox (this sandbox has no tmux): full CI gate at 405 files / 7717 tests / 0 failures, typecheck clean, 94 targeted tests covering exact per-CLI wire-body shapes unmodified and passing, and a live anti-vacuity check on the new guard (injected a real branch, confirmed it fails, reverted, confirmed green). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
|
Thanks for this, and for the write-up. The open questions and the "what I decided not to do" section made the review a lot faster than it would otherwise have been. To summarise for anyone else reading: this collapses the eight near-identical The refactor itself is faithful. I compared every launch body against master and the wire shapes are unchanged, including antigravity's deliberate absence of Two things I would like fixed before this lands.
Smaller things I am happy to take at merge rather than have you respin:
On your open questions: 2 is the one I want settled before merging. Nothing reads 1: leave the label ternary hardcoded. omp's 3: current head is clean against master, nothing else open conflicts with these files, so no further splitting needed on that account. 4: yes please, a small separate PR for the Also checked and confirming your reasoning: most of the Once the two guard fixes are in and you have picked an answer for question 2, this goes in. |
…nused catalogue Two required fixes from Ark0N's review of Ark0N#458: 1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on <file>::<line>::<expression>. A single inserted line anywhere above an entry shifted every subsequent line number, so all 21 entries went stale simultaneously and the same 21 branches were reported as "new" — on a file six other open PRs also touch. Dropped the line number from the key (<file>::<expression>, matching the backend guard's own design), which collapses 21 line-keyed entries to 11 or-collapse where the same expression recurs at multiple call sites in the same file. 2. test/run-mode-ui.test.ts's terminal-ownership guard scanned method bodies via `^ {2}async (run[A-Za-z]*)\(\) \{$`, which matched the 8 one-line run<Mode>() wrappers PR B2 introduced but not _runCliMode(mode), where the real logic (and the actual risk the guard exists to catch) now lives. Fixed the regex to `^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$` and added _runCliMode to the sanity list. Same-class fix in test/opencode-resize.test.ts, which had the identical blind spot via runOpenCode.toString(). Both reproduced live before fixing (inserted the same comment line; added this.terminal.clear() to _runCliMode) to confirm the bug, then confirmed the fix catches it and the suite stays green otherwise. Also resolves Open Question 2 by dropping window.__codemanCliCatalog entirely: nothing consumed it, and a registry DECLARED_FOR_LATER field costs nothing until read while an unconsumed script tag on every page render is a different trade. Reverts Phase 1 cleanly — server.ts's injection, shortBadge back in types.ts's DECLARED_FOR_LATER list and the pinned guard test, and the three associated render-index-html.test.ts / server-index-title.test.ts assertions. Full gate: 405 files / 7717 tests / 0 failures (net unchanged), typecheck/ lint/format clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
|
Thanks for the fast, thorough turnaround — and for reproducing both bugs rather than just describing them, that made both fixes unambiguous. Both required fixes are in, verified the same way you found them: I reproduced each bug live (inserted the same comment line at the top of 1 — Allowlist keyed on line numbersFixed as you suggested: dropped 2 —
|
|
Thanks for this, and for turning both guard fixes around so fast with the repros attached. To restate what lands: this collapses the eight near-identical I re-verified the refactor rather than reading it. A JSDOM harness drove all eight launch functions on your head and on master across 80 combinations (local, remote, docker, with and without envOverrides, with and without a pending custom-model pick, CLI unavailable, dsh installed but not runnable, codex with both app settings on, two-instance launch), capturing the status-probe URL, the banner text, every Both round-one fixes reproduce as fixes. The allowlist survives an inserted line at the top of Four things I will take at merge rather than send back:
Separately, the title and the body still describe Merging after those, which are all mine to apply. Thank you for measuring the |
…allowlist, RUN_MODE_LAUNCH drift guard Three of Ark0N's four "will take at merge" items, applied instead since they were straightforward to do properly: 1. test/frontend-cli-no-id-branching.test.ts's ALLOWED_BRANCHES keyed on <file>::<expression> (fixed last round) closed the line-shift problem but opened a new one: every stock id was already allowlisted for session-ui.js in the `mode === '<id>'` form, so a BRAND NEW branch reusing that exact expression anywhere in the file passed unnoticed. Reproduced live (`if (this.mode === 'codex')` injected into runOpenCode()) — stayed green under the old version. Each allowlist entry now carries the exact count of approved call sites, and a new test asserts actual-vs-declared count for every key; a mismatch in either direction is real (higher = new unreviewed branch riding in on an existing approval, lower = a reviewed site was removed and the entry is now stale). Reproduced again against the fix: same injection now fails with an exact diagnostic (expected 2, found 3). 2. Added test/run-mode-launch-table-drift.test.ts. RUN_MODE_LAUNCH restates four things stock.ts already owns (label, install command, supportsCustomModel, the external-mode key set), and they agree today with nothing enforcing it. supportsCustomModel is the dangerous one: the Run-menu picker's rows come from the server-injected window.__codemanCustomModelClis (built from capabilities.customModelInjection.kind), so a CLI gaining a real injection recipe later would be OFFERED in the picker while _runCliMode silently drops the customModel field for it — the session launches on the vendor's cloud while the UI claims the local endpoint. Drives the real session-ui.js via JSDOM and compares RUN_MODE_LAUNCH against STOCK_CLIS on all four axes. 3. Inlined the "Open Question 7 in PR-B2.md" references in the allowlist reasons — PR-B2.md is a local planning doc, never part of the committed tree, so the reference was dead on arrival for anyone reading the repo. Points at the PR Ark0N#458 review thread instead. 4. Added a sentence to docs/cli-registry.md naming the new frontend guard alongside the backend one it mirrors. Full gate: 406 files / 7721 tests / 0 failures, typecheck/lint/format/ check:frontend-syntax all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
|
Thank you for re-verifying with the 80-combination JSDOM harness rather than trusting the diff — that's a level of rigor I appreciate, especially on the byte-identity claims. I went ahead and applied three of your four "will take at merge" items myself instead, since they were straightforward once I had the reproductions: 1 — count-based allowlist. You're right, and I reproduced it exactly as you described ( 2 — the 3 — the dead 4 — the Left your title-change and the Full gate: 406 files, 7721 tests, 0 failures (net +4 from the new drift test), typecheck/lint/format:check/check:frontend-syntax all clean. One process note, unrelated to the code: partway through this round my local checkout got switched to an unrelated branch by another session sharing the same working directory (a documented hazard in this repo's own CLAUDE.md). Caught it before committing anything, via |
|
Merged into master for 1.32.0, with the merge-time items applied on the way in (0f95532): the opencode-resize guard now targets the real launcher code and fails when a pre-assignment is introduced, the two dropped invariants are back as comments, the docs paragraph sits below its backend antecedents, the frontend guard now catches a comparison on any variable name, a table-driven test pins the run() dispatch, CLAUDE.md names the second guard, and every injection in server.ts uses a replacer function so a label carrying $' cannot splice the document. I also retitled the PR to name the consolidation. Thank you for the byte-identity proof and for the fast rounds. |
…rk0N#458) - test/opencode-resize.test.ts: retarget the launcher guard at the real code (this.selectSession(firstSessionId), any this.activeSessionId assignment) with an anti-vacuity check; the old strings existed nowhere, so it could never fail - session-ui.js: restore as comments the two invariants the merged bodies lost (deepseek leaves statusReporting unset, i.e. ON; no effort field for external CLIs, it is Claude-specific) - docs/cli-registry.md: move the frontend-guard paragraph below the two backend-guard paragraphs so they keep their antecedent, and note the widened comparison shape - test/frontend-cli-no-id-branching.test.ts: the comparison shape accepts any left-hand identifier (const m = this._runMode; m === 'codex' was invisible), normalized to `mode`; the two `m !== 'shell'` display filters are allowlisted and the remaining blind spots documented - test/run-mode-dispatch.test.ts: table-driven pin of run() dispatch (claude to runClaude, each RUN_MODE_LAUNCH id to _runCliMode(id), shell to runShell, unknown to runClaude, lock held and released) - CLAUDE.md: name the second CI-gated guard next to the backend one - server.ts: every </head> injection passes a replacer function; a clis.json label containing $' re-injected the rest of the document past escapeScriptJson (two render tests pin it, proven failing on the string form) - _isAltCliMode(): no reference anywhere in the tree, nothing to fix Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 1ea363ff808a62861559bc141e724b163cc1c56e)
PR B2 — the frontend half of the CLI registry (window.__codemanCliCatalog, session-ui.js, mobile-overview.js)
Context
PR #380 (PR B, merged) drove
install.shand the Docker agent image from the CLIcatalogue, and explicitly held back the frontend half:
Reasons given: six of the thirteen PRs open at the time touched those files, so
merging conflict-free mattered more than doing everything in one PR.
Sequencing note: before starting, I found
feature/run-menu-custom-model-picker(the Run-menu picker for Custom Model Endpoint Profiles) was active, unmerged WIP
that directly rewrote the same 8 launch functions this PR also touches — 734 lines
of
session-ui.jschanges overlapping mine hunk-for-hunk. I waited for it to mergebefore starting rather than fight it in parallel; it has since landed (the
window.__codemanCliAvailable/window.__codemanCustomModelClisglobals and thecustom-model launch folding in each
run<Mode>()you'll see below are its work, notmine).
What changed
1.
window.__codemanCliCatalog(src/web/server.ts)A general-purpose run-menu catalogue, injected in
renderIndexHtml()beside theexisting
window.__codemanCliAvailable(booleans) andwindow.__codemanCustomModelClis(narrowed to custom-model-capable CLIs) globals, following their exact pattern —
enabledClis()read generically,escapeScriptJson()-guarded, resolved per-requestnever at import.
Carries
{id, label, shortBadge, order, kind}per enabled CLI — no filter beyondenabled, unlike the custom-model global. Deliberately excludeslaunch/env/capabilities/overlays/discovery, mirroring the same rulescripts/generate-cli-catalog.mtsalready follows forconfig/clis.stock.json.Reading
shortBadgehere is what made it a genuinely-read field — I removed it fromCliEntry'sDECLARED_FOR_LATERlist intypes.tsand updated the header comment,which
test/cli-registry-no-id-branching.test.ts's own pinned-list guard caught andrequired.
Current status: built, tested, has no consumer yet. See "What I decided not to
do" below for why — kept anyway as documented forward-looking infrastructure, the
same pattern this codebase already uses for
accent/capabilities.echo/etc.2.
session-ui.js: the 8 near-duplicate launch functionsrunOpenCode/runCodex/runGemini/runAntigravity/runPi/runOmp/runGrok/runDeepSeekwere each an independent ~45-line copy of the same probe → build-config→ launch → select skeleton, with only a handful of lines actually differing per CLI.
Consolidated into:
RUN_MODE_LAUNCH, a local table (label, install hint, per-CLI wire-config builder,custom-model eligibility) — deliberately not sourced from
window.__codemanCliCatalog(see below for why)_runCliMode(mode), the one shared implementationindex.html'swelcome-screen buttons call them by name (
app.runOpenCode()etc.) and severaltests assert on the name directly
run()'s 8-branch if-chain collapsed to: shell gets its own path, claude (oranything not in the launch table) falls through to
runClaude(), everything elsegoes through
_runCliMode(mode).Also collapsed a duplicated 8-way
session.mode === '<id>' || ...OR-chain thatappeared twice inside the same function (
openSessionOptions, forisAltModeand
isExternalCli— literally the same expression, copy-pasted) into oneEXTERNAL_CLI_MODES.has(session.mode)check, backed by the same Set the launch tablederives from.
Net diff: -446/+153 lines in
session-ui.js.3.
mobile-overview.js: investigated, no changesThe original plan assumed ~16 scattered hardcoded CLI occurrences here, based on a
rough grep before reading the file. On actual investigation there's exactly one
data structure (
MOBILE_OVERVIEW_RUN_MODES, a single literal array), already gatedby
isCliAvailable(), already cross-checked againstindex.html's menu (10/10 modesmatch, no drift), and its own test explicitly requires it to stay a literal array
(same anti-drift-guard-via-pinned-source-text pattern found in
session-ui.js).Making no change here was the correct call, not a shortfall — see below.
4. New guard:
test/frontend-cli-no-id-branching.test.tsMirrors
test/cli-registry-no-id-branching.test.ts(same four-shapeBRANCH_PATTERN, sameSTOCK_CLIS-derived id list, same allowlist-with-reasonsdesign), but scoped only to
session-ui.js/mobile-overview.js— not widenedonto the whole
src/web/public/directory, which would force fixing or allowlistingdozens of branches in files CLAUDE.md explicitly keeps out of scope (
app.js,terminal-ui.js,settings-ui.js, …).21 branches remain across both files post-consolidation, all reviewed and allowlisted
with reasons (claude/shell dispatch splits, the documented restart-vs-one-shot
custom-model mechanism, the Respawn/Ralph claude-only gate, the button-label ternary,
the
runModesetter's validity check,mobile-overview.js's shell-exemptavailability gate).
Verified for real, not just written: injected a genuine unrelated branch on the test
VM, confirmed the guard failed (both the unapproved-branch and stale-allowlist
checks fired), reverted, confirmed green again.
What I decided not to do, and why
Three items were in the original plan and turned out to be unsafe or unnecessary on
actual investigation — each verified against real pinned tests, not assumed:
The button-label ternary (
mode === 'opencode' ? 'Run OC' : ...). Cataloguefield is
shortBadge; the ternary's actual OMP text is'Run OMP'(3 chars)while
shortBadgefor omp is'OM'(2 chars).test/run-mode-ui.test.tspinsthe exact
'Run OMP'text. Deriving from the catalogue would silently changedisplayed text and fail that test.
_refreshRunModeAvailability's mode-iteration array. A pinned test literallyscans this function's source text for quoted mode strings — its own comment: "Catches
a sixth run mode being added to index.html without being gated." It's an
intentional anti-drift guard, not an anti-pattern.
window.__codemanCliCatalogitself wasn't consumed by either of the above,because several tests exercise these functions inside a bare
vm.createContext()with no
windowglobal at all — referencing it unguarded there throwsReferenceError, notundefined. Local static constants (RUN_MODE_LAUNCH,EXTERNAL_CLI_MODES) sidestep this while achieving the same actual goal: onesource of truth instead of duplicated logic.
I'm flagging rather than hiding these, same as the maintainer's own review style —
each is a place the plan's first-pass assumptions didn't survive contact with the
real code and its test suite.
Verification
All four phases run on a tmux-capable Debian VM I set up for this
(
codeman-devbox), since this sandbox can't runWebServerat all. Full CI gate runafter each phase, not just at the end:
__codemanCliCatalog)server.ts+ testssession-ui.jsconsolidation)session-ui.jsmobile-overview.js)Also:
npm run typecheckclean at every phasenpm run test:browser -- test/opencode-resize.test.ts(real Chromium): the onetest directly inspecting
runOpenCode.toString()for the historicalactiveSessionId-bypass bug passes; the other 5 tests in that file fail identically
with my changes stashed out (need an authenticated
claudeCLI this fresh VMdoesn't have — pre-existing gap, confirmed, not a regression)
test/run-mode-ui.test.ts,test/custom-model-run-menu-ui.test.ts,test/custom-model-one-shot-launch.test.ts— exact wire-body shape per CLI (codexConfig/geminiConfig/antigravityConfig/
grokConfig/deepSeekConfig, pi's deliberate absence of piConfig) — all pass unmodified
(checksum comparison against the devbox after every sync)
5.
stock.ts'saccentfield doesn't match reality for at least 4 of 9 CLIsNot part of this PR's diff, but worth flagging: I checked whether the "declared but
not yet read"
accentfield (docs/cli-registry.mdlists it alongsideecho/wheelForward/keyboardAccessory) was safe to wire up as a stretch goal. It isn't— its real consumer would be
styles.css's hand-authored per-CLI gradients, a fileoutside this PR's scope — but while checking, I compared the registered values
against the actual rendered gradients:
stock.tsaccent#d97757(orange — Claude's real brand color)#1a3358→#2563eb)#f59e0b(amber)#0a2e2a→#0d4a40, emerald text)#8b5cf6(purple)#0b2b33→#0891b2)#10b981(green)#33121f→#be185d)#4d6bfe#4d6bfeappears literally in the gradient)Confirms
docs/cli-registry.md's own "transcribed, not authoritative" warningempirically. Not fixing it here since it's a color-correctness question unrelated to
B2's actual goal, but you'll probably want it fixed before anyone wires
accentup.Open questions for you
'Run OMP'vs.shortBadge: 'OM'mismatch (found during Phase 2) — pre-existing,harmless today, but blocks making the label ternary catalogue-driven. Worth fixing
stock.ts's omp entry, or leave the ternary hardcoded permanently?window.__codemanCliCataloghas no consumer. Kept it anyway (cheap, tested,matches this codebase's own "declared but not yet read" precedent) rather than
reverting Phase 1's work — agree, or would you rather it came out until something
actually needs it?
feat(cli-registry): drive install.sh and the Docker agent image from the CLI catalogue #380's own rationale for holding B2 back was avoiding conflicts in a crowded area —
worth checking how many PRs currently open still touch these files before merging.
accentmismatches above — worth its own small fix PR before or after this one?🤖 Generated with Claude Code
https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n