#!/usr/bin/env node /** Design-document edit session (self-contained, zero dependencies). * * The picker server forks this detached sibling the moment the questionnaire * submits, so the review tab's design context document stays connected after * the picker itself exits 0 (the agent's completion signal). It runs on its * own pre-scanned port with CORS open to the picker origin, and it mediates * three parties the way the live server does, scaled down to polling: * * browser --POST /doc/edit-----------> applied here (simple edits) * browser --POST /doc/request-------> queue --GET /doc/poll--> agent * agent --POST /doc/reply---------> queue status + version bump * browser --GET /doc/state (poll)--> { version, requests } -> re-render * * Simple edits (a palette color) are deterministic: this process rewrites * answers.json and swaps the value in DESIGN.md itself, no model involved. * Anything needing judgment queues for the agent, which long-polls through * picker-doc-poll.mjs exactly like live mode's live-poll.mjs. * * Session discovery for the agent CLI: .impeccable/design-interview/ * doc-session.json { pid, port, token }. Removed on exit. Every applied * simple edit is journaled to doc-edits.jsonl in the same directory so the * agent can reconcile prose (a renamed color's description) at session end. * * Usage (spawned by picker-server.mjs, not by hand): * node picker-doc-session.mjs --port 8501 --timeout 60 * with IMPECCABLE_DOC_TOKEN in the environment. */ import http from 'node:http'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; const interviewDir = path.resolve(process.cwd(), '.impeccable/design-interview'); const answersPath = path.join(interviewDir, 'answers.json'); const sessionPath = path.join(interviewDir, 'doc-session.json'); const ledgerPath = path.join(interviewDir, 'doc-edits.jsonl'); const fontsDir = path.join(interviewDir, 'fonts'); const designPath = path.resolve(process.cwd(), 'DESIGN.md'); const MAX_BODY_BYTES = 1024 * 1024; const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']); const ROLES = new Set(['primary', 'secondary', 'tertiary', 'neutral']); const REQUEST_KINDS = new Set(['font', 'freeform']); /* Long polls are sliced under common proxy/undici header timeouts, the same 270s ceiling live-poll uses. */ const MAX_POLL_MS = 270_000; /* The tab polls /doc/state every couple of seconds while open; when it has been quiet this long the session is over and the agent's poll gets exit. */ const BROWSER_GONE_MS = 10 * 60_000; /* A tab adopts the session within seconds of the submit that forked it. If no poll ever arrives (a test harness, a closed tab), die young instead of holding a port for the full ceiling. */ const ADOPT_GRACE_MS = 90_000; const args = process.argv.slice(2); const readArg = (name, fallback) => { const at = args.indexOf(name); return at !== -1 && args[at + 1] ? args[at + 1] : fallback; }; const port = Number(readArg('--port', '0')); const timeoutMinutes = Number(readArg('--timeout', '60')); const token = process.env.IMPECCABLE_DOC_TOKEN || ''; if (!port || !token) { console.error('picker-doc-session is spawned by picker-server.mjs and needs --port plus IMPECCABLE_DOC_TOKEN.'); process.exit(1); } let version = 1; let requestSeq = 0; const requests = []; let lastBrowserSeen = Date.now(); let adopted = false; const parkedPolls = []; function sendJson(response, statusCode, body) { response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', }); response.end(JSON.stringify(body)); } function httpError(statusCode, message) { const error = new Error(message); error.statusCode = statusCode; return error; } async function readJsonBody(request) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB'); chunks.push(chunk); } let value; try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw httpError(400, 'Body must be valid JSON'); } if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object'); return value; } const summarize = (entry) => ({ id: entry.id, kind: entry.kind, prompt: entry.prompt, category: entry.category, status: entry.status, message: entry.message || '', }); async function appendLedger(entry) { await mkdir(interviewDir, { recursive: true }); await writeFile(ledgerPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { flag: 'a' }); } /* ============================================================ Simple edits — deterministic, applied here. ============================================================ */ async function applyColorEdit({ role, value }) { if (!ROLES.has(role)) throw httpError(400, 'Unknown palette role'); if (!/^#[0-9a-fA-F]{6}$/.test(value || '')) throw httpError(400, 'Value must be a #rrggbb hex color'); const hex = value.toUpperCase(); const answers = JSON.parse(await readFile(answersPath, 'utf8')); const previous = String(answers[`palette-${role}`] || '').toUpperCase(); answers[`palette-${role}`] = hex; await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`); /* DESIGN.md may not exist yet (the agent writes the seed while the user reads the document); the answers file is the source it will seed from, so an early edit is already carried. */ let designTouched = false; if (previous && previous !== hex) { try { const source = await readFile(designPath, 'utf8'); /* A hex value is regex-safe: a literal # and hex digits. */ const swapped = source.replace(new RegExp(previous, 'gi'), hex); if (swapped !== source) { await writeFile(designPath, swapped); designTouched = true; } } catch { /* No DESIGN.md yet. */ } } await appendLedger({ type: 'color', role, from: previous, to: hex, designTouched }); return { role, from: previous, to: hex, designTouched }; } const SIMPLE_EDITS = { color: applyColorEdit }; /* ============================================================ Complex edits — queued for the agent. ============================================================ */ function wakeParkedPolls() { while (parkedPolls.length) { const parked = parkedPolls.shift(); clearTimeout(parked.timer); parked.resolve(); } } function nextPending() { return requests.find((entry) => entry.status === 'pending'); } async function handleDocPoll(response, query) { const budget = Math.min(Number(query.get('timeout')) || MAX_POLL_MS, MAX_POLL_MS); const deadline = Date.now() + budget; for (;;) { if (Date.now() - lastBrowserSeen > BROWSER_GONE_MS) { sendJson(response, 200, { type: 'exit', reason: 'browser-gone' }); return; } const entry = nextPending(); if (entry) { entry.status = 'working'; bumpVersion(); sendJson(response, 200, { type: 'edit_request', ...summarize(entry), payload: entry.payload }); return; } const remaining = deadline - Date.now(); if (remaining <= 0) { sendJson(response, 200, { type: 'timeout' }); return; } await new Promise((resolve) => { const parked = { resolve, timer: setTimeout(resolve, Math.min(remaining, 5_000)) }; parkedPolls.push(parked); }); } } function bumpVersion() { version += 1; } /* ============================================================ Server ============================================================ */ const server = http.createServer((request, response) => { void handleRequest(request, response).catch((error) => { if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message }); else response.destroy(); }); }); async function handleRequest(request, response) { const url = new URL(request.url, 'http://localhost'); const requestPath = url.pathname; if (request.method === 'OPTIONS') { response.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-Font-Filename', 'Access-Control-Max-Age': '600', }); response.end(); return; } /* Font uploads carry bytes, not JSON; token rides the query string. */ if (request.method === 'POST' && requestPath === '/font-upload') { if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token'); const name = path.basename(request.headers['x-font-filename'] || ''); if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) { throw httpError(400, 'Expected a .woff2, .woff, .ttf, or .otf filename'); } const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > MAX_BODY_BYTES) throw httpError(413, 'Font exceeds 1 MB'); chunks.push(chunk); } await mkdir(fontsDir, { recursive: true }); await writeFile(path.join(fontsDir, name), Buffer.concat(chunks)); sendJson(response, 200, { ok: true, path: path.join('.impeccable/design-interview/fonts', name) }); return; } if (request.method === 'GET' && requestPath === '/doc/state') { if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token'); lastBrowserSeen = Date.now(); adopted = true; sendJson(response, 200, { ok: true, version, requests: requests.map(summarize), agentWaiting: parkedPolls.length > 0, }); return; } if (request.method === 'GET' && requestPath === '/doc/answers') { if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token'); const answers = JSON.parse(await readFile(answersPath, 'utf8')); sendJson(response, 200, { ok: true, version, answers }); return; } if (request.method === 'GET' && requestPath === '/doc/poll') { if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token'); await handleDocPoll(response, url.searchParams); return; } if (request.method !== 'POST') throw httpError(404, 'Not found'); const body = await readJsonBody(request); if (body.token !== token) throw httpError(403, 'Bad token'); if (requestPath === '/doc/edit') { const apply = SIMPLE_EDITS[body.kind]; if (!apply) throw httpError(400, `No simple edit named ${String(body.kind)}; complex changes go through /doc/request`); const applied = await apply(body); bumpVersion(); sendJson(response, 200, { ok: true, version, applied }); return; } if (requestPath === '/doc/request') { if (!REQUEST_KINDS.has(body.kind)) throw httpError(400, 'kind must be font or freeform'); const prompt = String(body.prompt || '').trim(); if (!prompt || prompt.length > 4000) throw httpError(400, 'prompt is required, 4000 characters max'); requestSeq += 1; const entry = { id: `req-${String(requestSeq).padStart(3, '0')}`, kind: body.kind, prompt, category: String(body.category || ''), payload: body.payload && typeof body.payload === 'object' ? body.payload : {}, status: 'pending', message: '', }; requests.push(entry); bumpVersion(); wakeParkedPolls(); sendJson(response, 200, { ok: true, id: entry.id, version }); return; } if (requestPath === '/doc/reply') { const entry = requests.find((item) => item.id === body.id); if (!entry) throw httpError(404, 'Unknown request id'); if (!['done', 'error', 'retry'].includes(body.status)) throw httpError(400, 'status must be done, error, or retry'); entry.status = body.status === 'retry' ? 'pending' : body.status; entry.message = String(body.message || ''); bumpVersion(); if (entry.status === 'pending') wakeParkedPolls(); sendJson(response, 200, { ok: true, version }); return; } throw httpError(404, 'Not found'); } server.listen(port, '127.0.0.1', async () => { await mkdir(interviewDir, { recursive: true }); await writeFile(sessionPath, `${JSON.stringify({ pid: process.pid, port, token }, null, 2)}\n`); }); server.on('error', () => process.exit(1)); /* The session dies with its audience: no browser poll for BROWSER_GONE_MS, or the hard ceiling, whichever lands first. */ const reaper = setInterval(() => { const quiet = Date.now() - lastBrowserSeen; if (quiet > BROWSER_GONE_MS || (!adopted && quiet > ADOPT_GRACE_MS)) shutdown(); }, 15_000); const ceiling = setTimeout(shutdown, timeoutMinutes * 60_000); async function shutdown() { clearInterval(reaper); clearTimeout(ceiling); wakeParkedPolls(); await rm(sessionPath, { force: true }).catch(() => {}); server.close(() => process.exit(0)); server.closeAllConnections?.(); setTimeout(() => process.exit(0), 1_000).unref(); } process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);