#!/usr/bin/env node /** * Visual question server: present a decision to the user as a themed page * instead of a plain-text prompt, then block until they answer. * * The script IS the wait: run it via the shell, it serves the page, prints * the URL (and tries to open the default browser), and does not exit until * the user chooses. The answer lands on stdout as one line: * * ANSWER: {"optionId":"...","steer":"..."} * * Exit codes: 0 answered · 2 timed out, closed without answering, or no * browser is available (IMPECCABLE_QUESTION_DISABLED, or a detected * CI/headless/remote environment; IMPECCABLE_QUESTION_FORCE=1 overrides * detection, --no-open skips it since the caller opens the URL itself). * * Payload (JSON file via --payload, or stdin): * { * "title": "Choose the visual world", * "question": "The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.", * "options": [ * { * "id": "assigned", // returned verbatim * "label": "Fillmore Handbill", * "kicker": "THE ROLL", // optional badge; the assigned option leads * "lineage": "1966-71 Fillmore ...", // optional * "body": "why it fits, first viewport, risk ...", // optional, plain text * "hero": "https://... or /abs/path.webp", // optional image * "board": "https://... or /abs/path.webp" // optional secondary image * }, ... * ], * "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"}) * "canon": true, // adds the quiet "Play it straight" standing exit * // (returns {"optionId":"canon"}); direction rounds only * "steer": true // adds a free-text steer field returned with any answer * } * * Options render as large cards: hero render first when present (the dealt * catalog worlds already have cards; grounded directions may present text-only * or a freshly generated mock). Local image paths are served by this server; * nothing is uploaded anywhere. * * Modes: * (default) block until answered; ANSWER on stdout; exit 0. * --schema print the canonical payload example and exit. * --start for harnesses that cannot leave a shell blocked: daemonize the * server, print QUESTION URL + QUESTION KEY, exit immediately. * Never auto-opens a browser: the agent routes the URL to the * best surface it has (in-app browser first, then the system * opener); pass --open to force the system browser instead. * --wait --key K [--poll 60] poll for the answer: exit 0 + ANSWER line, * exit 3 WAITING (run --wait again), exit 2 server gone, * exit 4 PAGE CLOSED (the tab went away without an answer; * re-present, reopen the URL, or fall back). * --stop --key K kill a daemonized question. * --update --key K --payload F deliver the next hand after a re-roll: the * live page swaps to loading cards when the user re-rolls, and * reloads into this new payload the moment it lands. * * node serve-question.mjs --payload question.json [--timeout 900] [--no-open] [--port 0] */ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; function arg(name, fallback = null) { const i = process.argv.indexOf(`--${name}`); if (i === -1) return fallback; const v = process.argv[i + 1]; return v && !v.startsWith('--') ? v : fallback; } const hasFlag = (name) => process.argv.includes(`--${name}`); if (process.env.IMPECCABLE_QUESTION_DISABLED) { console.log('serve-question: disabled in this session (no browser); use the structured question tool instead.'); process.exit(2); } // Headless self-detection, applied only where a browser is actually wanted. // --no-open means the caller opens the URL itself, and --wait / --stop / // --schema never open anything: --wait polls a daemon whose browser question // was already settled at --start, --stop kills one, --schema prints text. A // spurious exit 2 from those breaks the documented loop, which polls --wait // while it exits 3 and reads --schema before building a payload. const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema'); if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) { const headless = process.env.CI || (process.env.SSH_CONNECTION && !process.env.DISPLAY) || (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY); if (headless) { console.log('serve-question: no browser detected in this environment (CI/headless/remote); use the structured question tool instead. Set IMPECCABLE_QUESTION_FORCE=1 to serve anyway.'); process.exit(2); } } // Both answer channels (blocking stdout and --wait collection) print through // this: the ANSWER line, then a directive to open the chosen card's imagery // when it has any. The card viewing happens at the moment of choice, in the // working turn, because a build that never reopens the chosen world's board // and hero calibrates on nothing. function printAnswer(raw) { console.log(`ANSWER: ${raw}`); try { const a = JSON.parse(raw); if (a.hero || a.board) { console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach."); } if (a.optionId === 'canon') { console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.'); } } catch { /* raw answer */ } } const payloadPath = arg('payload'); const timeoutSec = Number(arg('timeout', '900')); const portArg = Number(arg('port', '0')); const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions'); const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`); const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`); if (hasFlag('schema')) { console.log(JSON.stringify({ title: 'Choose the visual world', question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.', options: [ { id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', body: 'Why it fits, the first viewport, the honest risk.', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' }, { id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' }, ], reroll: true, canon: true, steer: true, }, null, 2)); console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board accept URLs or local paths. canon adds a quiet standing "Play it straight" action for direction decisions: the user\'s explicit door to the category standard. Include it only for visual-direction rounds; never present canon as your own recommendation.'); process.exit(0); } if (hasFlag('wait')) { const key = arg('key'); if (!key) { console.error('serve-question: --wait needs --key'); process.exit(1); } const pollSec = Number(arg('poll', '60')); const deadline = Date.now() + pollSec * 1000; const answered = () => fs.existsSync(answerFile(key)); const alive = () => { try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); return true; } catch { return false; } }; let sawClose = false; while (Date.now() < deadline) { if (answered()) break; if (!alive()) { console.log('serve-question: the question server is gone with no answer'); process.exit(2); } try { const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8')); if (state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; } } catch { /* state mid-write */ } await new Promise((r) => setTimeout(r, 1000)); } if (sawClose && !answered()) { console.log('PAGE CLOSED: the question page went away without an answer; re-present, reopen the URL, or fall back to the structured question tool'); process.exit(4); } if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); } const collected = fs.readFileSync(answerFile(key), 'utf8').trim(); printAnswer(collected); // A re-roll keeps the table open: the server stays alive awaiting --update, // so only the answer file is consumed. Terminal choices clean up fully. let isRerollAnswer = false; try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ } try { fs.rmSync(answerFile(key)); } catch { /* already gone */ } if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } } process.exit(0); } if (hasFlag('stop')) { const key = arg('key'); if (!key) { console.error('serve-question: --stop needs --key'); process.exit(1); } try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid); } catch { /* dead already */ } try { fs.rmSync(answerFile(key)); } catch {} try { fs.rmSync(stateFile(key)); } catch {} console.log('stopped'); process.exit(0); } if (hasFlag('update')) { const key = arg('key'); if (!key || !payloadPath) { console.error('serve-question: --update needs --key and --payload'); process.exit(1); } JSON.parse(fs.readFileSync(payloadPath, 'utf8')); try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); } catch { console.error('serve-question: no live question server for that key'); process.exit(2); } fs.copyFileSync(payloadPath, path.join(QUESTION_DIR, `${key}.next.json`)); console.log('next round delivered; the page reloads itself'); process.exit(0); } if (hasFlag('start')) { if (!payloadPath) { console.error('serve-question: --start needs --payload '); process.exit(1); } JSON.parse(fs.readFileSync(payloadPath, 'utf8')); fs.mkdirSync(QUESTION_DIR, { recursive: true }); const key = arg('key') || Math.random().toString(16).slice(2, 10); // In start mode the agent is alive and owns browser routing; the server // only opens the system browser itself when --open forces it. const child = spawn(process.execPath, [ fileURLToPath(import.meta.url), '--payload', payloadPath, '--detached-serve', '--key', key, '--timeout', String(timeoutSec), ...(hasFlag('open') ? [] : ['--no-open']), ], { detached: true, stdio: 'ignore' }); child.unref(); const deadline = Date.now() + 8000; while (Date.now() < deadline && !fs.existsSync(stateFile(key))) await new Promise((r) => setTimeout(r, 100)); if (!fs.existsSync(stateFile(key))) { console.error('serve-question: server failed to start'); process.exit(1); } const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8')); console.log(`QUESTION URL: ${state.url}`); console.log(`QUESTION KEY: ${key}`); console.log('Open the URL for the user now: in-app browser when the harness has one, otherwise the system opener (macOS `open`, Linux `xdg-open`), otherwise show the URL.'); console.log(`Then collect the answer with: node ${fileURLToPath(import.meta.url)} --wait --key ${key}`); process.exit(0); } let raw; if (payloadPath) raw = fs.readFileSync(payloadPath, 'utf8'); else raw = fs.readFileSync(0, 'utf8'); // Round state is mutable: a re-roll keeps this server alive and --update // swaps in the next hand, so payload, options, and the local-image table // rebuild per round. let payload; let options; let localImages = []; function loadRound(json) { const parsed = JSON.parse(json); if (!parsed || !Array.isArray(parsed.options) || parsed.options.length === 0) { throw new Error('payload needs an options array'); } localImages = []; const imageSrc = (value) => { if (!value) return null; if (/^https?:\/\//.test(value)) return value; const abs = path.resolve(value); if (!fs.existsSync(abs)) return null; localImages.push(abs); return `/img/${localImages.length - 1}`; }; payload = parsed; options = parsed.options.map((option) => ({ ...option, heroSrc: imageSrc(option.hero), boardSrc: imageSrc(option.board), })); } try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); } const detachedKey = hasFlag('detached-serve') ? arg('key') : null; const nextFile = () => detachedKey ? path.join(QUESTION_DIR, `${detachedKey}.next.json`) : null; const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); function page() { const flipChip = (label) => ``; const expandChip = ``; const cards = options.map((option, index) => `
${option.kicker ? `${esc(option.kicker)}` : ''} ${option.heroSrc || option.boardSrc ? `
${expandChip}${option.boardSrc && option.heroSrc ? flipChip('Board') : ''}
` : ''}
${option.lineage ? `

${esc(option.lineage)}

` : ''}

