Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept

A ground-up hardening of live mode, driven by a production session in a
nested-app monorepo that hit six distinct failure classes. Full design
rationale in docs/LIVE-REWRITE-PLAN.md; every Codex-reported failure now
has a mechanical fix and a regression test.

Roots: live/roots.mjs resolves appRoot/repoRoot/contextRoot once at boot
(keyed on dev-server configs, not monorepo brand markers), persists a
manifest, and every live CLI re-anchors onto it at startup, so a helper
run from the wrong directory can no longer fork session state. Context
files are discovered upward to the git root.

Render truth: variant_mounted / variant_mount_failed events give the
journal per-variant mount state; failures reach the agent's poll queue,
raise a persistent error card with Retry (no more localStorage wipe), and
an attach probe names root/dev-server mismatches explicitly. The browser
rehydrates from the server when localStorage is gone.

Svelte: the scaffolder now parses with the app's own svelte 5 compiler.
Control flow survives (an each collection crosses the contract as one
structured prop), keyed each blocks hydrate synthetic keys, and anything
a detached preview cannot support falls back to source-preview instead of
shipping a wrong scaffold. Preview modules live in per-publish revision
directories, defeating stale transform caches.

Accept: CSS is reconciled, not appended. Matching selectors are replaced,
params bake from params.json kinds, the compiler's unused-selector pass
prunes superseded rules (pre-existing dead rules protected), a selector-
loss postcondition refuses any write that would drop hand-written rules,
and live-complete refuses to finish while live plumbing remains in source.

Also: framework registry (live/frameworks/) with a crash-safe injection
journal, session-store snapshot caching with read-only reads, protocol
enum consolidation, steer Send button, honest DESIGN-panel empty states.

Testing: new unit suites (roots, AST scaffolder, accept CSS, accept
pipeline, framework conformance); e2e now fails on preview-tree 404s,
proves computed-style mount for every variant, drives the Tune panel
through baked params, and injects failures (broken mounts, republish,
storage loss). New runtime fixtures: monorepo-nested-vite (repo root !=
app root) and vite8-sveltekit-stateful (each blocks + state). Nightly
full-matrix cron. An independent adversarial review pass preceded this
commit; its blocker and major findings are fixed and regression-tested.

