mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 10:36:27 +03:00
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>
This commit is contained in:
committed by
Abdul Wahab
co-authored by
Claude Fable 5
parent
1c043ea7c9
commit
fc89b0ed62
@@ -19,6 +19,10 @@
|
||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||
"argumentHint": ""
|
||||
},
|
||||
"generate": {
|
||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
||||
"argumentHint": "[count] [direction] variants of [element]"
|
||||
},
|
||||
"adapt": {
|
||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||
|
||||
@@ -2037,6 +2037,7 @@
|
||||
function setLiveState(next) {
|
||||
state = next;
|
||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||
retryDeclinedAgentTargets();
|
||||
syncPageInteractionCursor();
|
||||
// Whether a queued steer is still behind a generation is a function of this
|
||||
// state, so the hint has to move with it, not only with the 5s poll.
|
||||
@@ -4014,6 +4015,7 @@
|
||||
|
||||
function hidePendingApplyDock() {
|
||||
pendingApplyInFlight = false;
|
||||
retryDeclinedAgentTargets();
|
||||
clearStoredManualApplyState();
|
||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||
@@ -4047,6 +4049,7 @@
|
||||
function setPendingApplyLoading(loading, count) {
|
||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||
pendingApplyInFlight = loading === true;
|
||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||
else clearStoredManualApplyState();
|
||||
@@ -7112,6 +7115,262 @@
|
||||
}
|
||||
|
||||
//
|
||||
// ------------------------------------------------------------------
|
||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
||||
// element by CSS selector over POST /agent-target; the server pushes an
|
||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
||||
// scrolls the element into view, enters the same picked state a user
|
||||
// click produces, and fires the normal Go pipeline, so everything
|
||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
||||
// The verdict goes back through POST /agent-target-result, which resolves
|
||||
// the agent's held-open CLI call.
|
||||
|
||||
function postAgentTargetResult(targetId, result) {
|
||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: TOKEN, targetId, ...result }),
|
||||
}).catch(() => { /* server gone; nothing to report to */ });
|
||||
}
|
||||
|
||||
function describeAgentTargetCandidate(el) {
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || null,
|
||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
||||
text: (el.textContent || '').trim().slice(0, 80),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAgentTargetElement(msg) {
|
||||
let matched;
|
||||
try {
|
||||
matched = [...document.querySelectorAll(msg.selector)];
|
||||
} catch {
|
||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
||||
}
|
||||
let candidates = matched.filter((el) => pickable(el));
|
||||
if (msg.text) {
|
||||
const needle = String(msg.text).toLowerCase();
|
||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
error: {
|
||||
ok: false,
|
||||
error: 'no_match',
|
||||
selector: msg.selector,
|
||||
matchCount: 0,
|
||||
// How many nodes the raw selector hit before the pickable/text
|
||||
// filters: distinguishes a wrong selector from an unpickable match.
|
||||
rawMatchCount: matched.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Number.isInteger(msg.index)) {
|
||||
const el = candidates[msg.index - 1];
|
||||
if (!el) {
|
||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
||||
}
|
||||
return { el, matchCount: candidates.length };
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
return {
|
||||
error: {
|
||||
ok: false,
|
||||
error: 'ambiguous',
|
||||
selector: msg.selector,
|
||||
matchCount: candidates.length,
|
||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { el: candidates[0], matchCount: 1 };
|
||||
}
|
||||
|
||||
function scrollAgentTargetIntoView(el, done) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
||||
let settled = false;
|
||||
let fallback = null;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
removeEventListener('scrollend', finish, true);
|
||||
if (fallback) clearTimeout(fallback);
|
||||
done();
|
||||
};
|
||||
// scrollend where supported; a timer covers engines without it and the
|
||||
// no-movement case (element already at its final resting position).
|
||||
addEventListener('scrollend', finish, true);
|
||||
fallback = setTimeout(finish, 1200);
|
||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// One id per page load: the server keys claims and roll-call reports on
|
||||
// it, and only the tab that holds the lease can renew it.
|
||||
const AGENT_TARGET_CLIENT_ID = id8();
|
||||
|
||||
function claimAgentTarget(targetId, report) {
|
||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
||||
}).then((res) => res.json())
|
||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
||||
.catch(() => ({ granted: false, pending: false }));
|
||||
}
|
||||
|
||||
function agentTargetBusyReason() {
|
||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
||||
// at that moment: the moment it is free again (setLiveState), it claims
|
||||
// each of these as eligible, and the server drops the stale report, so a
|
||||
// busy verdict is never built on a tab that has since gone idle. The
|
||||
// server denies claims for resolved targets, so retries are harmless.
|
||||
const busyDeclinedTargets = new Map();
|
||||
|
||||
function declineAgentTargetBusy(msg, busy) {
|
||||
busyDeclinedTargets.set(msg.targetId, msg);
|
||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
||||
}
|
||||
|
||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
||||
// serve a target and must not even claim one: it would hold the lease for
|
||||
// a request it will never act on.
|
||||
function agentTargetOverlayGone() {
|
||||
return !evtSource;
|
||||
}
|
||||
|
||||
// A denied claimant retries at this cadence, a little over the lease, so
|
||||
// the first retry after a dead holder's lease lapses is granted.
|
||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
||||
|
||||
// Claim the lease and act as the holder. A denied claim means another tab
|
||||
// holds the lease. That holder can die before posting its result (reload,
|
||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
||||
// keeps retrying for as long as the server still holds the request: the
|
||||
// answer's `pending` is the server's word that the request is alive, and
|
||||
// it turns false the moment the request resolved or timed out, so no tab
|
||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
||||
// the roll call instead of taking a lease it cannot use. The first claim
|
||||
// and the busy-to-idle re-claim share this.
|
||||
function claimAndActOnAgentTarget(msg) {
|
||||
if (agentTargetOverlayGone()) return;
|
||||
const busy = agentTargetBusyReason();
|
||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
||||
if (claim.granted) { actOnAgentTarget(msg); return; }
|
||||
if (!claim.pending) return;
|
||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
function retryDeclinedAgentTargets() {
|
||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
||||
busyDeclinedTargets.delete(targetId);
|
||||
claimAndActOnAgentTarget(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAgentTarget(msg) {
|
||||
if (!msg || typeof msg.targetId !== 'string') return;
|
||||
const busy = agentTargetBusyReason();
|
||||
if (busy) {
|
||||
// Roll call: a busy tab reports itself and never acts. The server
|
||||
// answers `busy` the moment every connected overlay has reported, so
|
||||
// an idle tab elsewhere is never raced by a timer.
|
||||
declineAgentTargetBusy(msg, busy);
|
||||
return;
|
||||
}
|
||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
||||
// hidden tab yields a short head start so a visible one wins when both
|
||||
// exist, and still serves the request on its own: the user finds the
|
||||
// selection waiting when they return to it.
|
||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
||||
}
|
||||
|
||||
function actOnAgentTarget(msg) {
|
||||
if (agentTargetOverlayGone()) return;
|
||||
const reply = (result) => postAgentTargetResult(msg.targetId, result);
|
||||
const busy = agentTargetBusyReason();
|
||||
if (busy) {
|
||||
// Turned busy between claim and act: report it, which also hands the
|
||||
// lease back so the roll call can complete or a rescuer can claim.
|
||||
declineAgentTargetBusy(msg, busy);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveAgentTargetElement(msg);
|
||||
if (resolved.error) { reply(resolved.error); return; }
|
||||
const el = resolved.el;
|
||||
if (msg.dryRun) {
|
||||
reply({
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
matchCount: resolved.matchCount,
|
||||
element: describeAgentTargetCandidate(el),
|
||||
});
|
||||
return;
|
||||
}
|
||||
scrollAgentTargetIntoView(el, () => {
|
||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
||||
// for a rescuer instead of Go minting a session on a dismantled
|
||||
// overlay.
|
||||
if (agentTargetOverlayGone()) return;
|
||||
// Renew the lease right before the irreversible part: a tab whose
|
||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
||||
// one request never gets two Go presses.
|
||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
||||
if (!renewal.granted) return;
|
||||
// An insert placement left mid-configure gives way, exactly as a
|
||||
// click outside it does in handleClick.
|
||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
||||
// Mirror of the user-click pick entry in handleClick, minus the
|
||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
||||
// goes through beginNewLiveConfiguration like every other pick so
|
||||
// deferred recovery sees a fresh interaction revision.
|
||||
selectedElement = el;
|
||||
beginNewLiveConfiguration();
|
||||
showHighlight(selectedElement);
|
||||
clearAnnotations();
|
||||
showAnnotOverlay(selectedElement);
|
||||
showBar('configure');
|
||||
renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden');
|
||||
startScrollTracking();
|
||||
maybePrefetchPage();
|
||||
maybeWarnConditionalAncestor(selectedElement);
|
||||
// Preset what the agent asked for, then fire the same Go a user press
|
||||
// fires. handleGo reads exactly these inputs.
|
||||
selectedAction = msg.action;
|
||||
selectedCount = msg.count;
|
||||
// updateBarContent rebuilds the configure row and replaces the input
|
||||
// element, so the prompt must be written into the input it creates,
|
||||
// never before (the action-chip click handler does the same dance).
|
||||
updateBarContent('configure');
|
||||
const input = uiGetById(PREFIX + '-input');
|
||||
if (input) input.value = msg.prompt || '';
|
||||
handleGo();
|
||||
if (state === 'GENERATING' && currentSessionId) {
|
||||
reply({
|
||||
ok: true,
|
||||
matchCount: resolved.matchCount,
|
||||
sessionId: currentSessionId,
|
||||
action: msg.action,
|
||||
count: msg.count,
|
||||
element: describeAgentTargetCandidate(el),
|
||||
});
|
||||
} else {
|
||||
reply({ ok: false, error: 'go_failed', state });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// SSE (server→browser) + fetch POST (browser→server)
|
||||
// Zero-dependency replacement for WebSocket.
|
||||
//
|
||||
@@ -7121,7 +7380,7 @@
|
||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||
|
||||
function connectSSE() {
|
||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
||||
|
||||
evtSource.onopen = () => {
|
||||
sseRetries = 0; // reset on successful (re)connect
|
||||
@@ -7146,6 +7405,9 @@
|
||||
case 'agent_polling':
|
||||
syncAgentPollingUi(!!msg.connected);
|
||||
break;
|
||||
case 'agent_target':
|
||||
handleAgentTarget(msg);
|
||||
break;
|
||||
case 'agent_phase':
|
||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||
// Advance the visible phase monotonically. A behind/resumed
|
||||
@@ -11715,6 +11977,9 @@ void main() {
|
||||
|
||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||
function teardown() {
|
||||
// Declined targets die with the overlay: the IDLE transition below must
|
||||
// not re-claim a lease this page can no longer act on.
|
||||
busyDeclinedTargets.clear();
|
||||
stopAgentStatusPoll();
|
||||
hideAgentPollTooltip();
|
||||
if (agentPollTooltipEl) {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/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 }));
|
||||
Reference in New Issue
Block a user