diff --git a/scripts/capture-readme-gifs.mjs b/scripts/capture-readme-gifs.mjs
deleted file mode 100644
index 2c58e750..00000000
--- a/scripts/capture-readme-gifs.mjs
+++ /dev/null
@@ -1,482 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * capture-readme-gifs.mjs
- *
- * Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the
- * mock-injection pipeline from capture-readme-screenshots.mjs (static file
- * server + page.route mocks), drives a scripted timeline in the page, records
- * it with Playwright video, and converts to GIF via ffmpeg palette encoding.
- *
- * Scenes:
- * 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent
- * windows open one by one and stream tool-call activity live (driven
- * through the real _onSubagentDiscovered/_onSubagentToolCall handlers).
- * 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag)
- * vs bursty ~350 ms server echo, rendered with the vendored xterm.
- *
- * Usage: node scripts/capture-readme-gifs.mjs
- * SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs
- * Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR)
- * Requires: ffmpeg
- */
-
-import { chromium } from 'playwright';
-import { execSync } from 'child_process';
-import { mkdtempSync, rmSync } from 'fs';
-import { tmpdir } from 'os';
-import { join } from 'path';
-import {
- PORT,
- SESSION_IDS,
- STANDARD_SESSIONS,
- buildInitPayload,
- startStaticServer,
- setupRoutes,
- injectState,
- outPath,
- RST, GRN, YEL, MAG, CYN, GRY, BOLD,
-} from './capture-readme-screenshots.mjs';
-
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-
-const GIF_COLORS = 192;
-
-// ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ────────
-
-function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) {
- // One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame
- // palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch
- // on flat dark UI, and error-diffusion dither shimmers between frames.
- const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`;
- execSync(
- `ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` +
- `-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` +
- `[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`,
- { stdio: 'inherit' }
- );
-}
-
-// ─── Scene 1: subagent demo ──────────────────────────────────────────────────
-
-const SUBAGENT_VIEWPORT = { width: 1440, height: 810 };
-
-// Terminal content visible before the agents spawn
-const TERMINAL_PRESPAWN = [
- '',
- `${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/session.ts`,
- ` ${GRY}░${RST} ./src/web/server.ts`,
- ` ${GRY}░${RST} ${GRY}... (17 more)${RST}`,
- '',
- `${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`,
- '',
-].join('\r\n');
-
-function makeAgent(agentId, description, startedOffsetMs) {
- return {
- agentId,
- sessionId: 'claude-sess-w1-0001',
- projectHash: 'abc123',
- filePath: `/tmp/${agentId}.jsonl`,
- startedAt: new Date(Date.now() - startedOffsetMs).toISOString(),
- lastActivityAt: Date.now(),
- status: 'active',
- toolCallCount: 0,
- entryCount: 0,
- fileSize: 4000,
- description,
- model: 'claude-haiku-4-5-20251001',
- modelShort: 'haiku',
- totalInputTokens: 0,
- totalOutputTokens: 0,
- parentSessionId: SESSION_IDS.w1,
- };
-}
-
-// Timeline events: t (ms from scene start) + kind
-// term — write raw data to the session terminal
-// discover — register subagent + open + position its floating window
-// tool — stream a tool call into an agent window
-// msg — stream an assistant message into an agent window
-// complete — flip an agent to completed
-function buildSubagentTimeline() {
- const T = (lines) => lines.join('\r\n') + '\r\n';
- const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input });
- const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text });
-
- return [
- {
- t: 600,
- kind: 'term',
- data: T([
- `${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`,
- ` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`,
- '',
- ]),
- },
- {
- t: 1000,
- kind: 'discover',
- agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000),
- x: 440, y: 45,
- },
- tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }),
- {
- t: 2000,
- kind: 'term',
- data: T([
- `${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`,
- ` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`,
- '',
- ]),
- },
- tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }),
- {
- t: 2500,
- kind: 'discover',
- agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200),
- x: 880, y: 45,
- },
- tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }),
- {
- t: 3300,
- kind: 'term',
- data: T([
- `${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`,
- ` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`,
- '',
- ]),
- },
- tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }),
- {
- t: 3800,
- kind: 'discover',
- agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400),
- x: 660, y: 400,
- },
- tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }),
- {
- t: 4500,
- kind: 'term',
- data: T([
- `${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`,
- '',
- ]),
- },
- tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }),
- tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }),
- tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }),
- tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }),
- msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'),
- tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }),
- msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'),
- tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }),
- msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'),
- {
- t: 9300,
- kind: 'term',
- data: T([
- `${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`,
- `${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`,
- `${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`,
- '',
- ]),
- },
- tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }),
- tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }),
- tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }),
- {
- t: 11600,
- kind: 'term',
- data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']),
- },
- ];
-}
-
-const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame
-
-async function recordSubagentScene(browser, videoDir) {
- console.log('\n1/2 Recording subagent-demo...');
-
- const context = await browser.newContext({
- viewport: SUBAGENT_VIEWPORT,
- deviceScaleFactor: 1,
- recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT },
- });
- const recStart = Date.now();
- const page = await context.newPage();
- page.setDefaultTimeout(30000);
-
- // Start with NO subagents — they appear during the recording
- const initPayload = buildInitPayload(STANDARD_SESSIONS);
- await setupRoutes(page, initPayload, TERMINAL_PRESPAWN);
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
- await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1);
-
- await page.evaluate(() => {
- try { window.app?.fitAddon?.fit(); } catch {}
- window.app?.terminal?.scrollToBottom();
- });
- await sleep(500);
-
- const timeline = buildSubagentTimeline();
- const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD;
- const sceneStart = Date.now();
-
- // Run the whole timeline inside the page so events interleave naturally
- await page.evaluate((events) => {
- const app = window.app;
- for (const ev of events) {
- setTimeout(() => {
- try {
- if (ev.kind === 'term') {
- app.terminal.write(ev.data);
- app.terminal.scrollToBottom();
- } else if (ev.kind === 'discover') {
- app._onSubagentDiscovered(ev.agent);
- app.openSubagentWindow(ev.agent.agentId);
- // The spawn animation (400ms) lands on the auto-grid; glide to our tile after it
- setTimeout(() => {
- const win = app.subagentWindows.get(ev.agent.agentId);
- if (win?.element) {
- win.element.style.transition = 'left 0.25s ease, top 0.25s ease';
- win.element.style.left = `${ev.x}px`;
- win.element.style.top = `${ev.y}px`;
- }
- }, 520);
- setTimeout(() => {
- const win = app.subagentWindows.get(ev.agent.agentId);
- if (win?.element) win.element.style.transition = '';
- app.updateConnectionLines();
- }, 850);
- } else if (ev.kind === 'tool') {
- app._onSubagentToolCall({
- agentId: ev.agentId,
- tool: ev.tool,
- input: ev.input,
- timestamp: new Date().toISOString(),
- });
- } else if (ev.kind === 'msg') {
- app._onSubagentMessage({
- agentId: ev.agentId,
- role: 'assistant',
- text: ev.text,
- timestamp: new Date().toISOString(),
- });
- } else if (ev.kind === 'complete') {
- app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() });
- }
- } catch (err) {
- console.error('timeline event failed', ev, err);
- }
- }, ev.t);
- }
- }, timeline);
-
- await sleep(totalMs + 500);
-
- await page.close();
- const videoPath = await page.video().path();
- await context.close();
-
- return {
- videoPath,
- ss: (sceneStart - recStart) / 1000 - 0.4,
- duration: (totalMs + 400) / 1000,
- };
-}
-
-// ─── Scene 2: zerolag typing comparison ──────────────────────────────────────
-
-const ZEROLAG_VIEWPORT = { width: 1280, height: 470 };
-const TYPED_TEXT = 'echo "zero lag typing from anywhere"';
-const TYPE_INTERVAL_MS = 110;
-const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts
-const ZEROLAG_TAIL_HOLD = 1800;
-
-const ZEROLAG_HTML = `
-
-
-
-
-
-
-
-
-
-
-
- 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
deleted file mode 100644
index 2c4c8bf5..00000000
--- a/scripts/capture-readme-real.mjs
+++ /dev/null
@@ -1,253 +0,0 @@
-#!/usr/bin/env node
-/**
- * capture-readme-real.mjs
- *
- * Captures README desktop scenes (multi-session dashboard, monitor, subagent
- * windows) from a REAL Codeman instance — intended to run against an ISOLATED
- * dev/beta instance (CODEMAN_INSTANCE=beta on :5000) seeded from prod's settings,
- * NOT prod itself (never touch prod's live sessions).
- *
- * Reuses the high-quality capture recipe proven in capture-real-overview.mjs:
- * - DSF=2 + ?nowebgl → crisp retina at the TRUE font size (WebGL doubles
- * glyphs under DSF=2; the DOM renderer respects devicePixelRatio).
- * - per-device localStorage seeding so the capture matches a real device.
- *
- * SCENE=dashboard|monitor|subagent|all BASE=http://localhost:5000 \
- * OUT=screenshots-readme-real/desktop node scripts/capture-readme-real.mjs
- */
-import { chromium } from 'playwright';
-import { mkdirSync } from 'fs';
-import { join } from 'path';
-
-const BASE = process.env.BASE || 'http://localhost:5000';
-const OUT = process.env.OUT || 'screenshots-readme-real/desktop';
-const SKIN = process.env.SKIN || 'daylight-blue';
-const SCENE = process.env.SCENE || 'all';
-const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 13)));
-const VIEWPORT = { width: Number(process.env.VW || 1280), height: Number(process.env.VH || 720) };
-const DSF = Number(process.env.DSF || 2);
-const PLAN_USAGE = process.env.PLAN_USAGE !== '0';
-
-const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
-const url = (extra = '') => {
- const sep = BASE.includes('?') ? '&' : '?';
- const params = [];
- if (DSF > 1) params.push('nowebgl'); // DOM renderer → correct font size at DSF>1
- if (extra) params.push(extra);
- return params.length ? `${BASE}${sep}${params.join('&')}` : BASE;
-};
-
-async function newCtx(browser) {
- const context = await browser.newContext({
- viewport: VIEWPORT,
- deviceScaleFactor: DSF,
- ignoreHTTPSErrors: BASE.startsWith('https'),
- });
- const page = await context.newPage();
- page.setDefaultTimeout(30000);
- await page.addInitScript(
- ([skin, planUsage, font]) => {
- try {
- localStorage.setItem('codeman:skin', skin);
- localStorage.setItem('codeman-font-size', String(font));
- const blob = { skin, showFileBrowser: false, showProjectInsights: false, showTokenCount: 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 {
- /* ignore */
- }
- },
- [SKIN, PLAN_USAGE, FONT]
- );
- return { context, page };
-}
-
-async function bootstrap(page) {
- await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 });
- await sleep(1200);
-}
-
-async function listSessions(page) {
- return page.evaluate(() =>
- Array.from(window.app.sessions.values()).map((s) => ({ id: s.id, name: s.name, mode: s.mode }))
- );
-}
-
-async function shoot(page, name) {
- const out = join(OUT, name);
- await page.evaluate((f) => {
- try {
- if (window.app.setFontSize) window.app.setFontSize(f);
- } catch {}
- try {
- window.app.fitAddon && window.app.fitAddon.fit();
- } catch {}
- try {
- window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings();
- } catch {}
- }, FONT);
- await sleep(1500);
- await page.screenshot({ path: out, fullPage: false });
- console.log(' Saved: ' + out);
-}
-
-async function sceneDashboard(browser) {
- console.log('Scene: dashboard');
- const { context, page } = await newCtx(browser);
- await page.goto(url(), { waitUntil: 'domcontentloaded' });
- await bootstrap(page);
- const sessions = await listSessions(page);
- // Select a claude session so the active terminal shows rich content; all tabs render.
- const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
- if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
- await sleep(4000);
- await shoot(page, 'multi-session-dashboard.png');
- await context.close();
-}
-
-async function sceneMonitor(browser) {
- console.log('Scene: monitor');
- const { context, page } = await newCtx(browser);
- await page.goto(url(), { waitUntil: 'domcontentloaded' });
- await bootstrap(page);
- const sessions = await listSessions(page);
- const target = sessions.find((s) => s.mode === 'claude') || sessions[0];
- if (target) await page.evaluate((id) => window.app.selectSession(id), target.id);
- await sleep(2500);
- // toggleMonitorPanel() opens the panel, clears the hidden state, loads REAL
- // mux sessions (/api/mux), starts stats, and renders the task panel.
- await page.evaluate(async () => {
- try {
- await window.app.toggleMonitorPanel();
- } catch {}
- });
- await sleep(3000);
- await shoot(page, 'multi-session-monitor.png');
- await context.close();
-}
-
-async function sceneSubagent(browser) {
- console.log('Scene: subagent');
- const { context, page } = await newCtx(browser);
- await page.goto(url(), { waitUntil: 'domcontentloaded' });
- await bootstrap(page);
- // Select the session whose subagents we want (subagentActiveTabOnly means
- // app.subagents only fills for the active tab). Prefer SUBAGENT_SID env.
- 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 ~45s) for live subagents to arrive via SSE into app.subagents.
- let agents = [];
- 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 ?? '' }))
- );
- if (agents.length >= 1) break;
- await sleep(1000);
- }
- console.log(' live in-browser subagents:', JSON.stringify(agents));
- if (agents.length === 0) {
- console.log(' NO live subagents — skipping (stage a longer subagent task and run this while it runs).');
- 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) => {
- try {
- window.app.openSubagentWindow(id);
- } catch {}
- });
- },
- 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 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;
- // 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);
- await shoot(page, 'subagent-spawn.png');
- await context.close();
-}
-
-async function main() {
- mkdirSync(OUT, { recursive: true });
- const browser = await chromium.launch({
- headless: true,
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
- });
- console.log(`BASE=${BASE} SKIN=${SKIN} DSF=${DSF} VIEWPORT=${VIEWPORT.width}x${VIEWPORT.height} SCENE=${SCENE}`);
- if (SCENE === 'dashboard' || SCENE === 'all') await sceneDashboard(browser);
- if (SCENE === 'monitor' || SCENE === 'all') await sceneMonitor(browser);
- if (SCENE === 'subagent' || SCENE === 'all') await sceneSubagent(browser);
- await browser.close();
-}
-
-main().catch((e) => {
- console.error('FATAL', e.message);
- process.exit(1);
-});
diff --git a/scripts/capture-readme-skin.mjs b/scripts/capture-readme-skin.mjs
deleted file mode 100644
index 0c1f0c17..00000000
--- a/scripts/capture-readme-skin.mjs
+++ /dev/null
@@ -1,1074 +0,0 @@
-#!/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/scripts/capture-video-screenshots.mjs b/scripts/capture-video-screenshots.mjs
deleted file mode 100644
index 172e332c..00000000
--- a/scripts/capture-video-screenshots.mjs
+++ /dev/null
@@ -1,731 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * capture-video-screenshots.mjs
- *
- * Captures real Codeman UI screenshots for the Remotion demo video.
- * Uses Playwright + page.route() mock injection (same pattern as capture-readme-screenshots.mjs).
- * No real Claude CLI or server needed — all API responses are mocked.
- *
- * Usage: node scripts/capture-video-screenshots.mjs
- * Port: 3198 (static file server)
- * Output: scripts/scripts/remotion/public/ (6 PNGs)
- */
-
-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');
-const OUTPUT_DIR = join(PROJECT_ROOT, 'scripts', 'remotion', 'public');
-const PORT = 3198;
-
-const DESKTOP_VIEWPORT = { width: 1920, height: 1080 };
-const MOBILE_VIEWPORT = { width: 390, height: 844 };
-
-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 ───────────────────────────────────────────────────────────────
-
-function makeSession(id, name, mode, status, extra = {}) {
- return {
- id,
- pid: status === 'idle' ? null : 12345 + Math.floor(Math.random() * 10000),
- status,
- workingDir: '/home/user/my-project',
- 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.61',
- cliModel: mode === 'claude' ? 'Opus 4.6' : 'GPT-4o',
- cliAccountType: mode === 'claude' ? 'Claude Max' : 'API Key',
- cliLatestVersion: '2.1.61',
- messageCount: 15,
- 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,
- };
-}
-
-function buildInitPayload(sessions) {
- 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.1651',
- 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) ─────────────────────────────────────────────────
-
-const RST = '\x1b[0m';
-const RED = '\x1b[31m';
-const GRN = '\x1b[32m';
-const YEL = '\x1b[33m';
-const MAG = '\x1b[35m';
-const GRY = '\x1b[90m';
-const BOLD = '\x1b[1m';
-const DIM = '\x1b[2m';
-
-// Claude Code init banner
-const TERMINAL_CLAUDE = [
- '',
- ` ${RED}████${RST} ${BOLD}Claude Code${RST} v2.1.61`,
- ` ${RED}████${RST} ${GRY}Opus 4.6${RST} ${DIM}·${RST} ${GRY}Claude Max${RST}`,
- ` ${RED}██${RST} ${RED}██${RST} ${GRY}~/my-project${RST}`,
- '',
- '',
- `${GRY}❯ Try "how do I log an error?"${RST}`,
- '',
- '',
- `${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`,
- ` ${BOLD}0 tokens${RST}`,
- ` ${GRY}current: 2.1.61${RST} ${DIM}·${RST} ${GRY}latest: 2.1.61${RST}`,
-].join('\r\n');
-
-// OpenCode init banner
-const TERMINAL_OPENCODE = [
- '',
- ` ${GRN}████${RST} ${BOLD}OpenCode${RST} v0.3.12`,
- ` ${GRN}████${RST} ${GRY}GPT-4o${RST} ${DIM}·${RST} ${GRY}API Key${RST}`,
- ` ${GRN}██${RST} ${GRN}██${RST} ${GRY}~/my-project${RST}`,
- '',
- '',
- `${GRY}❯ Try "explain this codebase"${RST}`,
- '',
- '',
- `${YEL}»»${RST} ${BOLD}bypass permissions on${RST}`,
- ` ${BOLD}0 tokens${RST}`,
-].join('\r\n');
-
-// ─── Route Interceptors ──────────────────────────────────────────────────────
-
-async function setupRoutes(page, initPayload, terminalContent) {
- // Block SSE to prevent reconnection loops
- await page.route('**/api/events', async (route) => {
- await route.abort();
- });
-
- // Terminal buffer endpoint
- await page.route('**/api/sessions/*/terminal**', async (route) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- terminalBuffer: terminalContent,
- status: 'busy',
- fullSize: terminalContent.length,
- truncated: false,
- }),
- });
- });
-
- // Mux sessions
- 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: [], muxAvailable: true }),
- });
- });
-
- // Settings
- await page.route('**/api/settings', async (route) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({
- showSubagents: false,
- subagentTrackingEnabled: false,
- subagentActiveTabOnly: false,
- showMonitor: false,
- }),
- });
- });
-
- // Subagent window states
- await page.route('**/api/subagent-window-states', async (route) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({}),
- });
- });
-
- // Subagent parents
- 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: [] }),
- });
- });
-
- // Interactive attach
- await page.route('**/api/sessions/*/interactive', async (route) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ success: true }),
- });
- });
-
- // Resize
- await page.route('**/api/sessions/*/resize', async (route) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ success: true }),
- });
- });
-
- // Catch-all API
- await page.route('**/api/**', async (route) => {
- await route.fulfill({
- status: 200,
- contentType: 'application/json',
- body: JSON.stringify({ success: true }),
- });
- });
-}
-
-/**
- * Inject mock state and render the app.
- */
-async function injectState(page, initPayload, terminalContent, activeSessionId) {
- await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
- await sleep(2000);
-
- // Cancel SSE fallback timer and inject state
- await page.evaluate((payload) => {
- const app = window.app;
- if (app._initFallbackTimer) {
- clearTimeout(app._initFallbackTimer);
- app._initFallbackTimer = null;
- }
- app.handleInit(payload);
- }, initPayload);
-
- await sleep(3000);
-
- // Select the target session
- if (activeSessionId) {
- await page.evaluate((sid) => {
- window.app.activeSessionId = null;
- window.app.selectSession(sid);
- }, activeSessionId);
- await sleep(4000);
- }
-
- // Write terminal content directly as backup
- if (terminalContent && activeSessionId) {
- await page.evaluate((content) => {
- const app = window.app;
- if (app.terminal) {
- const buf = app.terminal.buffer?.active;
- const hasContent = buf && buf.length > 2 && buf.getLine(1)?.translateToString().trim();
- if (!hasContent) {
- app.terminal.clear();
- app.terminal.reset();
- app.terminal.write(content);
- app.terminal.scrollToBottom();
- }
- }
- }, terminalContent);
- await sleep(3000);
- }
-
- // Clean up UI artifacts
- await page.evaluate(() => {
- const app = window.app;
- if (app) {
- if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout);
- if (app.eventSource) { app.eventSource.close(); app.eventSource = null; }
- app._connectionStatus = 'connected';
- app._updateConnectionIndicator = () => {};
- app.connectSSE = () => {};
- app.setConnectionStatus = () => {};
- }
-
- // Remove connection indicator
- const indicator = document.getElementById('connectionIndicator');
- if (indicator) indicator.remove();
-
- // Hide respawn banner
- const respawnBanner = document.getElementById('respawnBanner');
- if (respawnBanner) respawnBanner.style.display = 'none';
-
- // Mock CPU/MEM stats
- const statsEl = document.getElementById('headerSystemStats');
- if (statsEl) {
- statsEl.innerHTML = `
- CPU
-
- 18%
- MEM
-
- 13.7G
- `;
- }
-
- // Hide subagents panel entirely
- const subagentsPanel = document.getElementById('subagentsPanel');
- if (subagentsPanel) subagentsPanel.style.display = 'none';
-
- // Close and hide monitor panel
- const monitorPanel = document.getElementById('monitorPanel');
- if (monitorPanel) {
- monitorPanel.classList.remove('open');
- monitorPanel.style.display = 'none';
- }
-
- // Hide monitor toggle button in toolbar
- const monitorBtn = document.getElementById('monitorToggle');
- if (monitorBtn) monitorBtn.style.display = 'none';
-
- // Update token display
- if (app && app.activeSessionId) {
- const session = app.sessions.get(app.activeSessionId);
- if (session) {
- const tokens = (session.tokens?.total || 0);
- const tokenEl = document.getElementById('headerTokens');
- if (tokenEl) {
- const formatted = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
- tokenEl.innerHTML = `⊙ ${formatted} tokens`;
- }
- }
- }
- });
-
- // Final settle — let xterm.js WebGL renderer, fonts, and layout fully stabilize
- await sleep(4000);
-}
-
-// ─── Screenshot Scenarios ────────────────────────────────────────────────────
-
-// 1. Desktop welcome — no sessions, welcome overlay visible
-async function captureDesktopWelcome(browser) {
- console.log('\n1/6 Capturing desktop-welcome.png...');
-
- const context = await browser.newContext({
- viewport: DESKTOP_VIEWPORT,
- deviceScaleFactor: 1,
- });
- const page = await context.newPage();
- page.setDefaultTimeout(30000);
-
- try {
- const initPayload = buildInitPayload([]);
- await setupRoutes(page, initPayload, '');
-
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
- await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 15000 });
- await sleep(2000);
-
- // Inject empty state — welcome overlay should show automatically
- await page.evaluate((payload) => {
- const app = window.app;
- if (app._initFallbackTimer) {
- clearTimeout(app._initFallbackTimer);
- app._initFallbackTimer = null;
- }
- app.handleInit(payload);
- }, initPayload);
- await sleep(3000);
-
- // Clean up UI
- await page.evaluate(() => {
- const app = window.app;
- if (app) {
- if (app.sseReconnectTimeout) clearTimeout(app.sseReconnectTimeout);
- if (app.eventSource) { app.eventSource.close(); app.eventSource = null; }
- app._connectionStatus = 'connected';
- app._updateConnectionIndicator = () => {};
- app.connectSSE = () => {};
- app.setConnectionStatus = () => {};
- }
- const indicator = document.getElementById('connectionIndicator');
- if (indicator) indicator.remove();
- const respawnBanner = document.getElementById('respawnBanner');
- if (respawnBanner) respawnBanner.style.display = 'none';
-
- // Mock stats
- const statsEl = document.getElementById('headerSystemStats');
- if (statsEl) {
- statsEl.innerHTML = `
- CPU
-
- 18%
- MEM
-
- 13.7G
- `;
- }
-
- // Hide monitor and subagent panels
- const subagentsPanel = document.getElementById('subagentsPanel');
- if (subagentsPanel) subagentsPanel.style.display = 'none';
- const monitorPanel = document.getElementById('monitorPanel');
- if (monitorPanel) {
- monitorPanel.classList.remove('open');
- monitorPanel.style.display = 'none';
- }
- const monitorBtn = document.getElementById('monitorToggle');
- if (monitorBtn) monitorBtn.style.display = 'none';
- });
- await sleep(3000);
-
- await page.screenshot({
- path: join(OUTPUT_DIR, 'desktop-welcome.png'),
- fullPage: false,
- });
- console.log(' Saved: scripts/remotion/public/desktop-welcome.png');
- } finally {
- await context.close();
- }
-}
-
-// 2. Desktop with single Claude tab active
-async function captureDesktopClaude(browser) {
- console.log('\n2/6 Capturing desktop-claude.png...');
-
- const claudeSession = makeSession('sess-claude-1', 'w1-my-project', 'claude', 'busy');
- const initPayload = buildInitPayload([claudeSession]);
-
- const context = await browser.newContext({
- viewport: DESKTOP_VIEWPORT,
- deviceScaleFactor: 1,
- });
- const page = await context.newPage();
- page.setDefaultTimeout(30000);
-
- try {
- await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
- await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-1');
-
- await page.screenshot({
- path: join(OUTPUT_DIR, 'desktop-claude.png'),
- fullPage: false,
- });
- console.log(' Saved: scripts/remotion/public/desktop-claude.png');
- } finally {
- await context.close();
- }
-}
-
-// 3. Desktop with 2 tabs (Claude + OpenCode), Claude active
-async function captureDesktopBothClaude(browser) {
- console.log('\n3/6 Capturing desktop-both-claude.png...');
-
- const claudeSession = makeSession('sess-claude-2', 'w1-my-project', 'claude', 'busy');
- const opencodeSession = makeSession('sess-opencode-2', 'w2-my-project', 'opencode', 'busy');
- const initPayload = buildInitPayload([claudeSession, opencodeSession]);
-
- const context = await browser.newContext({
- viewport: DESKTOP_VIEWPORT,
- deviceScaleFactor: 1,
- });
- const page = await context.newPage();
- page.setDefaultTimeout(30000);
-
- try {
- await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
- await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-2');
-
- await page.screenshot({
- path: join(OUTPUT_DIR, 'desktop-both-claude.png'),
- fullPage: false,
- });
- console.log(' Saved: scripts/remotion/public/desktop-both-claude.png');
- } finally {
- await context.close();
- }
-}
-
-// 4. Desktop with 2 tabs (Claude + OpenCode), OpenCode active
-async function captureDesktopBothOpencode(browser) {
- console.log('\n4/6 Capturing desktop-both-opencode.png...');
-
- const claudeSession = makeSession('sess-claude-3', 'w1-my-project', 'claude', 'busy');
- const opencodeSession = makeSession('sess-opencode-3', 'w2-my-project', 'opencode', 'busy');
- const initPayload = buildInitPayload([claudeSession, opencodeSession]);
-
- const context = await browser.newContext({
- viewport: DESKTOP_VIEWPORT,
- deviceScaleFactor: 1,
- });
- const page = await context.newPage();
- page.setDefaultTimeout(30000);
-
- try {
- await setupRoutes(page, initPayload, TERMINAL_OPENCODE);
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
- await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-3');
-
- await page.screenshot({
- path: join(OUTPUT_DIR, 'desktop-both-opencode.png'),
- fullPage: false,
- });
- console.log(' Saved: scripts/remotion/public/desktop-both-opencode.png');
- } finally {
- await context.close();
- }
-}
-
-// 5. Mobile with Claude tab
-async function captureMobileClaude(browser) {
- console.log('\n5/6 Capturing mobile-claude.png...');
-
- const claudeSession = makeSession('sess-claude-m1', 'w1-my-project', 'claude', 'busy');
- const initPayload = buildInitPayload([claudeSession]);
-
- const context = await browser.newContext({
- viewport: MOBILE_VIEWPORT,
- deviceScaleFactor: 2,
- });
- const page = await context.newPage();
- page.setDefaultTimeout(30000);
-
- try {
- await setupRoutes(page, initPayload, TERMINAL_CLAUDE);
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
- await injectState(page, initPayload, TERMINAL_CLAUDE, 'sess-claude-m1');
-
- await page.screenshot({
- path: join(OUTPUT_DIR, 'mobile-claude.png'),
- fullPage: false,
- });
- console.log(' Saved: scripts/remotion/public/mobile-claude.png');
- } finally {
- await context.close();
- }
-}
-
-// 6. Mobile with OpenCode tab
-async function captureMobileOpencode(browser) {
- console.log('\n6/6 Capturing mobile-opencode.png...');
-
- const opencodeSession = makeSession('sess-opencode-m1', 'w1-my-project', 'opencode', 'busy');
- const initPayload = buildInitPayload([opencodeSession]);
-
- const context = await browser.newContext({
- viewport: MOBILE_VIEWPORT,
- deviceScaleFactor: 2,
- });
- const page = await context.newPage();
- page.setDefaultTimeout(30000);
-
- try {
- await setupRoutes(page, initPayload, TERMINAL_OPENCODE);
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
- await injectState(page, initPayload, TERMINAL_OPENCODE, 'sess-opencode-m1');
-
- await page.screenshot({
- path: join(OUTPUT_DIR, 'mobile-opencode.png'),
- fullPage: false,
- });
- console.log(' Saved: scripts/remotion/public/mobile-opencode.png');
- } finally {
- await context.close();
- }
-}
-
-// ─── Main ────────────────────────────────────────────────────────────────────
-
-async function main() {
- console.log('='.repeat(60));
- console.log('Codeman Video Screenshot Capture');
- console.log('='.repeat(60));
- console.log(`Port: ${PORT}`);
- console.log(`Desktop: ${DESKTOP_VIEWPORT.width}x${DESKTOP_VIEWPORT.height}`);
- console.log(`Mobile: ${MOBILE_VIEWPORT.width}x${MOBILE_VIEWPORT.height} @2x`);
- console.log(`Output: ${OUTPUT_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',
- ],
- });
-
- await captureDesktopWelcome(browser);
- await captureDesktopClaude(browser);
- await captureDesktopBothClaude(browser);
- await captureDesktopBothOpencode(browser);
- await captureMobileClaude(browser);
- await captureMobileOpencode(browser);
-
- console.log('\n' + '='.repeat(60));
- console.log('All 6 screenshots captured!');
- console.log('='.repeat(60));
- console.log('\nOutput files:');
- console.log(' scripts/remotion/public/desktop-welcome.png');
- console.log(' scripts/remotion/public/desktop-claude.png');
- console.log(' scripts/remotion/public/desktop-both-claude.png');
- console.log(' scripts/remotion/public/desktop-both-opencode.png');
- console.log(' scripts/remotion/public/mobile-claude.png');
- console.log(' scripts/remotion/public/mobile-opencode.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.');
- }
-}
-
-process.on('SIGINT', () => {
- console.log('\nInterrupted.');
- process.exit(1);
-});
-
-main();
diff --git a/scripts/verify-session-sidebar.mts b/scripts/verify-session-sidebar.mts
deleted file mode 100644
index 6d31e5ff..00000000
--- a/scripts/verify-session-sidebar.mts
+++ /dev/null
@@ -1,179 +0,0 @@
-/**
- * Manual verification harness for the session-sidebar feature.
- *
- * Renders the real UI in headless Chromium against a testMode WebServer,
- * injects a synthetic 25-session fleet, and screenshots every layout state.
- * Not part of the automated suite — run it by hand:
- *
- * npx tsx scripts/verify-session-sidebar.mts
- *
- * SAFETY: uses the repo's own test harness (temp HOME, testMode server) on a
- * dedicated port. It never touches a real Codeman instance or tmux socket.
- */
-import { chromium } from 'playwright';
-import { WebServer } from '../src/web/server.js';
-import { mkdirSync } from 'node:fs';
-import { mkdtempSync } from 'node:fs';
-import { tmpdir } from 'node:os';
-import { join } from 'node:path';
-
-// Mirror test/setup.ts: isolate HOME before the app modules touch state.
-process.env.HOME = mkdtempSync(join(tmpdir(), 'codeman-sidebar-verify-'));
-process.env.VITEST = 'true';
-
-const PORT = 3299;
-const OUT = process.env.SIDEBAR_SHOTS_DIR ?? join(tmpdir(), 'codeman-sidebar-shots');
-mkdirSync(OUT, { recursive: true });
-
-// Generic on purpose: these names end up in the harness screenshots, so they
-// should not carry one contributor's project list into everyone else's review.
-// The mix of CLI modes matters (each renders a different badge); the names do not.
-const PROJECTS = [
- ['api-server', 'claude'],
- ['web-client', 'claude'],
- ['mobile-app', 'codex'],
- ['data-pipeline', 'claude'],
- ['shared-lib', 'gemini'],
- ['codeman', 'claude'],
- ['docs-site', 'claude'],
- ['batch-jobs', 'opencode'],
- ['search-index', 'claude'],
-];
-const STATUSES = ['idle', 'busy', 'idle', 'busy', 'error', 'idle'];
-
-function fleet(n: number) {
- const out: any[] = [];
- for (let i = 0; i < n; i++) {
- const [proj, mode] = PROJECTS[i % PROJECTS.length];
- const status = STATUSES[i % STATUSES.length];
- out.push({
- id: `sess-${String(i).padStart(4, '0')}-aaaa-bbbb-cccc-dddddddddddd`,
- pid: 10000 + i,
- status,
- workingDir: `${tmpdir()}/projects/${proj}`,
- name: `${proj}${i > 8 ? '-' + Math.floor(i / 9) : ''}`,
- mode,
- currentTaskId: null,
- createdAt: Date.now() - i * 60000,
- lastActivityAt: Date.now() - i * 1000,
- isWorking: status === 'busy',
- messageCount: i * 3,
- totalCost: 0,
- inputTokens: 0,
- outputTokens: 0,
- color: 'default',
- taskStats: { total: i % 4, running: i % 3 === 0 ? 2 : 0, completed: 0, failed: 0 },
- taskTree: [],
- tokens: { input: 0, output: 0, total: 0 },
- bufferStats: { terminalBufferSize: 0, textOutputSize: 0, messageCount: 0 },
- });
- }
- return out;
-}
-
-const SESSIONS = fleet(25);
-
-async function main() {
- const server = new WebServer(PORT, false, true);
- await server.start();
- const browser = await chromium.launch({ headless: true });
- const results: string[] = [];
-
- async function shot(
- name: string,
- opts: { layout: 'header' | 'sidebar'; collapsed?: boolean; width: number; height: number; touch?: boolean }
- ) {
- const ctx = await browser.newContext({
- viewport: { width: opts.width, height: opts.height },
- hasTouch: !!opts.touch,
- isMobile: !!opts.touch,
- deviceScaleFactor: 2,
- });
- const page = await ctx.newPage();
- const settings = JSON.stringify({ sessionListLayout: opts.layout });
- const collapsed = opts.collapsed === undefined ? null : opts.collapsed ? '1' : '0';
- await page.addInitScript(
- ([s, c]) => {
- localStorage.setItem('codeman-app-settings', s as string);
- localStorage.setItem('codeman-app-settings-mobile', s as string);
- if (c !== null) localStorage.setItem('codeman-sidebar-collapsed', c as string);
- else localStorage.removeItem('codeman-sidebar-collapsed');
- },
- [settings, collapsed]
- );
- await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
- await page.waitForTimeout(1500);
-
- await page.evaluate((list) => {
- const app = (window as any).app;
- if (!app) throw new Error('no window.app');
- app.sessions.clear();
- for (const s of list as any[]) app.sessions.set(s.id, s);
- // The renderer iterates sessionOrder, not the map.
- app.sessionOrder = (list as any[]).map((s) => s.id);
- app.activeSessionId = (list as any[])[3].id;
- // renderSessionTabs() is debounced; drive the immediate path directly.
- (app._fullRenderSessionTabs ?? app._renderSessionTabsImmediate)?.call(app);
- app.applySessionListLayout?.();
- }, SESSIONS as any);
- await page.waitForTimeout(600);
-
- const info = await page.evaluate(() => {
- const root = document.documentElement;
- const aside = document.getElementById('sessionSidebar');
- const tabsEl = document.getElementById('sessionTabs');
- const asideBox = aside?.getBoundingClientRect();
- const cs = aside ? getComputedStyle(aside) : null;
- return {
- dataSessionList: root.dataset.sessionList ?? null,
- dataSidebar: root.dataset.sidebar ?? null,
- rows: document.querySelectorAll('.session-tab').length,
- tabsParent: tabsEl?.parentElement?.id || tabsEl?.parentElement?.className || null,
- asideWidth: asideBox ? Math.round(asideBox.width) : null,
- asideVisible: cs ? cs.display !== 'none' && cs.visibility !== 'hidden' : null,
- asideInert: aside?.hasAttribute('inert') ?? null,
- ariaHidden: aside?.getAttribute('aria-hidden') ?? null,
- toggleAriaExpanded: document.getElementById('sidebarToggleBtn')?.getAttribute('aria-expanded') ?? null,
- firstRowText:
- (document.querySelector('.session-tab') as HTMLElement | null)?.innerText
- ?.trim()
- .replace(/\s+/g, ' ')
- .slice(0, 40) ?? null,
- listScrollable: (() => {
- const el = document.getElementById('sessionTabs');
- return el ? el.scrollHeight > el.clientHeight + 2 : null;
- })(),
- };
- });
-
- await page.waitForTimeout(400);
- const file = join(OUT, `${name}.png`);
- await page.screenshot({ path: file });
- results.push(`${name.padEnd(28)} ${JSON.stringify(info)}`);
- await ctx.close();
- return info;
- }
-
- await shot('01-header-desktop', { layout: 'header', width: 1600, height: 900 });
- await shot('02-sidebar-expanded', { layout: 'sidebar', collapsed: false, width: 1600, height: 900 });
- await shot('03-sidebar-collapsed-rail', { layout: 'sidebar', collapsed: true, width: 1600, height: 900 });
- await shot('04-sidebar-narrow-1000', { layout: 'sidebar', collapsed: true, width: 1000, height: 800 });
- await shot('05-sidebar-drawer-open-1000', { layout: 'sidebar', collapsed: false, width: 1000, height: 800 });
- await shot('06-sidebar-phone-closed', { layout: 'sidebar', collapsed: true, width: 393, height: 852, touch: true });
- await shot('07-sidebar-phone-open', { layout: 'sidebar', collapsed: false, width: 393, height: 852, touch: true });
-
- console.log('\n=== RESULTS ===');
- for (const r of results) console.log(r);
- console.log(`\nScreenshots in ${OUT}`);
-
- await browser.close();
- await server.stop();
-}
-
-main().then(
- () => process.exit(0),
- (e) => {
- console.error(e);
- process.exit(1);
- }
-);