This work was produced with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-27 15:09:40 -07:00
co-authored by Claude Code
parent 839dd10079
commit 17dabf4b7e
72 changed files with 9254 additions and 862 deletions
+349 -7
View File
@@ -89,13 +89,25 @@ export const STEER_MARKER_VALUE = 'e2e';
* - first variant visible (no display:none), rest hidden by the agent caller
* - inner content = single <h1> per variant
*/
export function createFakeAgent() {
export function createFakeAgent({ autoRepairMountFailures = true } = {}) {
return {
// Read by runAgentLoop's `variant_mount_failed` handler. Scenarios that
// assert on the persistent mount-error card turn this off so the agent
// does not republish the session out from under them.
autoRepairMountFailures,
/** @type {LiveAgent['generateVariants']} */
async generateVariants(event, context = {}) {
if (event.mode === 'insert') {
return generateInsertFakeVariants(context);
}
// Contract-v2 Svelte component previews get their own author path: the
// scaffolder already wrote stubs whose control flow and prop references
// are correct, so the only honest thing a variant can change is style.
if (context.wrapInfo?.previewMode === 'svelte-component') {
const svelteOutput = await generateSvelteComponentFakeVariants(event, context);
if (svelteOutput) return svelteOutput;
}
const text = event.element?.textContent?.trim() || extractText(event.element?.outerHTML) || 'Title';
const tag = (event.element?.tagName || 'h1').toLowerCase();
const cls = (event.element?.classes || ['hero-title'])
@@ -105,7 +117,14 @@ export function createFakeAgent() {
const preservedAttrs = buildPreservedVariantAttrs(event.element || {}, cls);
const elementOpen = `<${tag}${preservedAttrs}>`;
const elementClose = `</${tag}>`;
const variantHtml = `${elementOpen}${htmlEscape(text)}${elementClose}`;
// The JSX source may bind its text through an expression (`{item.title}`
// inside a `.map()`). The DOM only ever hands the agent the rendered
// string, so writing that back would freeze one item's text into the
// template for every item. The Svelte path already solves this with a
// prop contract; on the source-preview path the equivalent is to carry
// the original expression through untouched.
const sourceExprInner = await readJsxExpressionInner(context);
const variantHtml = `${elementOpen}${sourceExprInner ?? htmlEscape(text)}${elementClose}`;
const useAstroGlobalCss = context.wrapInfo?.styleMode === 'astro-global-prefixed';
// Variant 1 — red color, with a `range` param tuning hue lightness.
@@ -158,20 +177,25 @@ export function createFakeAgent() {
// Scoped CSS for most frameworks. Astro component styles are transformed
// and scoped by the compiler, so live preview CSS must use a global style
// tag plus explicit variant prefixes instead of raw @scope rules.
// Each variant carries a distinct font-weight so the E2E suite can prove
// which variant is actually rendered, not merely which one the bar says
// is selected. Keep these in sync with FAKE_VARIANT_FONT_WEIGHTS.
const scopedCss = useAstroGlobalCss
? [
`[data-impeccable-variant="1"] > ${tag} {`,
' font-weight: 300;',
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
'}',
`[data-impeccable-variant="2"] > ${tag} { font-weight: 900; }`,
`[data-impeccable-variant="2"][data-p-face="serif"] > ${tag} { font-family: ui-serif, serif; }`,
`[data-impeccable-variant="2"][data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`,
`[data-impeccable-variant="3"] > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`,
`[data-impeccable-variant="3"] > ${tag} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`,
`[data-impeccable-variant="3"][data-p-italic] > ${tag} { font-style: italic; }`,
].join('\n')
: [
'@scope ([data-impeccable-variant="1"]) {',
` :scope > ${tag} {`,
' font-weight: 300;',
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
' }',
'}',
@@ -181,7 +205,7 @@ export function createFakeAgent() {
` :scope[data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`,
'}',
'@scope ([data-impeccable-variant="3"]) {',
` :scope > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`,
` :scope > ${tag} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`,
` :scope[data-p-italic] > ${tag} { font-style: italic; }`,
'}',
].join('\n');
@@ -256,17 +280,19 @@ function generateInsertFakeVariants(context = {}) {
const scopedCss = useAstroGlobalCss
? [
'[data-impeccable-variant="1"] .inserted-copy {',
' font-weight: 300;',
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
'}',
'[data-impeccable-variant="2"] .inserted-copy { font-weight: 900; }',
'[data-impeccable-variant="2"][data-p-face="serif"] .inserted-copy { font-family: ui-serif, serif; }',
'[data-impeccable-variant="2"][data-p-face="mono"] .inserted-copy { font-family: ui-monospace, monospace; }',
'[data-impeccable-variant="3"] .inserted-copy { text-transform: uppercase; letter-spacing: 0.04em; }',
'[data-impeccable-variant="3"] .inserted-copy { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }',
'[data-impeccable-variant="3"][data-p-italic] .inserted-copy { font-style: italic; }',
].join('\n')
: [
'@scope ([data-impeccable-variant="1"]) {',
' :scope .inserted-copy {',
' font-weight: 300;',
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
' }',
'}',
@@ -276,7 +302,7 @@ function generateInsertFakeVariants(context = {}) {
' :scope[data-p-face="mono"] .inserted-copy { font-family: ui-monospace, monospace; }',
'}',
'@scope ([data-impeccable-variant="3"]) {',
' :scope .inserted-copy { text-transform: uppercase; letter-spacing: 0.04em; }',
' :scope .inserted-copy { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }',
' :scope[data-p-italic] .inserted-copy { font-style: italic; }',
'}',
].join('\n');
@@ -287,6 +313,187 @@ function generateInsertFakeVariants(context = {}) {
};
}
// ---------------------------------------------------------------------------
// Svelte component preview (contract v2)
//
// The scaffolder hands the agent stubs that already carry the selection's
// control flow ({#each}, {#if}) and prop references. A variant that re-derives
// markup from the live DOM would bake one item's rendered text into the loop
// template — exactly the failure the v2 contract exists to prevent. So the fake
// agent behaves like a well-behaved real one: it keeps every stub's script,
// comment, and markup byte-for-byte and rewrites only the <style> block.
//
// Each variant gets a distinct computed-style marker on the selection root
// (font-weight 300 / 900 / 600) so the E2E suite can prove which variant is
// actually mounted, plus the param hooks live.md section 7 describes:
// `var(--p-<id>, default)` for range/toggle and `:global([data-p-<id>="…"])`
// for steps. Params are declared in componentDir/params.json by the writer.
// ---------------------------------------------------------------------------
/**
* The computed `font-weight` each fake variant renders with, on every preview
* path (HTML/JSX scoped CSS, Astro global-prefixed, Svelte component). The E2E
* suite reads these back through getComputedStyle so "variant N is visible" is
* a render fact rather than a bar-label claim.
*/
export const FAKE_VARIANT_FONT_WEIGHTS = { 1: '300', 2: '900', 3: '600' };
async function generateSvelteComponentFakeVariants(event, context = {}) {
const manifest = await readSvelteComponentManifest(context);
if (!manifest || manifest.mode === 'insert') return null;
const shape = svelteSelectionShape(manifest.originalMarkup || '');
const count = Math.max(1, Number(event.count) || 3);
const variants = [];
for (let i = 0; i < count; i++) {
const variantId = i + 1;
variants.push({
// Stub-preserving path: the writer ignores innerHtml entirely and keeps
// the scaffolded markup. Kept as an empty string so the shared
// normalizeVariantOutput pass has a string to walk.
innerHtml: '',
params: svelteFakeVariantParams(variantId),
svelteComponent: { css: svelteFakeVariantCss(variantId, shape) },
});
}
return { scopedCss: '', variants };
}
/**
* Selection root + first classed descendant, read off the scaffold's own
* `originalMarkup`. Param-conditioned selectors need a descendant: after
* accept, `[data-p-*]` is stripped from the selector, and a rule whose whole
* selector was the stripped `:global([data-p-x="y"])` would be dropped along
* with it. Selections without a classed descendant still declare their params;
* they just don't wire CSS to them.
*/
function svelteSelectionShape(originalMarkup) {
const openTags = [...String(originalMarkup || '').matchAll(/<([a-zA-Z][\w:-]*)\b([^>]*)>/g)];
const described = openTags.map(([, tag, attrs]) => ({
tag: tag.toLowerCase(),
className: staticClassToken(attrs),
}));
const root = described[0] || { tag: 'div', className: '' };
const descendant = described.slice(1).find((entry) => entry.className);
return {
rootSelector: root.className ? `.${root.className}` : root.tag,
descendantSelector: descendant ? `.${descendant.className}` : null,
};
}
function staticClassToken(attrs) {
const match = String(attrs || '').match(/\bclass\s*=\s*(["'])(.*?)\1/);
if (!match) return '';
return match[2].split(/\s+/).find((token) => token && !token.includes('{')) || '';
}
function svelteFakeVariantParams(variantId) {
if (variantId === 1) {
return [
{ id: 'lightness', kind: 'range', min: 0.3, max: 0.7, step: 0.05, default: 0.5, label: 'Lightness' },
];
}
if (variantId === 2) {
return [
{ id: 'lead', kind: 'range', min: 1.2, max: 2, step: 0.1, default: 1.4, label: 'Lead' },
{
id: 'density',
kind: 'steps',
default: 'airy',
label: 'Density',
options: [
{ value: 'airy', label: 'Airy' },
{ value: 'snug', label: 'Snug' },
],
},
];
}
return [{ id: 'italic', kind: 'toggle', default: false, label: 'Italic' }];
}
function svelteFakeVariantCss(variantId, { rootSelector, descendantSelector }) {
const lines = [];
if (variantId === 1) {
lines.push(`${rootSelector} { font-weight: 300; color: oklch(var(--p-lightness, 0.5) 0.25 25); }`);
if (descendantSelector) lines.push(`${descendantSelector} { border-radius: 8px; }`);
} else if (variantId === 2) {
lines.push(`${rootSelector} { font-weight: 900; line-height: var(--p-lead, 1.4); }`);
if (descendantSelector) {
lines.push(`:global([data-p-density="airy"]) ${descendantSelector} { letter-spacing: 0.14em; }`);
lines.push(`:global([data-p-density="snug"]) ${descendantSelector} { letter-spacing: 0.01em; }`);
}
} else {
lines.push(`${rootSelector} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`);
if (descendantSelector) {
lines.push(`:global([data-p-italic]) ${descendantSelector} { font-style: italic; }`);
}
}
return lines.join('\n');
}
async function readSvelteComponentManifest(context = {}) {
const { tmp, wrapInfo } = context;
if (!tmp || !wrapInfo?.file) return null;
try {
return JSON.parse(await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8'));
} catch {
return null;
}
}
/**
* Replace a variant component's <style> block, keeping everything above it
* (script, prop comment, markup) exactly as the scaffolder wrote it.
*/
export function restyleSvelteComponentSource(source, css) {
const text = String(source || '');
const styleStart = text.lastIndexOf('<style');
const head = (styleStart === -1 ? text : text.slice(0, styleStart)).replace(/\s+$/, '');
const body = String(css || '').trim() || ':global(*) {}';
const indented = body.split('\n').map((line) => (line.trim() ? ' ' + line : '')).join('\n');
return `${head}\n\n<style>\n${indented}\n</style>\n`;
}
/**
* Re-author every variant of a live component session and tell the server the
* publish happened, exactly as the poll loop does after a generate. The server
* snapshots a fresh `r<N>/` revision dir on the `done` reply, so the browser
* imports from a path it has never seen and cannot serve a cached compile of
* the previous content.
*
* @param {object} opts
* @param {string} opts.tmp app root (where the manifest path resolves)
* @param {string} opts.manifestFile manifest path relative to `tmp`
* @param {{port: number, token: string}} opts.live
* @param {(variantNum: number, shape: object) => string} opts.css
*/
export async function republishSvelteComponentVariants({ tmp, manifestFile, live, css }) {
const manifestPath = path.join(tmp, manifestFile);
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
const componentDir = path.join(tmp, manifest.componentDir);
const shape = svelteSelectionShape(manifest.originalMarkup || '');
const count = Number(manifest.arrivedVariants) || Number(manifest.count) || 1;
for (let variantNum = 1; variantNum <= count; variantNum++) {
const file = path.join(componentDir, `v${variantNum}.svelte`);
let source;
try { source = await fs.readFile(file, 'utf-8'); } catch { continue; }
await fs.writeFile(file, restyleSvelteComponentSource(source, css(variantNum, shape)), 'utf-8');
}
const res = await fetch(`http://127.0.0.1:${live.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: live.token,
type: 'done',
sourceEventType: 'generate',
id: manifest.id,
file: manifestFile,
}),
});
if (!res.ok) throw new Error(`republish done reply failed: ${res.status} ${await res.text()}`);
return { manifest, shape };
}
export function insertTargetFromEvent(event) {
const anchor = event?.insert?.anchor || {};
const classes = Array.isArray(anchor.classes)
@@ -352,6 +559,74 @@ function attrEscape(str, { svelte = false } = {}) {
return s;
}
/**
* JSX-source counterpart to the Svelte prop contract.
*
* When the picked element's source content is a bare JSX expression (or text
* mixed with expressions), return it verbatim so the variant markup keeps the
* binding instead of the rendered snapshot. Returns null for every other
* shape, including nested elements, so this stays a narrow substitution rather
* than a general source-copy path.
*
* @param {{ wrapInfo?: object, tmp?: string }} context
* @returns {Promise<string|null>}
*/
async function readJsxExpressionInner(context = {}) {
const wrapInfo = context.wrapInfo;
if (!wrapInfo) return null;
// Svelte previews bind through propContract downstream; leave them alone.
if (wrapInfo.previewMode === 'svelte-component') return null;
if (wrapInfo.commentSyntax?.open !== '{/*') return null;
const original = await readOriginalMarkupFromWrap(wrapInfo, context.tmp);
const inner = extractInnerSourceMarkup(original);
if (inner == null) return null;
const trimmed = inner.trim();
if (!trimmed || trimmed.includes('<')) return null;
if (!/\{[^{}]+\}/.test(trimmed)) return null;
return trimmed;
}
/**
* Recover the picked element's source markup from the scaffold. Deferred
* writes carry it in `wrapperBlock`; otherwise the wrapper is already in the
* file and the same block can be read back from disk.
*/
async function readOriginalMarkupFromWrap(wrapInfo, tmp) {
let text = wrapInfo.wrapperBlock;
if (!text && tmp && wrapInfo.file) {
try {
text = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
} catch {
return null;
}
}
if (!text) return null;
const lines = String(text).split('\n');
const startIdx = lines.findIndex((line) => line.includes('data-impeccable-variant="original"'));
if (startIdx === -1) return null;
const indent = (lines[startIdx].match(/^\s*/) || [''])[0];
const closer = `${indent}</div>`;
for (let i = startIdx + 1; i < lines.length; i++) {
if (lines[i] === closer) return lines.slice(startIdx + 1, i).join('\n');
}
return null;
}
/** Content between an element's opening and closing tag, or null. */
function extractInnerSourceMarkup(markup) {
const src = String(markup || '').trim();
if (!src) return null;
const open = src.match(/^<([A-Za-z][\w:.-]*)([^>]*)>/);
if (!open || open[2].trim().endsWith('/')) return null;
const tag = open[1].toLowerCase();
const closeIdx = src.toLowerCase().lastIndexOf(`</${tag}`);
if (closeIdx < open[0].length) return null;
if (!/^<\/[A-Za-z][\w:.-]*\s*>$/.test(src.slice(closeIdx))) return null;
return src.slice(open[0].length, closeIdx);
}
/**
* Translate an HTML snippet to JSX. The fake and LLM agents write innerHtml
* in HTML form; the orchestrator translates per the target file's syntax.
@@ -1402,6 +1677,15 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writ
for (let i = 0; i < output.variants.length; i++) {
const variantId = i + 1;
const variant = output.variants[i];
// Contract-v2 path: keep the scaffolded stub (control flow + prop
// references) and swap only its <style> block.
if (variant.svelteComponent && !isInsert) {
const variantPath = path.join(componentDir, `v${variantId}.svelte`);
const stub = await fs.readFile(variantPath, 'utf-8');
await fs.writeFile(variantPath, restyleSvelteComponentSource(stub, variant.svelteComponent.css), 'utf-8');
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
continue;
}
const tag = firstTagName(variant.innerHtml) || firstTagName(baseMarkup) || 'div';
let markup = substituteLiveTextWithProps(variant.innerHtml || '', contract, textValues).trim();
if (!isInsert && contract.length > 0 && !propNames.some((name) => markup.includes(`{${name}}`))) {
@@ -1573,6 +1857,13 @@ export async function runAgentLoop({
steerTarget,
}) {
const base = `http://127.0.0.1:${port}`;
// Everything the agent needs to republish a component session without
// re-running generate: the scaffold info and the variant set it authored.
const publishedComponentSessions = new Map();
// A republish that fails to mount for the same reason would loop forever;
// repair each (session, variant) at most once and let the user's Retry (or
// the mount-error card) own anything beyond that.
const repairedMounts = new Set();
while (!signal.aborted) {
let event;
@@ -1689,7 +1980,7 @@ export async function runAgentLoop({
// the request for the remaining variants completes.
trace('agent.generate.start', { id: event.id, count: event.count });
let output = normalizeVariantOutput(
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
await agent.generateVariants(event, { wrapTarget, wrapInfo, tmp }),
wrapInfo,
);
if (atomicDelayMs > 0) {
@@ -1706,6 +1997,7 @@ export async function runAgentLoop({
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
if (wrapInfo.previewMode === 'svelte-component') {
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
publishedComponentSessions.set(event.id, { wrapInfo, event, output });
} else if (wrapInfo.sourceWritten === false) {
await writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId: event.id, output });
} else {
@@ -1745,6 +2037,56 @@ export async function runAgentLoop({
continue;
}
// The browser could not render something the agent published. Only the
// agent can fix that, which is why the server queues it as a first-class
// event instead of leaving it in the journal. Rewriting the variant files
// and replying `done` makes the server snapshot a fresh revision dir and
// rebroadcast, which is what drives the browser's remount.
if (event.type === 'variant_mount_failed') {
const key = `${event.id}|${event.variant}`;
const published = publishedComponentSessions.get(event.id);
log(`variant_mount_failed id=${event.id} variant=${event.variant} error=${JSON.stringify(event.error)}`);
if (agent.autoRepairMountFailures === false) {
log('auto-repair disabled; leaving the mount-error card for the user to retry');
continue;
}
if (!published) {
log('no published component session to repair; ignoring');
continue;
}
if (repairedMounts.has(key)) {
log('already republished this variant once; not looping');
continue;
}
repairedMounts.add(key);
try {
await writeSvelteComponentVariants({
tmp,
wrapInfo: published.wrapInfo,
event: published.event,
output: published.output,
writeParams: true,
});
await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
type: 'done',
sourceEventType: 'generate',
id: event.id,
file: published.wrapInfo.file,
}),
signal,
});
log(`republished component variants for ${event.id}`);
} catch (err) {
if (signal.aborted) return;
log('variant_mount_failed repair failed: ' + err.message);
}
continue;
}
if (event.type === 'manual_edit_apply') {
const entryCount = event.batch?.entries?.length || 0;
const opCount = (event.batch?.entries || []).reduce((sum, entry) => sum + (entry.ops?.length || 0), 0) || entryCount;