Files
pbakaus_impeccable/source/skills/impeccable/scripts/live-server.mjs
T
Paul BakausandClaude Opus 4.7 37b8e8ba33 fix(live): multi-file inject, generated-file protection, and accept-flow correctness
Addresses every issue surfaced during hands-on live-mode testing.

## Injection across multi-page sites

- Config schema: `file` → `files: string[]` so multi-page static sites can
  opt into script-tag injection across every HTML entry the browser loads.
- `live-inject.mjs` loops the array, reports per-file results, and
  refuses silently with `config_invalid` if the schema is stale.
- `insertBefore` switched from first-match to last-match (lastIndexOf)
  so the anchor lands at the true close of `</body>`, not the first one
  embedded inside a `<pre><code>` documentation sample.

## Source-vs-generated detection

- New `is-generated.mjs` helper: gitignore check + generated-header
  markers. Edge-case `generatedFiles` config dropped — the two real
  signals cover every project shape we tested.
- `live-wrap.mjs` excludes generated files from auto-search and returns
  clear fallback errors: `file_is_generated`, `element_not_in_source`
  (with `generatedMatch` path), and `element_not_found`.
- `live-accept.mjs` refuses to persist into generated files; returns
  `mode: "fallback"` so the agent takes over via the Handle fallback
  flow.

## Accept correctness

- `extractVariant` / `extractOriginal` now skip `<style>` regions when
  matching markers. Previous regex substring match treated
  `@scope ([data-impeccable-variant="N"])` in CSS as the target HTML
  div, capturing garbage and producing orphan CSS that rendered as
  prose on the page.
- On accept, the chosen variant's content is wrapped in
  `<div data-impeccable-variant="N" style="display: contents">` so the
  carbonize block's `@scope` selectors keep matching. Users see the
  accepted design immediately; no pre-carbonize dead state.

## Browser-side UI

- `positionBar` gains a third case: when the selected element is taller
  than the viewport, pin the bar to a stable viewport anchor instead of
  teleporting between top and bottom as the user scrolls.
- No-HMR source-fetch path (`injectVariantsFromSource`) now calls
  `hideShaderOverlay()` on state transition to CYCLING. Previously the
  shader kept running after variants arrived via the fetch fallback.
- `pickVariantContent` helper replaces fragile `> :first-child`
  selection for outline positioning. Skips non-visual tags (style,
  script, link, meta, template) and falls back to the variant div
  itself when a variant contains multiple visual children.
- `resumeSession` re-captures and restarts the shader overlay when
  the page reloads mid-generation (Bun HTML HMR does a full reload
  and destroys the canvas).
- MutationObserver re-anchors `selectedElement` when the original
  element is detached by HMR, preventing zero-rect highlight drift.

## Skill docs

- `live.md` reframes `config.files` as "the HTML files the browser
  actually loads" and documents the regen-wipes-inject caveat for
  multi-page generator projects.
- New Handle fallback section covers the three wrap error shapes and
  how the agent should manually wrap for preview and commit to real
  source on accept.
- Handle accept documents the new `data-impeccable-variant` wrapper
  and the carbonize agent's duty to strip it.

## Prefetch feature (landed but disabled)

A `prefetch` event fires from the browser on first CONFIGURING per
route so the agent can pre-Read the source file before Go. Real latency
win in the linger-before-Go case but costs a harness round trip when
Go fires quickly. Disabled via a `PREFETCH_ENABLED = false` flag in
`live-browser.js`; server validator and skill dispatch stay so re-
enabling (with a browser-side debounce) is a one-line change.

## Harness guidance

Earlier skill rewrite compressed two load-bearing instructions:
- Restored prescriptive wording for "open the tab via Chrome MCP
  before the first poll" and the Claude Code background-poll policy.
- Flag-mapping for `live-wrap` rewritten as explicit bullets so models
  don't collapse `--element-id`/`--classes`/`--tag` into a single
  `--query` argument.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:30:27 -07:00