${esc(option.label)}

${option.body ? `

${esc(option.body)}

` : ''}
${option.boardSrc && option.heroSrc ? `
${expandChip}${flipChip('Hero')}

Design-system board · ${esc(option.label)}

` : ''}
`).join('\n'); return ` ${esc(payload.title || 'impeccable · decision')}
Impeccable

${esc(payload.title || 'Choose a direction')}

${payload.question ? `

${esc(payload.question)}

` : ''}
${cards}
`; } const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/') { const pending = nextFile(); if (pending && fs.existsSync(pending)) { try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ } } res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(page()); return; } if (req.method === 'POST' && req.url === '/heartbeat') { res.writeHead(204); res.end(); if (detachedKey) { const now = Date.now(); if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) { server.lastBeatWrite = now; try { const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8')); state.lastBeat = now; fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state)); } catch { /* state file recreated on next beat */ } } } return; } if (req.method === 'GET' && req.url === '/next-status') { const pending = nextFile(); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) })); return; } const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)$/); if (imageMatch) { const abs = localImages[Number(imageMatch[1])]; if (!abs) { res.writeHead(404); res.end(); return; } const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : abs.endsWith('.svg') ? 'image/svg+xml' : abs.endsWith('.gif') ? 'image/gif' : 'image/jpeg'; res.writeHead(200, { 'content-type': type }); fs.createReadStream(abs).pipe(res); return; } if (req.method === 'POST' && req.url === '/answer') { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); let parsed = {}; try { parsed = JSON.parse(body); } catch { /* empty steer */ } const chosen = options.find((o) => o.id === parsed.optionId); const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '', ...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}), }); const isReroll = parsed.optionId === 'reroll'; if (detachedKey) { fs.mkdirSync(QUESTION_DIR, { recursive: true }); fs.writeFileSync(answerFile(detachedKey), answer + '\n'); } else { printAnswer(answer); } // A re-roll in detached mode keeps the table open: the client shows a // loading hand and reloads when --update delivers the next round. if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150); }); return; } res.writeHead(404); res.end(); }); server.listen(portArg, '127.0.0.1', () => { const { port } = server.address(); const url = `http://127.0.0.1:${port}/`; if (hasFlag('detached-serve')) { fs.mkdirSync(QUESTION_DIR, { recursive: true }); fs.writeFileSync(stateFile(arg('key')), JSON.stringify({ pid: process.pid, port, url })); } else { console.log(`QUESTION URL: ${url}`); console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...'); } if (!hasFlag('no-open')) { const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ } } if (timeoutSec > 0) { setTimeout(() => { console.log('serve-question: timed out with no answer'); process.exit(2); }, timeoutSec * 1000).unref?.(); } });