chore: version packages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 02:36:02 +02:00
parent 9046b95b7e
commit a21400614a
27 changed files with 638 additions and 92 deletions
+20
View File
@@ -1452,6 +1452,26 @@ class CodemanApp {
console.error('[SSE] docker export failed:', err);
}
});
// Import + drift-recreate completions: refresh case lists in EVERY open tab
// (the initiating tab already refreshes via its own fetch response).
addListener(SSE_EVENTS.DOCKER_IMPORT_COMPLETE, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Docker bundle imported as case "${d.name}"`, 'success');
this.loadQuickStartCases?.();
this.refreshDockerExports?.();
} catch (err) {
console.error('[SSE] docker import complete:', err);
}
});
addListener(SSE_EVENTS.DOCKER_CONTAINER_RECREATED, (e) => {
try {
const d = e.data ? JSON.parse(e.data) : {};
this.showToast(`Container for "${d.name}" removed — next launch recreates it with the new config`, 'info');
} catch (err) {
console.error('[SSE] docker container recreated:', err);
}
});
// Base image auto-build on first Docker case (build-on-first-use). A single
// multi-minute event; surface start/finish so the Run spinner is explained.
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
+1
View File
@@ -481,6 +481,7 @@ const SSE_EVENTS = {
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
DOCKER_CONTAINER_RECREATED: 'docker:containerRecreated',
};
// ═══════════════════════════════════════════════════════════════
+3 -2
View File
@@ -1449,10 +1449,11 @@
<label>Startup Mode</label>
<select id="appSettingsClaudeMode" class="form-select">
<option value="dangerously-skip-permissions">Skip Permissions (default)</option>
<option value="auto">Auto Mode (safety classifier, recommended by Anthropic)</option>
<option value="normal">Normal (with prompts)</option>
<option value="allowedTools">Allowed Tools Only</option>
</select>
<span class="form-hint">How Claude CLI is started in screen sessions</span>
<span class="form-hint">How Claude CLI is started in screen sessions. Auto Mode runs without routine prompts behind a background safety classifier (needs Claude Code 2.1.207+ and Opus 4.6+/Sonnet 4.6+/Fable 5)</span>
</div>
<div class="form-row" id="allowedToolsRow" style="display: none;">
<label>Allowed Tools</label>
@@ -1874,7 +1875,7 @@
</div>
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use.</span>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
</div>
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
<summary>Container settings (optional, sensible defaults)</summary>
+7 -5
View File
@@ -459,16 +459,18 @@ html.mobile-init .file-browser-panel {
height: 12px;
}
/* Hide header settings gear, lifecycle log, away digest, and session manager on
mobile - settings moved to toolbar; away digest and the session manager are
secondary controls that don't belong on the cramped phone header (the session
manager stays reachable via the Ctrl+K palette's "Browse all sessions" item).
/* Hide header settings gear, lifecycle log, away digest, session manager, and
file viewer on mobile - settings moved to toolbar; the others are secondary /
desktop-oriented controls that don't belong on the cramped phone header (the
session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
item; the file viewer button is opt-in but its panel is desktop-sized).
(The attachments button is opt-in / default-hidden everywhere via its own
--hidden marker, so it needs no mobile-specific rule here.) */
.btn-icon-header.btn-settings,
.btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager {
.btn-icon-header.btn-session-manager,
.btn-icon-header.btn-file-viewer {
display: none !important;
}
+43 -6
View File
@@ -551,14 +551,50 @@ Object.assign(CodemanApp.prototype, {
// Name remote/docker tabs with the same w<n>-<case> convention as local
// sessions (quick-start would otherwise auto-generate codeman-<id>).
const startNumber = this._nextCaseSessionStartNumber(caseName);
// Docker (NOT remote): the App Settings Claude Model choice applies — the
// workspace is a real host dir, so quick-start writes it to the case's
// .claude/settings.local.json and the in-container claude reads it.
// Remote quick-starts REJECT modelOverride (the file would land on the
// wrong machine), so never send it there.
let dockerModelOverride;
if (caseData.location === 'docker') {
const dockerGlobalSettings = this.loadAppSettingsFromStorage();
const dockerCaseSettings = this.getCaseSettings(caseName);
const dockerUseOpus1m = dockerCaseSettings.opusContext1m || dockerGlobalSettings.opusContext1mEnabled;
dockerModelOverride = dockerGlobalSettings.claudeModel || (dockerUseOpus1m ? 'opus[1m]' : '');
}
const remoteIds = [];
let driftHandled = false;
for (let i = 0; i < tabCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}` })
const quickStartBody = JSON.stringify({
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
});
const data = await res.json();
const doQuickStart = async () => {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: quickStartBody
});
return res.json();
};
let data = await doQuickStart();
// Docker config drift: the host config changed since the container was
// created (CONFLICT from quick-start). Confirm once, recreate, retry.
if (!data.success && data.errorCode === 'CONFLICT' && caseData.location === 'docker' && !driftHandled) {
driftHandled = true;
const recreate = confirm(
`Container config for "${caseName}" changed since its container was created.\n\n` +
'Recreate the container to apply the new config? Workspace files and the ' +
'conversation survive (the conversation resumes on launch).'
);
if (recreate) {
const recRes = await fetch(`/api/docker-cases/${encodeURIComponent(caseName)}/recreate`, { method: 'POST' });
const recData = await recRes.json();
if (!recData.success) throw new Error(recData.error || 'Failed to recreate container');
data = await doQuickStart();
}
}
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
remoteIds.push(data.data.sessionId);
}
@@ -1958,7 +1994,8 @@ Object.assign(CodemanApp.prototype, {
listEl.innerHTML = exports
.map(e => {
const mb = (e.sizeBytes / 1e6).toFixed(1);
const nm = this.escapeHtml ? this.escapeHtml(e.name) : e.name;
// escapeHtml is the free function from constants.js (never a method on `this`)
const nm = escapeHtml(e.name);
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;">
+73 -21
View File
@@ -30,7 +30,7 @@ import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
checkDockerAvailable,
@@ -42,6 +42,7 @@ import {
dockerDisplayPath,
readDockerCases,
readDockerHosts,
removeDockerContainer,
toSessionDocker,
writeDockerCases,
writeDockerHosts,
@@ -99,7 +100,7 @@ async function ensureCaseImage(
sessionDocker: SessionDocker,
name: string
): Promise<{ ok: true; imageBuilding: boolean } | { ok: false; error: string }> {
if (await checkDockerImagePresent(sessionDocker.engine, sessionDocker.image)) {
if (await checkDockerImagePresent(sessionDocker, sessionDocker.image)) {
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
if (!tmuxCheck.ok) return { ok: false, error: tmuxCheck.error || 'base image is missing tmux' };
return { ok: true, imageBuilding: false };
@@ -111,7 +112,7 @@ async function ensureCaseImage(
};
}
broadcast(SseEvent.DockerImageBuildStarted, { name, image: sessionDocker.image });
void ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
void ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
onProgress: (line) => broadcast(SseEvent.DockerImageBuildProgress, { name, line }),
})
.then((r) =>
@@ -127,7 +128,7 @@ async function ensureCaseImage(
return { ok: true, imageBuilding: true };
}
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void {
// ═══════════════════════════════════════════════════════════════
// Case CRUD (list, create, link, detail, fix-plan)
// ═══════════════════════════════════════════════════════════════
@@ -652,29 +653,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const timestamp = Date.now();
let result;
try {
result = await importDockerBundle({ bundlePath, destWorkspace: destWorkspacePath, engine: 'docker', timestamp });
result = await importDockerBundle({
bundlePath,
destWorkspace: destWorkspacePath,
engine: 'docker',
timestamp,
newCaseName,
});
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Import failed: ${getErrorMessage(err)}`);
}
// Create a dedicated docker host pointing at the quarantined imported image
// (full mode) or the manifest's base image (workspace-only).
// Create (or REFRESH) the dedicated docker host pointing at the quarantined
// imported image (full mode) or the manifest's base image (workspace-only).
// Refresh matters: after a case-delete + re-import of the same name, a stale
// `imported-<name>` host would silently pin the PREVIOUS import's image tag.
const hostId = `imported-${newCaseName}`;
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
if (!hosts.some((h) => h.id === hostId)) {
await writeDockerHosts(CODEMAN_CONFIG_DIR, [
...hosts,
{
id: hostId,
label: `Imported: ${newCaseName}`,
engine: result.manifest.engine,
image: result.importedImage ?? result.manifest.image,
network: (['bridge', 'none', 'custom'].includes(result.manifest.network)
? result.manifest.network
: 'bridge') as 'bridge' | 'none' | 'custom',
},
]);
}
const importedHost = {
id: hostId,
label: `Imported: ${newCaseName}`,
engine: result.manifest.engine,
image: result.importedImage ?? result.manifest.image,
network: (['bridge', 'none', 'custom'].includes(result.manifest.network) ? result.manifest.network : 'bridge') as
| 'bridge'
| 'none'
| 'custom',
};
await writeDockerHosts(
CODEMAN_CONFIG_DIR,
hosts.some((h) => h.id === hostId)
? hosts.map((h) => (h.id === hostId ? { ...h, ...importedHost } : h))
: [...hosts, importedHost]
);
const newCase = {
name: newCaseName,
type: 'docker' as const,
@@ -687,6 +698,47 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { case: newCase } };
});
// Recreate-on-drift confirm (docs/docker-cases-plan.md §4): remove the case
// container so the next launch recreates it with the CURRENT host config. The
// workspace + transcripts ride bind mounts and survive; the conversation resumes
// via the case's lastClaudeSessionId. Refused while sessions of the case are live
// (removal would yank the container out from under their panes).
app.post(
'/api/docker-cases/:name/recreate',
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
const { name } = req.params as { name: string };
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
for (const session of ctx.sessions.values()) {
if (session.docker?.containerName === sessionDocker.containerName && session.pid) {
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Sessions of case "${name}" are still running — stop them first, then recreate the container.`
);
}
}
try {
await removeDockerContainer(sessionDocker);
} catch (err) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Failed to remove container: ${getErrorMessage(err)}`
);
}
// Drop the per-container claude-config seed; it is regenerated at next launch.
await fs
.rm(join(dataPath('docker-seeds'), `${sessionDocker.containerName}.json`), { force: true })
.catch(() => {});
ctx.broadcast(SseEvent.DockerContainerRecreated, { name, container: sessionDocker.containerName });
return { success: true, data: { name, container: sessionDocker.containerName } };
}
);
// Link an existing folder as a case
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
+13
View File
@@ -8,6 +8,8 @@ import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
export function registerHookEventRoutes(
@@ -48,7 +50,18 @@ export function registerHookEventRoutes(
// the user ran `/clear` (which spins up a new conversation jsonl).
if (data && typeof data.session_id === 'string' && data.session_id) {
const session = ctx.sessions.get(sessionId);
const prevClaudeSessionId = session?.claudeSessionId;
session?.adoptClaudeSessionId(data.session_id);
// Docker sessions: keep the case's resume seed following the LIVE
// conversation (post-/clear id switches), so a container stop/reboot
// relaunch resumes the right transcript.
if (session?.docker && session.claudeSessionId && session.claudeSessionId !== prevClaudeSessionId) {
void persistDockerCaseClaudeSessionId(
getDataDir(),
session.docker.containerName,
session.claudeSessionId
).catch(() => {});
}
}
// Sanitize forwarded data: only include known safe fields, limit size
+45 -2
View File
@@ -76,9 +76,11 @@ import { dataPath, getDataDir } from '../../config/instance.js';
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
import {
checkDockerAvailable,
checkDockerConfigDrift,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
persistDockerCaseClaudeSessionId,
readDockerCases,
readDockerHosts,
toSessionDocker,
@@ -936,6 +938,12 @@ export function registerSessionRoutes(
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
if (activeId && activeId !== session.claudeSessionId) {
session.adoptClaudeSessionId(activeId);
// Docker sessions: keep the case's resume seed following the live conversation.
if (session.docker) {
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
() => {}
);
}
}
// The Claude conversation ID (used as JSONL filename)
@@ -1683,6 +1691,7 @@ export function registerSessionRoutes(
caseName = 'testcase',
sessionName,
mode = 'claude',
modelOverride,
openCodeConfig,
codexConfig,
geminiConfig,
@@ -1713,13 +1722,14 @@ export function registerSessionRoutes(
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
modelOverride !== undefined ||
codexConfig ||
geminiConfig ||
openCodeConfig
) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
'envOverrides, effort, modelOverride, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
);
}
@@ -1764,7 +1774,7 @@ export function registerSessionRoutes(
// Ensure the base image exists, auto-building the default image on first use so
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
// this awaits the SAME in-flight build rather than starting a second one.
const ensured = await ensureAgentBaseImage(sessionDocker.engine, sessionDocker.image, {
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
});
if (!ensured.ok) {
@@ -1783,6 +1793,19 @@ export function registerSessionRoutes(
}
}
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
// longer matches the existing container's codeman.confighash label. Refuse to
// silently launch into the stale container — the frontend confirms a recreate
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
// mounts and the conversation resumes), or the user reverts the host edit.
const drift = await checkDockerConfigDrift(sessionDocker);
if (drift.exists && drift.drifted) {
return createErrorResponse(
ApiErrorCode.CONFLICT,
`Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`
);
}
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
docker = sessionDocker;
// Seed resume so a relaunch resumes the case's last conversation from the
@@ -1894,6 +1917,13 @@ export function registerSessionRoutes(
}
}
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
// docker — the docker workspace is a real host dir, so the settings file crosses
// the bind mount and the in-container claude reads it). Remote was rejected above.
if (mode === 'claude' && modelOverride !== undefined) {
await updateCaseModel(resolvedCasePath, modelOverride || null);
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
if (
@@ -1990,6 +2020,19 @@ export function registerSessionRoutes(
}
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
// Docker + claude: the pane command pins the conversation id (--session-id /
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
// NOW — a later container stop/reboot relaunch resumes this conversation even
// if no in-container hook ever reaches the host (loopback bind, no bridge
// listener). Hook/last-response adoption updates it again after /clear.
if (docker && mode === 'claude') {
void persistDockerCaseClaudeSessionId(
CODEMAN_CONFIG_DIR,
docker.containerName,
session.claudeSessionId || session.id
).catch(() => {});
}
// Save lastUsedCase to settings for TUI/web sync
try {
const settingsFilePath = SETTINGS_PATH;
+11
View File
@@ -449,17 +449,22 @@ export const DockerHostSchema = z.object({
export const DockerCaseLinkSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid docker host id'),
// No commas: the path is embedded in a `--mount type=bind,src=<path>,dst=<path>`
// CSV spec, and docker's --mount parser splits fields on commas (shell escaping
// cannot protect it). Spaces are fine.
hostWorkspacePath: z
.string()
.min(1)
.max(2000)
.regex(/^\//, 'Workspace path must be absolute')
.regex(/^[^,]*$/, 'Workspace path must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in workspace path'),
containerWorkdir: z
.string()
.min(1)
.max(2000)
.regex(/^\//, 'Container workdir must be absolute')
.regex(/^[^,]*$/, 'Container workdir must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in container workdir')
.optional(),
container: z
@@ -487,6 +492,7 @@ export const DockerImportSchema = z.object({
.min(1)
.max(2000)
.regex(/^\//, 'Destination path must be absolute')
.regex(/^[^,]*$/, 'Destination path must not contain commas (docker --mount is comma-delimited)')
.regex(NO_SHELL_META, 'Invalid characters in destination path'),
});
@@ -539,6 +545,11 @@ export const QuickStartSchema = z.object({
/** Display name for the created session tab (e.g. w1-mycase). Cosmetic; the durable
* mux/container names derive from the session id, not this. Defaults server-side. */
sessionName: z.string().max(128).optional(),
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
* Empty string clears. Applied for local AND docker cases (the docker workspace is
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
+6 -1
View File
@@ -1514,7 +1514,12 @@ export class WebServer extends EventEmitter {
const claudeMode = settings.claudeMode as string | undefined;
const allowedTools = settings.allowedTools as string | undefined;
// Only return valid modes
if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') {
if (
claudeMode === 'dangerously-skip-permissions' ||
claudeMode === 'auto' ||
claudeMode === 'normal' ||
claudeMode === 'allowedTools'
) {
return { claudeMode, allowedTools };
}
return {};
+3
View File
@@ -385,6 +385,8 @@ export const DockerImageBuildProgress = 'docker:imageBuildProgress' as const;
export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
/** The agent base image build failed. */
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
/** A case container was removed after a config-drift confirm (recreated with the new config on next launch). */
export const DockerContainerRecreated = 'docker:containerRecreated' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
@@ -576,4 +578,5 @@ export const SseEvent = {
DockerImageBuildProgress,
DockerImageBuildComplete,
DockerImageBuildFailed,
DockerContainerRecreated,
} as const;