Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/live-poll.mjs
1/**2* CLI client for the live variant mode poll/reply protocol.3*4* Usage:5* npx impeccable poll # Block until browser event, print JSON6* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly7* npx impeccable poll --reply <id> done # Reply "done" to event <id>8* npx impeccable poll --reply <id> error "msg" # Reply with error9*/1011import { execFileSync } from 'node:child_process';12import path from 'node:path';13import { fileURLToPath } from 'node:url';14import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';15import { readLiveServerInfo } from './impeccable-paths.mjs';1617// Node's built-in fetch (undici under the hood) enforces a 300s headers18// timeout that can't be lowered per-request. We cap each request below19// that ceiling and loop in `pollOnce` to synthesize a long poll without20// depending on the standalone undici package.21const PER_REQUEST_TIMEOUT_MS = 270_000;2223function readServerInfo() {24const record = readLiveServerInfo(process.cwd());25if (!record) {26console.error('No running live server found. Start one with: npx impeccable live');27process.exit(1);28}29return record.info;30}3132export function buildPollReplyPayload(token, { id, type, message, file, data }) {33return { token, id, type, message, file, data };34}3536async function postReply(base, token, reply) {37const res = await fetch(`${base}/poll`, {38method: 'POST',39headers: { 'Content-Type': 'application/json' },40body: JSON.stringify(buildPollReplyPayload(token, reply)),41});42if (!res.ok) {43const body = await res.json().catch(() => ({}));44throw new Error(body.error || res.statusText);45}46}4748export async function pollCli() {49const args = process.argv.slice(2);5051if (args.includes('--help') || args.includes('-h')) {52console.log(`Usage: impeccable poll [options]5354Wait for a browser event from the live variant server, or reply to one.5556Modes:57poll Block until a browser event arrives, print JSON58poll --reply <id> done Reply "done" to event <id>59poll --reply <id> error "msg" Reply with an error message6061Options:62--timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn63--help Show this help message`);64process.exit(0);65}6667const info = readServerInfo();68const base = `http://localhost:${info.port}`;6970// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]71const replyIdx = args.indexOf('--reply');72if (replyIdx !== -1) {73const id = args[replyIdx + 1];74const status = args[replyIdx + 2] || 'done';75const fileIdx = args.indexOf('--file');76const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;77// Message is any remaining positional arg that isn't a flag78const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;7980if (!id) {81console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');82process.exit(1);83}8485try {86await postReply(base, info.token, { id, type: status, message, file: filePath });8788// Success — silent exit (agent doesn't need output for replies)89} catch (err) {90if (err.cause?.code === 'ECONNREFUSED') {91console.error('Live server not running. Start one with: npx impeccable live');92} else {93console.error('Reply failed:', err.message);94}95process.exit(1);96}97return;98}99100// Poll mode: block until browser event. Default 10 min. Node's built-in101// fetch enforces a 300s headers timeout, so we loop in slices under that102// ceiling and keep re-polling until we get a real event or the user's103// total timeout runs out.104const timeoutArg = args.find(a => a.startsWith('--timeout='));105const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000;106107const deadline = Date.now() + totalTimeout;108let event;109try {110while (true) {111const remaining = deadline - Date.now();112if (remaining <= 0) {113event = { type: 'timeout' };114break;115}116const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS);117const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`);118119if (res.status === 401) {120console.error('Authentication failed. The server token may have changed.');121console.error('Try restarting: npx impeccable live stop && npx impeccable live');122process.exit(1);123}124125if (!res.ok) {126console.error(`Poll failed: ${res.status} ${res.statusText}`);127process.exit(1);128}129130const next = await res.json();131// Server-side timeout means no browser event arrived in this slice.132// Loop and re-poll until we get a real event or we hit the user's133// total deadline.134if (next?.type === 'timeout' && Date.now() < deadline) continue;135event = next;136break;137}138139// Auto-handle accept/discard via deterministic script140if (event.type === 'accept' || event.type === 'discard') {141const __dirname = path.dirname(fileURLToPath(import.meta.url));142const acceptScript = path.join(__dirname, 'live-accept.mjs');143const scriptArgs = event.type === 'discard'144? ['--id', event.id, '--discard']145: ['--id', event.id, '--variant', event.variantId];146if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {147scriptArgs.push('--param-values', JSON.stringify(event.paramValues));148}149try {150const out = execFileSync(151'node',152[acceptScript, ...scriptArgs],153{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }154);155event._acceptResult = JSON.parse(out.trim());156} catch (err) {157event._acceptResult = { handled: false, mode: 'error', error: err.message };158}159160const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);161try {162await postReply(base, info.token, {163id: event.id,164type: completionType,165message: event._acceptResult?.error,166file: event._acceptResult?.file,167data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,168});169} catch (err) {170event._completionAck = { ok: false, error: err.message };171}172if (!event._completionAck) {173event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);174}175}176177// Second signal path: stderr banner in case the agent parses stdout178// JSON but skips nested fields. One line is enough — the full checklist179// is in reference/live.md.180if (event._acceptResult?.carbonize === true) {181process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');182}183184// Print the event as JSON — the agent reads this from stdout185console.log(JSON.stringify(event));186} catch (err) {187if (err.cause?.code === 'ECONNREFUSED') {188console.error('Live server not running. Start one with: npx impeccable live');189} else {190console.error('Poll failed:', err.message);191}192process.exit(1);193}194}195196// Auto-execute when run directly197const _running = process.argv[1];198if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {199pollCli();200}201