Files
Codeman/scripts/capture-readme-skin.mjs
Codeman maintainer ca731c67b3 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<n>-<case> 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) <noreply@anthropic.com>
2026-07-20 01:36:21 +02:00

1075 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 = `
<span class="stat-label">CPU</span>
<div class="stat-bar"><div class="stat-bar-fill cpu-bar" style="width: 24%"></div></div>
<span class="stat-value">24%</span>
<span class="stat-label">MEM</span>
<div class="stat-bar"><div class="stat-bar-fill mem-bar" style="width: 51%"></div></div>
<span class="stat-value">16.9G</span>
`;
}
// 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 <head> 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();