2026-06-15 01:24:30 +02:00
|
|
|
|
#!/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 };
|
2026-07-20 01:28:53 +02:00
|
|
|
|
// 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;
|
2026-06-15 01:24:30 +02:00
|
|
|
|
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);
|
2026-07-20 01:28:53 +02:00
|
|
|
|
// Wait (up to ~45s) for live subagents to arrive via SSE into app.subagents.
|
2026-06-15 01:24:30 +02:00
|
|
|
|
let agents = [];
|
2026-07-20 01:28:53 +02:00
|
|
|
|
for (let i = 0; i < 45; i++) {
|
2026-06-15 01:24:30 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-20 01:28:53 +02:00
|
|
|
|
// 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
|
|
|
|
|
|
);
|
2026-06-15 01:24:30 +02:00
|
|
|
|
await page.evaluate(
|
|
|
|
|
|
(ids) => {
|
|
|
|
|
|
ids.slice(0, 2).forEach((id) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.app.openSubagentWindow(id);
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-07-20 01:28:53 +02:00
|
|
|
|
openIds
|
2026-06-15 01:24:30 +02:00
|
|
|
|
);
|
|
|
|
|
|
await sleep(2000);
|
|
|
|
|
|
await page.evaluate(() => {
|
2026-07-20 01:28:53 +02:00
|
|
|
|
// 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.
|
2026-06-15 01:24:30 +02:00
|
|
|
|
const wins = Array.from(window.app.subagentWindows.values());
|
2026-07-20 01:28:53 +02:00
|
|
|
|
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));
|
2026-06-15 01:24:30 +02:00
|
|
|
|
wins.slice(0, 2).forEach((win, i) => {
|
|
|
|
|
|
const el = win.element;
|
2026-07-20 01:28:53 +02:00
|
|
|
|
// 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';
|
2026-06-15 01:24:30 +02:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|