diff --git a/scripts/capture-readme-real.mjs b/scripts/capture-readme-real.mjs new file mode 100644 index 00000000..9427b93d --- /dev/null +++ b/scripts/capture-readme-real.mjs @@ -0,0 +1,201 @@ +#!/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 }; + 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 ~25s) for live subagents to arrive via SSE into app.subagents. + let agents = []; + for (let i = 0; i < 25; 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; + } + await page.evaluate( + (ids) => { + ids.slice(0, 2).forEach((id) => { + try { + window.app.openSubagentWindow(id); + } catch {} + }); + }, + agents.map((a) => a.id) + ); + await sleep(2000); + await page.evaluate(() => { + const wins = Array.from(window.app.subagentWindows.values()); + const place = [ + { left: 360, top: 60, w: 430, h: 330 }, + { left: 810, top: 60, w: 430, h: 330 }, + ]; + wins.slice(0, 2).forEach((win, i) => { + const el = win.element; + const p = place[i]; + el.style.left = p.left + 'px'; + el.style.top = p.top + 'px'; + el.style.width = p.w + 'px'; + el.style.height = p.h + 'px'; + }); + }); + 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-real-overview.mjs b/scripts/capture-real-overview.mjs index 3a5f6b14..05889dae 100644 --- a/scripts/capture-real-overview.mjs +++ b/scripts/capture-real-overview.mjs @@ -89,8 +89,13 @@ const main = async () => { [SKIN, PLAN_USAGE, FONT] ); - console.log(`Loading ${BASE} ...`); - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + // At DSF>1, xterm's WebGL renderer draws glyphs at ~2x (see DSF comment above). + // The app honors a `?nowebgl` URL param that switches to xterm's DOM renderer, + // which respects devicePixelRatio correctly — so DSF=2 + nowebgl yields a crisp + // 2x (retina) capture at the TRUE font size. Auto-enable it whenever DSF>1. + const url = DSF > 1 ? `${BASE}${BASE.includes('?') ? '&' : '?'}nowebgl` : BASE; + console.log(`Loading ${url} (DSF=${DSF}) ...`); + await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 }); await sleep(1500); diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 08041dda..acb8e1b8 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -30,7 +30,7 @@ import { watch, existsSync, FSWatcher } from 'node:fs'; import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { homedir } from 'node:os'; -import { join, basename } from 'node:path'; +import { join, basename, dirname } from 'node:path'; import { execFile } from 'node:child_process'; import { readFile, readdir, stat as statAsync } from 'node:fs/promises'; import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS, MAX_TRACKED_AGENTS } from './config/map-limits.js'; @@ -1181,6 +1181,10 @@ export class SubagentWatcher extends EventEmitter { for (const file of files) { if (file.endsWith('.jsonl')) { await this.registerAgentFile(join(dir, file), projectHash, sessionId, true); + } else if (file.endsWith('.meta.json')) { + // Claude Code (2026-06) writes a `agent-{id}.meta.json` sidecar for TUI + // Task subagents and no longer always writes a per-agent `.jsonl` here. + await this.registerAgentMeta(join(dir, file), projectHash, sessionId, true); } } } catch { @@ -1190,13 +1194,23 @@ export class SubagentWatcher extends EventEmitter { // Single directory watcher handles both new files and file content changes try { const watcher = watch(dir, (_eventType, filename) => { - if (!filename?.endsWith('.jsonl')) return; - const filePath = join(dir, filename); + const isJsonl = filename?.endsWith('.jsonl'); + const isMeta = filename?.endsWith('.meta.json'); + if (!isJsonl && !isMeta) return; + const filePath = join(dir, filename as string); // Debounce 100ms to batch rapid writes this.fileDeb.schedule(filePath, () => { if (!existsSync(filePath)) return; + if (isMeta) { + // Meta sidecar — discovery only (not a transcript; never tail it). + if (!this.fileAgentContext.has(filePath)) { + this.registerAgentMeta(filePath, projectHash, sessionId).catch(() => {}); + } + return; + } + if (this.fileAgentContext.has(filePath)) { // Known file — handle content change this.handleFileChange(filePath).catch(() => {}); // Ignore - errors logged internally, don't crash watcher callback @@ -1356,6 +1370,83 @@ export class SubagentWatcher extends EventEmitter { this.resetIdleTimer(agentId); } + /** + * Register a subagent discovered via its `agent-{id}.meta.json` sidecar. + * + * As of the 2026-06 Claude Code format change, TUI Task subagents write a + * `agent-{id}.meta.json` (`{ agentType, description, toolUseId }`) into the + * session's `subagents/` dir and no longer reliably write a per-agent + * `agent-{id}.jsonl` transcript there. The legacy `.jsonl`-only discovery + * therefore saw nothing ("0 tracked"); this surfaces the agent from the + * sidecar so it is tracked again. If a real `.jsonl` transcript exists + * alongside (older agents / workflow agents), defer to registerAgentFile — + * it's richer (live tool-call activity). + * + * Limitation: a meta-only agent has no transcript to tail, so there is no + * live tool-call feed and no completion signal in the sidecar — status is + * left 'active' and ages out via the normal idle timer / stale cleanup. + */ + private async registerAgentMeta( + metaPath: string, + projectHash: string, + sessionId: string, + isInitialScan: boolean = false + ): Promise { + if (this.fileAgentContext.has(metaPath)) return; + const agentId = basename(metaPath).replace('agent-', '').replace('.meta.json', ''); + if (this.agentInfo.has(agentId)) return; + + // Prefer a real transcript if one was written alongside the sidecar. + const jsonlPath = join(dirname(metaPath), `agent-${agentId}.jsonl`); + if (existsSync(jsonlPath)) { + await this.registerAgentFile(jsonlPath, projectHash, sessionId, isInitialScan); + return; + } + + let fileStat; + try { + fileStat = await statAsync(metaPath); + } catch { + return; // deleted between discovery and stat + } + if (isInitialScan && Date.now() - fileStat.mtime.getTime() > STARTUP_MAX_FILE_AGE_MS) { + return; // skip stale historical agents on startup + } + + let description: string | undefined; + try { + const meta = JSON.parse(await readFile(metaPath, 'utf8')) as { agentType?: string; description?: string }; + description = meta.description || meta.agentType; + } catch { + return; // unreadable / not yet fully written — a later watch event retries + } + if (this.isInternalAgent(description)) return; + + const info: SubagentInfo = { + agentId, + sessionId, + projectHash, + filePath: metaPath, + startedAt: fileStat.birthtime.toISOString(), + lastActivityAt: fileStat.mtime.getTime(), + status: 'active', + toolCallCount: 0, + entryCount: 0, + fileSize: fileStat.size, + description, + }; + + if (this.agentInfo.size >= MAX_TRACKED_AGENTS) { + const oldestId = this.findOldestInactiveAgent(); + if (oldestId) this.removeAgent(oldestId); + } + + this.fileAgentContext.set(metaPath, { projectHash, sessionId }); + this.agentInfo.set(agentId, info); + this.emit('subagent:discovered', info); + this.resetIdleTimer(agentId); + } + /** * Tail a file from a specific position */ diff --git a/test/subagent-watcher.test.ts b/test/subagent-watcher.test.ts index dca84d5c..61a7c53d 100644 --- a/test/subagent-watcher.test.ts +++ b/test/subagent-watcher.test.ts @@ -404,6 +404,77 @@ describe('SubagentWatcher', () => { }); }); + describe('Meta-sidecar discovery (2026-06 Claude Code format)', () => { + it('should discover a subagent from agent-{id}.meta.json when no .jsonl exists', async () => { + // New format: TUI Task subagents write only a meta sidecar (no per-agent .jsonl). + mockExistsSync.mockImplementation((p: string) => !String(p).endsWith('.jsonl')); + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-meta1.meta.json']; + if (path.includes('session1')) return ['subagents']; + if (path.includes('project1')) return ['session1']; + return ['project1']; + }); + mockStatSync.mockReturnValue({ + isDirectory: () => true, + birthtime: new Date(), + mtime: new Date(), + size: 120, + }); + mockReadFileSync.mockReturnValue( + JSON.stringify({ agentType: 'general-purpose', description: 'Audit server.js', toolUseId: 'toolu_x' }) + ); + + const discoveredHandler = vi.fn(); + watcher.on('subagent:discovered', discoveredHandler); + + watcher.start(); + await flushAsyncScan(); + + expect(discoveredHandler).toHaveBeenCalled(); + const info = discoveredHandler.mock.calls[0][0] as SubagentInfo; + expect(info.agentId).toBe('meta1'); + expect(info.description).toBe('Audit server.js'); + expect(info.status).toBe('active'); + expect(watcher.getSubagents()).toHaveLength(1); + }); + + it('should prefer the real .jsonl transcript when one exists alongside the meta', async () => { + // Both sidecar and transcript present → defer to the richer .jsonl path. + mockExistsSync.mockReturnValue(true); // sibling agent-both.jsonl exists + mockReaddirSync.mockImplementation((path: string) => { + if (path.includes('subagents')) return ['agent-both.meta.json']; + if (path.includes('session1')) return ['subagents']; + if (path.includes('project1')) return ['session1']; + return ['project1']; + }); + mockStatSync.mockReturnValue({ + isDirectory: () => true, + birthtime: new Date(), + mtime: new Date(), + size: 100, + }); + // Mirror the passing discovery tests: leave createReadStream unmocked so + // _resolveDescription fails gracefully (undefined) and discovery still fires. + mockReadFileSync.mockReturnValue(''); + const mockRl = createMockRl(); + mockCreateInterface.mockReturnValue(mockRl); + + const discoveredHandler = vi.fn(); + watcher.on('subagent:discovered', discoveredHandler); + + watcher.start(); + await flushAsyncScan(); + mockRl.emit('close'); + await vi.advanceTimersByTimeAsync(100); + + expect(discoveredHandler).toHaveBeenCalled(); + const info = discoveredHandler.mock.calls[0][0] as SubagentInfo; + expect(info.agentId).toBe('both'); + // filePath points at the transcript, not the sidecar. + expect(info.filePath.endsWith('.jsonl')).toBe(true); + }); + }); + describe('Status Lifecycle', () => { it('should start agents as active', async () => { const mockRl = createMockRl();