1/**
2 * Attach an LLM-backed agent loop to a live-server that is already running
3 * in the current working directory. Reads port + token from the PID file
4 * (.impeccable-live.json) that live-server.mjs --background writes on boot.
5 *
6 * Usage (from the project root):
7 * ANTHROPIC_API_KEY=... node tools/live-loop.mjs
8 *
9 * Optional:
10 * IMPECCABLE_E2E_LLM_MODEL=claude-sonnet-4-6 (default: claude-haiku-4-5)
11 *
12 * Stop with Ctrl-C; the live-server keeps running until you call
13 * `node skill/scripts/live-server.mjs stop`.
14 */
15
16import fs from 'node:fs';
17import path from 'node:path';
18import { fileURLToPath } from 'node:url';
19
20import { runAgentLoop } from '../tests/live-e2e/agent.mjs';
21import { createLlmAgent } from '../tests/live-e2e/agents/llm-agent.mjs';
22
23const __dirname = path.dirname(fileURLToPath(import.meta.url));
24const REPO_ROOT = path.join(__dirname, '..');
25const SCRIPTS_DIR = path.join(REPO_ROOT, 'skill', 'scripts');
26const PID_FILE = path.join(REPO_ROOT, '.impeccable-live.json');
27
28if (!fs.existsSync(PID_FILE)) {
29 console.error(
30 `No live-server PID file at ${PID_FILE}. Start one first:\n` +
31 ` node skill/scripts/live-server.mjs --background\n` +
32 ` node skill/scripts/live-inject.mjs --port <PORT>`,
33 );
34 process.exit(1);
35}
36
37const { port, token } = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
38console.log(`Attaching agent loop to live-server on :${port}`);
39
40const agent = await createLlmAgent({
41 model: process.env.IMPECCABLE_E2E_LLM_MODEL,
42 log: (m) => console.log('[llm] ' + m),
43});
44if (!agent) {
45 console.error('ANTHROPIC_API_KEY is not set. Set it and re-run.');
46 process.exit(1);
47}
48
49const controller = new AbortController();
50
51process.on('SIGINT', () => {
52 console.log('\nStopping agent loop (live-server stays running).');
53 controller.abort();
54 setTimeout(() => process.exit(0), 200);
55});
56process.on('SIGTERM', () => controller.abort());
57
58console.log(
59 `Agent ready (model=${process.env.IMPECCABLE_E2E_LLM_MODEL || 'claude-haiku-4-5'}).\n` +
60 `Pick an element in the browser and hit Go. Ctrl-C to stop.`,
61);
62
63await runAgentLoop({
64 tmp: REPO_ROOT,
65 scriptsDir: SCRIPTS_DIR,
66 port,
67 token,
68 agent,
69 signal: controller.signal,
70 log: (m) => console.log('[agent] ' + m),
71 // Per-event wrap target derived from the picked element's payload.
72 // Preference: id > first class > tag-only.
73 wrapTarget: (event) => {
74 const el = event.element || {};
75 const out = {};
76 if (el.id) out.elementId = el.id;
77 else if (Array.isArray(el.classes) && el.classes.length > 0) {
78 // live-wrap matches when ALL listed classes are present on the source
79 // node, so include only the first to maximize match likelihood.
80 out.classes = el.classes[0];
81 }
82 if (el.tagName) out.tag = el.tagName.toLowerCase();
83 return out;
84 },
85});