live-server.mjs

  1#!/usr/bin/env node
  2/**
  3 * Live variant mode server (self-contained, zero dependencies).
  4 *
  5 * Serves the browser script (/live.js), the detection overlay (/detect.js),
  6 * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
  7 * browser→server events. Agent communicates via HTTP long-poll (/poll).
  8 *
  9 * Usage:
 10 *   node <scripts_path>/live-server.mjs              # start
 11 *   node <scripts_path>/live-server.mjs stop         # stop + remove injected live.js tag
 12 *   node <scripts_path>/live-server.mjs stop --keep-inject   # stop only
 13 *   node <scripts_path>/live-server.mjs --help
 14 */
 15
 16import http from 'node:http';
 17import { randomUUID } from 'node:crypto';
 18import { spawn, execFileSync } from 'node:child_process';
 19import fs from 'node:fs';
 20import path from 'node:path';
 21import net from 'node:net';
 22import { fileURLToPath } from 'node:url';
 23import { parseDesignMd } from './design-parser.mjs';
 24import { resolveContextDir } from './load-context.mjs';
 25import { createLiveSessionStore } from './live-session-store.mjs';
 26import {
 27  getDesignSidecarPath,
 28  getLiveAnnotationsDir,
 29  readLiveServerInfo,
 30  removeLiveServerInfo,
 31  resolveDesignSidecarPath,
 32  writeLiveServerInfo,
 33} from './impeccable-paths.mjs';
 34
 35const __dirname = path.dirname(fileURLToPath(import.meta.url));
 36// PRODUCT.md / DESIGN.md live wherever load-context.mjs resolves. The generated
 37// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
 38// DESIGN.json fallback for existing projects.
 39const CONTEXT_DIR = resolveContextDir(process.cwd());
 40const DEFAULT_POLL_TIMEOUT = 600_000;   // 10 min — agent re-polls on timeout anyway
 41const SSE_HEARTBEAT_INTERVAL = 30_000;  // keepalive ping every 30s
 42
 43// ---------------------------------------------------------------------------
 44// Port detection
 45// ---------------------------------------------------------------------------
 46
 47async function findOpenPort(start = 8400) {
 48  return new Promise((resolve) => {
 49    const srv = net.createServer();
 50    srv.listen(start, '127.0.0.1', () => {
 51      const port = srv.address().port;
 52      srv.close(() => resolve(port));
 53    });
 54    srv.on('error', () => resolve(findOpenPort(start + 1)));
 55  });
 56}
 57
 58// ---------------------------------------------------------------------------
 59// Session state
 60// ---------------------------------------------------------------------------
 61
 62const state = {
 63  token: null,
 64  port: null,
 65  sseClients: new Set(),   // SSE response objects (server→browser push)
 66  pendingEvents: [],        // browser events waiting for agent ack ({ event, leaseUntil })
 67  pendingPolls: [],         // agent poll callbacks waiting for browser events
 68  exitTimer: null,
 69  sessionDir: null,         // per-session tmp dir for annotation screenshots
 70  sessionStore: null,
 71  leaseTimer: null,
 72};
 73
 74// Cap per-annotation upload size. A full 1920Ă—1080 PNG is typically <1 MB;
 75// cap at 10 MB to guard against runaway writes from a misbehaving client.
 76const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
 77
 78function enqueueEvent(event) {
 79  if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
 80  state.pendingEvents.push({ event, leaseUntil: 0 });
 81  flushPendingPolls();
 82}
 83
 84function restorePendingEventsFromStore() {
 85  if (!state.sessionStore) return;
 86  for (const snapshot of state.sessionStore.listActiveSessions()) {
 87    if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
 88  }
 89}
 90
 91function findAvailablePendingEvent(now = Date.now()) {
 92  return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
 93}
 94
 95function leaseEvent(entry, leaseMs) {
 96  if (!entry.event?.id) {
 97    const idx = state.pendingEvents.indexOf(entry);
 98    if (idx !== -1) state.pendingEvents.splice(idx, 1);
 99    return entry.event;
100  }
101  entry.leaseUntil = Date.now() + leaseMs;
102  return entry.event;
103}
104
105function acknowledgePendingEvent(id) {
106  if (!id) return false;
107  const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
108  if (idx === -1) return false;
109  state.pendingEvents.splice(idx, 1);
110  scheduleLeaseFlush();
111  return true;
112}
113
114function scheduleLeaseFlush() {
115  if (state.leaseTimer) {
116    clearTimeout(state.leaseTimer);
117    state.leaseTimer = null;
118  }
119  if (state.pendingPolls.length === 0) return;
120  const now = Date.now();
121  const nextLeaseUntil = state.pendingEvents
122    .map((entry) => entry.leaseUntil || 0)
123    .filter((leaseUntil) => leaseUntil > now)
124    .sort((a, b) => a - b)[0];
125  if (!nextLeaseUntil) return;
126  state.leaseTimer = setTimeout(() => {
127    state.leaseTimer = null;
128    flushPendingPolls();
129  }, Math.max(0, nextLeaseUntil - now));
130}
131
132function flushPendingPolls() {
133  while (state.pendingPolls.length > 0) {
134    const entry = findAvailablePendingEvent();
135    if (!entry) {
136      scheduleLeaseFlush();
137      return;
138    }
139    const poll = state.pendingPolls.shift();
140    poll.resolve(leaseEvent(entry, poll.leaseMs));
141  }
142  scheduleLeaseFlush();
143}
144
145/** Push a message to all connected SSE clients. */
146function broadcast(msg) {
147  const data = 'data: ' + JSON.stringify(msg) + '\n\n';
148  for (const res of state.sseClients) {
149    try { res.write(data); } catch { /* client gone */ }
150  }
151}
152
153// ---------------------------------------------------------------------------
154// Load scripts
155// ---------------------------------------------------------------------------
156
157function loadBrowserScripts() {
158  // Detection script: prefer the skill-bundled detector, then fall back to
159  // source/npm package locations for local development and older installs.
160  // This one IS cached — detect.js rarely changes during a session.
161  const detectPaths = [
162    path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
163    path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
164    path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
165    path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
166  ];
167  let detectScript = '';
168  for (const p of detectPaths) {
169    try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
170  }
171
172  // live-browser.js: DO NOT cache. Return the path so the /live.js handler
173  // can re-read on every request. Editing the browser script during iteration
174  // should land on the next tab reload, not require a server restart.
175  const sessionPath = path.join(__dirname, 'live-browser-session.js');
176  const livePath = path.join(__dirname, 'live-browser.js');
177  for (const p of [sessionPath, livePath]) {
178    if (!fs.existsSync(p)) {
179      process.stderr.write('Error: live browser script not found at ' + p + '\n');
180      process.exit(1);
181    }
182  }
183
184  return { detectScript, sessionPath, livePath };
185}
186
187function hasProjectContext() {
188  // PRODUCT.md carries brand voice / anti-references — that's what determines
189  // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
190  // concern, surfaced by the design panel's own empty state. Legacy
191  // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
192  try {
193    fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
194    return true;
195  } catch { return false; }
196}
197
198function statOrNull(filePath) {
199  try { return fs.statSync(filePath); } catch { return null; }
200}
201
202// ---------------------------------------------------------------------------
203// Validation (inline — no external import needed for self-contained script)
204// ---------------------------------------------------------------------------
205
206const VISUAL_ACTIONS = [
207  'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
208  'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
209];
210
211// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
212// and variantIds via String(small integer). Restrict to those shapes so
213// any value that reaches a downstream child_process or DOM selector is
214// inert by construction.
215const ID_PATTERN = /^[0-9a-f]{8}$/;
216const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
217
218function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
219function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
220
221function validateEvent(msg) {
222  if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
223  switch (msg.type) {
224    case 'generate':
225      if (!isValidId(msg.id)) return 'generate: missing or malformed id';
226      if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
227      if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
228      if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
229      // Optional annotation fields (all-or-nothing: if any present, all must be well-formed).
230      if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string';
231      if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array';
232      if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
233      return null;
234    case 'accept':
235      if (!isValidId(msg.id)) return 'accept: missing or malformed id';
236      if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
237      if (msg.paramValues !== undefined) {
238        if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
239          return 'accept: paramValues must be an object';
240        }
241      }
242      return null;
243    case 'discard':
244      return isValidId(msg.id) ? null : 'discard: missing or malformed id';
245    case 'checkpoint':
246      if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
247      if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
248      if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
249        return 'checkpoint: paramValues must be an object';
250      }
251      return null;
252    case 'exit':
253      return null;
254    case 'prefetch':
255      if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
256      return null;
257    default:
258      return 'Unknown event type: ' + msg.type;
259  }
260}
261
262// ---------------------------------------------------------------------------
263// HTTP request handler
264// ---------------------------------------------------------------------------
265
266function createRequestHandler({ detectScript, sessionPath, livePath }) {
267  return (req, res) => {
268    const url = new URL(req.url, `http://localhost:${state.port}`);
269    res.setHeader('Access-Control-Allow-Origin', '*');
270    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
271    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
272    if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
273
274    const p = url.pathname;
275
276    // --- Scripts ---
277    if (p === '/live.js') {
278      // Re-read from disk each request so edits to live-browser.js land on
279      // the next tab reload. No-store headers prevent browser caching across
280      // sessions — during iteration, a cached old script silently breaks
281      // every subsequent session.
282      let sessionScript;
283      let liveScript;
284      try {
285        sessionScript = fs.readFileSync(sessionPath, 'utf-8');
286        liveScript = fs.readFileSync(livePath, 'utf-8');
287      } catch (err) {
288        res.writeHead(500, { 'Content-Type': 'text/plain' });
289        res.end('Error reading live browser scripts: ' + err.message);
290        return;
291      }
292      const body =
293        `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
294        `window.__IMPECCABLE_PORT__ = ${state.port};\n` +
295        sessionScript + '\n' +
296        liveScript;
297      res.writeHead(200, {
298        'Content-Type': 'application/javascript',
299        'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
300        'Pragma': 'no-cache',
301      });
302      res.end(body);
303      return;
304    }
305    if (p === '/detect.js' || p === '/') {
306      if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
307      res.writeHead(200, { 'Content-Type': 'application/javascript' });
308      res.end(detectScript);
309      return;
310    }
311
312    // --- Vendored modern-screenshot (UMD build) ---
313    // Lazy-loaded by live.js when the user clicks Go; exposes
314    // window.modernScreenshot.domToBlob(...) for capture.
315    if (p === '/modern-screenshot.js') {
316      const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js');
317      try {
318        res.writeHead(200, {
319          'Content-Type': 'application/javascript',
320          'Cache-Control': 'public, max-age=31536000, immutable',
321        });
322        res.end(fs.readFileSync(vendorPath));
323      } catch {
324        res.writeHead(404); res.end('Vendor script not found');
325      }
326      return;
327    }
328
329    // --- Annotation upload (browser → server, raw PNG body) ---
330    // Client generates the eventId, POSTs the PNG, then POSTs the generate
331    // event with screenshotPath already set. Keeps bytes out of the SSE/poll
332    // bridge and preserves the "one shot from the user's POV" UX.
333    if (p === '/annotation' && req.method === 'POST') {
334      const token = url.searchParams.get('token');
335      if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
336      const eventId = url.searchParams.get('eventId');
337      if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) {
338        res.writeHead(400, { 'Content-Type': 'application/json' });
339        res.end(JSON.stringify({ error: 'Invalid eventId' }));
340        return;
341      }
342      if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') {
343        res.writeHead(415, { 'Content-Type': 'application/json' });
344        res.end(JSON.stringify({ error: 'Content-Type must be image/png' }));
345        return;
346      }
347      if (!state.sessionDir) {
348        res.writeHead(500, { 'Content-Type': 'application/json' });
349        res.end(JSON.stringify({ error: 'Session dir unavailable' }));
350        return;
351      }
352      const chunks = [];
353      let total = 0;
354      let aborted = false;
355      req.on('data', (c) => {
356        if (aborted) return;
357        total += c.length;
358        if (total > MAX_ANNOTATION_BYTES) {
359          aborted = true;
360          res.writeHead(413, { 'Content-Type': 'application/json' });
361          res.end(JSON.stringify({ error: 'Payload too large' }));
362          req.destroy();
363          return;
364        }
365        chunks.push(c);
366      });
367      req.on('end', () => {
368        if (aborted) return;
369        const absPath = path.join(state.sessionDir, eventId + '.png');
370        try {
371          fs.writeFileSync(absPath, Buffer.concat(chunks));
372        } catch (err) {
373          res.writeHead(500, { 'Content-Type': 'application/json' });
374          res.end(JSON.stringify({ error: 'Write failed: ' + err.message }));
375          return;
376        }
377        res.writeHead(200, { 'Content-Type': 'application/json' });
378        res.end(JSON.stringify({ ok: true, path: absPath }));
379      });
380      req.on('error', () => {
381        if (!aborted) {
382          res.writeHead(500, { 'Content-Type': 'application/json' });
383          res.end(JSON.stringify({ error: 'Upload failed' }));
384        }
385      });
386      return;
387    }
388
389    // --- Health ---
390    if (p === '/status') {
391      const token = url.searchParams.get('token');
392      if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
393      const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
394      res.writeHead(200, { 'Content-Type': 'application/json' });
395      res.end(JSON.stringify({
396        status: 'ok',
397        port: state.port,
398        connectedClients: state.sseClients.size,
399        pendingEvents: state.pendingEvents.map((entry) => ({
400          id: entry.event?.id,
401          type: entry.event?.type,
402          leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
403          leaseUntil: entry.leaseUntil || null,
404        })),
405        activeSessions: sessions,
406      }));
407      return;
408    }
409
410    if (p === '/health') {
411      res.writeHead(200, { 'Content-Type': 'application/json' });
412      res.end(JSON.stringify({
413        status: 'ok', port: state.port, mode: 'variant',
414        hasProjectContext: hasProjectContext(),
415        connectedClients: state.sseClients.size,
416      }));
417      return;
418    }
419
420    // --- Design system (unified v2 response) + raw ---
421    //   /design-system.json    returns both parsed DESIGN.md and .impeccable/design.json
422    //                          sidecar when present. Panel merges them:
423    //                            { present, parsed, sidecar, hasMd, hasSidecar,
424    //                              mdNewerThanJson, parseError?, sidecarError? }
425    //                          - parsed: output of parseDesignMd (frontmatter
426    //                            + six canonical sections) when DESIGN.md exists.
427    //                          - sidecar: .impeccable/design.json contents when present.
428    //                            Expected shape: schemaVersion 2, carrying
429    //                            extensions + components + narrative.
430    //   /design-system/raw     returns DESIGN.md markdown verbatim
431    if (p === '/design-system.json' || p === '/design-system/raw') {
432      const token = url.searchParams.get('token');
433      if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
434
435      const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
436      const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
437      const mdStat = statOrNull(mdPath);
438      const jsonStat = statOrNull(jsonPath);
439
440      if (p === '/design-system/raw') {
441        if (!mdStat) { res.writeHead(404); res.end('Not found'); return; }
442        res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
443        res.end(fs.readFileSync(mdPath, 'utf-8'));
444        return;
445      }
446
447      if (!mdStat && !jsonStat) {
448        res.writeHead(404, { 'Content-Type': 'application/json' });
449        res.end(JSON.stringify({ present: false }));
450        return;
451      }
452
453      const response = {
454        present: true,
455        hasMd: !!mdStat,
456        hasSidecar: !!jsonStat,
457        mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000),
458      };
459
460      if (mdStat) {
461        try {
462          response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8'));
463        } catch (err) {
464          response.parseError = err.message;
465        }
466      }
467
468      if (jsonStat) {
469        try {
470          response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
471        } catch (err) {
472          response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
473        }
474      }
475
476      res.writeHead(200, { 'Content-Type': 'application/json' });
477      res.end(JSON.stringify(response));
478      return;
479    }
480
481    // --- Source file (no-HMR fallback) ---
482    if (p === '/source') {
483      const token = url.searchParams.get('token');
484      if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
485      const filePath = url.searchParams.get('path');
486      if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
487      const absPath = path.resolve(process.cwd(), filePath);
488      if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
489      let content;
490      try { content = fs.readFileSync(absPath, 'utf-8'); }
491      catch { res.writeHead(404); res.end('File not found'); return; }
492      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
493      res.end(content);
494      return;
495    }
496
497    // --- SSE: server→browser push (replaces WebSocket) ---
498    if (p === '/events' && req.method === 'GET') {
499      const token = url.searchParams.get('token');
500      if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
501      res.writeHead(200, {
502        'Content-Type': 'text/event-stream',
503        'Cache-Control': 'no-cache',
504        'Connection': 'keep-alive',
505      });
506      res.write('data: ' + JSON.stringify({
507        type: 'connected',
508        hasProjectContext: hasProjectContext(),
509      }) + '\n\n');
510
511      state.sseClients.add(res);
512      clearTimeout(state.exitTimer);
513
514      // Keepalive: SSE comment every 30s prevents silent connection drops.
515      const heartbeat = setInterval(() => {
516        try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); }
517      }, SSE_HEARTBEAT_INTERVAL);
518
519      req.on('close', () => {
520        clearInterval(heartbeat);
521        state.sseClients.delete(res);
522        if (state.sseClients.size === 0) {
523          clearTimeout(state.exitTimer);
524          state.exitTimer = setTimeout(() => {
525            if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
526          }, 8000);
527        }
528      });
529      return;
530    }
531
532    // --- Browser→server events (replaces WebSocket messages) ---
533    if (p === '/events' && req.method === 'POST') {
534      let body = '';
535      req.on('data', (c) => { body += c; });
536      req.on('end', () => {
537        let msg;
538        try { msg = JSON.parse(body); } catch {
539          res.writeHead(400, { 'Content-Type': 'application/json' });
540          res.end(JSON.stringify({ error: 'Invalid JSON' }));
541          return;
542        }
543        if (msg.token !== state.token) {
544          res.writeHead(401, { 'Content-Type': 'application/json' });
545          res.end(JSON.stringify({ error: 'Unauthorized' }));
546          return;
547        }
548        const error = validateEvent(msg);
549        if (error) {
550          res.writeHead(400, { 'Content-Type': 'application/json' });
551          res.end(JSON.stringify({ error }));
552          return;
553        }
554        if (state.sessionStore && msg.id) {
555          try {
556            state.sessionStore.appendEvent(msg);
557          } catch (err) {
558            res.writeHead(500, { 'Content-Type': 'application/json' });
559            res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
560            return;
561          }
562        }
563        if (msg.type !== 'checkpoint') enqueueEvent(msg);
564        res.writeHead(200, { 'Content-Type': 'application/json' });
565        res.end(JSON.stringify({ ok: true }));
566      });
567      return;
568    }
569
570    // --- Stop ---
571    if (p === '/stop') {
572      const token = url.searchParams.get('token');
573      if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
574      res.writeHead(200, { 'Content-Type': 'text/plain' });
575      res.end('stopping');
576      shutdown();
577      return;
578    }
579
580    // --- Agent poll ---
581    if (p === '/poll' && req.method === 'GET') {
582      handlePollGet(req, res, url);
583      return;
584    }
585    if (p === '/poll' && req.method === 'POST') {
586      handlePollPost(req, res);
587      return;
588    }
589
590    res.writeHead(404); res.end('Not found');
591  };
592}
593
594// ---------------------------------------------------------------------------
595// Agent poll endpoints (unchanged from WS version)
596// ---------------------------------------------------------------------------
597
598function handlePollGet(req, res, url) {
599  const token = url.searchParams.get('token');
600  if (token !== state.token) {
601    res.writeHead(401, { 'Content-Type': 'application/json' });
602    res.end(JSON.stringify({ error: 'Unauthorized' }));
603    return;
604  }
605  const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
606  const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
607  const available = findAvailablePendingEvent();
608  if (available) {
609    res.writeHead(200, { 'Content-Type': 'application/json' });
610    res.end(JSON.stringify(leaseEvent(available, leaseMs)));
611    return;
612  }
613  const poll = { resolve, leaseMs };
614  const timer = setTimeout(() => {
615    const idx = state.pendingPolls.indexOf(poll);
616    if (idx !== -1) state.pendingPolls.splice(idx, 1);
617    res.writeHead(200, { 'Content-Type': 'application/json' });
618    res.end(JSON.stringify({ type: 'timeout' }));
619  }, timeout);
620  function resolve(event) {
621    clearTimeout(timer);
622    res.writeHead(200, { 'Content-Type': 'application/json' });
623    res.end(JSON.stringify(event));
624  }
625  state.pendingPolls.push(poll);
626  scheduleLeaseFlush();
627  req.on('close', () => {
628    clearTimeout(timer);
629    const idx = state.pendingPolls.indexOf(poll);
630    if (idx !== -1) state.pendingPolls.splice(idx, 1);
631  });
632}
633
634function handlePollPost(req, res) {
635  let body = '';
636  req.on('data', (c) => { body += c; });
637  req.on('end', () => {
638    let msg;
639    try { msg = JSON.parse(body); } catch {
640      res.writeHead(400, { 'Content-Type': 'application/json' });
641      res.end(JSON.stringify({ error: 'Invalid JSON' }));
642      return;
643    }
644    if (msg.token !== state.token) {
645      res.writeHead(401, { 'Content-Type': 'application/json' });
646      res.end(JSON.stringify({ error: 'Unauthorized' }));
647      return;
648    }
649    acknowledgePendingEvent(msg.id);
650    if (state.sessionStore && msg.id) {
651      try {
652        const eventType = msg.type === 'discard' || msg.type === 'discarded'
653          ? 'discarded'
654          : msg.type === 'complete'
655            ? 'complete'
656            : msg.type === 'error'
657              ? 'agent_error'
658              : 'agent_done';
659        state.sessionStore.appendEvent({
660          type: eventType,
661          id: msg.id,
662          file: msg.file,
663          message: msg.message,
664          carbonize: msg.data?.carbonize === true,
665        });
666      } catch { /* keep reply path best-effort; browser still needs SSE */ }
667    }
668    flushPendingPolls();
669    // Forward the reply to the browser via SSE
670    broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
671    res.writeHead(200, { 'Content-Type': 'application/json' });
672    res.end(JSON.stringify({ ok: true }));
673  });
674}
675
676// ---------------------------------------------------------------------------
677// Lifecycle
678// ---------------------------------------------------------------------------
679
680let httpServer = null;
681
682function shutdown() {
683  removeLiveServerInfo(process.cwd());
684  if (state.leaseTimer) clearTimeout(state.leaseTimer);
685  state.leaseTimer = null;
686  if (state.sessionDir) {
687    try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {}
688  }
689  for (const res of state.sseClients) { try { res.end(); } catch {} }
690  state.sseClients.clear();
691  for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
692  state.pendingPolls.length = 0;
693  if (httpServer) httpServer.close();
694  process.exit(0);
695}
696
697// ---------------------------------------------------------------------------
698// Main
699// ---------------------------------------------------------------------------
700
701const args = process.argv.slice(2);
702
703if (args.includes('--help') || args.includes('-h')) {
704  console.log(`Usage: node live-server.mjs [options]
705
706Start the live variant mode server (zero dependencies).
707
708Commands:
709  (default)     Start the server (foreground)
710  stop          Stop the server and remove the injected live.js script tag
711  stop --keep-inject   Stop the server only (leave the script tag in the HTML entry)
712
713Options:
714  --background  Start detached, print connection JSON to stdout, then exit
715  --port=PORT   Use a specific port (default: auto-detect starting at 8400)
716  --keep-inject Only with stop: skip live-inject.mjs --remove
717  --help        Show this help
718
719Endpoints:
720  /live.js             Browser script (element picker + variant cycling)
721  /detect.js           Detection overlay (backwards compatible)
722  /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js)
723  /annotation          POST raw image/png to stage a variant screenshot
724  /events              SSE stream (server→browser) + POST (browser→server)
725  /poll                Long-poll for agent CLI
726  /source              Raw source file reader (no-HMR fallback)
727  /status              Durable recovery status (token-protected)
728  /health              Health check`);
729  process.exit(0);
730}
731
732if (args.includes('stop')) {
733  const keepInject = args.includes('--keep-inject');
734  try {
735    const { info } = readLiveServerInfo(process.cwd()) || {};
736    const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
737    if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
738  } catch {
739    console.log('No running live server found.');
740  }
741  if (!keepInject) {
742    const injectPath = path.join(__dirname, 'live-inject.mjs');
743    try {
744      const out = execFileSync(process.execPath, [injectPath, '--remove'], {
745        encoding: 'utf-8',
746        cwd: process.cwd(),
747      });
748      const line = out.trim().split('\n').filter(Boolean).pop();
749      if (line) {
750        try {
751          const j = JSON.parse(line);
752          if (j.removed === true) {
753            console.log(`Removed live script tag from ${j.file}.`);
754          }
755        } catch {
756          /* ignore non-JSON lines */
757        }
758      }
759    } catch (err) {
760      const detail = err.stderr?.toString?.().trim?.()
761        || err.stdout?.toString?.().trim?.()
762        || err.message
763        || String(err);
764      console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`);
765    }
766  }
767  process.exit(0);
768}
769
770// --background: spawn a detached child server, wait for it to be ready,
771// print the connection JSON, then exit.  This keeps the startup command
772// simple (no shell backgrounding or chained commands).
773if (args.includes('--background')) {
774  const childArgs = args.filter(a => a !== '--background');
775  const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
776    detached: true,
777    stdio: 'ignore',
778    cwd: process.cwd(),
779  });
780  child.unref();
781
782  // Poll for the PID file (the child writes it once the HTTP server is listening).
783  const deadline = Date.now() + 10_000;
784  while (Date.now() < deadline) {
785    try {
786      const { info } = readLiveServerInfo(process.cwd()) || {};
787      if (info.pid !== process.pid) {
788        // Output JSON so the agent can read port + token from stdout.
789        console.log(JSON.stringify(info));
790        process.exit(0);
791      }
792    } catch { /* not ready yet */ }
793    await new Promise(r => setTimeout(r, 200));
794  }
795  console.error('Timed out waiting for live server to start.');
796  process.exit(1);
797}
798
799// Check for existing session
800const existingRecord = readLiveServerInfo(process.cwd());
801if (existingRecord?.info) {
802  const existing = existingRecord.info;
803  try {
804    process.kill(existing.pid, 0);
805    console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
806    console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
807    process.exit(1);
808  } catch {
809    try { fs.unlinkSync(existingRecord.path); } catch {}
810  }
811}
812
813state.token = randomUUID();
814state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
815restorePendingEventsFromStore();
816const portArg = args.find(a => a.startsWith('--port='));
817state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
818// Annotation screenshots live in the project root so the agent's Read tool
819// doesn't trip a per-file permission prompt. Sessioned by token so concurrent
820// projects (or quick restarts) don't collide.
821const annotRoot = getLiveAnnotationsDir(process.cwd());
822fs.mkdirSync(annotRoot, { recursive: true });
823state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
824
825const { detectScript, sessionPath, livePath } = loadBrowserScripts();
826httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath }));
827
828httpServer.listen(state.port, '127.0.0.1', () => {
829  writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token });
830  const url = `http://localhost:${state.port}`;
831  console.log(`\nImpeccable live server running on ${url}`);
832  console.log(`Token: ${state.token}\n`);
833  console.log(`Inject: <script src="${url}/live.js"><\/script>`);
834  console.log(`Stop:   node ${path.basename(fileURLToPath(import.meta.url))} stop`);
835});
836
837process.on('SIGINT', shutdown);
838process.on('SIGTERM', shutdown);