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 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}