#!/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 (download local copies first when your harness only reads files); 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?.(); } });