mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2)
PR #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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2d573d8a34
commit
cd64b0a3f7
@@ -629,12 +629,15 @@ export interface CliOverlays {
|
|||||||
/**
|
/**
|
||||||
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
|
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
|
||||||
*
|
*
|
||||||
* `shortBadge`, `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
|
* `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
|
||||||
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
|
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
|
||||||
* behaviour, and the frontend is deliberately untouched by the change that introduced this
|
* behaviour, and most of the frontend is deliberately untouched by the change that introduced
|
||||||
* registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
|
* this registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
|
||||||
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
|
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
|
||||||
* being verified (a mobile/browser suite the CI gate cannot see).
|
* being verified (a mobile/browser suite the CI gate cannot see). `shortBadge` graduated out of
|
||||||
|
* this list (PR B2): it is read server-side into `window.__codemanCliCatalog`
|
||||||
|
* (`src/web/server.ts`), the general run-menu catalogue injected for the frontend to consume —
|
||||||
|
* see `docs/cli-registry.md`.
|
||||||
*
|
*
|
||||||
* They are declared now because each entry should describe its CLI completely, and because
|
* They are declared now because each entry should describe its CLI completely, and because
|
||||||
* transcribing them while the hand-written source is still on screen is when the values are
|
* transcribing them while the hand-written source is still on screen is when the values are
|
||||||
|
|||||||
+154
-447
@@ -11,6 +11,115 @@
|
|||||||
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
|
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
|
||||||
|
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
|
||||||
|
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
|
||||||
|
* `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select
|
||||||
|
* skeleton with only the CLI-specific pieces below actually differing — eight
|
||||||
|
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
||||||
|
* own no-id-branching rule exists to prevent server-side.
|
||||||
|
*
|
||||||
|
* Deliberately a LOCAL table rather than reading `window.__codemanCliCatalog`
|
||||||
|
* (server.ts, PR B2): that catalogue carries only menu-facing metadata
|
||||||
|
* (id/label/shortBadge/order/kind), and several unit tests exercise these
|
||||||
|
* run*() methods inside a bare `vm.createContext()` sandbox with no `window`
|
||||||
|
* global at all (see test/run-mode-ui.test.ts) — referencing `window` there
|
||||||
|
* unguarded would throw, not degrade. `buildConfig` returns the CLI's
|
||||||
|
* top-level legacy config field for a LOCAL launch, or `null` for a CLI that
|
||||||
|
* sends none (pi: no bypass flag exists, so there is nothing to send — see
|
||||||
|
* runPi's own history below for why that must stay true).
|
||||||
|
*/
|
||||||
|
const RUN_MODE_LAUNCH = {
|
||||||
|
opencode: {
|
||||||
|
label: 'OpenCode',
|
||||||
|
installHint: 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash',
|
||||||
|
supportsCustomModel: true,
|
||||||
|
buildConfig: () => ({ openCodeConfig: { autoAllowTools: true } }),
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
label: 'Codex',
|
||||||
|
installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex',
|
||||||
|
supportsCustomModel: true,
|
||||||
|
buildConfig: (globalSettings) => ({
|
||||||
|
codexConfig: {
|
||||||
|
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||||
|
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||||
|
renderMode: 'hybrid',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
label: 'Gemini',
|
||||||
|
installHint: 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli',
|
||||||
|
supportsCustomModel: true,
|
||||||
|
buildConfig: () => ({ geminiConfig: { approvalMode: 'yolo' } }),
|
||||||
|
},
|
||||||
|
antigravity: {
|
||||||
|
label: 'Antigravity',
|
||||||
|
installHint: 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
||||||
|
// antigravity has no customModelInjection recipe (docs/custom-model-endpoints-plan.md
|
||||||
|
// calls it `unsupported`) — never fold a pending pick into its launch body.
|
||||||
|
supportsCustomModel: false,
|
||||||
|
buildConfig: () => ({ antigravityConfig: { dangerouslySkipPermissions: true } }),
|
||||||
|
},
|
||||||
|
pi: {
|
||||||
|
label: 'Pi',
|
||||||
|
installHint: 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
|
||||||
|
supportsCustomModel: true,
|
||||||
|
// Deliberately NO piConfig: pi has no permission prompts, so there is no
|
||||||
|
// bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
||||||
|
// decision (an interactive prompt the user answers in the terminal).
|
||||||
|
// Sending `approveProjectTrust: true` here would silently opt every
|
||||||
|
// browser-launched pi session into executing repo-supplied TypeScript.
|
||||||
|
buildConfig: () => null,
|
||||||
|
},
|
||||||
|
omp: {
|
||||||
|
label: 'OMP',
|
||||||
|
installHint: 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh',
|
||||||
|
supportsCustomModel: true,
|
||||||
|
buildConfig: () => null,
|
||||||
|
},
|
||||||
|
grok: {
|
||||||
|
label: 'Grok',
|
||||||
|
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
|
||||||
|
supportsCustomModel: true,
|
||||||
|
// Sends `grokConfig: { alwaysApprove: true }` the way antigravity sends
|
||||||
|
// `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
||||||
|
// work, so the Run button opts into grok's bypassPermissions mode
|
||||||
|
// (`--always-approve`; config-level deny rules still apply on top). The
|
||||||
|
// multi-user clamp forces it back off for non-granted owners server-side.
|
||||||
|
buildConfig: () => ({ grokConfig: { alwaysApprove: true } }),
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
label: 'DeepSeek',
|
||||||
|
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh',
|
||||||
|
// The two-part availability check is deliberate. `dsh` being installed is
|
||||||
|
// not enough — DeepSeek ships no terminal front door, so a box can have a
|
||||||
|
// perfect binary and nothing a pane can run.
|
||||||
|
unrunnableHint:
|
||||||
|
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||||
|
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||||
|
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui',
|
||||||
|
supportsCustomModel: true,
|
||||||
|
// Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||||
|
// sibling Run button sends its bypass switch. The harness has no bypass
|
||||||
|
// FLAG, so this rides the `DSH_PERMISSION_MODE` export instead, and the
|
||||||
|
// multi-user clamp forces it back down to `workspace-write` server-side.
|
||||||
|
buildConfig: () => ({ deepSeekConfig: { permissionMode: 'danger-full-access' } }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External (non-Claude, non-Shell) CLI run modes — the keys of RUN_MODE_LAUNCH
|
||||||
|
* above, kept as its own Set so `_isAltCliMode()` doesn't recompute an array
|
||||||
|
* every call. Single source for what used to be two hand-copied 8-way
|
||||||
|
* `session.mode === '<id>' || ...` chains inside one function
|
||||||
|
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
||||||
|
* ninth CLI landed in one and not the other.
|
||||||
|
*/
|
||||||
|
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/**
|
/**
|
||||||
* Build envOverrides payload from case + global settings.
|
* Build envOverrides payload from case + global settings.
|
||||||
@@ -395,34 +504,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const mode = this._runMode || 'claude';
|
const mode = this._runMode || 'claude';
|
||||||
if (mode === 'opencode') {
|
|
||||||
return await this.runOpenCode();
|
|
||||||
}
|
|
||||||
if (mode === 'codex') {
|
|
||||||
return await this.runCodex();
|
|
||||||
}
|
|
||||||
if (mode === 'gemini') {
|
|
||||||
return await this.runGemini();
|
|
||||||
}
|
|
||||||
if (mode === 'antigravity') {
|
|
||||||
return await this.runAntigravity();
|
|
||||||
}
|
|
||||||
if (mode === 'omp') {
|
|
||||||
return await this.runOmp();
|
|
||||||
}
|
|
||||||
if (mode === 'pi') {
|
|
||||||
return await this.runPi();
|
|
||||||
}
|
|
||||||
if (mode === 'grok') {
|
|
||||||
return await this.runGrok();
|
|
||||||
}
|
|
||||||
if (mode === 'deepseek') {
|
|
||||||
return await this.runDeepSeek();
|
|
||||||
}
|
|
||||||
if (mode === 'shell') {
|
if (mode === 'shell') {
|
||||||
return await this.runShell();
|
return await this.runShell();
|
||||||
}
|
}
|
||||||
return await this.runClaude();
|
if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) {
|
||||||
|
return await this.runClaude();
|
||||||
|
}
|
||||||
|
return await this._runCliMode(mode);
|
||||||
} finally {
|
} finally {
|
||||||
const remaining = minLockMs - (Date.now() - startedAt);
|
const remaining = minLockMs - (Date.now() - startedAt);
|
||||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||||
@@ -1917,88 +2005,41 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return firstSessionId;
|
return firstSessionId;
|
||||||
},
|
},
|
||||||
|
|
||||||
async runOpenCode() {
|
/**
|
||||||
|
* Shared launcher for every RUN_MODE_LAUNCH entry (every run mode except
|
||||||
|
* claude/shell, which have their own flows — claude for its remote/docker
|
||||||
|
* branching and parallel-create path, shell for needing no CLI probe at
|
||||||
|
* all). The eight run<Mode>() methods below are thin named wrappers: their
|
||||||
|
* names stay because index.html's welcome-screen buttons and the run-mode
|
||||||
|
* menu call them directly by name (`app.runOpenCode()` etc.), and several
|
||||||
|
* tests assert on that name directly too.
|
||||||
|
*/
|
||||||
|
async _runCliMode(mode) {
|
||||||
|
const entry = RUN_MODE_LAUNCH[mode];
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
// Remote/docker cases run the CLI on the OTHER side — the local status
|
||||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
// probe and the local-only config/env below don't apply (quick-start
|
||||||
|
// rejects them for remote cases).
|
||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||||
|
|
||||||
const tabCount = this._readTabCount();
|
const tabCount = this._readTabCount();
|
||||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||||
`Starting ${tabCount} OpenCode session(s) in ${caseName}...`
|
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
|
||||||
);
|
);
|
||||||
// Focus in sync gesture context (see runClaude comment)
|
// Focus in sync gesture context (see runClaude comment)
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if OpenCode is available (local sessions only)
|
|
||||||
if (!isRemote) {
|
if (!isRemote) {
|
||||||
const statusRes = await fetch('/api/opencode/status');
|
const statusRes = await fetch(`/api/${mode}/status`);
|
||||||
const status = (await statusRes.json()).data;
|
const status = (await statusRes.json()).data;
|
||||||
if (!status.available) {
|
if (!status.available) {
|
||||||
this._reportSessionLaunchError(
|
this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint);
|
||||||
ownsLaunchTerminal,
|
|
||||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (entry.unrunnableHint && !status.runnable) {
|
||||||
|
this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint);
|
||||||
// Quick-start with opencode mode (auto-allow tools by default).
|
|
||||||
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
|
||||||
caseName,
|
|
||||||
tabCount,
|
|
||||||
'OpenCode',
|
|
||||||
(sessionName) => ({
|
|
||||||
caseName,
|
|
||||||
mode: 'opencode',
|
|
||||||
sessionName,
|
|
||||||
...(isRemote ? {} : {
|
|
||||||
openCodeConfig: { autoAllowTools: true },
|
|
||||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
||||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
ownsLaunchTerminal
|
|
||||||
);
|
|
||||||
|
|
||||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
|
||||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
|
||||||
if (firstSessionId) {
|
|
||||||
await this.selectSession(firstSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.terminal.focus();
|
|
||||||
} catch (err) {
|
|
||||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async runCodex() {
|
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
|
||||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
|
||||||
// local-only config/env below (quick-start rejects them for remote cases).
|
|
||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
||||||
|
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
||||||
`Starting ${tabCount} Codex session(s) in ${caseName}...`
|
|
||||||
);
|
|
||||||
this.terminal.focus();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isRemote) {
|
|
||||||
const statusRes = await fetch('/api/codex/status');
|
|
||||||
const status = (await statusRes.json()).data;
|
|
||||||
if (!status.available) {
|
|
||||||
this._reportSessionLaunchError(
|
|
||||||
ownsLaunchTerminal,
|
|
||||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2008,19 +2049,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const firstSessionId = await this._launchQuickStartInstances(
|
const firstSessionId = await this._launchQuickStartInstances(
|
||||||
caseName,
|
caseName,
|
||||||
tabCount,
|
tabCount,
|
||||||
'Codex',
|
entry.label,
|
||||||
(sessionName) => ({
|
(sessionName) => ({
|
||||||
caseName,
|
caseName,
|
||||||
mode: 'codex',
|
mode,
|
||||||
sessionName,
|
sessionName,
|
||||||
...(isRemote ? {} : {
|
...(isRemote ? {} : {
|
||||||
codexConfig: {
|
...(entry.buildConfig(globalSettings) || {}),
|
||||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
|
||||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
|
||||||
renderMode: 'hybrid',
|
|
||||||
},
|
|
||||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
...(entry.supportsCustomModel && this._pendingCustomModelForLaunch
|
||||||
|
? { customModel: this._pendingCustomModelForLaunch }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
ownsLaunchTerminal
|
ownsLaunchTerminal
|
||||||
@@ -2038,368 +2077,36 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async runOpenCode() {
|
||||||
|
return this._runCliMode('opencode');
|
||||||
|
},
|
||||||
|
|
||||||
|
async runCodex() {
|
||||||
|
return this._runCliMode('codex');
|
||||||
|
},
|
||||||
|
|
||||||
async runGemini() {
|
async runGemini() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
return this._runCliMode('gemini');
|
||||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
|
||||||
// local-only config/env below (quick-start rejects them for remote cases).
|
|
||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
||||||
|
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
||||||
`Starting ${tabCount} Gemini session(s) in ${caseName}...`
|
|
||||||
);
|
|
||||||
this.terminal.focus();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isRemote) {
|
|
||||||
const statusRes = await fetch('/api/gemini/status');
|
|
||||||
const status = (await statusRes.json()).data;
|
|
||||||
if (!status.available) {
|
|
||||||
this._reportSessionLaunchError(
|
|
||||||
ownsLaunchTerminal,
|
|
||||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
|
||||||
caseName,
|
|
||||||
tabCount,
|
|
||||||
'Gemini',
|
|
||||||
(sessionName) => ({
|
|
||||||
caseName,
|
|
||||||
mode: 'gemini',
|
|
||||||
sessionName,
|
|
||||||
...(isRemote ? {} : {
|
|
||||||
geminiConfig: { approvalMode: 'yolo' },
|
|
||||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
||||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
ownsLaunchTerminal
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstSessionId) {
|
|
||||||
await this.selectSession(firstSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.terminal.focus();
|
|
||||||
} catch (err) {
|
|
||||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async runAntigravity() {
|
async runAntigravity() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
return this._runCliMode('antigravity');
|
||||||
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
|
|
||||||
// local-only config/env below (quick-start rejects them for remote cases).
|
|
||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
||||||
|
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
||||||
`Starting ${tabCount} Antigravity session(s) in ${caseName}...`
|
|
||||||
);
|
|
||||||
this.terminal.focus();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isRemote) {
|
|
||||||
const statusRes = await fetch('/api/antigravity/status');
|
|
||||||
const status = (await statusRes.json()).data;
|
|
||||||
if (!status.available) {
|
|
||||||
this._reportSessionLaunchError(
|
|
||||||
ownsLaunchTerminal,
|
|
||||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
|
||||||
caseName,
|
|
||||||
tabCount,
|
|
||||||
'Antigravity',
|
|
||||||
(sessionName) => ({
|
|
||||||
caseName,
|
|
||||||
mode: 'antigravity',
|
|
||||||
sessionName,
|
|
||||||
...(isRemote ? {} : {
|
|
||||||
antigravityConfig: { dangerouslySkipPermissions: true },
|
|
||||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
ownsLaunchTerminal
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstSessionId) {
|
|
||||||
await this.selectSession(firstSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.terminal.focus();
|
|
||||||
} catch (err) {
|
|
||||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch a Pi (pi.dev) session.
|
|
||||||
*
|
|
||||||
* Deliberately sends NO piConfig: pi has no permission prompts, so there is no
|
|
||||||
* bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
|
||||||
* decision (an interactive prompt the user answers in the terminal). Sending
|
|
||||||
* `approveProjectTrust: true` here would silently opt every browser-launched pi
|
|
||||||
* session into executing repo-supplied TypeScript.
|
|
||||||
*/
|
|
||||||
async runPi() {
|
async runPi() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
return this._runCliMode('pi');
|
||||||
// Remote/docker cases run pi on the OTHER side — skip the local status probe and the
|
|
||||||
// local-only config/env below (quick-start rejects them for remote cases).
|
|
||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
||||||
|
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
||||||
`Starting ${tabCount} Pi session(s) in ${caseName}...`
|
|
||||||
);
|
|
||||||
this.terminal.focus();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isRemote) {
|
|
||||||
const statusRes = await fetch('/api/pi/status');
|
|
||||||
const status = (await statusRes.json()).data;
|
|
||||||
if (!status.available) {
|
|
||||||
this._reportSessionLaunchError(
|
|
||||||
ownsLaunchTerminal,
|
|
||||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
|
||||||
caseName,
|
|
||||||
tabCount,
|
|
||||||
'Pi',
|
|
||||||
(sessionName) => ({
|
|
||||||
caseName,
|
|
||||||
mode: 'pi',
|
|
||||||
sessionName,
|
|
||||||
...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }),
|
|
||||||
...(!isRemote && this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
||||||
}),
|
|
||||||
ownsLaunchTerminal
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstSessionId) {
|
|
||||||
await this.selectSession(firstSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.terminal.focus();
|
|
||||||
} catch (err) {
|
|
||||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async runOmp() {
|
async runOmp() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
return this._runCliMode('omp');
|
||||||
// Remote/docker cases run omp on the OTHER side — skip the local status probe
|
|
||||||
// and the local-only config below (quick-start rejects them for remote cases).
|
|
||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
||||||
|
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
||||||
`Starting ${tabCount} OMP session(s) in ${caseName}...`
|
|
||||||
);
|
|
||||||
this.terminal.focus();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isRemote) {
|
|
||||||
const statusRes = await fetch('/api/omp/status');
|
|
||||||
const status = (await statusRes.json()).data;
|
|
||||||
if (!status.available) {
|
|
||||||
this._reportSessionLaunchError(
|
|
||||||
ownsLaunchTerminal,
|
|
||||||
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
|
||||||
caseName,
|
|
||||||
tabCount,
|
|
||||||
'OMP',
|
|
||||||
(sessionName) => ({
|
|
||||||
caseName,
|
|
||||||
mode: 'omp',
|
|
||||||
sessionName,
|
|
||||||
...(isRemote ? {} : {
|
|
||||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
||||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
ownsLaunchTerminal
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstSessionId) {
|
|
||||||
await this.selectSession(firstSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.terminal.focus();
|
|
||||||
} catch (err) {
|
|
||||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch a Grok Build (xAI `grok`) session.
|
|
||||||
*
|
|
||||||
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
|
|
||||||
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
|
||||||
* work, so the Run button opts into grok's bypassPermissions mode
|
|
||||||
* (`--always-approve`; config-level deny rules still apply on top). The
|
|
||||||
* multi-user clamp forces it back off for non-granted owners server-side.
|
|
||||||
*/
|
|
||||||
async runGrok() {
|
async runGrok() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
return this._runCliMode('grok');
|
||||||
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
|
|
||||||
// local-only config/env below (quick-start rejects them for remote cases).
|
|
||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
||||||
|
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
||||||
`Starting ${tabCount} Grok session(s) in ${caseName}...`
|
|
||||||
);
|
|
||||||
this.terminal.focus();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isRemote) {
|
|
||||||
const statusRes = await fetch('/api/grok/status');
|
|
||||||
const status = (await statusRes.json()).data;
|
|
||||||
if (!status.available) {
|
|
||||||
this._reportSessionLaunchError(
|
|
||||||
ownsLaunchTerminal,
|
|
||||||
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
|
||||||
caseName,
|
|
||||||
tabCount,
|
|
||||||
'Grok',
|
|
||||||
(sessionName) => ({
|
|
||||||
caseName,
|
|
||||||
mode: 'grok',
|
|
||||||
sessionName,
|
|
||||||
...(isRemote ? {} : {
|
|
||||||
grokConfig: { alwaysApprove: true },
|
|
||||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
||||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
ownsLaunchTerminal
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstSessionId) {
|
|
||||||
await this.selectSession(firstSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.terminal.focus();
|
|
||||||
} catch (err) {
|
|
||||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Launch a DeepSeek Harness (`dsh`) session.
|
|
||||||
*
|
|
||||||
* Sends `permissionMode: 'danger-full-access'` for the same reason every
|
|
||||||
* sibling Run button sends its bypass switch: Codeman sessions exist for
|
|
||||||
* autonomous work. The harness has no bypass FLAG, so this rides the
|
|
||||||
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
|
|
||||||
* back down to `workspace-write` for non-granted owners server-side.
|
|
||||||
*
|
|
||||||
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
|
|
||||||
* output-stabilization guessing to definitive idle/blocked hook events.
|
|
||||||
*
|
|
||||||
* The two-part availability check is deliberate. `dsh` being installed is not
|
|
||||||
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
|
|
||||||
* binary and nothing a pane can run. Reporting that precisely, with the exact
|
|
||||||
* command that fixes it, is the difference between "the Run button is broken"
|
|
||||||
* and a 30-second fix.
|
|
||||||
*/
|
|
||||||
async runDeepSeek() {
|
async runDeepSeek() {
|
||||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
return this._runCliMode('deepseek');
|
||||||
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
|
|
||||||
// local-only config/env below (quick-start rejects them for remote cases).
|
|
||||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
|
||||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
|
||||||
|
|
||||||
const tabCount = this._readTabCount();
|
|
||||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
|
||||||
`Starting ${tabCount} DeepSeek session(s) in ${caseName}...`
|
|
||||||
);
|
|
||||||
this.terminal.focus();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isRemote) {
|
|
||||||
const statusRes = await fetch('/api/deepseek/status');
|
|
||||||
const status = (await statusRes.json()).data;
|
|
||||||
if (!status.available) {
|
|
||||||
this._reportSessionLaunchError(
|
|
||||||
ownsLaunchTerminal,
|
|
||||||
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!status.runnable) {
|
|
||||||
this._reportSessionLaunchError(
|
|
||||||
ownsLaunchTerminal,
|
|
||||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
|
||||||
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
|
||||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
|
||||||
const firstSessionId = await this._launchQuickStartInstances(
|
|
||||||
caseName,
|
|
||||||
tabCount,
|
|
||||||
'DeepSeek',
|
|
||||||
(sessionName) => ({
|
|
||||||
caseName,
|
|
||||||
mode: 'deepseek',
|
|
||||||
sessionName,
|
|
||||||
...(isRemote ? {} : {
|
|
||||||
deepSeekConfig: { permissionMode: 'danger-full-access' },
|
|
||||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
|
||||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
ownsLaunchTerminal
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstSessionId) {
|
|
||||||
await this.selectSession(firstSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.terminal.focus();
|
|
||||||
} catch (err) {
|
|
||||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -2467,7 +2174,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
const isAltMode = EXTERNAL_CLI_MODES.has(session.mode);
|
||||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||||
|
|
||||||
// Update respawn status display and buttons
|
// Update respawn status display and buttons
|
||||||
@@ -2497,7 +2204,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hide Claude-specific options for external CLI sessions
|
// Hide Claude-specific options for external CLI sessions
|
||||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
const isExternalCli = isAltMode;
|
||||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||||
|
|
||||||
|
|||||||
@@ -1674,6 +1674,24 @@ export class WebServer extends EventEmitter {
|
|||||||
'</head>',
|
'</head>',
|
||||||
`<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
`<script>window.__codemanCustomModelClis=${customModelClisJson};</script>\n</head>`
|
||||||
);
|
);
|
||||||
|
// The general-purpose run-menu catalogue (PR B2, docs/cli-registry.md): every
|
||||||
|
// ENABLED CliEntry's menu-facing fields, unfiltered by capability — unlike
|
||||||
|
// __codemanCustomModelClis above, which is narrowed to one picker's needs.
|
||||||
|
// Field list mirrors what scripts/generate-cli-catalog.mts exports to
|
||||||
|
// config/clis.stock.json (id/label/shortBadge/order/kind); launch/env/
|
||||||
|
// capabilities/overlays are spawn-time concerns the server alone interprets
|
||||||
|
// and must never leak here, same rule as that generated artifact.
|
||||||
|
const cliCatalog = enabledClis().map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
shortBadge: entry.shortBadge,
|
||||||
|
order: entry.order,
|
||||||
|
kind: entry.kind,
|
||||||
|
}));
|
||||||
|
// `label`/`shortBadge` are user-clis.json-settable strings, so this needs the
|
||||||
|
// same </script>-breakout guard as __codemanCustomModelClis above.
|
||||||
|
const cliCatalogJson = escapeScriptJson(JSON.stringify(cliCatalog));
|
||||||
|
html = html.replace('</head>', `<script>window.__codemanCliCatalog=${cliCatalogJson};</script>\n</head>`);
|
||||||
}
|
}
|
||||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||||
|
|||||||
@@ -314,7 +314,6 @@ describe('declared-for-later fields', () => {
|
|||||||
* list — and wiring one up should make its line here fail, which is the good direction.
|
* list — and wiring one up should make its line here fail, which is the good direction.
|
||||||
*/
|
*/
|
||||||
const DECLARED_FOR_LATER = [
|
const DECLARED_FOR_LATER = [
|
||||||
'shortBadge',
|
|
||||||
'accent',
|
'accent',
|
||||||
'capabilities.echo',
|
'capabilities.echo',
|
||||||
'capabilities.wheelForward',
|
'capabilities.wheelForward',
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
|
||||||
|
* (`session-ui.js`, `mobile-overview.js`), mirroring
|
||||||
|
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
|
||||||
|
*
|
||||||
|
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
|
||||||
|
* `docs/cli-registry.md` and CLAUDE.md are explicit that the rest of the
|
||||||
|
* frontend (`app.js`, `terminal-ui.js`, `styles.css`, `settings-ui.js`, …)
|
||||||
|
* keeps its own hand-authored per-CLI rules deliberately — "moving them is
|
||||||
|
* its own piece of work verified by a browser/mobile suite the CI gate cannot
|
||||||
|
* see." Widening this guard to the whole directory would force either fixing
|
||||||
|
* or allowlisting dozens of branches in files nobody has touched or reviewed
|
||||||
|
* for this change, which is scope B2 never took on.
|
||||||
|
*
|
||||||
|
* Port: none (pure static analysis).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||||
|
|
||||||
|
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
|
||||||
|
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every currently-surviving branch, each with the reason it is not a
|
||||||
|
* CLI-behaviour branch a `CliCapabilities` field should express, keyed
|
||||||
|
* `<file>::<line>::<the matched expression>`. Found by running the scanner
|
||||||
|
* below against the post-B2 state of both files (2026-09-19) and reviewing
|
||||||
|
* each hit in context — none of these are leftover oversights, each is a
|
||||||
|
* verified, deliberate exception.
|
||||||
|
*/
|
||||||
|
const ALLOWED_BRANCHES: Record<string, string> = {
|
||||||
|
// --- run(): claude/shell get their own dispatch path, everything else goes ---
|
||||||
|
// --- through the shared _runCliMode() — see RUN_MODE_LAUNCH's header comment ---
|
||||||
|
"session-ui.js::507::mode === 'shell'": 'run() dispatch: shell needs no CLI probe at all',
|
||||||
|
"session-ui.js::510::mode === 'claude'":
|
||||||
|
'run() dispatch: claude has its own remote/docker branching and parallel-create path ' +
|
||||||
|
'(runClaude), unlike every RUN_MODE_LAUNCH entry',
|
||||||
|
|
||||||
|
// --- runCustomModelEntry(): claude restarts its CLI process in place; every ---
|
||||||
|
// --- other custom-model-eligible CLI applies the pick one-shot, before the ---
|
||||||
|
// --- session exists at all. Documented in CLAUDE.md's Custom Model Endpoint ---
|
||||||
|
// --- Profiles section: "Two launch paths, chosen by mechanism, not preference." ---
|
||||||
|
"session-ui.js::862::mode === 'claude'": 'restart-vs-one-shot custom-model launch mechanism, not a preference',
|
||||||
|
|
||||||
|
// --- Button-label ternary (Open Question 7, DEPLOYMENT_PLAN.md): pinned by ---
|
||||||
|
// --- test/run-mode-ui.test.ts's exact-text assertions (e.g. 'Run OMP'), which ---
|
||||||
|
// --- diverge from CliEntry.shortBadge for at least one CLI (omp: 'OM' vs the ---
|
||||||
|
// --- displayed 'OMP') — a catalogue-driven rewrite would silently change ---
|
||||||
|
// --- user-visible text and break that pinned test. ---
|
||||||
|
"session-ui.js::1527::mode === 'opencode'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
"session-ui.js::1527::mode === 'codex'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
"session-ui.js::1527::mode === 'gemini'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
"session-ui.js::1527::mode === 'antigravity'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
"session-ui.js::1527::mode === 'pi'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
"session-ui.js::1527::mode === 'grok'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
"session-ui.js::1527::mode === 'deepseek'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
"session-ui.js::1527::mode === 'omp'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
"session-ui.js::1527::mode === 'shell'": 'button-label ternary, pinned exact text (see Open Question 7)',
|
||||||
|
|
||||||
|
// --- Respawn/Ralph section: claude-only by product design, mirroring the ---
|
||||||
|
// --- backend's own capabilities.ralph gate (isExternalCliMode() already ---
|
||||||
|
// --- excludes Ralph tracking for every non-claude mode server-side). ---
|
||||||
|
"session-ui.js::2200::mode === 'claude'": 'Respawn/Ralph section is claude-only by design',
|
||||||
|
|
||||||
|
// --- runMode property setter: a validity allowlist, not a behaviour branch. ---
|
||||||
|
// --- Left untouched in Phase 2 (uncertain 'shell' asymmetry — this chain has ---
|
||||||
|
// --- no shell arm at all — and no evidence of what callers rely on it). ---
|
||||||
|
"session-ui.js::4431::mode === 'opencode'": 'runMode setter validity check, not behaviour',
|
||||||
|
"session-ui.js::4431::mode === 'codex'": 'runMode setter validity check, not behaviour',
|
||||||
|
"session-ui.js::4431::mode === 'gemini'": 'runMode setter validity check, not behaviour',
|
||||||
|
"session-ui.js::4431::mode === 'antigravity'": 'runMode setter validity check, not behaviour',
|
||||||
|
"session-ui.js::4431::mode === 'pi'": 'runMode setter validity check, not behaviour',
|
||||||
|
"session-ui.js::4431::mode === 'grok'": 'runMode setter validity check, not behaviour',
|
||||||
|
"session-ui.js::4431::mode === 'deepseek'": 'runMode setter validity check, not behaviour',
|
||||||
|
"session-ui.js::4431::mode === 'omp'": 'runMode setter validity check, not behaviour',
|
||||||
|
"session-ui.js::4431::mode === 'claude'": 'runMode setter validity check, not behaviour',
|
||||||
|
|
||||||
|
// --- mobile-overview.js: shell is exempt from the isCliAvailable() gate the ---
|
||||||
|
// --- same way the toolbar's #runModeMenu exempts it (shell needs no CLI). ---
|
||||||
|
"mobile-overview.js::574::mode !== 'shell'": 'shell needs no CLI, so it is exempt from the availability gate',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
||||||
|
const IDS = STOCK_CLIS.map((e) => e.id as string);
|
||||||
|
const ID_ALT = IDS.join('|');
|
||||||
|
|
||||||
|
/** Same four shapes as the backend guard — see its own comment for why all four matter. */
|
||||||
|
const BRANCH_PATTERN = new RegExp(
|
||||||
|
[
|
||||||
|
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||||
|
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
||||||
|
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
|
||||||
|
].join('|'),
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Blanks comment lines before scanning — see the backend guard's own comment on why. */
|
||||||
|
function uncommented(source: string): string {
|
||||||
|
return source
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Finding {
|
||||||
|
file: string;
|
||||||
|
expression: string;
|
||||||
|
line: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan(): Finding[] {
|
||||||
|
const findings: Finding[] = [];
|
||||||
|
for (const file of SCANNED_FILES) {
|
||||||
|
const lines = uncommented(readFileSync(PUBLIC + file, 'utf-8')).split('\n');
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
||||||
|
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
||||||
|
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
|
||||||
|
findings.push({ file, expression, line: i + 1, key: `${file}::${i + 1}::${expression}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const findings = scan();
|
||||||
|
|
||||||
|
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => {
|
||||||
|
it('scans both files (sanity)', () => {
|
||||||
|
// If this drops to zero the scanner or the file list drifted and every
|
||||||
|
// assertion below would pass vacuously.
|
||||||
|
const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0);
|
||||||
|
expect(scannedBytes).toBeGreaterThan(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds its id list from the live catalog (sanity)', () => {
|
||||||
|
expect(IDS).toContain('claude');
|
||||||
|
expect(IDS).toContain('deepseek');
|
||||||
|
expect(IDS.length).toBeGreaterThanOrEqual(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still detects a branch when one exists (anti-vacuity)', () => {
|
||||||
|
const samples = [
|
||||||
|
"if (session.mode === 'codex') { doSomething(); }",
|
||||||
|
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
||||||
|
"switch (mode) { case 'gemini': return 1; }",
|
||||||
|
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
||||||
|
];
|
||||||
|
for (const sample of samples) {
|
||||||
|
BRANCH_PATTERN.lastIndex = 0;
|
||||||
|
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
|
||||||
|
}
|
||||||
|
BRANCH_PATTERN.lastIndex = 0;
|
||||||
|
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no unapproved id branches', () => {
|
||||||
|
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
|
||||||
|
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
|
||||||
|
expect(
|
||||||
|
offenders,
|
||||||
|
offenders.length === 0
|
||||||
|
? ''
|
||||||
|
: `Found ${offenders.length} new CLI-id branch(es) in session-ui.js/mobile-overview.js:\n${detail}\n\n` +
|
||||||
|
'Two ways out, in order of preference:\n' +
|
||||||
|
' 1. Derive the difference from window.__codemanCliCatalog (server.ts) or a shared\n' +
|
||||||
|
' module-level constant, the way _runCliMode()/EXTERNAL_CLI_MODES do.\n' +
|
||||||
|
' 2. If it is a genuine mechanism difference (not a CLI-behaviour branch), add it to\n' +
|
||||||
|
' ALLOWED_BRANCHES in this file WITH the reason.'
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no stale allowlist entries', () => {
|
||||||
|
// An allowlisted branch that no longer exists at that line is a lie about the
|
||||||
|
// codebase, and the next person to reintroduce that exact branch elsewhere
|
||||||
|
// would sail straight through under the old line number.
|
||||||
|
const present = new Set(findings.map((f) => f.key));
|
||||||
|
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
|
||||||
|
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -209,6 +209,38 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes the general run-menu CLI catalogue with the menu-facing fields only', async () => {
|
||||||
|
// Unlike __codemanCustomModelClis above (narrowed to one picker's needs), this
|
||||||
|
// one carries every ENABLED CliEntry, agent and shell alike, with no capability
|
||||||
|
// filter — but still excludes launch/env/capabilities/overlays, the same rule
|
||||||
|
// scripts/generate-cli-catalog.mts follows for config/clis.stock.json.
|
||||||
|
const { server } = makeServer({});
|
||||||
|
const html = await render(server);
|
||||||
|
expect(html).toContain('window.__codemanCliCatalog=');
|
||||||
|
const catalog = JSON.parse(html.match(/window\.__codemanCliCatalog=(\[.*?\]);/)![1]) as Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
shortBadge: string;
|
||||||
|
order: number;
|
||||||
|
kind: string;
|
||||||
|
}>;
|
||||||
|
const ids = catalog.map((c) => c.id);
|
||||||
|
expect(ids).toContain('claude');
|
||||||
|
expect(ids).toContain('shell');
|
||||||
|
for (const cli of catalog) {
|
||||||
|
expect(typeof cli.id).toBe('string');
|
||||||
|
expect(typeof cli.label).toBe('string');
|
||||||
|
expect(typeof cli.shortBadge).toBe('string');
|
||||||
|
expect(typeof cli.order).toBe('number');
|
||||||
|
expect(['agent', 'shell']).toContain(cli.kind);
|
||||||
|
expect(cli).not.toHaveProperty('launch');
|
||||||
|
expect(cli).not.toHaveProperty('env');
|
||||||
|
expect(cli).not.toHaveProperty('capabilities');
|
||||||
|
expect(cli).not.toHaveProperty('overlays');
|
||||||
|
expect(cli).not.toHaveProperty('discovery');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('escapeScriptJson neutralizes a literal </script>, and still round-trips as a JS literal', () => {
|
it('escapeScriptJson neutralizes a literal </script>, and still round-trips as a JS literal', () => {
|
||||||
// CliEntry.label is a plain string a user's own clis.json can set (up to 60
|
// CliEntry.label is a plain string a user's own clis.json can set (up to 60
|
||||||
// chars), unlike __codemanCliAvailable's booleans-only payload, so this is
|
// chars), unlike __codemanCliAvailable's booleans-only payload, so this is
|
||||||
@@ -254,6 +286,7 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
const html = await render(server, 'sess-123');
|
const html = await render(server, 'sess-123');
|
||||||
expect(html).not.toContain('__codemanCliAvailable');
|
expect(html).not.toContain('__codemanCliAvailable');
|
||||||
expect(html).not.toContain('__codemanCustomModelClis');
|
expect(html).not.toContain('__codemanCustomModelClis');
|
||||||
|
expect(html).not.toContain('__codemanCliCatalog');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
|
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
|||||||
|
|
||||||
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
|
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
|
||||||
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
|
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
|
||||||
// .js/.css refs, and injects the CLI-availability flags plus the custom-model
|
// .js/.css refs, and injects the CLI-availability flags, the custom-model
|
||||||
// Run-menu picker's CLI list before </head>; strip all so the title remains
|
// Run-menu picker's CLI list, and the general run-menu CLI catalogue (PR B2)
|
||||||
// the only other change.
|
// before </head>; strip all so the title remains the only other change.
|
||||||
//
|
//
|
||||||
// The flag strips are what keep this test environment-independent. The
|
// The flag strips are what keep this test environment-independent. The
|
||||||
// CLI-availability one used to pass here by luck: that script was injected
|
// CLI-availability one used to pass here by luck: that script was injected
|
||||||
@@ -109,7 +109,8 @@ describe('WebServer index.html <title> templating (#82)', () => {
|
|||||||
const html = (await render('laptop'))
|
const html = (await render('laptop'))
|
||||||
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
|
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
|
||||||
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '')
|
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '')
|
||||||
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '');
|
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '')
|
||||||
|
.replace(/<script>window\.__codemanCliCatalog=\[.*?\];<\/script>\n/, '');
|
||||||
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
|
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
|
||||||
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
|
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
|
||||||
expect(html.startsWith(beforeTitle)).toBe(true);
|
expect(html.startsWith(beforeTitle)).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user