Files
pbakaus_impeccable/skill/scripts/live-generate.mjs
T
fc89b0ed62 Add /impeccable generate: agent-initiated live variants (Node-era squash)
Squash of the ten commits reviewed on PR #626, plus the last review
round's connection-aware roll call, before the rebase onto the Rust
engine: the generate command reference and router row, the overlay's
agent-target handling (roll call, leases, replay, rescue), the Node-era
live-server routes and live-generate CLI, the hook stand-down, the pricing
cards e2e fixture, and the unit, contract, e2e, and skill-behavior tests.
The server, CLI, hook, and pin halves are ported to the engine crates in
the commits that follow.

AI-assisted: implemented and tested with Claude Code under maintainer
direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-15 05:45:49 +05:00

228 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* Agent-initiated element targeting for the `generate` command.
*
* Asks the live overlay to find an element by CSS selector, scroll to it,
* enter the picked state, and fire the normal Go pipeline with the given
* action and count. On success the browser starts a standard generate
* session; the agent then handles the resulting `generate` event from the
* poll loop exactly as live.md describes. Requires a running live helper
* server (live.mjs boot) and an open page with the overlay attached.
*
* Usage:
* node <scripts_path>/live-generate.mjs --selector "section.pricing" --action bolder --count 3
* node <scripts_path>/live-generate.mjs --selector ".card" --text "Studio" --action impeccable --prompt "warmer"
*
* Flags:
* --selector <css> required; resolved with document.querySelectorAll
* --text <snippet> optional; keeps only matches whose textContent contains it
* --index <n> optional; 1-based pick among the remaining matches
* --action <name> optional; one of the live action vocabulary (default: impeccable)
* --count <n> optional; variants to request, 1-8 (default: 3)
* --prompt <text> optional; freeform direction, same as typing before Go
* --dry-run optional; resolve and report without starting anything
* --wait-for-browser <ms> optional; poll the helper until a page with the
* overlay connects (or the budget runs out) before
* sending the target. For harnesses with no browser
* tool: hand the user the URL, run with this flag, and
* the command fires as soon as they open the page.
*/
import process from 'node:process';
import { enterLiveRoot } from './live/roots.mjs';
import { VISUAL_ACTIONS } from './live/vocabulary.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
enterLiveRoot(process.cwd());
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful print (nodejs/node#56645, issue #573), matching context.mjs.
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
async function fail(payload) {
console.log(JSON.stringify(payload, null, 2));
await destroyFetchDispatcher();
process.exit(1);
}
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
const key = arg.slice(2);
if (key === 'dry-run') { args['dry-run'] = true; continue; }
const value = argv[i + 1];
if (value === undefined || value.startsWith('--')) {
// Pre-fetch validation inside a sync helper: no socket can exist yet,
// so a plain synchronous exit is safe here.
console.log(JSON.stringify({ ok: false, error: 'missing_flag_value', flag: arg }, null, 2));
process.exit(1);
}
args[key] = value;
i += 1;
}
return args;
}
const args = parseArgs(process.argv.slice(2));
const selector = (args.selector || '').trim();
if (!selector) {
await fail({
ok: false,
error: 'selector_required',
_instructions: 'Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text "<visible text>" when the class repeats.',
});
}
const action = args.action || 'impeccable';
if (!VISUAL_ACTIONS.includes(action)) {
await fail({
ok: false,
error: 'invalid_action',
action,
validActions: VISUAL_ACTIONS,
_instructions: 'Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.',
});
}
const count = args.count === undefined ? 3 : Number(args.count);
if (!Number.isInteger(count) || count < 1 || count > 8) {
await fail({ ok: false, error: 'invalid_count', count: args.count, _instructions: 'Pass --count as an integer from 1 to 8.' });
}
let index;
if (args.index !== undefined) {
index = Number(args.index);
if (!Number.isInteger(index) || index < 1) {
await fail({ ok: false, error: 'invalid_index', index: args.index, _instructions: 'Pass --index as a 1-based integer position among the matches.' });
}
}
let waitForBrowserMs = 0;
if (args['wait-for-browser'] !== undefined) {
waitForBrowserMs = Number(args['wait-for-browser']);
if (!Number.isInteger(waitForBrowserMs) || waitForBrowserMs < 1) {
await fail({ ok: false, error: 'invalid_wait', wait: args['wait-for-browser'], _instructions: 'Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.' });
}
}
const found = readLiveServerInfo(process.cwd());
if (!found || !found.info || !found.info.port || !found.info.token) {
await fail({
ok: false,
error: 'server_not_running',
_instructions: 'No live helper server is recorded for this project. Run the live boot first (node <scripts_path>/live.mjs), open the app URL that serves a pageFiles entry, then rerun this command.',
});
}
const { port, token } = found.info;
const INSTRUCTIONS = {
ok: (r) => (r.dryRun
? `Dry run only: the selector resolves to one element (${r.element?.tag}${r.element?.id ? '#' + r.element.id : ''}) and no session was started. Rerun without --dry-run to generate.`
: `Session ${r.sessionId} started: the browser scrolled to the target and fired Go (action "${r.action}", count ${r.count}). Poll now with live-poll.mjs; the next event for this session is its generate event. Handle it exactly per live.md's Handle generate, then reply done and keep polling.`),
no_browser_connected: () => 'No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.',
browser_timeout: () => 'The overlay did not answer in time. The page may be mid-reload: run live-status.mjs to check whether a session started anyway, reload the app page, then rerun this command.',
invalid_selector: () => 'The selector is not valid CSS. Fix the selector syntax and rerun.',
no_match: (r) => (r.rawMatchCount > 0
? `The selector hit ${r.rawMatchCount} node(s) but none is pickable (too small, chrome, or filtered by --text). Target a larger element or adjust --text.`
: 'The selector matched nothing on the open page. Derive a better selector from the page source (an id, a unique class, or a landmark), or add --text with a snippet of the element\'s visible text.'),
ambiguous: (r) => `The selector matched ${r.matchCount} elements. Either target their common container instead, or disambiguate with --text "<visible text>" or --index <1-based position>. The candidates are listed in this output.`,
index_out_of_range: (r) => `--index is out of range: only ${r.matchCount} match(es). Use an index from 1 to ${r.matchCount}.`,
busy: (r) => `A live session is already mid-flight (browser state ${r.state}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.`,
go_failed: (r) => `The overlay could not start generation from the picked state (browser state ${r.state}). Reload the app page and rerun this command.`,
server_stopping: () => 'The live helper server is shutting down. Re-run the live boot (live.mjs), reopen the page, then rerun this command.',
};
async function waitForBrowserConnection(budgetMs) {
const deadline = Date.now() + budgetMs;
for (;;) {
let status;
try {
const res = await fetch(`http://127.0.0.1:${port}/status?token=${token}`, {
signal: AbortSignal.timeout(5_000),
});
status = await res.json();
} catch (err) {
await fail({
ok: false,
error: 'server_unreachable',
detail: err?.message,
_instructions: 'The recorded live server did not answer while waiting for a browser; it likely died. Re-run the live boot (node <scripts_path>/live.mjs), reopen the app page, then rerun this command.',
});
}
if ((status.connectedClients || 0) > 0) return;
if (Date.now() >= deadline) {
await fail({
ok: false,
error: 'no_browser_connected',
waitedMs: budgetMs,
_instructions: INSTRUCTIONS.no_browser_connected(),
});
}
await new Promise((r) => setTimeout(r, 1_000));
}
}
async function main() {
if (waitForBrowserMs > 0) await waitForBrowserConnection(waitForBrowserMs);
const body = {
token,
selector,
action,
count,
...(args.text ? { text: args.text } : {}),
...(index !== undefined ? { index } : {}),
...(args.prompt ? { prompt: args.prompt } : {}),
...(args['dry-run'] ? { dryRun: true } : {}),
};
let res;
try {
res = await fetch(`http://127.0.0.1:${port}/agent-target`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
// Client-side cap just above the server's 15s hold, so a hung helper
// still fails fast.
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError';
await fail({
ok: false,
error: timedOut ? 'request_timeout' : 'server_unreachable',
detail: err?.message,
_instructions: timedOut
? INSTRUCTIONS.browser_timeout()
: 'The recorded live server did not answer; it likely died. Re-run the live boot (node <scripts_path>/live.mjs), reopen the app page, then rerun this command.',
});
}
let result;
try {
result = await res.json();
} catch {
await fail({ ok: false, error: 'bad_server_response', status: res.status });
}
if (!res.ok) {
await fail({ ok: false, error: result.error || `http_${res.status}`, ...result });
}
const instructions = INSTRUCTIONS[result.ok ? 'ok' : result.error];
const output = {
...result,
...(instructions ? { _instructions: instructions(result) } : {}),
};
console.log(JSON.stringify(output, null, 2));
await destroyFetchDispatcher();
process.exit(result.ok ? 0 : 1);
}
main().catch((err) => fail({ ok: false, error: 'unexpected_failure', detail: err?.message }));