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 --timeout=600000 # Custom timeout (ms); default is long-poll friendly
7 * npx impeccable poll --reply <id> done # Reply "done" to event <id>
8 * npx impeccable poll --reply <id> error "msg" # Reply with error
9 */
10
11import { 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';
16
17// Node's built-in fetch (undici under the hood) enforces a 300s headers
18// timeout that can't be lowered per-request. We cap each request below
19// that ceiling and loop in `pollOnce` to synthesize a long poll without
20// depending on the standalone undici package.
21const PER_REQUEST_TIMEOUT_MS = 270_000;
22
23function readServerInfo() {
24 const record = readLiveServerInfo(process.cwd());
25 if (!record) {
26 console.error('No running live server found. Start one with: npx impeccable live');
27 process.exit(1);
28 }
29 return record.info;
30}
31
32export function buildPollReplyPayload(token, { id, type, message, file, data }) {
33 return { token, id, type, message, file, data };
34}
35
36async function postReply(base, token, reply) {
37 const res = await fetch(`${base}/poll`, {
38 method: 'POST',
39 headers: { 'Content-Type': 'application/json' },
40 body: JSON.stringify(buildPollReplyPayload(token, reply)),
41 });
42 if (!res.ok) {
43 const body = await res.json().catch(() => ({}));
44 throw new Error(body.error || res.statusText);
45 }
46}
47
48export async function pollCli() {
49 const args = process.argv.slice(2);
50
51 if (args.includes('--help') || args.includes('-h')) {
52 console.log(`Usage: impeccable poll [options]
53
54Wait for a browser event from the live variant server, or reply to one.
55
56Modes:
57 poll Block until a browser event arrives, print JSON
58 poll --reply <id> done Reply "done" to event <id>
59 poll --reply <id> error "msg" Reply with an error message
60
61Options:
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 turn
63 --help Show this help message`);
64 process.exit(0);
65 }
66
67 const info = readServerInfo();
68 const base = `http://localhost:${info.port}`;
69
70 // Reply mode: npx impeccable poll --reply <id> <status> [--file path] [message]
71 const replyIdx = args.indexOf('--reply');
72 if (replyIdx !== -1) {
73 const id = args[replyIdx + 1];
74 const status = args[replyIdx + 2] || 'done';
75 const fileIdx = args.indexOf('--file');
76 const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
77 // Message is any remaining positional arg that isn't a flag
78 const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
79
80 if (!id) {
81 console.error('Usage: npx impeccable poll --reply <id> <status> [--file path] [message]');
82 process.exit(1);
83 }
84
85 try {
86 await postReply(base, info.token, { id, type: status, message, file: filePath });
87
88 // Success ā silent exit (agent doesn't need output for replies)
89 } catch (err) {
90 if (err.cause?.code === 'ECONNREFUSED') {
91 console.error('Live server not running. Start one with: npx impeccable live');
92 } else {
93 console.error('Reply failed:', err.message);
94 }
95 process.exit(1);
96 }
97 return;
98 }
99
100 // Poll mode: block until browser event. Default 10 min. Node's built-in
101 // fetch enforces a 300s headers timeout, so we loop in slices under that
102 // ceiling and keep re-polling until we get a real event or the user's
103 // total timeout runs out.
104 const timeoutArg = args.find(a => a.startsWith('--timeout='));
105 const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000;
106
107 const deadline = Date.now() + totalTimeout;
108 let event;
109 try {
110 while (true) {
111 const remaining = deadline - Date.now();
112 if (remaining <= 0) {
113 event = { type: 'timeout' };
114 break;
115 }
116 const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS);
117 const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`);
118
119 if (res.status === 401) {
120 console.error('Authentication failed. The server token may have changed.');
121 console.error('Try restarting: npx impeccable live stop && npx impeccable live');
122 process.exit(1);
123 }
124
125 if (!res.ok) {
126 console.error(`Poll failed: ${res.status} ${res.statusText}`);
127 process.exit(1);
128 }
129
130 const 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's
133 // total deadline.
134 if (next?.type === 'timeout' && Date.now() < deadline) continue;
135 event = next;
136 break;
137 }
138
139 // Auto-handle accept/discard via deterministic script
140 if (event.type === 'accept' || event.type === 'discard') {
141 const __dirname = path.dirname(fileURLToPath(import.meta.url));
142 const acceptScript = path.join(__dirname, 'live-accept.mjs');
143 const scriptArgs = event.type === 'discard'
144 ? ['--id', event.id, '--discard']
145 : ['--id', event.id, '--variant', event.variantId];
146 if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
147 scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
148 }
149 try {
150 const out = execFileSync(
151 'node',
152 [acceptScript, ...scriptArgs],
153 { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
154 );
155 event._acceptResult = JSON.parse(out.trim());
156 } catch (err) {
157 event._acceptResult = { handled: false, mode: 'error', error: err.message };
158 }
159
160 const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
161 try {
162 await postReply(base, info.token, {
163 id: event.id,
164 type: completionType,
165 message: event._acceptResult?.error,
166 file: event._acceptResult?.file,
167 data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
168 });
169 } catch (err) {
170 event._completionAck = { ok: false, error: err.message };
171 }
172 if (!event._completionAck) {
173 event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
174 }
175 }
176
177 // Second signal path: stderr banner in case the agent parses stdout
178 // JSON but skips nested fields. One line is enough ā the full checklist
179 // is in reference/live.md.
180 if (event._acceptResult?.carbonize === true) {
181 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');
182 }
183
184 // Print the event as JSON ā the agent reads this from stdout
185 console.log(JSON.stringify(event));
186 } catch (err) {
187 if (err.cause?.code === 'ECONNREFUSED') {
188 console.error('Live server not running. Start one with: npx impeccable live');
189 } else {
190 console.error('Poll failed:', err.message);
191 }
192 process.exit(1);
193 }
194}
195
196// Auto-execute when run directly
197const _running = process.argv[1];
198if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
199 pollCli();
200}