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)
Both were sitting as untracked scratch files in the master checkout, never committed to any branch. Moving them here rather than leaving them loose: - DEPLOYMENT_PLAN.md is the live tracker for the CLI-registry follow-up series (PR A Ark0N#347 merged, PR B Ark0N#380 merged, PR B2 merged as Ark0N#458) and is where PR C (this branch's own CLI-management work) belongs. - docs/copilot-integration-plan.md is explicitly PARKED, referenced by name in docs/cli-enable-disable-plan.md's own header as a sibling plan tracked separately — kept for continuity, not active on this branch. The other scratch files found alongside these (PRA.md, PRB.md, PR-B2.md and their review-response counterparts) described PR A/B/B2, all now merged — deleted from the master checkout as stale rather than committed anywhere, since their content is superseded by the real merged PRs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
…6) (#476) * feat(cli-registry): add cliManagementEnabled flag and GET /api/clis Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the #343 review): a synced, default-OFF master flag gating the upcoming CLI management surface, plus a read-only GET /api/clis endpoint listing every registry entry (stock + custom, enabled or not) for the Settings UI. Non-admins in multi-user mode see an empty list rather than a 403. Write endpoints, auto-install, custom entry CRUD and the Settings UI list itself land in later phases. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n * feat(cli-registry): Phases 3-6 - write API + custom entries + Settings UI Completes docs/cli-enable-disable-plan.md ("PR C" from the #343 review). Phase 3: PUT /api/clis/:id toggles enabled for any EXISTING entry (stock or custom) via a shallow merge onto its clis.json override; shell/claude are structurally un-disableable (Decision 4), an unknown id 404s rather than becoming a creation backdoor. Phase 4: POST /api/clis/:id/install runs a STOCK entry's already-vetted install command (shell:true, bounded by timeout, process-group killed on expiry, output captured, audit-logged). A custom entry's id is refused outright, independent of anything Phase 5 does (Decision 3: a custom entry's install text is display-only, never executed). Phase 5: POST /api/clis (create) / PUT /api/clis/custom/:id (update) / DELETE /api/clis/:id (custom only) — a deliberately minimal request shape (id/label/shortBadge/binaries/a simple launch variant), assembled into a full CliEntry with conservative capability defaults and re-validated through CliEntrySchema before writing, never a relaxed path for UI-originated entries. Stock-id collisions, duplicate custom ids, and edits/deletes against a stock id are all rejected explicitly. Phase 6: the Settings UI section (App Settings -> Agents & CLIs), gated independently on cliManagementEnabled AND admin-in-multi-user-mode (Decision 5), fetching/rendering GET /api/clis and wiring every write endpoint above. Every write endpoint answers the same way when the feature is off: 403 FORBIDDEN via one shared requireCliManagementGate() (Phase 1's own checklist item). registry-writer.ts is a new, deliberately separate write module so registry.ts itself stays import-side-effect-free, same tmp+ rename+0600 shape as custom-model-hosts.ts. 27 new/updated route tests covering every gate, collision, and cleanup path; full CI gate green (415/416 files, 7854 tests). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n * fix(cli-registry): toggling a CLI off in Settings never hid it anywhere else window.__codemanCliAvailable — the flag isCliAvailable() reads client-side to gate the welcome-screen buttons, the Run-menu dropdown and the mobile overview — was built purely from each CLI's own installed-on-PATH resolver (isClaudeAvailable() etc.), with no reference to the registry's `enabled` flag at all. So disabling a CLI via the new Settings UI (or a hand-edited clis.json) updated the settings row and nothing else: every launch surface kept offering it, both live and after a full page reload, since even a fresh render never consulted the registry. Fixed in two places: - server.ts: after building `available`, intersect the nine real SessionMode ids against `enabledClis()`. git/cloudflared (utility binaries, not CLI registry entries) and deepseekBinary (a secondary installed-only flag for the "add a profile" affordance) are deliberately left alone. - settings-ui.js: `toggleCliEnabled()` now patches `window.__codemanCliAvailable` in place and refreshes the welcome screen, the mobile overview and an already-open Run menu, mirroring the existing `installDeepSeekProfile()` pattern for the same "injected once, needs an explicit patch" reason — without this half, the server-side fix alone still left every surface stale until the next reload. New test in test/render-index-html.test.ts: an installed-but-disabled CLI (codex, forced via clis.json + reloadCliRegistry()) reads as unavailable, while an installed-and-enabled one (claude) is unaffected by the override. Verified on the Debian devbox (codeman-devbox, real tmux — this sandbox has none and WebServer's constructor hard-requires it): typecheck clean, the new test passes (17/17 in render-index-html.test.ts), the CLI-registry suites pass (86/86), and the full CI gate is green (415 test files, 7855 tests, 0 failures). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD * docs(cli-registry): update the CLI-management plan with status, gotchas, and the Run-menu gap Phases 1-6 were implemented across two commits (da07b38, db4557d) with no corresponding update to the plan doc itself — every checklist still read Status: TODO and every box unchecked. Brings the doc in line with the tree: - A new "Status as of 2026-09-22" section up top: what's actually implemented (verified by grepping the routes/schema/UI, not just trusting the commit messages), the availability-flag staleness bug found and fixed in this session (commit 0c77dd0) with its devbox verification record, and one real outstanding gap. - The outstanding gap: a custom CLI created via Phase 5's write API has no way to actually be launched. The Run menu is static per-mode markup with no consumer of window.__codemanCliCatalog, so Phase 6's own "create a custom entry, confirm it can be launched" verify step was never actually exercised against this. Documented with two candidate fixes, neither started. - Each phase's checklist flipped to [x] where confirmed present in the tree, Status lines updated from TODO to DONE, and the two originally-open questions (Phase 2's installed source, Phase 5's PUT endpoint shape) marked resolved against what actually shipped. No code changes in this commit — documentation only, so a future session (or the one already mid-flight on a separate checkout of this same branch) picks up accurate status instead of a stale plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD * docs: add the CLI-registry deployment plan and the parked Copilot plan Both were sitting as untracked scratch files in the master checkout, never committed to any branch. Moving them here rather than leaving them loose: - DEPLOYMENT_PLAN.md is the live tracker for the CLI-registry follow-up series (PR A #347 merged, PR B #380 merged, PR B2 merged as #458) and is where PR C (this branch's own CLI-management work) belongs. - docs/copilot-integration-plan.md is explicitly PARKED, referenced by name in docs/cli-enable-disable-plan.md's own header as a sibling plan tracked separately — kept for continuity, not active on this branch. The other scratch files found alongside these (PRA.md, PRB.md, PR-B2.md and their review-response counterparts) described PR A/B/B2, all now merged — deleted from the master checkout as stale rather than committed anywhere, since their content is superseded by the real merged PRs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD * fix(cli-registry): render enabled CLIs in launch surfaces * test(cli-registry): update frontend branch guard * fix(test): isolate suite from deployment environment * fix(cli-registry): revise Decision 4 - claude is toggleable, shell stays permanent shell/claude were both structurally un-disableable in the original plan (Decision 4). Revised: shell keeps the hard backend guarantee (it is the one non-agent mode several code paths assume always exists as a raw- terminal fallback), but claude is now a normal toggleable entry like any other CLI. Safe to do because internal session creation (tmux-manager.ts, session.ts, Ralph, plan-orchestrator) resolves a CLI via getCli(), which does not check `enabled` at all - only the Run menu and the HTTP-facing sessionModeSchema() (new session requests through the normal API) key off it. Disabling claude therefore behaves identically in kind to disabling any other CLI: no internal fallback path breaks, it just stops being offered for new sessions until re-enabled. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n * fix(cli-registry): hide shell's toggle entirely instead of greying it out A permanently-disabled switch next to every other row's working toggle read as broken rather than intentional. shell now renders no switch at all - a plain "Always available" label - so there is nothing to click that could look like it should work but doesn't. Backend guard is unchanged (UNDISABLEABLE_IDS still refuses shell unconditionally); this is UI-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n * fix(cli-registry): sort the Installed CLIs list, installed-first then alphabetical renderCliList() previously rendered in registry order (each entry's fixed order field). Now sorts installed CLIs first, then not-installed, each group alphabetical by label - matches how a user actually scans the list (what's ready to use, then what needs installing). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n * style: prettier fixes from the master merge * fix(cli-registry): install/edit take effect immediately, confirm before install, phone labels Four gaps found verifying #476 against the #343 review trail: - Installed or edited CLIs kept reading as missing/stale. Every binary lookup (the nine per-CLI resolvers and the generic registry one) caches in its own closure, with a negative-cache backoff of up to 5 minutes, and nothing cleared them. invalidateCliExecutableResolvers(binaries) now drops those caches per binary; install (success or failure), create, edit and delete call it plus invalidateCliResolverCache(id). Before this, a CLI installed from Settings could fail to launch for minutes, and an edited custom entry kept launching its old binary until a restart. - The Settings "installed" badge for a custom entry used a private `which`, ignoring the entry's searchDirs and the login-shell lookup that spawn and the Run menu use; it now asks the same generic resolver they do. - Install ran on a single click. The #343 review asked for auto-install to sit behind an explicit confirm; the confirm now names the exact command, which GET /api/clis returns for stock entries only (installCommand). - The phone Run button showed the two-letter tab badge ("CC", "CX") instead of the word ("Claude", "Codex"). It uses the registry label again, which is identical to the old static table for every stock CLI (now pinned). 14 new tests; 9 of them fail against the previous head and pass here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n * fix(cli-registry): address #476 review — safe serialized writes, no id branches, docs Must-fix: - registry-writer: start fresh only on ENOENT; refuse (409) a clis.json that does not parse or has group/world permission bits instead of overwriting it (isUnsafePermissions now exported from registry.ts) - mutateRegistryFile(): one promise chain for every mutation, with the existence/duplicate checks inside the serialized step, plus a unique tmp name per write - docs: CLAUDE.md, architecture-invariants, cli-registry (new Settings section) and api-reference (the six /api/clis routes) - drop DEPLOYMENT_PLAN.md and docs/copilot-integration-plan.md Smaller: - PUT /api/clis/custom/:id keeps the entry's current enabled state when the body omits it - runMode setter falls back to the first enabled catalogue entry, not 'claude' - shell guard keyed on kind === 'shell' (routes + Settings list); stock probe map shared with server.ts via utils/cli-installed-probes.ts - stock claude label is now 'Claude Code', so the Run menu / phone overview label rewrites are gone (doctor row keeps "Claude CLI" via its override) - welcome buttons are translatable again and read "Run Claude Code" / "Run Shell"; zh-CN gains "Run Codex" / "Run OMP" - install: per-id in-flight guard (409) and CODEMAN_* stripped from its env - fileoverview / CliEnableSchema comments no longer say stock-only - test-env isolation changes moved to their own PR Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n * test(cli-registry): pin the #343/#347 findings #476 makes reachable A CLI toggled or created through the routes is accepted or rejected by CreateSessionSchema with no restart (#343 finding 2), and a custom CLI created through the API renders a real local, remote and docker launch command (#347 finding 5: no more `cd <path> && undefined`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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