feat(docker): take run-mode availability from the container

The run-mode dropdown hides CLIs that are not installed on the HOST (#201). That
is right for local sessions and wrong for a container case, whose agents run
inside the container: a host with no claude installed hides the mode while the
container ships one, which is exactly what happened on a real deployment.

The adoption preflight already probes what the container has, so that result is
persisted on the case and surfaced through CaseInfo. Docker cases gate on it;
every other case keeps the host probe unchanged.

An absent list reads as "do not gate" rather than "nothing available": an owned
container runs our base image, which ships every CLI, and treating unknown as
empty would leave the menu with Shell alone.
This commit is contained in:
d fei
2026-08-29 21:02:19 -07:00
parent 34c12ca18b
commit 2f83a37c6d
5 changed files with 68 additions and 5 deletions
+15 -1
View File
@@ -474,9 +474,23 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
// A DOCKER case runs its agents INSIDE the container, so host CLI
// availability answers the wrong question: the host may have no claude at
// all while the container ships one, and gating on the host hides a mode
// that would have worked. Adoption records what the container really has
// (`availableModes`); an owned container runs our base image, which ships
// every CLI, so an absent list means "do not gate" rather than "nothing".
// Same source every run* path reads the selected case from.
const caseName = document.getElementById('quickStartCase')?.value;
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
const containerModes = activeCase?.location === 'docker' ? activeCase.docker?.availableModes : null;
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
if (!btn) continue;
let available;
if (activeCase?.location === 'docker') available = containerModes ? containerModes.includes(mode) : true;
else available = this.isCliAvailable(mode);
btn.style.display = available ? 'flex' : 'none';
}
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
// perfectly installed while no pane-capable profile exists, because DeepSeek
+9 -4
View File
@@ -297,6 +297,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
image: host.image,
path: dockerCase.hostWorkspacePath,
network: host.network ?? 'bridge',
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
},
};
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
@@ -851,15 +852,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable');
}
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, dockerCase]);
// Persist what the container actually has: the run-mode picker gates on
// HOST CLIs, which is the wrong question for a case whose agents run inside
// a container the host knows nothing about.
const adoptedCase = { ...dockerCase, availableModes: probe.availableModes };
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, adoptedCase]);
ctx.broadcast(SseEvent.CaseLinked, {
name: dockerCase.name,
path: dockerCase.hostWorkspacePath,
name: adoptedCase.name,
path: adoptedCase.hostWorkspacePath,
type: 'docker',
});
return {
success: true,
data: { case: dockerCase, image: probe.image, availableModes: probe.availableModes },
data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes },
};
}
);