654 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Live variant mode server (self-contained, zero dependencies).
*
* Serves the browser script (/live.js), the detection overlay (/detect.js),
* uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
* browser→server events. Agent communicates via HTTP long-poll (/poll).
*
* Usage:
* node <scripts_path>/live-server.mjs # start
* node <scripts_path>/live-server.mjs stop # stop + remove injected live.js tag
* node <scripts_path>/live-server.mjs stop --keep-inject # stop only
* node <scripts_path>/live-server.mjs --help
*/
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import { parseDesignMd } from './design-parser.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PID file in the project root so both the server and agent can find it
// predictably (os.tmpdir() varies across platforms).
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
// ---------------------------------------------------------------------------
// Port detection
// ---------------------------------------------------------------------------
async function findOpenPort(start = 8400) {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(start, '127.0.0.1', () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
srv.on('error', () => resolve(findOpenPort(start + 1)));
});
}
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingPolls: [], // agent poll callbacks waiting for browser events
exitTimer: null,
sessionDir: null, // per-session tmp dir for annotation screenshots
};
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
}
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
for (const res of state.sseClients) {
try { res.write(data); } catch { /* client gone */ }
}
}
// ---------------------------------------------------------------------------
// Load scripts
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js)
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
}
const livePath = path.join(__dirname, 'live-browser.js');
let liveScript = '';
try {
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
}
return { detectScript, liveScript };
}
function hasProjectContext() {
// PRODUCT.md carries brand voice / anti-references — that's what determines
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
// concern, surfaced by the design panel's own empty state. Legacy
// .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
try {
fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
function statOrNull(filePath) {
try { return fs.statSync(filePath); } catch { return null; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
// Optional annotation fields (all-or-nothing: if any present, all must be well-formed).
if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string';
if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array';
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
case 'prefetch':
if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptWithToken }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const p = url.pathname;
// --- Scripts ---
if (p === '/live.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(liveScriptWithToken);
return;
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(detectScript);
return;
}
// --- Vendored modern-screenshot (UMD build) ---
// Lazy-loaded by live.js when the user clicks Go; exposes
// window.modernScreenshot.domToBlob(...) for capture.
if (p === '/modern-screenshot.js') {
const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js');
try {
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Cache-Control': 'public, max-age=31536000, immutable',
});
res.end(fs.readFileSync(vendorPath));
} catch {
res.writeHead(404); res.end('Vendor script not found');
}
return;
}
// --- Annotation upload (browser → server, raw PNG body) ---
// Client generates the eventId, POSTs the PNG, then POSTs the generate
// event with screenshotPath already set. Keeps bytes out of the SSE/poll
// bridge and preserves the "one shot from the user's POV" UX.
if (p === '/annotation' && req.method === 'POST') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const eventId = url.searchParams.get('eventId');
if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid eventId' }));
return;
}
if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') {
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Content-Type must be image/png' }));
return;
}
if (!state.sessionDir) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session dir unavailable' }));
return;
}
const chunks = [];
let total = 0;
let aborted = false;
req.on('data', (c) => {
if (aborted) return;
total += c.length;
if (total > MAX_ANNOTATION_BYTES) {
aborted = true;
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Payload too large' }));
req.destroy();
return;
}
chunks.push(c);
});
req.on('end', () => {
if (aborted) return;
const absPath = path.join(state.sessionDir, eventId + '.png');
try {
fs.writeFileSync(absPath, Buffer.concat(chunks));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Write failed: ' + err.message }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, path: absPath }));
});
req.on('error', () => {
if (!aborted) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Upload failed' }));
}
});
return;
}
// --- Health ---
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok', port: state.port, mode: 'variant',
hasProjectContext: hasProjectContext(),
connectedClients: state.sseClients.size,
}));
return;
}
// --- Design system sidecar + raw ---
// /design-system.json prefers DESIGN.json; falls back to parsed DESIGN.md
// returns { mode, model, mdNewerThanJson, ... }
// /design-system/raw returns DESIGN.md markdown verbatim
if (p === '/design-system.json' || p === '/design-system/raw') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const mdPath = path.join(process.cwd(), 'DESIGN.md');
const jsonPath = path.join(process.cwd(), 'DESIGN.json');
const mdStat = statOrNull(mdPath);
const jsonStat = statOrNull(jsonPath);
if (p === '/design-system/raw') {
if (!mdStat) { res.writeHead(404); res.end('Not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
res.end(fs.readFileSync(mdPath, 'utf-8'));
return;
}
if (!mdStat && !jsonStat) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: false }));
return;
}
// Prefer DESIGN.json — it's the richer source (live component HTML).
if (jsonStat) {
let model;
try {
model = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, error: 'Failed to parse DESIGN.json: ' + err.message }));
return;
}
const mdNewerThanJson = !!(mdStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, mode: 'sidecar', model, mdNewerThanJson }));
return;
}
// Fallback: DESIGN.md present but no sidecar. Panel shows a "basic mode"
// view + a CTA to run /impeccable document for the full visualization.
try {
const raw = fs.readFileSync(mdPath, 'utf-8');
const parsedMd = parseDesignMd(raw);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, mode: 'parsed-md', parsedMd }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, error: err.message }));
}
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(absPath, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
return;
}
// --- SSE: server→browser push (replaces WebSocket) ---
if (p === '/events' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
}) + '\n\n');
state.sseClients.add(res);
clearTimeout(state.exitTimer);
// Keepalive: SSE comment every 30s prevents silent connection drops.
const heartbeat = setInterval(() => {
try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); }
}, SSE_HEARTBEAT_INTERVAL);
req.on('close', () => {
clearInterval(heartbeat);
state.sseClients.delete(res);
if (state.sseClients.size === 0) {
clearTimeout(state.exitTimer);
state.exitTimer = setTimeout(() => {
if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
}, 8000);
}
});
return;
}
// --- Browser→server events (replaces WebSocket messages) ---
if (p === '/events' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const error = validateEvent(msg);
if (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
return;
}
// --- Stop ---
if (p === '/stop') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('stopping');
shutdown();
return;
}
// --- Agent poll ---
if (p === '/poll' && req.method === 'GET') {
handlePollGet(req, res, url);
return;
}
if (p === '/poll' && req.method === 'POST') {
handlePollPost(req, res);
return;
}
res.writeHead(404); res.end('Not found');
};
}
// ---------------------------------------------------------------------------
// Agent poll endpoints (unchanged from WS version)
// ---------------------------------------------------------------------------
function handlePollGet(req, res, url) {
const token = url.searchParams.get('token');
if (token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
return;
}
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
function resolve(event) {
clearTimeout(timer);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
});
}
function handlePollPost(req, res) {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
if (msg.token !== state.token) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
if (state.sessionDir) {
try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {}
}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-server.mjs [options]
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server (foreground)
stop Stop the server and remove the injected live.js script tag
stop --keep-inject Stop the server only (leave the script tag in the HTML entry)
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--keep-inject Only with stop: skip live-inject.mjs --remove
--help Show this help
Endpoints:
/live.js Browser script (element picker + variant cycling)
/detect.js Detection overlay (backwards compatible)
/modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js)
/annotation POST raw image/png to stage a variant screenshot
/events SSE stream (server→browser) + POST (browser→server)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/health Health check`);
process.exit(0);
}
if (args.includes('stop')) {
const keepInject = args.includes('--keep-inject');
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch {
console.log('No running live server found.');
}
if (!keepInject) {
const injectPath = path.join(__dirname, 'live-inject.mjs');
try {
const out = execFileSync(process.execPath, [injectPath, '--remove'], {
encoding: 'utf-8',
cwd: process.cwd(),
});
const line = out.trim().split('\n').filter(Boolean).pop();
if (line) {
try {
const j = JSON.parse(line);
if (j.removed === true) {
console.log(`Removed live script tag from ${j.file}.`);
}
} catch {
/* ignore non-JSON lines */
}
}
} catch (err) {
const detail = err.stderr?.toString?.().trim?.()
|| err.stdout?.toString?.().trim?.()
|| err.message
|| String(err);
console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`);
}
}
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
state.token = randomUUID();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
// Annotation screenshots live in the project root so the agent's Read tool
// doesn't trip a per-file permission prompt. Sessioned by token so concurrent
// projects (or quick restarts) don't collide.
const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations');
fs.mkdirSync(annotRoot, { recursive: true });
state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
const { detectScript, liveScript } = loadBrowserScripts();
const liveScriptWithToken =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
liveScript;
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
console.log(`Inject: <script src="${url}/live.js"><\/script>`);
console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
});
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);