From ca731c67b32d007da96c7037ef71cb095b98fd51 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 20 Jul 2026 01:28:53 +0200 Subject: [PATCH] feat(docker): harden session mode + File Viewer button (v1.4.1) Docker cases: seamless Claude auth (seed ~/.claude.json instead of the corruption-prone single-file mount), full credential-store isolation for claude + codex/gemini/gcloud/opencode (share only transcripts/rollouts, seed the rest), auto-build the base image on first use, C.UTF-8 locale (fixes box-drawing), collapsed/shortened Create-Case UI + short "(docker)" case-menu tags, and w- tab naming for docker/remote sessions. Also: opt-in File Viewer header button; fix a TZ-boundary flaky test. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 23 + CLAUDE.md | 134 ++-- docker/agent.Dockerfile | 11 + package-lock.json | 4 +- package.json | 2 +- scripts/capture-readme-gifs.mjs | 482 +++++++++++++ scripts/capture-readme-real.mjs | 76 +- scripts/capture-readme-skin.mjs | 1074 +++++++++++++++++++++++++++++ src/docker-hosts.ts | 347 +++++++++- src/tmux-manager.ts | 55 +- src/web/public/app.js | 24 + src/web/public/constants.js | 4 + src/web/public/index.html | 20 +- src/web/public/panels-ui.js | 30 + src/web/public/session-ui.js | 67 +- src/web/public/settings-ui.js | 14 +- src/web/public/styles.css | 29 + src/web/routes/case-routes.ts | 105 ++- src/web/routes/session-routes.ts | 26 +- src/web/schemas.ts | 3 + src/web/sse-events.ts | 12 + test/docker-exec-options.test.ts | 30 + test/docker-hosts.test.ts | 180 ++++- test/routes/system-routes.test.ts | 6 +- test/run-mode-ui.test.ts | 26 + 25 files changed, 2607 insertions(+), 177 deletions(-) create mode 100644 scripts/capture-readme-gifs.mjs create mode 100644 scripts/capture-readme-skin.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b39bf409..bbeac2da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # aicodeman +## 1.4.1 + +### Patch Changes + +- **Docker session mode** hardening + fixes, plus a File Viewer header button. + + **What Docker session mode is** (recap): a case can run inside an isolated, hardened Docker container instead of on the host, and any of the CLI backends (Claude, Codex, Gemini, OpenCode, or a plain shell) runs inside it. It is a location overlay on cases — not a new session mode — and the container analog of remote-SSH cases: a local tmux pane `docker exec`s into a durable in-container tmux, with exactly one long-lived container per case that multiple sessions share. The workspace, credentials, and conversation transcripts are bind-mounted so the agent is authenticated and resumable; containers are hardened by default (`--cap-drop ALL`, `--security-opt no-new-privileges`, non-root, pids/memory caps, `--init`, never `--privileged` or the docker socket) and export-safe. Start one with the one-click "Run in Docker" checkbox on Create Case, or the Docker tab for full control. + + This release fixes the rough edges found running it for real: + + Docker cases: + - **Seamless Claude auth in containers**: `~/.claude.json` is no longer bind-mounted as a single file (a mount point that broke Claude's atomic-rename config writes — forcing re-auth and, via failed in-place writes, corrupting the host `~/.claude.json`). It is now seeded as a writable, onboarding-complete copy, so a docker session boots straight to the prompt (no theme picker, login, or folder-trust prompt). + - **Claude-state isolation**: containers no longer bind-mount the whole `~/.claude` directory (which wrote backups/tasks/teams/settings back into the host). Only `~/.claude/projects` transcripts are shared (host watchers + `--resume`); credentials, settings, and stats-cache are seeded as writable copies; everything else stays container-local. + - **Codex/Gemini/gcloud/opencode isolation**: same treatment — codex shares `sessions/` + `history.jsonl` (response-viewer + resume) and seeds `auth.json`/`config.toml`; gemini/gcloud/opencode are whole seed-copies. Containers never write their credential state back into the host dirs. + - **Base image auto-builds on first use**: a missing `codeman/agent:base` no longer blocks case creation or launch; it builds locally on first use (concurrency-safe, with SSE progress toasts). + - **UTF-8 locale**: containers set `LANG`/`LC_ALL=C.UTF-8` so tmux renders Claude's box-drawing correctly (fixes `qqqq` line artifacts). + - **Create Case UI**: larger, collapsed-by-default "Run in Docker" settings with a shorter hint; dockerized cases show a short `(docker)` tag (or the custom host id) in the case menus. + - **Tab naming**: docker/remote (and codex/gemini/opencode) sessions now follow the `w-` convention instead of `codeman-`. + + Other: + - **File Viewer header button** (opt-in via App Settings, Header Displays): toggle the file browser panel from the header. + - Fixed a timezone-boundary flaky test in the away-digest route suite. + ## 1.4.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index 74dce36d..042f5b98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,15 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Quick Reference -| Task | Command | -|------|---------| -| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) | -| Type check | `tsc --noEmit` | -| Lint | `npm run lint` (fix: `npm run lint:fix`) | -| Format | `npm run format` (check: `npm run format:check`) | +| Task | Command | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Dev server | `npm run dev` (or `npx tsx src/index.ts web`) | +| Type check | `tsc --noEmit` | +| Lint | `npm run lint` (fix: `npm run lint:fix`) | +| Format | `npm run format` (check: `npm run format:check`) | | Single test | `npm test -- test/.test.ts` (or `npx vitest run --config config/vitest.config.ts test/.test.ts`) — ⚠ **never** run bare `npm test`, see Testing section | -| Build | `npm run build` (esbuild via `scripts/build.mjs`, NOT tsc — `tsc --noEmit` is type-check only) | -| Production | `npm run build && systemctl --user restart codeman-web` | +| Build | `npm run build` (esbuild via `scripts/build.mjs`, NOT tsc — `tsc --noEmit` is type-check only) | +| Production | `npm run build && systemctl --user restart codeman-web` | ## CRITICAL: Session Safety @@ -30,15 +30,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values 3. **Only after verification passes**, proceed with COM -The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an *absolute* URL or from JS rather than a ` + + + +
+
+
+ + With zerolag-input + instant local echo +
+
+
+
keystrokes echo in 0 ms
+
+
+
+
+ + Without + server round-trip echo +
+
+
+
keystrokes echo after ~350 ms
+
+ +`; + +async function recordZerolagScene(browser, videoDir) { + console.log('\n2/2 Recording zerolag-demo...'); + + const context = await browser.newContext({ + viewport: ZEROLAG_VIEWPORT, + deviceScaleFactor: 1, + recordVideo: { dir: videoDir, size: ZEROLAG_VIEWPORT }, + }); + const recStart = Date.now(); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + await page.setContent(ZEROLAG_HTML, { waitUntil: 'load' }); + await page.waitForFunction(() => typeof Terminal !== 'undefined'); + + await page.evaluate(() => { + const theme = { + background: '#131316', + foreground: '#e8e8ea', + cursor: '#22c55e', + cursorAccent: '#131316', + }; + const mk = (id) => { + const term = new Terminal({ + cols: 44, + rows: 5, + fontSize: 20, + fontFamily: "'SF Mono', 'Cascadia Code', Menlo, monospace", + cursorBlink: true, + cursorStyle: 'block', + theme, + }); + term.open(document.getElementById(id)); + term.write('\x1b[32m❯\x1b[0m '); + return term; + }; + window.termLeft = mk('termLeft'); + window.termRight = mk('termRight'); + }); + await sleep(600); + + const sceneStart = Date.now(); + const typingMs = TYPED_TEXT.length * TYPE_INTERVAL_MS; + const totalMs = typingMs + REMOTE_FLUSH_MS + ZEROLAG_TAIL_HOLD; + + await page.evaluate( + ({ text, interval, flushEvery }) => { + let i = 0; + const remoteQueue = []; + const typer = setInterval(() => { + if (i >= text.length) { clearInterval(typer); return; } + const ch = text[i++]; + window.termLeft.write(ch); // local echo: instant + remoteQueue.push(ch); // server echo: waits for the round-trip + }, interval); + const flusher = setInterval(() => { + if (remoteQueue.length) window.termRight.write(remoteQueue.splice(0).join('')); + if (i >= text.length && remoteQueue.length === 0) clearInterval(flusher); + }, flushEvery); + }, + { text: TYPED_TEXT, interval: TYPE_INTERVAL_MS, flushEvery: REMOTE_FLUSH_MS } + ); + + await sleep(totalMs + 400); + + await page.close(); + const videoPath = await page.video().path(); + await context.close(); + + return { + videoPath, + ss: (sceneStart - recStart) / 1000 - 0.6, // small lead-in with idle cursors + duration: (totalMs + 600) / 1000, + }; +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log('='.repeat(60)); + console.log('Codeman README GIF Capture'); + console.log('='.repeat(60)); + + const server = await startStaticServer(); + const videoDir = mkdtempSync(join(tmpdir(), 'codeman-gifs-')); + let browser; + + try { + browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }); + + const sub = await recordSubagentScene(browser, videoDir); + const subGif = outPath('images', 'subagent-demo.gif'); + webmToGif(sub.videoPath, subGif, { ss: Math.max(0, sub.ss), duration: sub.duration, width: 960, fps: 8 }); + console.log(` Saved: ${subGif}`); + + const zl = await recordZerolagScene(browser, videoDir); + const zlGif = outPath('images', 'zerolag-demo.gif'); + webmToGif(zl.videoPath, zlGif, { ss: Math.max(0, zl.ss), duration: zl.duration, width: 900, fps: 10 }); + console.log(` Saved: ${zlGif}`); + + console.log('\nDone.'); + } catch (err) { + console.error('\nFatal error:', err.message); + console.error(err.stack); + process.exitCode = 1; + } finally { + if (browser) await browser.close().catch(() => {}); + server.close(); + rmSync(videoDir, { recursive: true, force: true }); + } +} + +process.on('SIGINT', () => process.exit(1)); + +main(); diff --git a/scripts/capture-readme-real.mjs b/scripts/capture-readme-real.mjs index 9427b93d..7142c106 100644 --- a/scripts/capture-readme-real.mjs +++ b/scripts/capture-readme-real.mjs @@ -51,6 +51,9 @@ async function newCtx(browser) { localStorage.setItem('codeman:skin', skin); localStorage.setItem('codeman-font-size', String(font)); const blob = { skin, showFileBrowser: false, showProjectInsights: false }; + // Don't auto-hide subagent windows that belong to a non-active tab — the + // subagent scene re-homes agents and needs both windows visible at once. + blob.subagentActiveTabOnly = false; if (planUsage) blob.showPlanUsageLimits = true; localStorage.setItem('codeman-app-settings', JSON.stringify(blob)); } catch { @@ -136,9 +139,9 @@ async function sceneSubagent(browser) { const sessions = await listSessions(page); const targetId = process.env.SUBAGENT_SID || (sessions.find((s) => s.mode === 'claude') || sessions[0])?.id; if (targetId) await page.evaluate((id) => window.app.selectSession(id), targetId); - // Wait (up to ~25s) for live subagents to arrive via SSE into app.subagents. + // Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents. let agents = []; - for (let i = 0; i < 25; i++) { + for (let i = 0; i < 45; i++) { agents = await page.evaluate(() => Array.from(window.app.subagents?.entries?.() || []).map(([id, a]) => ({ id, name: a.name ?? a.agentType ?? '' })) ); @@ -151,6 +154,44 @@ async function sceneSubagent(browser) { await context.close(); return; } + // The window body renders from app.subagentActivity, which fills ONLY from live + // SSE tool-call/progress events — a fresh client never gets past activity replayed. + // So sit connected and wait for live activity to accumulate, then open the two + // agents that actually have content (otherwise the windows read "No activity yet"). + let active = []; + for (let i = 0; i < 100; i++) { + active = await page.evaluate(() => + Array.from(window.app.subagentActivity?.entries?.() || []) + .filter(([, arr]) => Array.isArray(arr) && arr.length >= 1) + .map(([id, arr]) => ({ id, n: arr.length })) + .sort((a, b) => b.n - a.n) + ); + if (active.length >= 2) break; + // xhigh-effort agents churn in bursts between long thinking pauses, so be + // patient (~150s); accept a single populated window after ~45s if that's all. + if (i >= 30 && active.length >= 1) break; + await sleep(1500); + } + console.log(' agents with live activity:', JSON.stringify(active)); + const openIds = (active.length ? active : agents).map((a) => a.id); + // Capture-only DOM nudge: on fresh dev sessions, a tab's claudeSessionId stays the + // Codeman id and never becomes the real Claude conversation UUID, so the window + // open-gate (claudeSessionId === agent.sessionId) + the activeTabOnly hide rule both + // fail. Re-home the chosen agents onto the active tab and align its claudeSessionId + // to the agents' (shared) sessionId so the windows open AND show their live activity. + await page.evaluate( + (ids) => { + const activeId = window.app.activeSessionId; + const tab = window.app.sessions.get(activeId); + ids.slice(0, 2).forEach((id) => { + const a = window.app.subagents.get(id); + if (!a) return; + a.parentSessionId = activeId; + if (tab && a.sessionId) tab.claudeSessionId = a.sessionId; + }); + }, + openIds + ); await page.evaluate( (ids) => { ids.slice(0, 2).forEach((id) => { @@ -159,22 +200,33 @@ async function sceneSubagent(browser) { } catch {} }); }, - agents.map((a) => a.id) + openIds ); await sleep(2000); await page.evaluate(() => { + // Viewport-relative tiling: center two subagent windows over the terminal so + // the layout adapts to whatever VW/VH the capture uses (e.g. the HQ 1100×650 + // recipe) instead of overflowing at narrower widths. const wins = Array.from(window.app.subagentWindows.values()); - const place = [ - { left: 360, top: 60, w: 430, h: 330 }, - { left: 810, top: 60, w: 430, h: 330 }, - ]; + const W = window.innerWidth; + const H = window.innerHeight; + const winW = Math.min(440, Math.floor((W - 60) / 2 - 10)); + const winH = Math.min(360, Math.floor(H * 0.56)); + const top = Math.floor(H * 0.16); + const gap = 16; + const totalW = winW * 2 + gap; + const startLeft = Math.max(16, Math.floor((W - totalW) / 2)); wins.slice(0, 2).forEach((win, i) => { const el = win.element; - const p = place[i]; - el.style.left = p.left + 'px'; - el.style.top = p.top + 'px'; - el.style.width = p.w + 'px'; - el.style.height = p.h + 'px'; + // Force visible: a freshly opened window may be hidden by the activeTabOnly + // rule before we override it (we also seed subagentActiveTabOnly:false). + win.hidden = false; + win.minimized = false; + el.style.display = 'flex'; + el.style.left = startLeft + i * (winW + gap) + 'px'; + el.style.top = top + 'px'; + el.style.width = winW + 'px'; + el.style.height = winH + 'px'; }); }); await sleep(1500); diff --git a/scripts/capture-readme-skin.mjs b/scripts/capture-readme-skin.mjs new file mode 100644 index 00000000..0c1f0c17 --- /dev/null +++ b/scripts/capture-readme-skin.mjs @@ -0,0 +1,1074 @@ +#!/usr/bin/env node + +/** + * capture-readme-screenshots.mjs + * + * Captures deterministic README screenshots using Playwright + page.route() mock injection. + * No real Claude CLI or server needed — all API responses are mocked. + * + * Usage: node scripts/capture-readme-screenshots.mjs + * Port: 3199 (static file server) + * Output: docs/images/ and docs/screenshots/ + */ + +import { chromium } from 'playwright'; +import { createServer } from 'http'; +import { readFileSync, existsSync } from 'fs'; +import { join, extname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const PROJECT_ROOT = join(__dirname, '..'); +const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public'); + +// Non-destructive review run: which skin to force + where to write. +// Does NOT touch docs/images or docs/screenshots. Override via env. +const SKIN = process.env.SKIN || 'daylight-blue'; +const OUT_DIR = process.env.SCREENSHOT_OUT_DIR || join(PROJECT_ROOT, 'screenshots-readme', SKIN); +const PORT = 3199; +const VIEWPORT = { width: 1280, height: 720 }; +const DEVICE_SCALE_FACTOR = 1; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ─── MIME Types ────────────────────────────────────────────────────────────── + +const MIME_TYPES = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', +}; + +// ─── Static File Server ────────────────────────────────────────────────────── + +function startStaticServer() { + return new Promise((resolve) => { + const server = createServer((req, res) => { + let urlPath = req.url.split('?')[0]; + if (urlPath === '/') urlPath = '/index.html'; + + const filePath = join(PUBLIC_DIR, urlPath); + + if (!existsSync(filePath)) { + res.writeHead(404); + res.end('Not Found'); + return; + } + + try { + const data = readFileSync(filePath); + const ext = extname(filePath); + res.writeHead(200, { + 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', + 'Cache-Control': 'no-cache', + }); + res.end(data); + } catch { + res.writeHead(500); + res.end('Internal Server Error'); + } + }); + + server.listen(PORT, () => { + console.log(`Static server on http://localhost:${PORT}`); + resolve(server); + }); + }); +} + +// ─── Mock Data ─────────────────────────────────────────────────────────────── + +const SESSION_IDS = { + w1: 'sess-w1-0001', + w3: 'sess-w3-0003', + w4: 'sess-w4-0004', + w5: 'sess-w5-0005', + s1: 'sess-s1-0006', + s2: 'sess-s2-0007', + s3: 'sess-s3-0008', +}; + +const RALPH_SESSION_ID = 'sess-ralph-demo'; +const RALPH_SHELL_ID = 'sess-ralph-shell'; + +function makeSession(id, name, mode, status, extra = {}) { + return { + id, + pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000), + status, + workingDir: '/home/arkon/codeman-cases/testcase', + currentTaskId: null, + createdAt: Date.now() - 3600000, + lastActivityAt: Date.now() - (status === 'idle' ? 60000 : 5000), + name, + mode, + autoClearEnabled: false, + autoClearThreshold: 140000, + autoCompactEnabled: false, + autoCompactThreshold: 110000, + autoCompactPrompt: '', + imageWatcherEnabled: false, + totalCost: mode === 'claude' ? 0.12 : 0, + inputTokens: mode === 'claude' ? 18000 : 0, + outputTokens: mode === 'claude' ? 11500 : 0, + ralphEnabled: false, + niceEnabled: false, + niceValue: 10, + color: 'default', + flickerFilterEnabled: false, + cliVersion: '2.1.177', + cliModel: 'Opus 4.8', + cliAccountType: 'Claude Max', + cliLatestVersion: '2.1.177', + messageCount: mode === 'claude' ? 15 : 0, + isWorking: status === 'busy', + lastPromptTime: Date.now() - 30000, + bufferStats: { + terminalBufferSize: 4096, + textOutputSize: 2048, + messageCount: 15, + maxTerminalBuffer: 2097152, + maxTextOutput: 1048576, + maxMessages: 1000, + }, + taskStats: { total: 0, running: 0, completed: 0, failed: 0 }, + taskTree: [], + tokens: { + input: mode === 'claude' ? 18000 : 0, + output: mode === 'claude' ? 11500 : 0, + total: mode === 'claude' ? 29500 : 0, + }, + autoClear: { enabled: false, threshold: 140000 }, + nice: { enabled: false, niceValue: 10 }, + ralphLoop: null, + ralphTodos: [], + ralphTodoStats: { total: 0, completed: 0, percentComplete: 0 }, + respawnEnabled: false, + respawnConfig: null, + respawn: null, + claudeSessionId: `claude-${id}`, + ...extra, + }; +} + +// Standard session set — kept small (4 tabs) so the header isn't crowded. +// w1/w3/w4 are the sessions the scenes select; s1 adds a shell tab for variety. +const STANDARD_SESSIONS = [ + makeSession(SESSION_IDS.w1, 'w1-testcase', 'claude', 'busy'), + makeSession(SESSION_IDS.w3, 'w3-testcase', 'claude', 'busy'), + makeSession(SESSION_IDS.w4, 'w4-testcase', 'claude', 'idle'), + makeSession(SESSION_IDS.s1, 's1-testcase', 'shell', 'busy'), +]; + +// Ralph demo sessions (2 tabs) +const RALPH_SESSIONS = [ + makeSession(RALPH_SESSION_ID, 'ralph-8tasks-demo', 'claude', 'busy', { + ralphEnabled: true, + tokens: { input: 22000, output: 15300, total: 37300 }, + inputTokens: 22000, + outputTokens: 15300, + totalCost: 0.28, + ralphLoop: { + enabled: true, + active: true, + completionPhrase: 'ALL_TASKS_DONE', + startedAt: Date.now() - 60000, + cycleCount: 3, + maxIterations: null, + elapsedHours: null, + }, + ralphTodos: [ + { id: '1', text: 'Add TypeScript types to all functions', status: 'completed' }, + { id: '2', text: 'Add input validation with proper error messages', status: 'completed' }, + { id: '3', text: 'Add JSDoc documentation to each function', status: 'completed' }, + { id: '4', text: 'Add unit tests for formatDate and parseJSON', status: 'in_progress' }, + { id: '5', text: 'Add unit tests for debounce and deepClone', status: 'pending' }, + { id: '6', text: 'Add unit tests for slugify and truncate', status: 'pending' }, + { id: '7', text: 'Add unit tests for randomId and groupBy', status: 'pending' }, + { id: '8', text: 'Create an index.ts that exports all utilities', status: 'pending' }, + { id: '9', text: 'Add TypeScript types to all functions', status: 'completed' }, + ], + ralphTodoStats: { total: 9, completed: 4, percentComplete: 44 }, + }), + makeSession(RALPH_SHELL_ID, 's1-demo-testing', 'shell', 'busy'), +]; + +// Mock mux (tmux) sessions for Monitor panel +const MUX_SESSIONS = [ + { + sessionId: 'mux-w1', + name: 'w1-testcase', + muxName: 'w1-testcase', + mode: 'claude', + pid: 292239, + stats: { memoryMB: 2.3, cpuPercent: 0, childCount: 1 }, + }, + { + sessionId: 'mux-w3', + name: 'w3-testcase', + muxName: 'w3-testcase', + mode: 'claude', + pid: 292394, + stats: { memoryMB: 2.4, cpuPercent: 0.1, childCount: 1 }, + }, + { + sessionId: 'mux-w4', + name: 'w4-testcase', + muxName: 'w4-testcase', + mode: 'claude', + pid: 292497, + stats: { memoryMB: 2.4, cpuPercent: 0, childCount: 1 }, + }, +]; + +// Mock subagents for subagent-spawn screenshot +const MOCK_SUBAGENTS = [ + { + agentId: 'agent-001', + sessionId: 'claude-sess-w1-0001', + projectHash: 'abc123', + filePath: '/tmp/agent-001.jsonl', + startedAt: new Date(Date.now() - 120000).toISOString(), + lastActivityAt: Date.now() - 5000, + status: 'active', + toolCallCount: 12, + entryCount: 45, + fileSize: 32000, + description: 'Find and document all API endpoints in src/', + model: 'claude-haiku-4-5-20251001', + modelShort: 'haiku', + totalInputTokens: 15000, + totalOutputTokens: 8000, + parentSessionId: SESSION_IDS.w1, + }, + { + agentId: 'agent-002', + sessionId: 'claude-sess-w1-0001', + projectHash: 'abc123', + filePath: '/tmp/agent-002.jsonl', + startedAt: new Date(Date.now() - 90000).toISOString(), + lastActivityAt: Date.now() - 3000, + status: 'active', + toolCallCount: 8, + entryCount: 30, + fileSize: 22000, + description: 'Explore and understand test structure in test/', + model: 'claude-haiku-4-5-20251001', + modelShort: 'haiku', + totalInputTokens: 12000, + totalOutputTokens: 6000, + parentSessionId: SESSION_IDS.w1, + }, + { + agentId: 'agent-003', + sessionId: 'claude-sess-w1-0001', + projectHash: 'abc123', + filePath: '/tmp/agent-003.jsonl', + startedAt: new Date(Date.now() - 60000).toISOString(), + lastActivityAt: Date.now() - 8000, + status: 'active', + toolCallCount: 5, + entryCount: 18, + fileSize: 14000, + description: 'Analyze TypeScript type definitions in src/types.ts', + model: 'claude-haiku-4-5-20251001', + modelShort: 'haiku', + totalInputTokens: 8000, + totalOutputTokens: 4000, + parentSessionId: SESSION_IDS.w1, + }, +]; + +function buildInitPayload(sessions, subagents = []) { + const respawnStatus = {}; + for (const s of sessions) { + respawnStatus[s.id] = { + state: 'idle', + cycleCount: 0, + lastActivityTime: Date.now(), + timeSinceActivity: 0, + promptDetected: false, + workingDetected: false, + detection: {}, + config: null, + }; + } + + return { + version: '0.1556', + sessions, + scheduledRuns: [], + respawnStatus, + globalStats: { + totalInputTokens: 145000, + totalOutputTokens: 87000, + totalCost: 1.82, + totalSessionsCreated: 12, + firstRecordedAt: Date.now() - 86400000, + lastUpdatedAt: Date.now(), + }, + subagents, + timestamp: Date.now(), + }; +} + +// ─── Terminal Content (ANSI) ───────────────────────────────────────────────── + +// Colors +const RST = '\x1b[0m'; +const RED = '\x1b[31m'; +const GRN = '\x1b[32m'; +const YEL = '\x1b[33m'; +const BLU = '\x1b[34m'; +const MAG = '\x1b[35m'; +const CYN = '\x1b[36m'; +const GRY = '\x1b[90m'; +const WHT = '\x1b[37m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; + +// Claude Code init banner (matching the overview screenshot) +// Claude TUI input-box border — spans nearly the full terminal width (~158 cols +// at 1280px) so the input box and status reach the right edge like the wrapped +// conversation above. A short rule leaves an empty bottom-right corner. +const BOX = '─'.repeat(157); + +// Right-align `right` against `left` across `width` cols, ignoring ANSI SGR codes +// for length — used to push token/version status to the right edge of the footer. +// 157 ≈ the terminal's full col count (158 at 1280px), so the rule + status reach +// the same right edge the wrapped conversation does (no lower-right sliver). +const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length; +const spread = (left, right, width = 157) => + left + ' '.repeat(Math.max(2, width - visibleLen(left) - visibleLen(right))) + right; + +// Claude scenes are { body, footer }: `body` scrolls from the top, `footer` +// (the input box + status bar) is pinned to the bottom of the terminal by +// injectState(), with blank padding filling the gap — exactly like the real +// Claude Code TUI. This keeps every screenshot looking full-height regardless +// of how short the scripted conversation is. +const TERMINAL_INIT = { + body: [ + '', + `${GRN}●${RST} I'll implement the utility module end-to-end — strict TypeScript types on every export, runtime input validation with clear error messages, JSDoc with examples, and a full Vitest suite. Let me read the current file and the test setup first so I match the existing conventions.`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(src/utils.ts)`, + ` ${GRY}░${RST} Read ${BOLD}84${RST} lines ${GRY}·${RST} exports: formatDate, parseJSON, debounce, deepClone, slugify, truncate, randomId, groupBy`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(config/vitest.config.ts)`, + ` ${GRY}░${RST} Read ${BOLD}41${RST} lines ${GRY}·${RST} globals enabled, node environment, coverage via v8`, + '', + `${GRN}●${RST} The functions currently accept ${BOLD}any${RST} and assume well-formed input. I'll add explicit parameter and return types, guard every one against null/undefined with descriptive errors, and document the expected shapes with JSDoc examples.`, + '', + `${GRN}●${RST} ${BOLD}Edit${RST}(src/utils.ts)`, + ` ${GRY}░${RST} Updated with ${GRN}+42${RST} ${GRY}/${RST} ${RED}-6${RST} ${GRY}·${RST} src/utils.ts is now ${BOLD}126${RST} lines, fully typed and validated`, + '', + `${GRN}●${RST} ${BOLD}Write${RST}(src/index.ts)`, + ` ${GRY}░${RST} Wrote ${BOLD}11${RST} lines ${GRY}·${RST} barrel file re-exporting every utility for one clean import surface`, + '', + `${GRN}●${RST} ${BOLD}Write${RST}(test/utils.test.ts)`, + ` ${GRY}░${RST} Wrote ${BOLD}96${RST} lines ${GRY}·${RST} 4 describe blocks, 18 assertions covering happy paths and edge cases`, + '', + `${GRN}●${RST} ${BOLD}Bash${RST}(npm test -- utils.test.ts)`, + ` ${GRY}░${RST} ${GRN}✓${RST} formatDate ${GRY}(3ms)${RST} ${GRN}✓${RST} parseJSON ${GRY}(1ms)${RST} ${GRN}✓${RST} debounce ${GRY}(12ms)${RST} ${GRN}✓${RST} deepClone ${GRY}(2ms)${RST} ${GRN}✓${RST} slugify ${GRY}(1ms)${RST}`, + ` ${GRY}░${RST} ${GRN}✓${RST} truncate ${GRY}(1ms)${RST} ${GRN}✓${RST} randomId ${GRY}(1ms)${RST} ${GRN}✓${RST} groupBy ${GRY}(2ms)${RST} ${GRY}·${RST} ${BOLD}Tests${RST} ${GRN}8 passed (8)${RST} ${GRY}·${RST} ${GRY}Duration 312ms${RST}`, + '', + `${GRN}●${RST} All green. The utilities now have strict types across all 8 exports, runtime validation with descriptive errors, JSDoc usage examples, a barrel export in src/index.ts, and 8 passing unit tests.`, + '', + `${GRN}●${RST} Done — fully typed, validated, documented, re-exported, and covered by green tests. Want me to wire these into the existing modules next, or add property-based tests with fast-check?`, + ], + footer: [ + BOX, + `${GRY}❯${RST} now add property-based tests with fast-check for parseJSON and deepClone█`, + BOX, + ` ${GRY}Opus 4.8 (1M context)${RST} ${DIM}·${RST} ${GRY}in: 84,712 out: 1,203 ctx: 21%${RST}`, + spread( + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + `${BOLD}84.7k tokens${RST}` + ), + spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), + ], +}; + +// Claude working output (for multi-session-dashboard / subagent-spawn) +const TERMINAL_WORKING = { + body: [ + '', + `${GRN}●${RST} The codebase is essentially a template/scaffold with just a CLAUDE.md file and empty ${BOLD}src/${RST} directory. Since there's already a comprehensive`, + `CLAUDE.md file present, let me read it fully and suggest improvements based on the /init command requirements:`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`, + ` ${GRY}░${RST} Read ${BOLD}407${RST} lines`, + '', + `${GRN}●${RST} ${BOLD}Bash${RST}(find . -maxdepth 2 -type f -not -path "*/node_modules/*" | head)`, + ` ${GRY}░${RST} ./CLAUDE.md ${GRY}·${RST} ./package.json ${GRY}·${RST} ./tsconfig.json ${GRY}·${RST} ${GRY}(src/ is empty)${RST}`, + '', + `${GRN}●${RST} Now I understand the situation. This is a ${BOLD}template CLAUDE.md file${RST} designed for the Claude Code framework with the "Ralph Loop" autonomous work`, + `mode. The current file is very comprehensive for workflow instructions but lacks the key technical information that /init is supposed to generate:`, + '', + `1. ${BOLD}No build/test/lint commands${RST} - The file references npm test, npm run lint, npm run build but these are generic examples, not project-specific`, + `2. ${BOLD}No architecture documentation${RST} - The session log mentions files that don't exist in the empty repo`, + `3. ${BOLD}Placeholder tech stack${RST} - Still shows [TECHNOLOGIES_USED]`, + `4. ${BOLD}Too much workflow/process documentation${RST} - Much of this duplicates Claude Code's built-in behavior`, + '', + `The /init command is asking me to create a CLAUDE.md focused on ${BOLD}codebase-specific guidance${RST} (commands, architecture), not workflow processes. Since`, + `this is essentially an empty project, I should create a minimal CLAUDE.md that:`, + `1. Has the required header`, + `2. Notes that the project needs initialization`, + `3. Removes the verbose workflow documentation that's either built-in to Claude Code or should be in separate docs`, + '', + `Let me create an improved, streamlined CLAUDE.md appropriate for this empty project state.`, + '', + `${MAG}✻${RST} ${YEL}Caramelizing...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 54s · ↓ 2.1k tokens)${RST}`, + ], + footer: [ + BOX, + `${GRY}❯${RST} █`, + BOX, + ` ${GRY}Opus 4.8 (1M context)${RST} ${DIM}·${RST} ${GRY}in: 28,163 out: 2,104 ctx: 14%${RST}`, + spread( + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + `${BOLD}28.1k tokens${RST}` + ), + spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), + ], +}; + +// Ralph terminal content (matching ralph-tracker screenshot) +const TERMINAL_RALPH = { + body: [ + '', + `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/*.test.ts")`, + ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, + '', + `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/test/**")`, + ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, + '', + `${GRN}●${RST} ${BOLD}Search${RST}(pattern: "**/package.json")`, + ` ${GRY}░${RST} Found ${BOLD}0${RST} files`, + '', + `${MAG}✻${RST} ${YEL}Adding unit tests for formatDate and parseJSON...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · ${BOLD}ctrl+t${RST}${GRY} to hide todos · 1m 27s · ↓ 5.8k tokens · thinking)${RST}`, + ` ${GRY}░${RST} ${GRY}☒${RST} Add TypeScript types to all functions`, + ` ${GRY}☒${RST} Add input validation with proper error messages`, + ` ${GRY}☒${RST} Add JSDoc documentation to each function`, + ` ${GRY}░${RST} ${BOLD}Add unit tests for formatDate and parseJSON${RST}`, + ` ${GRY}░${RST} ☐ Add unit tests for debounce and deepClone`, + ` ${GRY}░${RST} ☐ Add unit tests for slugify and truncate`, + ` ${GRY}░${RST} ☐ Add unit tests for randomId and groupBy`, + ` ${GRY}░${RST} ☐ Create an index.ts that exports all utilities`, + ], + footer: [ + BOX, + `${GRY}❯${RST} █`, + '', + BOX, + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + ` ${BOLD}37267 tokens${RST}`, + ` ${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`, + ], +}; + +// Subagent window content (tool call activity) +const SUBAGENT_ACTIVITY = { + 'agent-001': [ + { type: 'tool', tool: 'Glob', input: { pattern: 'src/**/*.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/src/web/server.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, + { type: 'tool', tool: 'Grep', input: { pattern: 'app\\.get|app\\.post|app\\.put|app\\.delete', path: 'src/' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/src/web/schemas.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-001' }, + { type: 'message', role: 'assistant', text: 'Found 47 API endpoints across server.ts. Documenting REST paths...', timestamp: new Date().toISOString(), agentId: 'agent-001' }, + ], + 'agent-002': [ + { type: 'tool', tool: 'Glob', input: { pattern: 'test/**/*.test.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/codeman/config/vitest.config.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, + { type: 'message', role: 'assistant', text: 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...', timestamp: new Date().toISOString(), agentId: 'agent-002' }, + ], +}; + +// Subagent spawn terminal content +const TERMINAL_SUBAGENT = { + body: [ + '', + `${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`, + ` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`, + '', + `${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`, + ` ${GRY}░${RST} ./src/index.ts`, + ` ${GRY}░${RST} ./src/types.ts`, + ` ${GRY}░${RST} ./src/session.ts`, + ` ${GRY}░${RST} ./src/web/server.ts`, + ` ${GRY}░${RST} ./src/web/schemas.ts`, + ` ${GRY}░${RST} ./test/session.test.ts`, + ` ${GRY}░${RST} ${GRY}... (14 more)${RST}`, + '', + `${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`, + '', + `${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`, + ` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`, + '', + `${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`, + ` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`, + '', + `${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`, + ` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`, + '', + `${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`, + '', + ` ${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls${RST} — Glob, Read(server.ts), Grep(endpoints)...`, + ` ${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls${RST} — Glob, Read(test-utils), Read(vitest.config)...`, + ` ${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}5 tool calls${RST} — Read(types.ts), Grep(interface)...`, + '', + `${GRN}●${RST} ${DIM}171.8k, 13s │ 1.7k tokens │ thinking${RST}`, + ], + footer: [ + spread( + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + `${BOLD}171.8k tokens${RST}` + ), + spread('', `${GRY}current: 2.1.177${RST} ${DIM}·${RST} ${GRY}latest: 2.1.177${RST}`), + ], +}; + +// Flatten a { body, footer } scene into the raw buffer the mocked /terminal +// fetch returns. injectState() re-renders it bottom-anchored once the terminal +// exists and its real row count is known; this naive join is only the fallback +// the fetch path needs before that happens. +function joinScene(scene) { + return [...scene.body, '', '', ...scene.footer].join('\r\n'); +} + +// ─── Route Interceptors ────────────────────────────────────────────────────── + +async function setupRoutes(page, initPayload, terminalContent) { + const terminalBuffer = joinScene(terminalContent); + // CRITICAL: Block SSE entirely to prevent reconnection loops that clear state. + // We'll inject data directly via page.evaluate() instead. + await page.route('**/api/events', async (route) => { + await route.abort(); + }); + + // Terminal buffer endpoint — used by selectSession() + await page.route('**/api/sessions/*/terminal**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + terminalBuffer, + status: 'busy', + fullSize: terminalBuffer.length, + truncated: false, + }), + }); + }); + + // Mux sessions (subpath routes before base route) + await page.route('**/api/mux-sessions/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + await page.route('**/api/mux-sessions', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ sessions: MUX_SESSIONS, muxAvailable: true }), + }); + }); + + // Settings + await page.route('**/api/settings', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + showSubagents: true, + subagentTrackingEnabled: true, + subagentActiveTabOnly: false, + showMonitor: true, + }), + }); + }); + + // Subagent window states (restore) + await page.route('**/api/subagent-window-states', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // Subagent parents (restore) + await page.route('**/api/subagent-parents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // Session-specific subagents + await page.route('**/api/sessions/*/subagents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: initPayload.subagents || [] }), + }); + }); + + // Interactive attach (no-op) + await page.route('**/api/sessions/*/interactive', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + // Resize (no-op) + await page.route('**/api/sessions/*/resize', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); + + // Catch-all for any remaining API endpoints + await page.route('**/api/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); +} + +/** + * Inject mock state into the app and render. + * Bypasses SSE entirely — calls handleInit directly, then selects a session + * and writes terminal content. + */ +async function injectState(page, initPayload, terminalContent, activeSessionId) { + // Wait for app to be ready + await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 }); + await sleep(1000); + + // Cancel the SSE fallback timer and inject state directly + await page.evaluate((payload) => { + const app = window.app; + // Cancel the init fallback timer (prevents double handleInit) + if (app._initFallbackTimer) { + clearTimeout(app._initFallbackTimer); + app._initFallbackTimer = null; + } + // Inject state + app.handleInit(payload); + }, initPayload); + + await sleep(1500); + + // Select the target session (this triggers terminal fetch via our mocked route) + if (activeSessionId) { + await page.evaluate((sid) => { + window.app.activeSessionId = null; // Force re-select + window.app.selectSession(sid); + }, activeSessionId); + await sleep(2000); + } + + await sleep(1000); + + // Clean up UI elements that look wrong in screenshots + await page.evaluate(() => { + // Kill SSE reconnection entirely and hide the connection indicator + const app2 = window.app; + if (app2) { + // Stop reconnection timers + if (app2.sseReconnectTimeout) clearTimeout(app2.sseReconnectTimeout); + if (app2.eventSource) { app2.eventSource.close(); app2.eventSource = null; } + app2._connectionStatus = 'connected'; + // Monkey-patch so it never re-shows + app2._updateConnectionIndicator = () => {}; + app2.connectSSE = () => {}; + app2.setConnectionStatus = () => {}; + } + // Remove the indicator element from the DOM entirely + const indicator = document.getElementById('connectionIndicator'); + if (indicator) indicator.remove(); + + // Hide respawn banner (shows "idle" state which isn't needed in screenshots) + const respawnBanner = document.getElementById('respawnBanner'); + if (respawnBanner) respawnBanner.style.display = 'none'; + + // Fix system stats display (CPU/MEM) — inject mock values + const statsEl = document.getElementById('headerSystemStats'); + if (statsEl) { + statsEl.innerHTML = ` + CPU +
+ 24% + MEM +
+ 16.9G + `; + } + + // Hide subagents panel by default (will be shown per-scenario) + const subagentsPanel = document.getElementById('subagentsPanel'); + if (subagentsPanel) subagentsPanel.style.display = 'none'; + + // Ensure monitor panel is closed by default (will be opened per-scenario) + const monitorPanel = document.getElementById('monitorPanel'); + if (monitorPanel) monitorPanel.classList.remove('open'); + + // Slim the header for a cleaner screenshot: keep ONLY CPU/MEM stats and the + // settings gear. Hide the token count and the other header affordances + // (font A-/A+ controls, lifecycle-log doc icon). The response-viewer / + // multi-monitor / notification buttons are already hidden by default. + const headerHide = [ + '#headerTokens', + '.header-font-controls', + '.btn-lifecycle-log', + '.btn-response-viewer-header', + '.btn-multimonitor', + '.btn-notifications', + ]; + for (const sel of headerHide) { + document.querySelectorAll(sel).forEach((el) => { + el.style.setProperty('display', 'none', 'important'); + }); + } + + // Make the terminal / Claude pane dominate the window: slim the header and + // toolbar (default 36px + 42px ≈ 13% chrome) down to ~7%, so the terminal + // fills ~93% of the height. injectState re-fits the terminal to the enlarged + // container right after this, so the extra space becomes real terminal rows. + const slim = document.createElement('style'); + slim.id = 'codeman-screenshot-slim'; + slim.textContent = ` + :root { --header-height: 22px !important; --toolbar-height: 28px !important; } + .header { min-height: 22px !important; padding: 1px 10px !important; align-items: center !important; } + .session-tab { padding-top: 1px !important; padding-bottom: 1px !important; } + .toolbar { height: 28px !important; min-height: 28px !important; padding: 0 10px !important; } + .btn-toolbar { padding: 2px 8px !important; } + .toolbar-select, .toolbar-input { padding-top: 2px !important; padding-bottom: 2px !important; } + `; + document.head.appendChild(slim); + }); + + // Let the layout reflow to its FINAL height before sizing the terminal. The + // cleanup above hides the respawn banner (~48px), so the terminal container + // only grows to its full height now — fitting earlier under-counts rows and + // leaves a dead strip below the last row. + await sleep(700); + + // Render the scene bottom-anchored at the terminal's true full size: re-fit to + // the settled container, size rows to fill it exactly (fit() under-counts on + // initial load — the WebGL cell metric lags — which is the "switch tabs/reload + // to fix it" symptom), then neutralize later re-fits. `body` scrolls from the + // top; `footer` (input box + status bar) is pinned to the bottom with blank + // padding between — exactly like the real Claude Code TUI. Runs unconditionally + // so it overrides whatever the mocked /terminal fetch wrote. + await page.evaluate((scene) => { + const app = window.app; + const term = app.terminal; + if (!term) return; + try { + app.fitAddon?.fit(); + } catch { + /* ignore */ + } + try { + const cont = document.getElementById('terminalContainer'); + const cellH = term._core?._renderService?.dimensions?.css?.cell?.height || 21; + if (cont && cellH) { + const fitRows = Math.max(1, Math.floor(cont.clientHeight / cellH)); + if (fitRows !== term.rows) term.resize(term.cols, fitRows); + } + } catch { + /* ignore */ + } + if (app.fitAddon) app.fitAddon.fit = () => {}; + const rows = term.rows || 27; + const cols = term.cols || 158; + const body = scene.body || []; + const footer = scene.footer || []; + // Count how many TERMINAL ROWS a line array actually occupies, accounting for + // line wrapping — a long paragraph wraps to 2+ rows. Using the raw array + // length under-counts wrapped lines and leaves a dead band above the footer + // (and term.write() is async so measuring the live cursor afterward is + // unreliable). Strip ANSI SGR codes first so only VISIBLE width counts. + const rowsFor = (arr) => + arr.reduce((sum, line) => { + const visible = line.replace(/\x1b\[[0-9;]*m/g, ''); + return sum + Math.max(1, Math.ceil(visible.length / cols)); + }, 0); + const pad = Math.max(0, rows - rowsFor(body) - rowsFor(footer)); + const lines = [...body, ...Array(pad).fill(''), ...footer]; + term.clear(); + term.reset(); + term.write(lines.join('\r\n')); + term.scrollToBottom(); + }, terminalContent); + + // Final settle — let xterm.js WebGL renderer, fonts, and layout stabilize + await sleep(1500); +} + +// ─── Screenshot Scenarios ──────────────────────────────────────────────────── + +async function captureOverview(page) { + console.log('\n1/4 Capturing claude-overview.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS); + await setupRoutes(page, initPayload, TERMINAL_INIT); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_INIT, SESSION_IDS.w4); + + // Open Monitor panel and populate mux sessions + await page.evaluate((muxSessions) => { + const app = window.app; + if (!app) return; + app.muxSessions = muxSessions; + const panel = document.getElementById('monitorPanel'); + if (panel) { + panel.classList.add('open'); + app._renderMuxSessionsImmediate(); + } + }, MUX_SESSIONS); + await sleep(1500); + + await page.screenshot({ + path: join(OUT_DIR, 'claude-overview.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'claude-overview.png')); +} + +async function captureSubagentSpawn(page) { + console.log('\n2/4 Capturing subagent-spawn.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS, MOCK_SUBAGENTS); + await setupRoutes(page, initPayload, TERMINAL_SUBAGENT); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_SUBAGENT, SESSION_IDS.w1); + + // Open subagent windows and populate activity + await page.evaluate((activity) => { + const app = window.app; + if (!app) return; + + // Populate subagent activity data + for (const [agentId, entries] of Object.entries(activity)) { + app.subagentActivity.set(agentId, entries); + } + + // Open subagent windows (first two for cleaner screenshot) + app.openSubagentWindow('agent-001'); + app.openSubagentWindow('agent-002'); + }, SUBAGENT_ACTIVITY); + await sleep(2000); + + // Position windows nicely for 1280x720 viewport + await page.evaluate(() => { + const app = window.app; + if (!app) return; + + const windows = Array.from(app.subagentWindows.values()); + if (windows.length >= 2) { + const w1 = windows[0].element; + w1.style.left = '420px'; + w1.style.top = '40px'; + w1.style.width = '420px'; + w1.style.height = '320px'; + + const w2 = windows[1].element; + w2.style.left = '850px'; + w2.style.top = '40px'; + w2.style.width = '420px'; + w2.style.height = '320px'; + } + }); + await sleep(1500); + + await page.screenshot({ + path: join(OUT_DIR, 'subagent-spawn.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'subagent-spawn.png')); +} + +async function captureRalphTracker(page) { + console.log('\n3/5 Capturing ralph-tracker-8tasks-44percent.png...'); + + const initPayload = buildInitPayload(RALPH_SESSIONS); + await setupRoutes(page, initPayload, TERMINAL_RALPH); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_RALPH, RALPH_SESSION_ID); + + // Ensure Ralph state panel is visible and expanded + await page.evaluate((sessionId) => { + const app = window.app; + if (!app) return; + + const session = app.sessions.get(sessionId); + if (session) { + app.ralphStates.set(sessionId, { + loop: session.ralphLoop, + todos: session.ralphTodos || [], + }); + app.ralphStatePanelCollapsed = false; + app.ralphClosedSessions.delete(sessionId); + app._renderRalphStatePanelImmediate(); + } + }, RALPH_SESSION_ID); + await sleep(1500); + + await page.screenshot({ + path: join(OUT_DIR, 'ralph-tracker-8tasks-44percent.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'ralph-tracker-8tasks-44percent.png')); +} + +async function captureMultiSessionDashboard(page) { + console.log('\n3/4 Capturing multi-session-dashboard.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS); + await setupRoutes(page, initPayload, TERMINAL_WORKING); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_WORKING, SESSION_IDS.w3); + + await page.screenshot({ + path: join(OUT_DIR, 'multi-session-dashboard.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'multi-session-dashboard.png')); +} + +async function captureMultiSessionMonitor(page) { + console.log('\n4/4 Capturing multi-session-monitor.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS); + await setupRoutes(page, initPayload, TERMINAL_INIT); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await injectState(page, initPayload, TERMINAL_INIT, SESSION_IDS.w4); + + // Open Monitor panel and populate mux sessions + await page.evaluate((muxSessions) => { + const app = window.app; + if (!app) return; + app.muxSessions = muxSessions; + const panel = document.getElementById('monitorPanel'); + if (panel) { + panel.classList.add('open'); + app._renderMuxSessionsImmediate(); + } + }, MUX_SESSIONS); + await sleep(1500); + + await page.screenshot({ + path: join(OUT_DIR, 'multi-session-monitor.png'), + fullPage: false, + }); + console.log(' Saved: ' + join(OUT_DIR, 'multi-session-monitor.png')); +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log('='.repeat(60)); + console.log(`Codeman README Screenshot Capture — skin: ${SKIN}`); + console.log('='.repeat(60)); + console.log(`Port: ${PORT} | Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`); + console.log(`Output: ${OUT_DIR}`); + console.log(''); + + const server = await startStaticServer(); + let browser; + + try { + browser = await chromium.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + ], + }); + + // Each scenario gets its own fresh page to avoid route conflicts. + // (Ralph tracker intentionally omitted — de-prioritized feature.) + for (const scenario of [ + captureOverview, + captureSubagentSpawn, + captureMultiSessionDashboard, + captureMultiSessionMonitor, + ]) { + const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: DEVICE_SCALE_FACTOR }); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + // Force the chosen theme skin before any page script runs (matches the + // pre-paint contract in index.html: localStorage 'codeman:skin'). + await page.addInitScript((skin) => { + try { + localStorage.setItem('codeman:skin', skin); + } catch { + /* ignore */ + } + }, SKIN); + + try { + await scenario(page); + } catch (err) { + console.error(` ERROR: ${err.message}`); + } finally { + await context.close(); + } + } + + console.log('\n' + '='.repeat(60)); + console.log('All screenshots captured!'); + console.log('='.repeat(60)); + console.log(`\nOutput files (in ${OUT_DIR}):`); + console.log(' claude-overview.png'); + console.log(' subagent-spawn.png'); + console.log(' multi-session-dashboard.png'); + console.log(' multi-session-monitor.png'); + } catch (err) { + console.error('\nFatal error:', err.message); + console.error(err.stack); + process.exitCode = 1; + } finally { + if (browser) await browser.close().catch(() => {}); + server.close(); + console.log('\nDone.'); + } +} + +// Handle interrupts +process.on('SIGINT', () => { + console.log('\nInterrupted.'); + process.exit(1); +}); + +main(); diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 10389bcc..bbbae840 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -21,13 +21,15 @@ * @module docker-hosts */ -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { createHash } from 'node:crypto'; -import { execFile } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; +import { dataPath } from './config/instance.js'; import type { DockerCase, DockerCommandMode, @@ -388,33 +390,236 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] { return args; } +/** + * PURE argv for building the agent base image locally (the programmatic mirror of + * scripts/build-agent-image.mjs): `build -f -t [--no-cache] + * `. Kept pure + unit-testable; the caller prepends the engine binary. + */ +export function agentImageBuildArgs(dockerfile: string, image: string, contextDir: string, noCache = false): string[] { + return ['build', '-f', dockerfile, '-t', image, ...(noCache ? ['--no-cache'] : []), contextDir]; +} + // ========== Credential mount resolution (IO) ========== -/** Host cred paths mapped to their in-container HOME location. */ -const CREDENTIAL_PATHS: Array<{ rel: string }> = [ - { rel: '.claude' }, - { rel: '.claude.json' }, - { rel: '.codex' }, - { rel: '.gemini' }, - { rel: '.config/gcloud' }, - { rel: '.config/opencode' }, +/** Container Claude config dir (created gid-0 writable in the image). */ +export const CONTAINER_CLAUDE_DIR = `${CONTAINER_HOME}/.claude`; +/** In-container path of the seeded (writable) `~/.claude.json`. */ +export const CLAUDE_JSON_HOME = `${CONTAINER_HOME}/.claude.json`; +/** In-container path of the read-only host-seeded `~/.claude.json` (copied into HOME at launch). */ +export const CLAUDE_JSON_SEED = `${CONTAINER_HOME}/.codeman/claude.seed.json`; +/** Read-only seed paths for the files copied into the container's `.claude`. */ +const CLAUDE_CREDS_SEED = `${CONTAINER_HOME}/.codeman/claude-creds.seed.json`; +const CLAUDE_SETTINGS_SEED = `${CONTAINER_HOME}/.codeman/claude-settings.seed.json`; +const CLAUDE_STATS_SEED = `${CONTAINER_HOME}/.codeman/claude-stats.seed.json`; +/** Staging root for read-only host-cred seed mounts (codex/gemini/gcloud/opencode). */ +const CRED_SEED_DIR = `${CONTAINER_HOME}/.codeman/cred-seeds`; + +/** + * PURE: merge the host `~/.claude.json` into a config that makes an + * already-authenticated Claude skip its INTERACTIVE onboarding inside the container + * (the host file itself lacks these flags — the host install is grandfathered, so a + * verbatim copy still triggers the theme picker + login wizard + folder-trust + * prompt). Forces `hasCompletedOnboarding`, a `theme` (so the theme picker is + * skipped), and marks the workspace project trusted + onboarded. Auth still comes + * from the copied `oauthAccount` + the dir-mounted `~/.claude/.credentials.json`. + */ +export function buildSeamlessClaudeConfig( + hostConfig: Record, + workspacePath: string, + theme = 'dark' +): Record { + const merged: Record = { ...hostConfig }; + merged.hasCompletedOnboarding = true; + if (typeof merged.theme !== 'string') merged.theme = theme; + const projects = { ...((merged.projects as Record> | undefined) ?? {}) }; + const existing = (projects[workspacePath] as Record | undefined) ?? {}; + const seenCount = existing.projectOnboardingSeenCount; + projects[workspacePath] = { + ...existing, + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + projectOnboardingSeenCount: typeof seenCount === 'number' && seenCount > 0 ? seenCount : 1, + }; + merged.projects = projects; + return merged; +} + +/** Best-effort read of the host `~/.claude/settings.json` theme (drives the seed's theme). */ +function readHostClaudeTheme(home: string): string | undefined { + try { + const parsed = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8')) as { theme?: unknown }; + return typeof parsed.theme === 'string' ? parsed.theme : undefined; + } catch { + return undefined; + } +} + +/** + * Resolve the read-only seed mount for `~/.claude.json`. Reads the host file, merges + * in the seamless-onboarding flags + workspace trust (buildSeamlessClaudeConfig), + * writes the result to a per-container seed file under `~/.codeman/docker-seeds/`, + * and returns its mount. The launch chain copies it to `~/.claude.json` inside HOME + * once — giving Claude a NORMAL writable, already-onboarded config (no atomic-rename + * EBUSY, no re-auth, no theme/trust prompts). Falls back to the RAW host file when + * parse/write fails (auth still works; the wizard may show). Returns null when the + * host has no `~/.claude.json`. IO; under VITEST returns the raw mount (no write). + */ +export function resolveClaudeJsonSeedMount( + home: string = homedir(), + containerName?: string, + workspacePath?: string +): DockerMount | null { + const src = join(home, '.claude.json'); + if (!existsSync(src)) return null; + const rawMount: DockerMount = { src, dst: CLAUDE_JSON_SEED, readonly: true }; + if (IS_TEST_MODE || !containerName || !workspacePath) return rawMount; + try { + const hostConfig = JSON.parse(readFileSync(src, 'utf-8')) as Record; + const merged = buildSeamlessClaudeConfig(hostConfig, workspacePath, readHostClaudeTheme(home) ?? 'dark'); + const seedsDir = dataPath('docker-seeds'); + if (!existsSync(seedsDir)) mkdirSync(seedsDir, { recursive: true }); + const seedFile = join(seedsDir, `${containerName}.json`); + writeFileSync(seedFile, JSON.stringify(merged), { mode: 0o600 }); + return { src: seedFile, dst: CLAUDE_JSON_SEED, readonly: true }; + } catch { + return rawMount; // partial host write / unreadable — auth still carries, wizard may show + } +} + +/** A file (or dir, when `recursive`) copied into the container HOME once at launch + * (`[ -e to ] || cp [-a] from to`). */ +export interface DockerSeedCopy { + from: string; + to: string; + /** `cp -a` for whole-directory credential seeds (gemini/gcloud/opencode). */ + recursive?: boolean; +} + +export interface DockerClaudeArtifacts { + /** Bind mounts to add: the shared `projects/` transcripts (RW) + read-only seed files. */ + mounts: DockerMount[]; + /** Files copied into the container's writable HOME/.claude (+ HOME/.claude.json) at launch. */ + seedCopies: DockerSeedCopy[]; +} + +/** + * Resolve the ISOLATED Claude artifacts for a docker session (replaces the old + * whole-`~/.claude` RW mount that polluted the host). Shares ONLY what must cross + * the boundary and seeds the rest as writable copies: + * - `~/.claude/projects` → RW dir mount (transcripts: host watchers + `--resume`). + * - `~/.claude.json` → merged onboarding seed, copied to HOME (no re-auth/wizard). + * - `~/.claude/.credentials.json` + `~/.claude/settings.json` → read-only seeds + * copied into the container's own `~/.claude` (token + global prefs carry in; + * the container refreshes its own copy and never writes back to the host). + * Everything else Claude writes (backups, tasks, teams, session-env, history) stays + * container-local. IO (reads host files, writes the merged `.claude.json` seed). + */ +export function resolveDockerClaudeArtifacts( + home: string, + containerName: string, + workspacePath: string +): DockerClaudeArtifacts { + const mounts: DockerMount[] = []; + const seedCopies: DockerSeedCopy[] = []; + + // The ONE genuinely-shared part: conversation transcripts (dir mount → renames work). + const projectsSrc = join(home, '.claude', 'projects'); + if (existsSync(projectsSrc)) { + mounts.push({ src: projectsSrc, dst: `${CONTAINER_CLAUDE_DIR}/projects` }); + } + + // ~/.claude.json → merged, onboarding-complete seed at HOME root. + const jsonSeed = resolveClaudeJsonSeedMount(home, containerName, workspacePath); + if (jsonSeed) { + mounts.push(jsonSeed); + seedCopies.push({ from: CLAUDE_JSON_SEED, to: CLAUDE_JSON_HOME }); + } + + // credentials (token) + settings (theme/model/effort/permissions) + stats-cache + // (drives the model/effort status indicator) → writable copies inside the + // container's own ~/.claude (never a wholesale mount → no host pollution). + const files: Array<[rel: string, seed: string, dest: string]> = [ + ['.credentials.json', CLAUDE_CREDS_SEED, `${CONTAINER_CLAUDE_DIR}/.credentials.json`], + ['settings.json', CLAUDE_SETTINGS_SEED, `${CONTAINER_CLAUDE_DIR}/settings.json`], + ['stats-cache.json', CLAUDE_STATS_SEED, `${CONTAINER_CLAUDE_DIR}/stats-cache.json`], + ]; + for (const [rel, seed, dest] of files) { + const src = join(home, '.claude', rel); + if (existsSync(src)) { + mounts.push({ src, dst: seed, readonly: true }); + seedCopies.push({ from: seed, to: dest }); + } + } + + return { mounts, seedCopies }; +} + +/** + * Per-CLI credential-store isolation policy (the codex/gemini/gcloud/opencode analog + * of resolveDockerClaudeArtifacts). Codex is the direct Claude-analog: its + * `sessions/` rollouts + `history.jsonl` are read HOST-SIDE (response-viewer + + * `codex resume`), so they are SHARED (RW), while `auth.json`/`config.toml` are + * seeded. The other three have no host-read/resume dependency and are fully + * seed-copied (writable copy in the container, no write-back to the host). + */ +interface CredStorePolicy { + /** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */ + rel: string; + /** Subdirs bind-mounted RW (shared: resume + host reads). */ + shareDirs?: string[]; + /** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */ + shareFiles?: string[]; + /** Files seeded (RO mount → cp) into the container's own copy. */ + seedFiles?: string[]; + /** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */ + seedWhole?: boolean; +} + +const CRED_STORES: CredStorePolicy[] = [ + { rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] }, + { rel: '.gemini', seedWhole: true }, + { rel: '.config/gcloud', seedWhole: true }, + { rel: '.config/opencode', seedWhole: true }, ]; /** - * Resolve which host credential dirs/files EXIST and map them to their container - * HOME location. Only-existing avoids docker auto-creating root-owned empty dirs - * in the user's home. `~/.claude` also carries the transcripts (bind-mounted so - * host watchers + `--resume` see them) and is therefore mounted read-WRITE. + * Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old + * whole-dir RW mounts that let each in-container CLI write its refreshed tokens + + * session state back into the host). Every path is existsSync-gated (on most hosts + * only a subset exists). Pure-ish IO (no writes; just existence checks + mount specs). */ -export function resolveCredentialMounts(home: string = homedir()): DockerMount[] { +export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts { const mounts: DockerMount[] = []; - for (const { rel } of CREDENTIAL_PATHS) { - const src = join(home, rel); - if (existsSync(src)) { - mounts.push({ src, dst: `${CONTAINER_HOME}/${rel}` }); + const seedCopies: DockerSeedCopy[] = []; + for (const store of CRED_STORES) { + const hostBase = join(home, store.rel); + if (!existsSync(hostBase)) continue; + const containerBase = `${CONTAINER_HOME}/${store.rel}`; + const seedName = store.rel.replace(/\//g, '-'); // '.config/gcloud' → '.config-gcloud' + if (store.seedWhole) { + const seed = `${CRED_SEED_DIR}/${seedName}`; + mounts.push({ src: hostBase, dst: seed, readonly: true }); + seedCopies.push({ from: seed, to: containerBase, recursive: true }); + continue; + } + for (const sub of store.shareDirs ?? []) { + const src = join(hostBase, sub); + if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${sub}` }); + } + for (const file of store.shareFiles ?? []) { + const src = join(hostBase, file); + if (existsSync(src)) mounts.push({ src, dst: `${containerBase}/${file}` }); + } + for (const file of store.seedFiles ?? []) { + const src = join(hostBase, file); + if (existsSync(src)) { + const seed = `${CRED_SEED_DIR}/${seedName}-${file}`; + mounts.push({ src, dst: seed, readonly: true }); + seedCopies.push({ from: seed, to: `${containerBase}/${file}` }); + } } } - return mounts; + return { mounts, seedCopies }; } // ========== Daemon probes (IO; no-op under VITEST) ========== @@ -510,6 +715,104 @@ export async function checkDockerImagePresent(engine: DockerEngine, image: strin } } +export interface EnsureImageResult { + ok: boolean; + /** true when this call actually ran a build (vs. the image already existing). */ + built: boolean; + alreadyPresent: boolean; + error?: string; +} + +/** In-flight builds keyed by `engine:image`, so concurrent callers share ONE build. */ +const inFlightImageBuilds = new Map>(); + +/** + * Resolve the repo's Dockerfile + build context. Works from BOTH src (dev/tsx) and + * dist/index.js (esbuild prod: dist sits at repo root), since both are one level + * under the repo root. Returns null when the Dockerfile is absent (npm-global + * installs don't ship docker/ — Docker cases are a git-clone feature). + */ +function resolveAgentDockerfile(): { dockerfile: string; contextDir: string } | null { + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + const dockerfile = join(repoRoot, 'docker', 'agent.Dockerfile'); + return existsSync(dockerfile) ? { dockerfile, contextDir: repoRoot } : null; +} + +/** + * Ensure the agent base image exists, BUILDING it locally on first use so a missing + * image is never a hard blocker (decision: "build locally on first use", + * docs/docker-cases-plan.md). Idempotent, concurrency-safe (one build per + * engine:image shared by concurrent callers), and a no-op under VITEST. Only the + * DEFAULT image is auto-built — we can never build a user's custom ref, and the + * `--pull=never` invariant forbids pulling. `onProgress` receives build output + * lines for SSE surfacing. + */ +export async function ensureAgentBaseImage( + engine: DockerEngine, + image: string, + opts: { onProgress?: (line: string) => void; noCache?: boolean } = {} +): Promise { + if (IS_TEST_MODE) return { ok: true, built: false, alreadyPresent: true }; + if (await checkDockerImagePresent(engine, image)) { + return { ok: true, built: false, alreadyPresent: true }; + } + if (image !== DEFAULT_AGENT_IMAGE) { + return { + ok: false, + built: false, + alreadyPresent: false, + error: `image ${image} is not present and only ${DEFAULT_AGENT_IMAGE} is auto-built. Build or pull ${image} yourself.`, + }; + } + const key = `${engine}:${image}`; + const existing = inFlightImageBuilds.get(key); + if (existing) return existing; + const build = buildAgentImage(engine, image, opts).finally(() => inFlightImageBuilds.delete(key)); + inFlightImageBuilds.set(key, build); + return build; +} + +function buildAgentImage( + engine: DockerEngine, + image: string, + opts: { onProgress?: (line: string) => void; noCache?: boolean } +): Promise { + const resolved = resolveAgentDockerfile(); + if (!resolved) { + return Promise.resolve({ + ok: false, + built: false, + alreadyPresent: false, + error: `docker/agent.Dockerfile not found in this install; clone the repo or build ${image} manually`, + }); + } + const args = agentImageBuildArgs(resolved.dockerfile, image, resolved.contextDir, opts.noCache); + return new Promise((resolve) => { + // async spawn (NEVER spawnSync) so a multi-minute build never wedges the event loop. + const child = spawn(engine, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const forward = (buf: Buffer) => { + for (const line of buf.toString('utf-8').split('\n')) { + const trimmed = line.trimEnd(); + if (trimmed) opts.onProgress?.(trimmed); + } + }; + child.stdout?.on('data', forward); + child.stderr?.on('data', forward); + child.on('error', (err) => { + resolve({ + ok: false, + built: false, + alreadyPresent: false, + error: `could not spawn ${engine} build: ${err.message}`, + }); + }); + child.on('exit', (code) => { + if (code === 0) resolve({ ok: true, built: true, alreadyPresent: false }); + else resolve({ ok: false, built: false, alreadyPresent: false, error: `${engine} build failed (exit ${code})` }); + }); + }); +} + export interface DockerTmuxCheckResult { ok: boolean; tmuxPath?: string; @@ -532,7 +835,7 @@ export async function checkDockerTmuxAvailable( return { ok: false, imageMissing: true, - error: `base image ${docker.image} not present: build it with 'node scripts/build-agent-image.mjs' (or pull it)`, + error: `image ${docker.image} not present (the default image is auto-built on first use; a custom image must be built or pulled first)`, }; } try { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index b1914d50..13200fe8 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -56,9 +56,11 @@ import { CONTAINER_HOME, defaultDockerCommandForMode, hostGatewayAlias, - resolveCredentialMounts, + resolveDockerClaudeArtifacts, + resolveDockerCredentialArtifacts, type DockerCreateContext, type DockerMount, + type DockerSeedCopy, } from './docker-hosts.js'; import { wrapWithNice, @@ -885,6 +887,14 @@ export interface DockerLaunchOptions { execEnv: Record; /** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */ execEnvNames: string[]; + /** + * Files to copy from read-only seed mounts into the container's writable HOME once + * before launch (guarded so reconnects never clobber). Isolates Claude state: the + * merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`, + * are writable copies (not host mounts), so the container never re-auths and never + * writes its runtime state back into the host `~/.claude`. + */ + seedCopies?: DockerSeedCopy[]; } /** @@ -895,7 +905,7 @@ export interface DockerLaunchOptions { * command -> `docker exec … sh -lc ''` -> tmux `''`. */ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { - const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames } = opts; + const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts; const base = buildDockerBaseArgs(docker).join(' '); const createArgs = buildDockerCreateArgs(createContext).join(' '); const name = shellescape(docker.containerName); @@ -932,7 +942,7 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra)); const imageMissingMsg = shellescape( - `Codeman: base image ${docker.image} not present (build: node scripts/build-agent-image.mjs)` + `Codeman: base image ${docker.image} not present (it is normally auto-built on first use)` ); const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`); @@ -940,7 +950,18 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string { // create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain. const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`; const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`; - const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(tmuxInvocation)}`; + // Seed writable credential config from read-only host mounts ONCE per container + // (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for + // whole-dir credential seeds). mkdir -p the parent so a file seed works even when + // no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME + // constants (no shell metachars), so the whole inner command is shell-quoted once. + const seedSteps = (seedCopies ?? []).map((s) => { + const cp = s.recursive ? 'cp -a' : 'cp'; + const parent = s.to.slice(0, s.to.lastIndexOf('/')); + return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`; + }); + const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation; + const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`; return [imageCheck, ensure, start, execCmd].join(' ; '); } @@ -991,12 +1012,29 @@ export function resolveDockerLaunchOptions( : ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME) const gatewayAlias = hostGatewayAlias(docker.engine); - const credentialMounts: DockerMount[] = docker.mountCredentials ? resolveCredentialMounts(home) : []; + const credentialMounts: DockerMount[] = []; const extraMounts: DockerMount[] = []; + // Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store + // shares ONLY what a host feature / --resume needs (Claude projects/, codex + // sessions/+history) and seeds everything else (tokens, settings, configs) as + // writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing + // its runtime state back into the host dirs. Only when credentials are mounted. + let seedCopies: DockerSeedCopy[] = []; + if (docker.mountCredentials) { + const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir); + const credArtifacts = resolveDockerCredentialArtifacts(home); + extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts); + seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies]; + } const envCreate: Record = { HOME: CONTAINER_HOME, TERM: 'xterm-256color', COLORTERM: 'truecolor', + // Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux + // runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw + // VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen). + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', // Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-` // is refused when that path pre-exists root-owned — which happens when the // workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-). @@ -1031,6 +1069,11 @@ export function resolveDockerLaunchOptions( const execEnv: Record = { TERM: 'xterm-256color', COLORTERM: 'truecolor', + // UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and + // renders box-drawing correctly even when reattaching to a container created + // before this fix (client_utf8 is per-client, resolved from the exec's locale). + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', CODEMAN_SESSION_ID: sessionId.slice(0, 8), CODEMAN_MUX: '1', }; @@ -1043,7 +1086,7 @@ export function resolveDockerLaunchOptions( ? ['GEMINI_API_KEY', 'GOOGLE_API_KEY'] : []; - return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames }; + return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies }; } /** diff --git a/src/web/public/app.js b/src/web/public/app.js index 8036f532..f36238e1 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1452,6 +1452,30 @@ class CodemanApp { console.error('[SSE] docker export failed:', 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, () => { + this.showToast('Building the Codeman agent image (first Docker case, a few minutes)...', 'info', { + duration: 8000, + }); + }); + addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_COMPLETE, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + if (d.error) this.showToast(`Agent image build failed: ${d.error}`, 'error'); + else this.showToast('Agent image ready. Starting the container...', 'success'); + } catch (err) { + console.error('[SSE] docker image build complete:', err); + } + }); + addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_FAILED, (e) => { + try { + const d = e.data ? JSON.parse(e.data) : {}; + this.showToast(`Agent image build failed: ${d.error || 'unknown error'}`, 'error'); + } catch (err) { + console.error('[SSE] docker image build failed:', err); + } + }); } // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 4924c31e..fb81deaf 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -477,6 +477,10 @@ const SSE_EVENTS = { DOCKER_EXPORT_COMPLETE: 'docker:exportComplete', DOCKER_EXPORT_FAILED: 'docker:exportFailed', DOCKER_IMPORT_COMPLETE: 'docker:importComplete', + DOCKER_IMAGE_BUILD_STARTED: 'docker:imageBuildStarted', + DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress', + DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete', + DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/index.html b/src/web/public/index.html index 2a33cf09..a70f360e 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -124,6 +124,7 @@ +
—
@@ -1268,6 +1269,13 @@ +
+ File Viewer + +
Attachments Button
-
- - One checkbox is enough — it creates the case folder AND a hardened container with default settings, then starts the session inside it. Click to expand for optional presets. Requires the base image (node scripts/build-agent-image.mjs). +
+ + Runs this case in a hardened, isolated container. The base image is built automatically on first use.
-