Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-07-23 05:50:13 +00:00
parent 2fa0e7d327
commit bdaa5a4eb9
120 changed files with 7155 additions and 555 deletions
+11 -5
View File
@@ -15,10 +15,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -114,7 +114,7 @@ node .agents/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -139,7 +139,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -156,7 +158,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -595,11 +597,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` — it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .claude/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .claude/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .cursor/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .cursor/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .gemini/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .gemini/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .github/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .github/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .grok/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .grok/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
+51 -16
View File
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .kiro/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .kiro/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
+51 -16
View File
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .opencode/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count E
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .opencode/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .pi/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT_C
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .pi/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
+51 -16
View File
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
+25 -1
View File
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
+24 -6
View File
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .qoder/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVEN
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .qoder/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .rovodev/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EV
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .rovodev/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .trae-cn/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EV
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .trae-cn/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -235,11 +235,12 @@ function recordAgentPhase(id, phase, details = {}) {
/**
* Detect a browser that missed the generation `done` broadcast.
*
* The preflight scaffold write triggers a framework full-reload (Astro reloads
* the page for any .astro edit). If the agent's variant write + `done` land
* while the browser is mid-reload, the new page misses both the second HMR
* reload and the SSE `done` it resumes from the scaffold-only source and
* sits in GENERATING at 0/N forever. That resumed page always checkpoints
* The preflight no longer writes the scaffold into source for source-preview
* targets (the agent writes wrapper + variants in one atomic edit), so the old
* scaffold-write full-reload that opened the "stranded at 0/N" race is gone.
* This recovery stays as defense in depth: any framework reload that drops the
* agent's variant write + `done` while the browser is mid-reload leaves the new
* page in GENERATING at 0/N. That resumed page always checkpoints
* (`browser_resumed`), so a checkpoint claiming "still generating, variants
* missing" for a session whose generation already completed is direct
* evidence of the miss. Rebuild the `done` payload from the snapshot so the
@@ -68,6 +68,13 @@ The agent should insert variant HTML at insertLine.`);
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
const pageUrl = argVal(args, '--page-url');
// Preflight passes this for source-preview targets. It computes the scaffold
// (element location + wrapper text) but does NOT write it into source. The
// agent then writes the wrapper + all variants in one atomic edit. The
// premature server-side write full-reloaded the framework mid-generate and
// stranded the browser at 0/N (live-server.mjs missed-completion note). It is
// a no-op on the svelte-component path, which never writes the route source.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -334,6 +341,7 @@ The agent should insert variant HTML at insertLine.`);
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
let deferredWrapper = null;
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
@@ -353,6 +361,20 @@ The agent should insert variant HTML at insertLine.`);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else if (deferSourceWrite) {
// Deferred source write: compute the scaffold text but leave source
// untouched. The agent replaces the picked element's source range with
// `wrapperBlock` (variants spliced at the marker) in one edit. Writing the
// scaffold here first would reload the framework before the agent's write
// lands, and a browser caught mid-reload misses the `done` and sits at 0/N.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: startLine + 1, // 1-indexed picked-element range the
replaceEndLine: endLine + 1, // agent's wrapper block replaces
};
// insertLine matches the final file position the wrapper occupies once the
// agent replaces the picked range, so downstream consumers stay consistent.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
} else {
// Replace the original element with the wrapper
const newLines = [
@@ -383,6 +405,13 @@ The agent should insert variant HTML at insertLine.`);
file: outputRelFile,
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
previewMode,
// Deferred source write: the wrapper is NOT yet in source. The agent
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
// spliced at the "insert below this line" marker) in one atomic edit.
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
@@ -5,7 +5,32 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const PREFLIGHT_TIMEOUT_MS = 15_000;
export function buildGenerationPreflight(event, scriptsDir) {
// Per-target cache of the resolved source file. The wrap search walks the whole
// project tree and was measured at ~7.6s on a large repo; it re-ran on every
// generate for the same picked element (re-rolls, param passes). Keyed by the
// target signature (locator + route), so it invalidates automatically when the
// element or route changes; a failed resolution evicts its entry (see below).
const sourceResolutionCache = new Map();
/** Test/lifecycle hook: drop all cached source resolutions. */
export function clearSourceResolutionCache() {
sourceResolutionCache.clear();
}
function targetSignature(event) {
const isInsert = event.mode === 'insert';
const target = isInsert ? insertTarget(event) : replaceTarget(event);
return JSON.stringify({
mode: isInsert ? 'insert' : 'replace',
position: isInsert ? target.position : null,
elementId: target.elementId || null,
classes: target.classes || null,
tag: target.tag || null,
pageUrl: event.pageUrl || null,
});
}
export function buildGenerationPreflight(event, scriptsDir, { cache = null } = {}) {
if (!event || event.type !== 'generate' || !event.id) return null;
const isInsert = event.mode === 'insert';
@@ -14,13 +39,24 @@ export function buildGenerationPreflight(event, scriptsDir) {
const script = path.join(scriptsDir, isInsert ? 'live-insert.mjs' : 'live-wrap.mjs');
const args = [script, '--id', event.id, '--count', String(event.count || 3)];
// Compute the scaffold but do not write it into source for source-preview
// targets. The agent writes wrapper + variants atomically; a premature
// server-side write reloads the framework and strands the browser at 0/N.
// No-op on the svelte-component path, which never writes the route source.
args.push('--defer-source-write');
if (isInsert) args.push('--position', target.position);
if (target.elementId) args.push('--element-id', target.elementId);
if (target.classes) args.push('--classes', target.classes);
if (target.tag) args.push('--tag', target.tag);
if (target.text) args.push('--text', target.text);
if (!isInsert && event.pageUrl) args.push('--page-url', event.pageUrl);
return { script, args, mode: isInsert ? 'insert' : 'replace' };
const signature = targetSignature(event);
// A cached resolution points the helper straight at the file, skipping the
// tree search. The helper still reads current content, so line ranges stay
// fresh; only discovery is cached.
const cachedFile = cache ? cache.get(signature) : null;
if (cachedFile) args.push('--file', cachedFile);
return { script, args, mode: isInsert ? 'insert' : 'replace', signature };
}
/**
@@ -38,8 +74,9 @@ export async function runGenerationPreflight(event, {
scriptsDir,
execFileImpl = execFileAsync,
timeoutMs = PREFLIGHT_TIMEOUT_MS,
cache = sourceResolutionCache,
} = {}) {
const command = buildGenerationPreflight(event, scriptsDir);
const command = buildGenerationPreflight(event, scriptsDir, { cache });
if (!command) {
return { ok: false, skipped: true, reason: 'insufficient_locator' };
}
@@ -53,13 +90,23 @@ export async function runGenerationPreflight(event, {
});
const line = String(stdout).trim().split('\n').filter(Boolean).pop();
if (!line) throw new Error('preflight returned no scaffold metadata');
const scaffold = JSON.parse(line);
// Cache the resolved SOURCE file (route source, not the svelte manifest) so
// the next generate on this target skips the tree search.
const resolvedSource = scaffold.sourceFile || scaffold.file;
if (cache && command.signature && typeof resolvedSource === 'string') {
cache.set(command.signature, resolvedSource);
}
return {
ok: true,
mode: command.mode,
durationMs: performance.now() - startedAt,
scaffold: JSON.parse(line),
scaffold,
};
} catch (error) {
// Evict a stale/failed resolution so the next attempt does a full search
// (the element may have moved out of the previously cached file).
if (cache && command.signature) cache.delete(command.signature);
return {
ok: false,
mode: command.mode,
@@ -0,0 +1,280 @@
/**
* TanStack Start live-mode adapter.
*
* TanStack Start is SSR: there is no static index.html to patch. The document
* shell is a React component (`shellComponent`/`component`) defined in the root
* route file, `src/routes/__root.tsx`, which renders `<html>…<body>{children}
* <Scripts /></body></html>`.
*
* A raw `<script src>` placed in that JSX is server-rendered into the streamed
* HTML, but React's script handling and hydration make it an unreliable place
* to load a cross-origin dev bundle. So, like the Nuxt and SvelteKit adapters,
* this keeps the injected code in a dev-only managed component that appends the
* live script on mount (client-only, after hydration). The adapter mounts that
* component from the root document and removes it cleanly on stop.
*
* The managed component lives OUTSIDE `src/routes/` (in `src/impeccable/`) so
* the TanStack Router file-based route generator never treats it as a route.
*/
import fs from 'node:fs';
import path from 'node:path';
import { buildLiveScriptSrc } from '../live-inject.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
export const TANSTACK_MARKER_CLOSE = '{/* impeccable-live-tanstack-end */}';
export const TANSTACK_COMPONENT_DIR = 'src/impeccable';
export const TANSTACK_COMPONENT_BASENAME = 'ImpeccableLiveRoot';
const ROOT_ROUTE_CANDIDATES = [
'src/routes/__root.tsx',
'src/routes/__root.jsx',
'src/routes/__root.ts',
'src/routes/__root.js',
'app/routes/__root.tsx',
'app/routes/__root.jsx',
];
const START_PACKAGES = [
'@tanstack/react-start',
'@tanstack/solid-start',
'@tanstack/start',
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
const componentExt = ext === '.jsx' || ext === '.js' ? '.jsx' : '.tsx';
const componentFile = `${TANSTACK_COMPONENT_DIR}/${TANSTACK_COMPONENT_BASENAME}${componentExt}`;
const componentImport = relativeImportSpecifier(rootRoute, componentFile);
return { rootRoute, componentFile, componentImport, ext };
}
export function applyTanStackLiveAdapter({ cwd = process.cwd(), port, token, project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
if (!Number.isFinite(Number(port))) {
throw new Error('TanStack Start live adapter requires a numeric port');
}
// Write the managed mount component.
const componentAbs = path.join(cwd, project.componentFile);
const componentBody = buildTanStackLiveRootComponent(Number(port), token);
const componentExisted = fs.existsSync(componentAbs);
if (componentExisted && !isManagedComponent(fs.readFileSync(componentAbs, 'utf-8'))) {
// A non-Impeccable file already sits at our managed path — refuse to clobber.
return {
file: project.componentFile,
error: 'tanstack_component_conflict',
hint: `${project.componentFile} already exists and is not managed by Impeccable Live`,
};
}
fs.mkdirSync(path.dirname(componentAbs), { recursive: true });
fs.writeFileSync(componentAbs, componentBody, 'utf-8');
// Patch the root document to import + render the mount component.
const rootAbs = path.join(cwd, project.rootRoute);
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = patchTanStackRoot(before, project.componentImport);
const changed = after !== before;
if (changed) fs.writeFileSync(rootAbs, after, 'utf-8');
return {
file: project.rootRoute,
adapter: 'tanstack-start',
inserted: changed || !componentExisted,
componentFile: project.componentFile,
devOnly: true,
};
}
export function removeTanStackLiveAdapter({ cwd = process.cwd(), project = detectTanStackStartProject(cwd) } = {}) {
if (!project) return { error: 'tanstack_not_detected' };
let removed = false;
const rootAbs = path.join(cwd, project.rootRoute);
if (fs.existsSync(rootAbs)) {
const before = fs.readFileSync(rootAbs, 'utf-8');
const after = unpatchTanStackRoot(before);
if (after !== before) {
fs.writeFileSync(rootAbs, after, 'utf-8');
removed = true;
}
}
const componentAbs = path.join(cwd, project.componentFile);
if (fs.existsSync(componentAbs)) {
fs.rmSync(componentAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(componentAbs), path.join(cwd, 'src'));
return {
file: project.rootRoute,
adapter: 'tanstack-start',
removed,
componentFile: project.componentFile,
};
}
export function patchTanStackRoot(content, componentImport) {
let out = String(content || '');
const importStatement = `import ImpeccableLiveRoot from '${componentImport}';`;
if (!out.includes(importStatement)) {
out = insertAfterLastImport(out, importStatement);
}
if (!out.includes(TANSTACK_MARKER_OPEN)) {
const block =
`${TANSTACK_MARKER_OPEN}\n`
+ ` <ImpeccableLiveRoot />\n`
+ ` ${TANSTACK_MARKER_CLOSE}\n `;
// Anchor before <Scripts …/> (the stable TanStack Start document marker);
// fall back to before </body>.
const scriptsMatch = out.match(/<Scripts\b/);
if (scriptsMatch) {
out = out.slice(0, scriptsMatch.index) + block + out.slice(scriptsMatch.index);
} else {
const bodyClose = out.lastIndexOf('</body>');
if (bodyClose !== -1) {
out = out.slice(0, bodyClose) + block + out.slice(bodyClose);
}
}
}
return out;
}
export function unpatchTanStackRoot(content) {
let out = String(content || '');
// Remove exactly the inserted block (open marker → component → close marker →
// trailing newline + the indent that leads back to the anchor). Leaving the
// leading indent before the open marker intact hands it back to the anchor
// (e.g. `<Scripts />`) so the file round-trips byte-for-byte.
const blockRe = new RegExp(
escapeRegExp(TANSTACK_MARKER_OPEN)
+ '\\s*<ImpeccableLiveRoot\\s*/>\\s*'
+ escapeRegExp(TANSTACK_MARKER_CLOSE)
+ '\\r?\\n?[ \\t]*',
'g',
);
out = out.replace(blockRe, '');
// Remove only the managed import line — not any following blank line.
out = out.replace(
new RegExp("^import ImpeccableLiveRoot from '[^']*';[ \\t]*\\r?\\n", 'gm'),
'',
);
return out;
}
export function buildTanStackLiveRootComponent(port, token) {
const liveSrc = buildLiveScriptSrc(Number(port), token);
return `/* impeccable-live-tanstack-start */
import { useEffect } from 'react';
const LIVE_SRC = '${liveSrc}';
const LIVE_SELECTOR = 'script[data-impeccable-live-tanstack]';
// Dev-only mount for Impeccable Live. TanStack Start server-renders the root
// document, so this appends the live-mode bundle from the client after
// hydration (mirrors the Nuxt/SvelteKit adapters). Renders nothing on the
// server, so there is no hydration mismatch.
export default function ImpeccableLiveRoot() {
useEffect(() => {
if (typeof document === 'undefined') return;
const expected = new URL(LIVE_SRC, window.location.href).href;
let script = document.querySelector(LIVE_SELECTOR);
if (script && script.src === expected) return;
if (script) script.remove();
script = document.createElement('script');
script.src = LIVE_SRC;
script.async = true;
script.setAttribute('data-impeccable-live-tanstack', '');
script.setAttribute('data-impeccable-live-script', 'true');
document.head.appendChild(script);
return () => {
if (script && script.isConnected) script.remove();
};
}, []);
return null;
}
`;
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// The managed mount component carries the `impeccable-live-tanstack` marker in
// its leading comment and its script data-attribute; user files never do.
function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
toFile.split(path.sep).join('/'),
).replace(/\.(tsx|ts|jsx|js)$/, '');
return rel.startsWith('.') ? rel : `./${rel}`;
}
function insertAfterLastImport(content, importStatement) {
const importRe = /^import\b[^\n]*\n/gm;
let lastEnd = -1;
let m;
while ((m = importRe.exec(content)) !== null) {
lastEnd = m.index + m[0].length;
}
if (lastEnd === -1) {
return `${importStatement}\n${content}`;
}
return content.slice(0, lastEnd) + importStatement + '\n' + content.slice(lastEnd);
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+11 -5
View File
@@ -13,10 +13,10 @@ Execute in order. No step skipped, no step reordered.
3. Poll loop with the default long timeout (600000 ms). Run `live-poll.mjs` again immediately after every event or `--reply`; Codex runs this one-shot poll in the foreground. Never pass a short `--timeout=`.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead.
4. On `generate`: reuse `event.scaffold` when present; read the screenshot if present; load the action's reference; deliver variants using the delivery policy below; `--reply done`; poll again. Generate in this thread. You already hold the project's tokens, conventions, and file layout; that context is the job, not overhead. During a live cycle the overlay's preview IS the verification channel: the user sees every variant rendered in their real page and picks. Do not screenshot, re-render, or QA variants between generate and accept; apply craft-floor's contrast, spacing, and type floors by construction as you write, not as a post-write inspection pass. Full verification, computed contrast, breakpoints, real-copy overflow, runs once at accept on the chosen variant during carbonize cleanup.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately. Carbonize accepts remain recoverable until the foreground task runs `live-complete.mjs --id EVENT_ID`; finish that cleanup before polling again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart. A dropped SSE connection or a closed tab does not end the session: the journal under `.impeccable/live/sessions/` is canonical, the injected `live.js` re-attaches when the page reopens, and `live-resume.mjs` replays the active snapshot. Tell the user to reopen the app URL (or restart `live-poll.mjs`) and continue; fall back to the direct-edit loop only when `live-resume.mjs` reports no active session, never because disconnects felt frequent.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
@@ -112,7 +112,7 @@ node .trae/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -137,7 +137,9 @@ Reading annotations precisely:
### 2. Wrap the element
When `event.scaffold` is present, the local helper already found and wrapped the source before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
When `event.scaffold` is present, the local helper already found the source and computed the wrapper before the poll returned. Treat `event.scaffold` as the successful helper output and skip this command entirely. `event.scaffoldAttempted` with `scaffoldError` means local preflight could not finish; use the command/fallback path below. This optimization removes a deterministic tool round trip without changing the generated design.
**On source-preview targets `event.scaffold` carries `sourceWritten: false`.** The helper did NOT write the wrapper into source; it hands you the wrapper as `scaffold.wrapperBlock` plus the picked element's source range (`scaffold.replaceStartLine`, `scaffold.replaceEndLine`, 1-indexed). Write the wrapper **and** all variants in ONE edit: splice your variants into `wrapperBlock` at the "Variants: insert below this line" marker, then replace source lines `[replaceStartLine, replaceEndLine]` with the result. A separate scaffold write reloads the framework before your variant write lands, and a browser caught mid-reload misses the `done` and sits at 0/N; the single edit avoids it. (`replaceEndLine < replaceStartLine` means insert mode: insert `wrapperBlock`, remove nothing.) The `svelte-component` path never sets `sourceWritten`; it follows the component-preview flow below unchanged.
```bash
node .trae/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
@@ -154,7 +156,7 @@ The helper searches ID first, then classes, then tag + class combo. If `event.pa
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
@@ -593,11 +595,15 @@ Schema:
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| TanStack Router (SPA, Vite) | `["index.html"]` | `</body>` | `html` |
| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `<Scripts` | `jsx` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
**Framework adapters (auto-detected at inject time).** SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `<script>` in the entry template will not execute reliably. `live-inject.mjs` detects these from the project and routes to a dedicated adapter instead of the literal `files` patch: SvelteKit mounts a dev-only root component from `+layout.svelte`; Nuxt writes a dev-only `.client.ts` plugin; TanStack Start (detected by `@tanstack/react-start` plus `src/routes/__root.tsx`) patches the `__root` document to render a generated dev-only `src/impeccable/ImpeccableLiveRoot` component that appends the bundle on mount. The `files` value stays a valid detection/CSP hint but is not the literal insertion site. A plain TanStack Router SPA (no `@tanstack/react-start`) has a static `index.html` and takes the baseline Vite path with no adapter.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
+51 -16
View File
@@ -127,6 +127,41 @@
let arrivedVariants = 0;
let visibleVariant = 0;
let generationPhase = null;
// Ascending order of the agent-generation lifecycle. The visible progress bar
// must never regress: a `browser_resumed`/behind checkpoint re-broadcasts an
// earlier phase (the server regresses the snapshot phase to `generating` on a
// behind checkpoint), and without this the bar jumps backward mid-generation.
// Unranked phases (params sidecar flow, unknown values) always pass so we
// never block a phase we do not model.
const PHASE_RANK = {
queued: 0,
picked_up: 1,
scaffolding: 2,
scaffold_fallback: 3,
source_ready: 4,
generation_ready: 5,
generating: 5,
variants_progress: 5,
first_variant_generating: 6,
first_variant_validating: 7,
first_reviewable: 8,
remaining_variants_generating: 9,
remaining_variants_validating: 10,
second_reviewable: 11,
all_variants_ready: 12,
variants_ready: 12,
variant_parameters_generating: 13,
variant_parameters_validating: 14,
parameters_ready: 15,
};
function shouldAdvancePhase(current, next) {
if (!next || next === current) return false;
const nextRank = PHASE_RANK[next];
const currentRank = PHASE_RANK[current];
// Only block a known-lower phase from overwriting a known-higher one.
if (nextRank === undefined || currentRank === undefined) return true;
return nextRank >= currentRank;
}
let parameterGenerationState = 'idle';
let parameterReadyAnnouncedSession = null;
let svelteComponentSession = null;
@@ -6347,7 +6382,10 @@
break;
case 'agent_phase':
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
generationPhase = msg.phase || generationPhase;
// Advance the visible phase monotonically. A behind/resumed
// checkpoint may carry an earlier phase for internal bookkeeping,
// but the bar must not move backward.
if (shouldAdvancePhase(generationPhase, msg.phase)) generationPhase = msg.phase;
if (msg.phase === 'variant_parameters_generating' || msg.phase === 'variant_parameters_validating') {
parameterGenerationState = 'loading';
}
@@ -6363,22 +6401,19 @@
if (msg.publicationKind === 'params') parameterGenerationState = 'loading';
rememberSessionFileMeta(msg);
if (isFrameworkComponentPreviewMode(msg.previewMode) && msg.previewFile) {
// Component-preview (Svelte/Vue) progressive delivery: the browser
// mounts compiled components, so there is no framework-owned DOM
// to race. Keep streaming each checkpoint into the preview.
injectSvelteComponentsFromManifest(msg.previewFile, msg.id);
} else if ((msg.previewMode === 'source' || !msg.previewMode) && (msg.previewFile || msg.file)) {
// Give normal framework HMR the first chance to reconcile its
// own managed tree. Nuxt route-module HMR can skip intermediate
// revisions, so fall back to source injection only when the
// advertised progress still has not appeared after a short
// settle. Immediate injection races React/Vue ownership and can
// trigger removeChild errors on the next HMR commit.
const targetArrived = Number(msg.arrivedVariants) || 1;
setTimeout(() => {
if (msg.id !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
if (msg.publicationKind !== 'params' && arrivedVariants >= targetArrived) return;
injectVariantsFromSource(msg.previewFile || msg.file, msg.id);
}, 150);
}
// Source-preview targets: do NOT source-inject per checkpoint.
// Immediate injection races framework (React/Vue) ownership mid-
// generation and triggers removeChild errors on the next HMR
// commit. Let HMR own reconciliation while variants stream in;
// source injection runs only on the final `done` (which keeps its
// 750ms settle + retry ladder for non-HMR harnesses like Cursor).
// The visible progress count still advances from the variant
// MutationObserver as HMR lands each variant.
}
break;
case 'steer_done':
@@ -6489,7 +6524,7 @@
function handleServerLost() {
const recoveryState = currentSessionId ? state : 'IDLE';
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
showToast('Live server disconnected. Session ended.', 5000);
showToast('Live server connection lost. Your session is saved; reopen this page or restart live-poll.mjs to continue.', 6000);
}
hideBar();
hideHighlight();
@@ -27,6 +27,11 @@ import {
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
import {
applyTanStackLiveAdapter,
detectTanStackStartProject,
removeTanStackLiveAdapter,
} from './live/tanstack-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
@@ -129,6 +134,7 @@ Output (JSON):
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
const nuxt = detectNuxtProject(process.cwd());
const tanstack = svelteKit || nuxt ? null : detectTanStackStartProject(process.cwd());
if (args.includes('--remove')) {
if (svelteKit) {
@@ -136,6 +142,12 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = removeTanStackLiveAdapter({ cwd: process.cwd(), project: tanstack });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'tanstack-start', results: [adapterResult] }));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = removeNuxtLiveAdapter({ cwd: process.cwd(), project: nuxt });
console.log(JSON.stringify({ ok: !adapterResult.error, adapter: 'nuxt', results: [adapterResult] }));
@@ -173,7 +185,7 @@ Output (JSON):
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
nuxt ? [nuxt.pluginFile] : tanstack ? [tanstack.componentFile] : [],
);
if (svelteKit) {
@@ -181,6 +193,18 @@ Output (JSON):
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (tanstack) {
const adapterResult = applyTanStackLiveAdapter({ cwd: process.cwd(), port, token, project: tanstack });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
adapter: 'tanstack-start',
gitIgnore,
results: [adapterResult],
}));
if (adapterResult.error) process.exitCode = 1;
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
@@ -131,6 +131,9 @@ Output (JSON):
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
// See live-wrap.mjs: preflight computes the scaffold but leaves source
// untouched so the agent's single edit is the only framework reload.
const deferSourceWrite = args.includes('--defer-source-write');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
@@ -244,12 +247,23 @@ Output (JSON):
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let deferredWrapper = null;
if (deferSourceWrite) {
// Insert-as-empty-range: the agent inserts `wrapperBlock` (variants spliced
// at the marker) at spliceIndex without removing any source line.
deferredWrapper = {
block: wrapperLines.join('\n'),
replaceStartLine: spliceIndex + 1,
replaceEndLine: spliceIndex, // empty range (endLine < startLine) => insertion
};
} else {
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
}
const insertLine = spliceIndex + 3;
@@ -257,6 +271,10 @@ Output (JSON):
mode: 'insert',
position,
file: relTargetFile,
sourceWritten: deferredWrapper ? false : undefined,
wrapperBlock: deferredWrapper ? deferredWrapper.block : undefined,
replaceStartLine: deferredWrapper ? deferredWrapper.replaceStartLine : undefined,
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,

Some files were not shown because too many files have changed in this diff Show More