diff --git a/CLAUDE.md b/CLAUDE.md index 161e32e9..b495b974 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1540 (must match `package.json` for npm publish) +**Version**: 0.1541 (must match `package.json` for npm publish) ## Project Overview diff --git a/docs/images/claude-overview.png b/docs/images/claude-overview.png index de471d67..d25e3b2d 100644 Binary files a/docs/images/claude-overview.png and b/docs/images/claude-overview.png differ diff --git a/docs/images/ralph-tracker-8tasks-44percent.png b/docs/images/ralph-tracker-8tasks-44percent.png index 52784b7d..deb41633 100644 Binary files a/docs/images/ralph-tracker-8tasks-44percent.png and b/docs/images/ralph-tracker-8tasks-44percent.png differ diff --git a/docs/images/subagent-spawn.png b/docs/images/subagent-spawn.png index 1c7e8e80..deb41633 100644 Binary files a/docs/images/subagent-spawn.png and b/docs/images/subagent-spawn.png differ diff --git a/docs/screenshots/multi-session-dashboard.png b/docs/screenshots/multi-session-dashboard.png index 36e301b1..efc39055 100644 Binary files a/docs/screenshots/multi-session-dashboard.png and b/docs/screenshots/multi-session-dashboard.png differ diff --git a/docs/screenshots/multi-session-monitor.png b/docs/screenshots/multi-session-monitor.png index 38f5487a..d25e3b2d 100644 Binary files a/docs/screenshots/multi-session-monitor.png and b/docs/screenshots/multi-session-monitor.png differ diff --git a/package.json b/package.json index ce646084..8477c5d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1540", + "version": "0.1541", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/scripts/capture-readme-screenshots.mjs b/scripts/capture-readme-screenshots.mjs new file mode 100644 index 00000000..0a08d3b2 --- /dev/null +++ b/scripts/capture-readme-screenshots.mjs @@ -0,0 +1,995 @@ +#!/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'); +const PORT = 3199; +const VIEWPORT = { width: 1280, height: 720 }; + +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/claudeman-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.14', + cliModel: 'Opus 4.5', + cliAccountType: 'Claude Max', + cliLatestVersion: '2.1.14', + 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 7-session set (matching existing screenshots) +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.w5, 'w5-testcase', 'claude', 'idle'), + makeSession(SESSION_IDS.s1, 's1-testcase', 'shell', 'busy'), + makeSession(SESSION_IDS.s2, 's2-testcase', 'shell', 'idle'), + makeSession(SESSION_IDS.s3, 's3-testcase', 'shell', 'idle'), +]; + +// 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.1540', + 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) +const TERMINAL_INIT = [ + '', + ` ${RED}████${RST} ${BOLD}Claude Code${RST} v2.1.14`, + ` ${RED}████${RST} ${GRY}Opus 4.5${RST} ${DIM}·${RST} ${GRY}Claude Max${RST}`, + ` ${RED}██${RST} ${RED}██${RST} ${GRY}~/claudeman-cases/testcase${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.14${RST} ${DIM}·${RST} ${GRY}latest: 2.1.14${RST}`, +].join('\r\n'); + +// Claude working output (for multi-session-dashboard / subagent-spawn) +const TERMINAL_WORKING = [ + '', + `${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/claudeman-cases/testcase/CLAUDE.md)`, + ` ${GRY}░${RST} Read ${BOLD}407${RST} lines`, + '', + `${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}`, + '', + '', + `────────────────────────────────────────────────────────────────────────────────────────────────────────────────`, + `${GRY}❯${RST} █`, + '', + `────────────────────────────────────────────────────────────────────────────────────────────────────────────────`, + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + ` ${BOLD}28163 tokens${RST}`, + ` ${GRY}current: 2.1.14${RST} ${DIM}·${RST} ${GRY}latest: 2.1.14${RST}`, +].join('\r\n'); + +// Ralph terminal content (matching ralph-tracker screenshot) +const TERMINAL_RALPH = [ + '', + `${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`, + '', + '', + `────────────────────────────────────────────────────────────────────────────────────────────────────────────────`, + `${GRY}❯${RST} █`, + '', + `────────────────────────────────────────────────────────────────────────────────────────────────────────────────`, + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + ` ${BOLD}37267 tokens${RST}`, + ` ${GRY}current: 2.1.14${RST} ${DIM}·${RST} ${GRY}latest: 2.1.14${RST}`, +].join('\r\n'); + +// 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/claudeman/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/claudeman/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/claudeman/test/respawn-test-utils.ts' }, timestamp: new Date().toISOString(), agentId: 'agent-002' }, + { type: 'tool', tool: 'Read', input: { file_path: '/home/arkon/claudeman/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 = [ + '', + `${GRN}●${RST} Working on ${CYN}/home/arkon/claudeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`, + '', + `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/claudeman-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}`, + '', + '', + ` ${YEL}»»${RST} ${BOLD}bypass permissions on${RST} ${GRY}(shift+tab to cycle)${RST}`, + ` ${GRY}current: 2.1.14${RST} ${DIM}·${RST} ${GRY}latest: 2.1.14${RST}`, +].join('\r\n'); + +// ─── Route Interceptors ────────────────────────────────────────────────────── + +async function setupRoutes(page, initPayload, terminalContent) { + // Intercept SSE endpoint — return init event then keep connection open + await page.route('**/api/events', async (route) => { + const sseData = `event: init\ndata: ${JSON.stringify(initPayload)}\n\n`; + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + body: sseData, + }); + }); + + // 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: 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, + }), + }); + }); + + // Subagents + await page.route('**/api/subagents', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: initPayload.subagents || [] }), + }); + }); + + // System stats + await page.route('**/api/system/stats', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ cpu: 24, memory: { usedMB: 16300, totalMB: 32000, percent: 51 } }), + }); + }); + + // Status (fallback for handleInit) + await page.route('**/api/status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(initPayload), + }); + }); + + // Cases + await page.route('**/api/cases**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ cases: [{ name: 'testcase', workingDir: '/home/arkon/claudeman-cases/testcase' }] }), + }); + }); + + // 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({}), + }); + }); + + // Token stats + await page.route('**/api/token-stats', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: {} }), + }); + }); + + // Execution model config + await page.route('**/api/execution/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + // Sessions list + await page.route('**/api/sessions', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(initPayload.sessions), + }); + }); + + // 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 other API endpoints + await page.route('**/api/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + }); +} + +// ─── Screenshot Scenarios ──────────────────────────────────────────────────── + +async function captureOverview(page) { + console.log('\n1/5 Capturing claude-overview.png...'); + + const initPayload = buildInitPayload(STANDARD_SESSIONS); + + await setupRoutes(page, initPayload, TERMINAL_INIT); + + await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await sleep(2000); + + // Configure settings for display + await page.evaluate(() => { + localStorage.setItem('claudeman-app-settings', JSON.stringify({ + showSubagents: true, + subagentTrackingEnabled: true, + subagentActiveTabOnly: false, + showMonitor: true, + })); + }); + + // Select the w4-testcase tab (matching overview screenshot) + await page.evaluate((sessionId) => { + if (window.app) { + window.app.selectSession(sessionId); + } + }, SESSION_IDS.w4); + await sleep(1000); + + // Open Monitor panel and populate mux sessions + await page.evaluate((muxSessions) => { + const app = window.app; + if (!app) return; + + // Set mux sessions data + app.muxSessions = muxSessions; + + // Open monitor panel + const panel = document.getElementById('monitorPanel'); + if (panel) { + panel.classList.add('open'); + // Render the mux sessions + app._renderMuxSessionsImmediate(); + } + }, MUX_SESSIONS); + await sleep(500); + + // Hide connection indicator (looks cleaner) + await page.evaluate(() => { + const indicator = document.getElementById('connectionIndicator'); + if (indicator) indicator.style.display = 'none'; + }); + + await page.screenshot({ + path: join(PROJECT_ROOT, 'docs', 'images', 'claude-overview.png'), + fullPage: false, + }); + console.log(' Saved: docs/images/claude-overview.png'); +} + +async function captureSubagentSpawn(page) { + console.log('\n2/5 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 sleep(2000); + + // Configure settings + await page.evaluate(() => { + localStorage.setItem('claudeman-app-settings', JSON.stringify({ + showSubagents: true, + subagentTrackingEnabled: true, + subagentActiveTabOnly: false, + showMonitor: false, + })); + }); + + // Select w1-testcase (the parent session with subagents) + await page.evaluate((sessionId) => { + if (window.app) window.app.selectSession(sessionId); + }, SESSION_IDS.w1); + await sleep(1000); + + // 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 (only the first two for cleaner screenshot) + app.openSubagentWindow('agent-001'); + app.openSubagentWindow('agent-002'); + }, SUBAGENT_ACTIVITY); + await sleep(1000); + + // Position windows nicely (matching existing screenshot layout) + await page.evaluate(() => { + const app = window.app; + if (!app) return; + + const windows = Array.from(app.subagentWindows.values()); + if (windows.length >= 2) { + // Position first window top-right area + const w1 = windows[0].element; + w1.style.left = '420px'; + w1.style.top = '40px'; + w1.style.width = '420px'; + w1.style.height = '320px'; + + // Position second window right side + const w2 = windows[1].element; + w2.style.left = '850px'; + w2.style.top = '40px'; + w2.style.width = '420px'; + w2.style.height = '320px'; + } + }); + await sleep(500); + + // Hide connection indicator + await page.evaluate(() => { + const indicator = document.getElementById('connectionIndicator'); + if (indicator) indicator.style.display = 'none'; + }); + + await page.screenshot({ + path: join(PROJECT_ROOT, 'docs', 'images', 'subagent-spawn.png'), + fullPage: false, + }); + console.log(' Saved: docs/images/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 sleep(2000); + + // Configure settings + await page.evaluate(() => { + localStorage.setItem('claudeman-app-settings', JSON.stringify({ + showSubagents: false, + subagentTrackingEnabled: false, + showMonitor: false, + })); + }); + + // Select ralph session + await page.evaluate((sessionId) => { + if (window.app) window.app.selectSession(sessionId); + }, RALPH_SESSION_ID); + await sleep(1000); + + // Ensure Ralph state panel is visible and expanded + await page.evaluate((sessionId) => { + const app = window.app; + if (!app) return; + + // Force the ralph state to be set + 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(500); + + // Hide connection indicator + await page.evaluate(() => { + const indicator = document.getElementById('connectionIndicator'); + if (indicator) indicator.style.display = 'none'; + }); + + await page.screenshot({ + path: join(PROJECT_ROOT, 'docs', 'images', 'ralph-tracker-8tasks-44percent.png'), + fullPage: false, + }); + console.log(' Saved: docs/images/ralph-tracker-8tasks-44percent.png'); +} + +async function captureMultiSessionDashboard(page) { + console.log('\n4/5 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 sleep(2000); + + // Select w3-testcase (matching existing screenshot) + await page.evaluate((sessionId) => { + if (window.app) window.app.selectSession(sessionId); + }, SESSION_IDS.w3); + await sleep(1000); + + // Hide connection indicator + await page.evaluate(() => { + const indicator = document.getElementById('connectionIndicator'); + if (indicator) indicator.style.display = 'none'; + }); + + await page.screenshot({ + path: join(PROJECT_ROOT, 'docs', 'screenshots', 'multi-session-dashboard.png'), + fullPage: false, + }); + console.log(' Saved: docs/screenshots/multi-session-dashboard.png'); +} + +async function captureMultiSessionMonitor(page) { + console.log('\n5/5 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 sleep(2000); + + // Select w4-testcase (matching existing screenshot) + await page.evaluate((sessionId) => { + if (window.app) window.app.selectSession(sessionId); + }, SESSION_IDS.w4); + await sleep(1000); + + // 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(500); + + // Hide connection indicator + await page.evaluate(() => { + const indicator = document.getElementById('connectionIndicator'); + if (indicator) indicator.style.display = 'none'; + }); + + await page.screenshot({ + path: join(PROJECT_ROOT, 'docs', 'screenshots', 'multi-session-monitor.png'), + fullPage: false, + }); + console.log(' Saved: docs/screenshots/multi-session-monitor.png'); +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log('='.repeat(60)); + console.log('Claudeman README Screenshot Capture'); + console.log('='.repeat(60)); + console.log(`Port: ${PORT} | Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`); + 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 + for (const scenario of [ + captureOverview, + captureSubagentSpawn, + captureRalphTracker, + captureMultiSessionDashboard, + captureMultiSessionMonitor, + ]) { + const context = await browser.newContext({ viewport: VIEWPORT }); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + + 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:'); + console.log(' docs/images/claude-overview.png'); + console.log(' docs/images/subagent-spawn.png'); + console.log(' docs/images/ralph-tracker-8tasks-44percent.png'); + console.log(' docs/screenshots/multi-session-dashboard.png'); + console.log(' docs/screenshots/multi-session-monitor.png'); + } catch (err) { + console.error('\nFatal error:', err.message); + console.error(err.stack); + process.exitCode = 1; + } finally { + if (browser) await browser.close().catch(() => {}); + server.close(); + console.log('\nDone.'); + } +} + +// Handle interrupts +process.on('SIGINT', () => { + console.log('\nInterrupted.'); + process.exit(1); +}); + +main(); diff --git a/src/web/public/app.js b/src/web/public/app.js index c319fbf8..594e11e2 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1454,8 +1454,8 @@ class ClaudemanApp { // No local echo — let PTY/Ink handle all character echoing. // Local echo was removed because it's fundamentally incompatible with Ink's - // terminal management (caused "typing visible below" bug where locally-echoed - // chars leaked onto Ink's status bar rows). + // terminal management (causes chars to leak onto Ink's status bar rows below the prompt, + // even when only echoing during idle state). // Accessibility: Focus trap for modals this.activeFocusTrap = null; @@ -4142,7 +4142,6 @@ class ClaudemanApp { } this.pendingWrites = []; this.writeFrameScheduled = false; - this.activeSessionId = sessionId; try { localStorage.setItem('claudeman-active-session', sessionId); } catch {} this.hideWelcome(); @@ -9431,7 +9430,7 @@ class ClaudemanApp { document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? true; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; - document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false; + document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? true; document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; @@ -9767,9 +9766,9 @@ class ClaudemanApp { showMonitor: false, showProjectInsights: false, showFileBrowser: false, - showSubagents: false, - // Feature toggles - disable tracking features on mobile - subagentTrackingEnabled: false, + showSubagents: true, + // Feature toggles - keep tracking on even on mobile + subagentTrackingEnabled: true, subagentActiveTabOnly: true, // Only show subagents for active tab imageWatcherEnabled: false, ralphTrackerEnabled: false, @@ -9870,7 +9869,7 @@ class ClaudemanApp { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); const showMonitor = settings.showMonitor ?? defaults.showMonitor ?? true; - const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? false; + const showSubagents = settings.showSubagents ?? defaults.showSubagents ?? true; const showFileBrowser = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; const monitorPanel = document.getElementById('monitorPanel'); diff --git a/test/screenshot-capture.test.ts b/test/screenshot-capture.test.ts new file mode 100644 index 00000000..1f7f384c --- /dev/null +++ b/test/screenshot-capture.test.ts @@ -0,0 +1,76 @@ +/** + * screenshot-capture.test.ts + * + * Verification test for the README screenshot capture script. + * Runs the capture script and verifies output files exist with valid dimensions. + * + * Port: 3199 (shared with capture script — not run simultaneously) + * + * Usage: RUN_SCREENSHOT_TESTS=1 npx vitest run test/screenshot-capture.test.ts + */ + +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { describe, it, expect, beforeAll } from 'vitest'; + +const PROJECT_ROOT = join(__dirname, '..'); + +const EXPECTED_SCREENSHOTS = [ + { path: 'docs/images/claude-overview.png', minSize: 10_000 }, + { path: 'docs/images/subagent-spawn.png', minSize: 10_000 }, + { path: 'docs/images/ralph-tracker-8tasks-44percent.png', minSize: 10_000 }, + { path: 'docs/screenshots/multi-session-dashboard.png', minSize: 10_000 }, + { path: 'docs/screenshots/multi-session-monitor.png', minSize: 10_000 }, +]; + +const EXPECTED_WIDTH = 1280; +const EXPECTED_HEIGHT = 720; + +/** + * Parse PNG IHDR chunk to extract width and height. + * PNG format: 8-byte signature, then chunks. + * IHDR is the first chunk: 4-byte length, 4-byte "IHDR", 4-byte width (BE), 4-byte height (BE). + * Width is at bytes 16-19, height at bytes 20-23. + */ +function parsePngDimensions(filePath: string): { width: number; height: number } { + const buf = readFileSync(filePath); + + // Verify PNG signature (first 8 bytes) + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (buf.subarray(0, 8).compare(pngSignature) !== 0) { + throw new Error(`Not a valid PNG file: ${filePath}`); + } + + // IHDR chunk: width at offset 16, height at offset 20 (big-endian uint32) + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + return { width, height }; +} + +describe.skipIf(!process.env.RUN_SCREENSHOT_TESTS)('Screenshot Capture', () => { + beforeAll(() => { + console.log('Running capture script... (this may take 15-30 seconds)'); + execSync('node scripts/capture-readme-screenshots.mjs', { + cwd: PROJECT_ROOT, + stdio: 'inherit', + timeout: 60_000, + }); + }, 90_000); // 90s timeout for beforeAll + + for (const screenshot of EXPECTED_SCREENSHOTS) { + const fullPath = join(PROJECT_ROOT, screenshot.path); + + it(`${screenshot.path} exists and is not empty`, () => { + expect(existsSync(fullPath)).toBe(true); + const stat = readFileSync(fullPath); + expect(stat.length).toBeGreaterThan(screenshot.minSize); + }); + + it(`${screenshot.path} has ${EXPECTED_WIDTH}x${EXPECTED_HEIGHT} dimensions`, () => { + const { width, height } = parsePngDimensions(fullPath); + expect(width).toBe(EXPECTED_WIDTH); + expect(height).toBe(EXPECTED_HEIGHT); + }); + } +});