| 1 | /** |
| 2 | * CLI client for the live variant mode poll/reply protocol. |
| 3 | * |
| 4 | * Usage: |
| 5 | * npx impeccable poll # Block until browser event, print JSON |
| 6 | * npx impeccable poll --stream # Experimental: keep polling; one JSON line per event |
| 7 | * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly |
| 8 | * npx impeccable poll --reply <id> done # Reply "done" to event <id> |
| 9 | * npx impeccable poll --reply <id> error "msg" # Reply with error |
| 10 | */ |
| 11 | |
| 12 | import { execFileSync } from 'node:child_process'; |
| 13 | import path from 'node:path'; |
| 14 | import { fileURLToPath } from 'node:url'; |
| 15 | import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs'; |
| 16 | import { readLiveServerInfo } from './impeccable-paths.mjs'; |
| 17 | |
| 18 | // Node's built-in fetch (undici under the hood) enforces a 300s headers |
| 19 | // timeout that can't be lowered per-request. We cap each request below |
| 20 | // that ceiling and loop in `pollOnce` to synthesize a long poll without |
| 21 | // depending on the standalone undici package. |
| 22 | export const PER_REQUEST_TIMEOUT_MS = 270_000; |
| 23 | export const DEFAULT_EVENT_LEASE_MS = 600_000; |
| 24 | |
| 25 | const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']); |
| 26 | |
| 27 | function readServerInfo() { |
| 28 | const record = readLiveServerInfo(process.cwd()); |
| 29 | if (!record) { |
| 30 | console.error('No running live server found. Start one with: npx impeccable live'); |
| 31 | process.exit(1); |
| 32 | } |
| 33 | return record.info; |
| 34 | } |
| 35 | |
| 36 | export function buildPollReplyPayload(token, { id, type, message, file, data }) { |
| 37 | return { token, id, type, message, file, data }; |
| 38 | } |
| 39 | |
| 40 | export function manualApplyPollBanner(event = {}) { |
| 41 | const id = event.id || 'EVENT_ID'; |
| 42 | return [ |
| 43 | `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply ${id} done --data '<json>'\`.`, |
| 44 | 'The JSON data must include status, appliedEntryIds, failed, files, and notes; summary counters are only a recovery fallback.', |
| 45 | 'Do not run live-commit-manual-edits.mjs for this leased event.', |
| 46 | 'Do not poll again before replying.', |
| 47 | ].join('\n') + '\n'; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Parse `--reply <id> <status> [--file path] [--data '<json>'] [message]` argv |
| 52 | * into a reply object. Returns null when `--reply` is absent. Throws (code |
| 53 | * INVALID_REPLY_ARGS) when the reply shape is missing its event id/status and |
| 54 | * INVALID_DATA_JSON when `--data` is present but not valid JSON. |
| 55 | */ |
| 56 | export function parseReplyArgs(args) { |
| 57 | const replyIdx = args.indexOf('--reply'); |
| 58 | if (replyIdx === -1) return null; |
| 59 | const id = args[replyIdx + 1]; |
| 60 | const status = args[replyIdx + 2]; |
| 61 | validateReplyArgs({ id, status }); |
| 62 | const fileIdx = args.indexOf('--file'); |
| 63 | const file = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; |
| 64 | const dataIdx = args.indexOf('--data'); |
| 65 | let data; |
| 66 | if (dataIdx !== -1 && dataIdx + 1 < args.length) { |
| 67 | try { |
| 68 | data = JSON.parse(args[dataIdx + 1]); |
| 69 | } catch (err) { |
| 70 | const wrapped = new Error('--data must be valid JSON: ' + err.message); |
| 71 | wrapped.code = 'INVALID_DATA_JSON'; |
| 72 | throw wrapped; |
| 73 | } |
| 74 | } |
| 75 | const message = args.find((a, i) => |
| 76 | i > replyIdx + 2 |
| 77 | && !a.startsWith('--') |
| 78 | && i !== fileIdx + 1 |
| 79 | && i !== dataIdx + 1 |
| 80 | ) || undefined; |
| 81 | return { id, type: status, message, file, data }; |
| 82 | } |
| 83 | |
| 84 | function validateReplyArgs({ id, status }) { |
| 85 | const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]"; |
| 86 | if (!id || id.startsWith('--')) { |
| 87 | const err = new Error(`${usage}\nMissing event id after --reply.`); |
| 88 | err.code = 'INVALID_REPLY_ARGS'; |
| 89 | throw err; |
| 90 | } |
| 91 | if (['done', 'error', 'complete', 'discard', 'discarded'].includes(id)) { |
| 92 | const err = new Error(`${usage}\nThe value after --reply must be the event id, not the status ${JSON.stringify(id)}. Use --reply EVENT_ID ${id}.`); |
| 93 | err.code = 'INVALID_REPLY_ARGS'; |
| 94 | throw err; |
| 95 | } |
| 96 | if (!status || status.startsWith('--')) { |
| 97 | const err = new Error(`${usage}\nMissing reply status after event id ${JSON.stringify(id)}.`); |
| 98 | err.code = 'INVALID_REPLY_ARGS'; |
| 99 | throw err; |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | export function requiresAgentReply(event) { |
| 104 | return EVENT_TYPES_NEEDING_AGENT_REPLY.has(event?.type); |
| 105 | } |
| 106 | |
| 107 | export async function postReply(base, token, reply) { |
| 108 | const res = await fetch(`${base}/poll`, { |
| 109 | method: 'POST', |
| 110 | headers: { 'Content-Type': 'application/json' }, |
| 111 | body: JSON.stringify(buildPollReplyPayload(token, reply)), |
| 112 | }); |
| 113 | if (!res.ok) { |
| 114 | const body = await res.json().catch(() => ({})); |
| 115 | const parts = [body.error || res.statusText, body.reason, body.hint].filter(Boolean); |
| 116 | throw new Error(parts.join(': ')); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | export async function fetchServerStatus(base, token) { |
| 121 | const res = await fetch(`${base}/status?token=${token}`); |
| 122 | if (res.status === 401) { |
| 123 | const err = new Error('Authentication failed. The server token may have changed.'); |
| 124 | err.code = 'AUTH_FAILED'; |
| 125 | throw err; |
| 126 | } |
| 127 | if (!res.ok) { |
| 128 | throw new Error(`Status failed: ${res.status} ${res.statusText}`); |
| 129 | } |
| 130 | return res.json(); |
| 131 | } |
| 132 | |
| 133 | export function isEventPending(status, eventId) { |
| 134 | return (status.pendingEvents || []).some((entry) => entry.id === eventId); |
| 135 | } |
| 136 | |
| 137 | export async function waitForEventAck(base, token, eventId, { |
| 138 | pollIntervalMs = 400, |
| 139 | maxWaitMs = 600_000, |
| 140 | } = {}) { |
| 141 | const deadline = Date.now() + maxWaitMs; |
| 142 | while (Date.now() < deadline) { |
| 143 | const status = await fetchServerStatus(base, token); |
| 144 | if (!isEventPending(status, eventId)) return true; |
| 145 | await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); |
| 146 | } |
| 147 | return false; |
| 148 | } |
| 149 | |
| 150 | export async function fetchNextEvent(base, token, { totalDeadline } = {}) { |
| 151 | while (true) { |
| 152 | if (totalDeadline && Date.now() >= totalDeadline) { |
| 153 | return { type: 'timeout' }; |
| 154 | } |
| 155 | |
| 156 | const remaining = totalDeadline |
| 157 | ? totalDeadline - Date.now() |
| 158 | : PER_REQUEST_TIMEOUT_MS; |
| 159 | const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS); |
| 160 | const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`); |
| 161 | |
| 162 | if (res.status === 401) { |
| 163 | const err = new Error('Authentication failed. The server token may have changed.'); |
| 164 | err.code = 'AUTH_FAILED'; |
| 165 | throw err; |
| 166 | } |
| 167 | |
| 168 | if (!res.ok) { |
| 169 | throw new Error(`Poll failed: ${res.status} ${res.statusText}`); |
| 170 | } |
| 171 | |
| 172 | const next = await res.json(); |
| 173 | if (next?.type === 'timeout') { |
| 174 | if (totalDeadline && Date.now() < totalDeadline) continue; |
| 175 | if (!totalDeadline) continue; |
| 176 | return next; |
| 177 | } |
| 178 | return next; |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | export async function augmentEventWithAcceptHandling(event, base, token) { |
| 183 | if (event.type !== 'accept' && event.type !== 'discard') return event; |
| 184 | |
| 185 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 186 | const acceptScript = path.join(__dirname, 'live-accept.mjs'); |
| 187 | const scriptArgs = buildAcceptScriptArgs(event); |
| 188 | |
| 189 | try { |
| 190 | const out = execFileSync( |
| 191 | 'node', |
| 192 | [acceptScript, ...scriptArgs], |
| 193 | { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }, |
| 194 | ); |
| 195 | event._acceptResult = JSON.parse(out.trim()); |
| 196 | } catch (err) { |
| 197 | event._acceptResult = { handled: false, mode: 'error', error: err.message }; |
| 198 | } |
| 199 | |
| 200 | const completionType = completionTypeForAcceptResult(event.type, event._acceptResult); |
| 201 | try { |
| 202 | await postReply(base, token, { |
| 203 | id: event.id, |
| 204 | type: completionType, |
| 205 | message: event._acceptResult?.error, |
| 206 | file: event._acceptResult?.file, |
| 207 | data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined, |
| 208 | }); |
| 209 | } catch (err) { |
| 210 | event._completionAck = { ok: false, error: err.message }; |
| 211 | } |
| 212 | if (!event._completionAck) { |
| 213 | event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult); |
| 214 | } |
| 215 | |
| 216 | return event; |
| 217 | } |
| 218 | |
| 219 | export function buildAcceptScriptArgs(event) { |
| 220 | const scriptArgs = event.type === 'discard' |
| 221 | ? ['--id', String(event.id), '--discard'] |
| 222 | : ['--id', String(event.id), '--variant', String(event.variantId)]; |
| 223 | if (event.pageUrl) scriptArgs.push('--page-url', String(event.pageUrl)); |
| 224 | if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { |
| 225 | scriptArgs.push('--param-values', JSON.stringify(event.paramValues)); |
| 226 | } |
| 227 | return scriptArgs; |
| 228 | } |
| 229 | |
| 230 | export function writeCarbonizeBanner(event) { |
| 231 | if (event.type === 'manual_edit_apply') { |
| 232 | process.stderr.write('\n' + manualApplyPollBanner(event) + '\n'); |
| 233 | } |
| 234 | if (event._acceptResult?.carbonize === true) { |
| 235 | process.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'); |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | export function printPollEvent(event) { |
| 240 | console.log(JSON.stringify(event)); |
| 241 | } |
| 242 | |
| 243 | export async function runPollOnce(base, token, { totalTimeout = 600_000 } = {}) { |
| 244 | const deadline = Date.now() + totalTimeout; |
| 245 | const event = await fetchNextEvent(base, token, { totalDeadline: deadline }); |
| 246 | await augmentEventWithAcceptHandling(event, base, token); |
| 247 | writeCarbonizeBanner(event); |
| 248 | printPollEvent(event); |
| 249 | return event; |
| 250 | } |
| 251 | |
| 252 | export async function runPollStream(base, token, { |
| 253 | ackTimeoutMs = 600_000, |
| 254 | ackPollIntervalMs = 400, |
| 255 | shouldContinue = () => true, |
| 256 | } = {}) { |
| 257 | process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n'); |
| 258 | |
| 259 | while (shouldContinue()) { |
| 260 | const event = await fetchNextEvent(base, token); |
| 261 | await augmentEventWithAcceptHandling(event, base, token); |
| 262 | writeCarbonizeBanner(event); |
| 263 | printPollEvent(event); |
| 264 | |
| 265 | if (event.type === 'exit') return event; |
| 266 | |
| 267 | if (requiresAgentReply(event)) { |
| 268 | const acked = await waitForEventAck(base, token, event.id, { |
| 269 | pollIntervalMs: ackPollIntervalMs, |
| 270 | maxWaitMs: ackTimeoutMs, |
| 271 | }); |
| 272 | if (!acked) { |
| 273 | const err = new Error(`Timed out waiting for --reply on event ${event.id}`); |
| 274 | err.code = 'ACK_TIMEOUT'; |
| 275 | throw err; |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | return null; |
| 281 | } |
| 282 | |
| 283 | function handlePollError(err) { |
| 284 | if (err.code === 'AUTH_FAILED') { |
| 285 | console.error(err.message); |
| 286 | console.error('Try restarting: npx impeccable live stop && npx impeccable live'); |
| 287 | process.exit(1); |
| 288 | } |
| 289 | if (err.cause?.code === 'ECONNREFUSED') { |
| 290 | console.error('Live server not running. Start one with: npx impeccable live'); |
| 291 | process.exit(1); |
| 292 | } |
| 293 | if (err.code === 'ACK_TIMEOUT') { |
| 294 | console.error(err.message); |
| 295 | process.exit(1); |
| 296 | } |
| 297 | console.error('Poll failed:', err.message); |
| 298 | process.exit(1); |
| 299 | } |
| 300 | |
| 301 | export async function pollCli() { |
| 302 | const args = process.argv.slice(2); |
| 303 | |
| 304 | if (args.includes('--help') || args.includes('-h')) { |
| 305 | console.log(`Usage: impeccable poll [options] |
| 306 | |
| 307 | Wait for a browser event from the live variant server, or reply to one. |
| 308 | |
| 309 | Modes: |
| 310 | poll Block until a browser event arrives, print JSON, exit |
| 311 | poll --stream Keep polling; print one JSON line per event (see live.md) |
| 312 | poll --reply <id> done Reply "done" to event <id> (replace or insert generate) |
| 313 | poll --reply <id> steer_done Reply after handling a steer event (unlocks Steer bar) |
| 314 | poll --reply <id> error "msg" Reply with an error message |
| 315 | poll --reply <id> done --data '<json>' |
| 316 | Reply with a structured JSON result (manual_edit_apply) |
| 317 | |
| 318 | Options: |
| 319 | --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode |
| 320 | --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) |
| 321 | --file PATH Attach a source file path to the reply (generate/steer flow) |
| 322 | --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON |
| 323 | --help Show this help message |
| 324 | |
| 325 | Harness note: |
| 326 | Default one-shot mode is the portable contract for Claude Code, Codex, and Cursor. |
| 327 | --stream is experimental for harnesses with fast incremental stdout; do not use on Cursor.`); |
| 328 | process.exit(0); |
| 329 | } |
| 330 | |
| 331 | const info = readServerInfo(); |
| 332 | const base = `http://localhost:${info.port}`; |
| 333 | |
| 334 | // Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message] |
| 335 | if (args.includes('--reply')) { |
| 336 | let reply; |
| 337 | try { |
| 338 | reply = parseReplyArgs(args); |
| 339 | } catch (err) { |
| 340 | console.error(err.message); |
| 341 | process.exit(1); |
| 342 | } |
| 343 | |
| 344 | try { |
| 345 | await postReply(base, info.token, reply); |
| 346 | } catch (err) { |
| 347 | if (err.cause?.code === 'ECONNREFUSED') { |
| 348 | console.error('Live server not running. Start one with: npx impeccable live'); |
| 349 | } else { |
| 350 | console.error('Reply failed:', err.message); |
| 351 | } |
| 352 | process.exit(1); |
| 353 | } |
| 354 | return; |
| 355 | } |
| 356 | |
| 357 | const streamMode = args.includes('--stream'); |
| 358 | const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout=')); |
| 359 | const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000; |
| 360 | |
| 361 | try { |
| 362 | if (streamMode) { |
| 363 | await runPollStream(base, info.token, { ackTimeoutMs }); |
| 364 | return; |
| 365 | } |
| 366 | |
| 367 | const timeoutArg = args.find((a) => a.startsWith('--timeout=')); |
| 368 | const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000; |
| 369 | await runPollOnce(base, info.token, { totalTimeout }); |
| 370 | } catch (err) { |
| 371 | handlePollError(err); |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | // Auto-execute when run directly |
| 376 | const _running = process.argv[1]; |
| 377 | if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { |
| 378 | pollCli(); |
| 379 | } |
| 380 |