From 45492a30137925121b450a6c60a7aa75fcd5b1f2 Mon Sep 17 00:00:00 2001 From: JD Date: Wed, 7 Oct 2026 22:08:28 -0400 Subject: [PATCH 1/2] fix(session-options): keep Respawn and Ralph visible for Claude sessions Since the CLI registry gave claude kind 'agent' (#476), isExternalCliRunMode() reads claude as an external CLI, so Session Options opened every Claude session on Summary and hid the Respawn and Ralph tabs and every Claude-only control (auto-resume, the respawn loop). The browser catalog now carries the registry's capabilities.external, the flag the server's isExternalCliMode() already reads, and Session Options asks that instead. run() keeps isExternalCliRunMode(): choosing a launch path is a different question, and custom agents rely on it. --- src/web/public/session-ui.js | 9 ++++++++- src/web/server.ts | 1 + test/render-index-html.test.ts | 11 +++++++++-- test/run-mode-ui.test.ts | 19 ++++++++++++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 9b2bf1b4..b13497a7 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -140,6 +140,13 @@ function isExternalCliRunMode(mode) { return EXTERNAL_CLI_MODES.has(mode) || registryCliById(mode)?.kind === 'agent'; } +// Does this session lack the Claude-only features (Respawn, Ralph)? The registry's +// `capabilities.external`, the flag the server's isExternalCliMode() reads. Not +// isExternalCliRunMode(): that picks a launch path, and claude is `kind: 'agent'` too. +function isExternalCliSession(mode) { + return registryCliById(mode)?.external ?? isExternalCliRunMode(mode); +} + Object.assign(CodemanApp.prototype, { /** * Build envOverrides payload from case + global settings. @@ -2430,7 +2437,7 @@ Object.assign(CodemanApp.prototype, { if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) - const isAltMode = isExternalCliRunMode(session.mode); + const isAltMode = isExternalCliSession(session.mode); this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); // Update respawn status display and buttons diff --git a/src/web/server.ts b/src/web/server.ts index 4442e544..2f00ae58 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1713,6 +1713,7 @@ export class WebServer extends EventEmitter { shortBadge: entry.shortBadge, order: entry.order, kind: entry.kind, + external: entry.capabilities.external, enabled, available: enabled && installed, }; diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 7f541443..9d3427cc 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -248,16 +248,23 @@ describe('WebServer.renderIndexHtml', () => { shortBadge: string; order: number; kind: string; + external: boolean; enabled: boolean; available: boolean; }>; expect(catalog.map((entry) => entry.id)).toEqual(STOCK_CLIS.map((entry) => entry.id)); - expect(catalog.find((entry) => entry.id === 'codex')).toMatchObject({ label: 'Codex', kind: 'agent' }); + expect(catalog.find((entry) => entry.id === 'codex')).toMatchObject({ + label: 'Codex', + kind: 'agent', + external: true, + }); + // claude is `kind: 'agent'` too; `external` is what keeps its Respawn/Ralph options visible. + expect(catalog.find((entry) => entry.id === 'claude')).toMatchObject({ kind: 'agent', external: false }); expect(catalog.find((entry) => entry.id === 'shell')).toMatchObject({ enabled: true, available: true }); expect( catalog.every((entry) => Object.keys(entry).every((key) => - ['id', 'label', 'shortBadge', 'order', 'kind', 'enabled', 'available'].includes(key) + ['id', 'label', 'shortBadge', 'order', 'kind', 'external', 'enabled', 'available'].includes(key) ) ) ).toBe(true); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index b290cf8f..d237b129 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -453,7 +453,15 @@ describe('Codex quick start settings', () => { const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8'); vm.runInContext(src, context, { filename: file }); } - return { app: new (CodemanApp as any)(), welcomeCliActions, tunnelBtn, runModeCliOptions, modeBtns, menu }; + return { + app: new (CodemanApp as any)(), + welcomeCliActions, + tunnelBtn, + runModeCliOptions, + modeBtns, + menu, + context, + }; } const ALL_OFF = { @@ -539,6 +547,15 @@ describe('Codex quick start settings', () => { expect(src).not.toContain('data-mode="codex"'); }); + it('keeps Claude-only session options for claude even though it is a registry agent', () => { + const catalog = CATALOG.map((cli) => ({ ...cli, external: cli.id !== 'claude' && cli.id !== 'shell' })); + const { context } = loadUi(undefined, catalog); + expect(context.isExternalCliSession('claude')).toBe(false); + expect(context.isExternalCliSession('shell')).toBe(false); + expect(context.isExternalCliSession('codex')).toBe(true); + expect(context.isExternalCliSession('custom-agent')).toBe(true); + }); + it('shows everything when the flags were never injected', () => { // A cached page from a build without the injection, or a solo popup. Hiding // every run button on a doubt would leave a working install nothing to click. From 354c4641a9608847c053ed5b209f6e0a8804602b Mon Sep 17 00:00:00 2001 From: JD Date: Thu, 8 Oct 2026 10:05:57 -0400 Subject: [PATCH 2/2] fix(session-options): keep the external flag when App Settings resyncs the CLI catalog _syncCliLaunchCatalog() rebuilt window.__codemanCliCatalog from /api/clis rows, which carry no capabilities, so after App Settings loaded the CLI list isExternalCliSession('claude') fell back to the kind check and Respawn and Ralph disappeared again until a reload. The rebuild now carries external over from the served catalog; a newly created custom CLI has no previous entry and falls back to kind, which is right since custom entries are external. Tests pin the resync and the openSessionOptions() call site, and the /api/clis comment names the page catalog as the deliberate capabilities exception. --- src/web/public/settings-ui.js | 6 ++++++ src/web/routes/cli-registry-routes.ts | 4 +++- test/run-mode-ui.test.ts | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index e8fed034..f967a3c8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -3160,12 +3160,18 @@ Object.assign(CodemanApp.prototype, { /** Keep the launch surfaces in sync with Settings mutations without a reload. */ _syncCliLaunchCatalog() { if (!Array.isArray(this._cliList) || this._cliList.length === 0) return; + // /api/clis rows carry no capabilities, so keep the served catalog's `external` + // (isExternalCliSession() reads it). A new custom CLI has none and falls back to `kind`. + const previous = new Map( + (Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : []).map((cli) => [cli.id, cli]) + ); window.__codemanCliCatalog = this._cliList.map((cli) => ({ id: cli.id, label: cli.label, shortBadge: cli.shortBadge, order: cli.order, kind: cli.kind, + external: previous.get(cli.id)?.external, enabled: cli.enabled, available: cli.kind === 'shell' || (cli.enabled && cli.installed), })); diff --git a/src/web/routes/cli-registry-routes.ts b/src/web/routes/cli-registry-routes.ts index a4d4b136..3b75ffda 100644 --- a/src/web/routes/cli-registry-routes.ts +++ b/src/web/routes/cli-registry-routes.ts @@ -312,7 +312,9 @@ export function registerCliRegistryRoutes(app: FastifyInstance): void { // admin/settings surface; every SPAWN-time caller elsewhere uses // enabledClis() instead). Deliberately excludes launch/env/capabilities/ // overlays/discovery — the same rule every other catalogue-export surface in - // this codebase follows. + // this codebase follows, with one deliberate exception: the page catalog + // (`window.__codemanCliCatalog`, server.ts) carries `capabilities.external`, + // which Session Options reads to keep Claude's Respawn and Ralph tabs. // // NOT gated on cliManagementEnabled: reading the list is cheap and is not // the risky part. The Settings UI section simply never fetches this while diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index d237b129..b6f82047 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -556,6 +556,25 @@ describe('Codex quick start settings', () => { expect(context.isExternalCliSession('custom-agent')).toBe(true); }); + it('keeps the served external flag when App Settings resyncs the catalog from /api/clis', () => { + const catalog = CATALOG.map((cli) => ({ ...cli, external: cli.id !== 'claude' && cli.id !== 'shell' })); + const { app, context } = loadUi(undefined, catalog); + // /api/clis rows: no capabilities, so no `external`. + app._cliList = CATALOG.map((cli) => ({ ...cli, installed: true })); + app.runMode = 'claude'; + app._syncCliLaunchCatalog(); + expect(context.isExternalCliSession('claude')).toBe(false); + expect(context.isExternalCliSession('codex')).toBe(true); + }); + + it('gates Session Options on isExternalCliSession, not the launch-path check', () => { + const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + const open = src.slice(src.indexOf('\n openSessionOptions(')); + const body = open.slice(0, open.indexOf('\n },')); + expect(body).toContain('isExternalCliSession(session.mode)'); + expect(body).not.toContain('isExternalCliRunMode('); + }); + it('shows everything when the flags were never injected', () => { // A cached page from a build without the injection, or a solo popup. Hiding // every run button on a doubt would leave a working install nothing to click.