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:
Abdul Wahab
2026-09-15 05:45:49 +05:00
committed by Abdul Wahab
co-authored by Claude Fable 5
parent 1c043ea7c9
commit fc89b0ed62
27 changed files with 1851 additions and 16 deletions
+2 -1
View File
@@ -67,7 +67,8 @@ Choose the mode from the requested surface, not the product, and persist it only
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
Routing: <!-- rule:skill-routing -->
+92
View File
@@ -0,0 +1,92 @@
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
Generate is a programmatic entry into live mode: the user names an element, a direction, and a count in one sentence, and you boot the live session, point the browser at the element, and the overlay scrolls to it, selects it, and fires the same Go a user click fires. Everything downstream is the standard live session. Read [live.md](live.md) in full now if you have not this session; this file is the entry ramp into its contract, and from Step 4 on you are inside it, with one deliberate divergence: Step 5 closes the session on its own once the accept lands, instead of staying open the way `live` does.
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
Three prohibitions cover the known ways this command goes wrong. Each names the tempting move first:
- The poll shows no generate event yet, and writing variants straight into source feels faster. **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go), and the server refuses events for any other id; a missing event is fixed in Step 2 or Step 3, never with a direct source edit.
- Handing the user a link to click feels polite. **Open the page yourself** (Step 2); a pasted link usually means no page ever connects.
- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `live-complete.mjs` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag.
## Step 1: Parse the request
Three parts, all from the user's sentence:
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
- **bold, bolder, stronger, punchier**: `bolder`
- **quiet, calmer, softer, toned down**: `quieter`
- **simpler, minimal, stripped**: `distill`
- **refined, tightened, polished**: `polish`
- **font and type words**: `typeset`
- **color words**: `colorize`
- **arrangement and spacing words**: `layout`
- **device and breakpoint words**: `adapt`
- **motion words**: `animate`
- **playful words**: `delight`
- **rule-breaking words**: `overdrive`
- **Nothing fits**: `impeccable`, with the user's wording passed as the prompt.
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
- **The element description** ("the pricing cards", "the hero heading"): Step 3 resolves it to a selector.
Done when you hold an action from the vocabulary, a count from 1 to 8, and the element description.
## Step 2: Boot live mode and open the page
Run the boot exactly as [live.md](live.md)'s Start section describes:
```bash
node {{scripts_path}}/live.mjs
```
**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first.
Then open the app URL that serves a `pageFiles` entry (never `serverPort`; that is the helper, not the app):
- **Cursor**: `browser_navigate` to the URL now; do not skip it.
- **Any other harness with a browser tool**: open the URL with that tool.
- **No browser tool exists in this harness**: tell the user the exact URL to open, and pass `--wait-for-browser 120000` in Step 3 so the command fires the moment their page connects.
Done when the boot printed `"ok": true` and a page with the overlay is connected, which Step 3 proves by answering anything other than `no_browser_connected`.
## Step 3: Target the element
Derive the selector from project source, not from guesswork: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so scoped CSS restyles every instance at once. **Unsure the selector resolves uniquely**: probe with `--dry-run`; it resolves and reports without starting anything, and it works even mid-session.
```bash
node {{scripts_path}}/live-generate.mjs --selector "section.pricing" --action bolder --count 3
```
Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser <ms>`.
Every verdict carries `_instructions` with the next move for that exact situation, with real values filled in; follow them over your recollection of this file. Two verdicts deserve naming because their fix sits outside the command:
- **`no_browser_connected`**: Step 2's page is not actually open; open it yourself, then rerun.
- **`ambiguous`**: the candidates are listed in the output; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
Done when the verdict is `ok: true` with a `sessionId`: the browser has scrolled to the element, entered the picked state, and fired Go.
## Step 4: Generate
Start the poll loop per your harness policy in [live.md](live.md). The queued event for the returned `sessionId` is a standard `generate` event with the picked element's context and a preflighted scaffold; handle it exactly per live.md's Handle generate, which owns everything from planning to the done reply.
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
**Publishing variants does not end the session.** Keep servicing the poll; accept, discard, and carbonize cleanup follow live.md unchanged, and the helper server stays up through the accept. Done when live.md's contract marks the event you handled complete and the poll is running again.
## Step 5: Close the session
Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `live-complete.mjs` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup:
```bash
node {{scripts_path}}/live-server.mjs stop
```
Stopping removes the injected live script, and that removal reloads the page one last time: the user's browser now shows the accepted design with no overlay chrome, still served by their dev server.
- **The user asks for more variants before you wrapped up**: skip the wrap-up, target the next element through the same session (Step 3, with `--dry-run` first when the selector is uncertain), and wrap up after the last accept.
- Restarting the dev server to freshen the page feels like tidying. **Never kill or restart the user's dev server**, including one you started in Step 2. It keeps serving the accepted source after wrap-up; a tab that still looks stale needs one hard refresh, not a new server. A relaunched server also hops to the next free port and strands every open tab on the dead one.
Done when the helper is stopped, the stop output reported the script tag removed, and the dev site still answers with the accepted design.
+1 -1
View File
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `{{scripts_path}}/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
+4
View File
@@ -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...)]"
+266 -1
View File
@@ -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) {
+227
View File
@@ -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 }));