diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 2a4d50def..9788c211c 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -21,6 +21,7 @@ const TOKEN = window.__IMPECCABLE_TOKEN__; const PORT = window.__IMPECCABLE_PORT__; + const APP_ROOT = window.__IMPECCABLE_APP_ROOT__ || null; if (!TOKEN || !PORT) { window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init return; @@ -7132,6 +7133,13 @@ if (currentSessionId) saveSession(); } + // Progress events must never overtake the event that CREATES their session: + // the Go-time checkpoint and the generate POST are concurrent fetches, and + // when the checkpoint lands first the server rightly refuses it as + // unknown_session — which must mean "foreign leftovers", not "you raced + // your own Go click". The gate serializes creation before progress. + let sessionCreationGate = Promise.resolve(); + function sendEvent(msg, opts) { msg.token = TOKEN; function handleFailure(err) { @@ -7142,15 +7150,42 @@ console.debug('[impeccable] Dropped optional live event:', err); return null; } - return fetch('http://localhost:' + PORT + '/events', { + const doSend = () => fetch('http://localhost:' + PORT + '/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(msg), }).then(async res => { if (res.ok) return res; const body = await res.json().catch(() => ({})); + // The server refused to journal progress for a session it has never + // seen: this browser is carrying state from another project or a + // wiped store (two apps sharing a localhost port). Continuing to + // report it would freeze the picker behind a session that can never + // complete, so drop the local state and hand the surface back. + if (body.error === 'unknown_session' && msg.type === 'checkpoint' + && msg.id && msg.id === currentSessionId) { + abandonForeignSession(msg.id); + return null; + } return handleFailure(new Error(body.error || ('HTTP ' + res.status + ' ' + res.statusText))); }).catch(handleFailure); + + if (msg.type === 'generate' || msg.type === 'steer') { + const creation = doSend(); + sessionCreationGate = creation.then(() => {}, () => {}); + return creation; + } + return sessionCreationGate.then(doSend); + } + + let abandonedForeignSessionId = null; + function abandonForeignSession(sessionId) { + if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return; + abandonedForeignSessionId = sessionId; + console.warn('[impeccable] The live server has no record of session ' + sessionId + '; clearing stale local state.'); + markSessionHandled(); + cleanup({ instantChrome: true }); + showToast('A saved live session belonged to a different project, so it was cleared. Pick an element to start fresh.', 6000); } function checkpointPayload(reason) { @@ -8894,6 +8929,7 @@ void main() { // it here would overwrite the Go-time value every time state changes. sessionState.saveSession({ id: currentSessionId, + appRoot: APP_ROOT || undefined, state, action: selectedAction, count: selectedCount, @@ -8915,7 +8951,17 @@ void main() { } function loadSession() { - return sessionState.loadSession(); + const saved = sessionState.loadSession(); + // localStorage is per-origin, and two projects routinely reuse the same + // localhost port. A saved session stamped with another project's appRoot + // is that project's leftover, never a session this server can complete; + // resuming it freezes the picker behind an unfinishable banner. + if (saved?.appRoot && APP_ROOT && saved.appRoot !== APP_ROOT) { + console.warn('[impeccable] Ignoring saved live session from another project (' + saved.appRoot + ').'); + sessionState.clearSession(); + return null; + } + return saved; } function clearSession() { @@ -10205,10 +10251,11 @@ void main() { const id = id8(); steerRequestId = id; steerPendingMessage = text; - if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); lockSteerChat(); scheduleSteerAwaitTimeout(id); - sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); + // Checkpoints follow the steer event, never precede it: the steer event + // is what creates the session journal server-side, and a checkpoint for + // a not-yet-created session is rejected as unknown_session. sendEvent({ type: 'steer', id, @@ -10216,9 +10263,11 @@ void main() { pageUrl: location.href, }).then((res) => { if (!res) { - sendSteerCheckpoint(id, 'steer_send_failed', { message: text }); unlockSteerChat({ error: 'Could not reach live server', restoreMessage: text }); + return; } + if (steerInputWasFocused) sendSteerCheckpoint(id, 'steer_input_focused', { focused: true }); + sendSteerCheckpoint(id, 'steer_submitted', { message: text, pageUrl: location.href }); }); } diff --git a/skill/scripts/live-inject.mjs b/skill/scripts/live-inject.mjs index 65455c3f4..81848010b 100644 --- a/skill/scripts/live-inject.mjs +++ b/skill/scripts/live-inject.mjs @@ -205,9 +205,19 @@ Output (JSON): process.exit(1); } // Optional server token: appended to the /live.js src so the token-gated - // /live.js handler authorizes the browser fetch. `live.mjs` always passes it. + // /live.js handler authorizes the browser fetch. `live.mjs` always passes + // it; a manual `--port`-only invocation reads the running helper's token + // from server.json instead of writing an unauthenticated URL that 401s. const tokenIdx = args.indexOf('--token'); - const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined; + let token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined; + if (!token) { + try { + const info = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', 'live', 'server.json'), 'utf-8')); + // A record for a DIFFERENT port is a stale or foreign helper; its token + // would 401 just the same, so only adopt a matching one. + if (info?.token && Number(info.port) === port) token = info.token; + } catch { /* no running helper recorded; keep legacy tokenless behavior */ } + } // Reconcile before writing anything. Artifacts this run is about to own are // kept (so a repeat inject stays byte-idempotent); artifacts left behind by diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 4f0381fbc..bfad7a245 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -91,6 +91,12 @@ function resolveProjectContext() { } const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// The browser events allowed to mint a NEW session journal. `generate` starts +// a variant session at Go; `steer` mints its own request id. Every other +// id-carrying event must land on an existing session (see the unknown_session +// gate in the /events handler). +const SESSION_CREATING_EVENT_TYPES = new Set(['generate', 'steer']); // The browser checkpoints for several unrelated reasons (see checkpointPayload // in live-browser.js). Only these two report that variant availability changed, // and only they may drive variant_progress / the *_reviewable phases. @@ -730,6 +736,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { port: state.port, vocabulary: LIVE_COMMANDS, commandPrefix: IMPECCABLE_COMMAND_PREFIX, + appRoot: process.cwd(), parts, }); res.writeHead(200, { @@ -1020,6 +1027,20 @@ function createRequestHandler({ detectScript, liveScriptParts }) { res.end(JSON.stringify({ ok: true })); return; } + // Only the events that START a session may create its journal. + // Everything else (checkpoints, mount acks, accept/discard) must + // reference a session THIS store already knows: appendEvent creates a + // journal for any id it is handed, so without this gate a browser + // resuming another project's session from per-origin storage (two + // apps sharing a localhost port) materializes a ghost session here + // that keeps reattaching after every discard. + if (msg.id && state.sessionStore + && !SESSION_CREATING_EVENT_TYPES.has(msg.type) + && !state.sessionStore.has(msg.id)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unknown_session', id: msg.id })); + return; + } const missedCompletion = detectMissedGenerationCompletion(msg); if (state.sessionStore && msg.id) { try { diff --git a/skill/scripts/live.mjs b/skill/scripts/live.mjs index 306de3cae..b04d98f50 100644 --- a/skill/scripts/live.mjs +++ b/skill/scripts/live.mjs @@ -169,12 +169,20 @@ The agent should then: let surfaceBrief = null; let surfaceBriefPath = null; try { - const resolvedBrief = resolveSurfaceBrief(roots.appRoot, liveTarget.absoluteTargetPath || null); - if (resolvedBrief?.brief) { + // Briefs live under .impeccable/surfaces, which in a nested-app repo sits + // at the CONTEXT or repo root, not the app root; context.mjs already finds + // them there, and live must not report "no brief" for the same project. + const briefRoots = [roots.appRoot, roots.contextRoot, roots.repoRoot] + .filter(Boolean) + .filter((dir, i, arr) => arr.findIndex((other) => path.resolve(other) === path.resolve(dir)) === i); + for (const briefRoot of briefRoots) { + const resolvedBrief = resolveSurfaceBrief(briefRoot, liveTarget.absoluteTargetPath || null); + if (!resolvedBrief?.brief) continue; surfaceBrief = resolvedBrief.brief.text ?? safeRead(resolvedBrief.brief.path); surfaceBriefPath = resolvedBrief.brief.path ? path.relative(liveTarget.originalCwd, resolvedBrief.brief.path) : null; + break; } } catch { /* briefs are optional context */ } console.log(JSON.stringify({ diff --git a/skill/scripts/live/browser-script-parts.mjs b/skill/scripts/live/browser-script-parts.mjs index b77f6a542..5925136fb 100644 --- a/skill/scripts/live/browser-script-parts.mjs +++ b/skill/scripts/live/browser-script-parts.mjs @@ -32,10 +32,15 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re })); } -export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) { +export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) { const prelude = `window.__IMPECCABLE_TOKEN__ = '${token}';\n` + `window.__IMPECCABLE_PORT__ = ${port};\n` + + // Project identity for browser-side session storage. localStorage is + // keyed by ORIGIN, and two projects routinely share a localhost port + // across time; saved sessions carry this value so a resume can tell a + // foreign project's leftovers from its own. + `window.__IMPECCABLE_APP_ROOT__ = ${JSON.stringify(appRoot)};\n` + `window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` + // Canonical command vocabulary (values + labels + icons). live-browser.js // builds its action picker from this instead of an inline copy. diff --git a/skill/scripts/live/session-store.mjs b/skill/scripts/live/session-store.mjs index 0d1715e7c..a017cb157 100644 --- a/skill/scripts/live/session-store.mjs +++ b/skill/scripts/live/session-store.mjs @@ -119,6 +119,18 @@ export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) persist(normalized.id, next, prior.nextSeq + 1); return next; }, + /** + * True when a journal exists for the id in either root. appendEvent + * CREATES a journal for any id it is handed, so callers that should only + * ever touch existing sessions (browser checkpoints, mount acks) check + * here first — otherwise a stale id from another project's browser + * storage materializes a ghost session in this store. + */ + has(id) { + if (!id || typeof id !== 'string') return false; + return fs.existsSync(getJournalPath(rootDir, id)) + || fs.existsSync(getJournalPath(legacyRootDir, id)); + }, /** * Read-only. `live-status` and `live-resume` call this against a session a * running server owns; writing the snapshot file here made every read a diff --git a/skill/scripts/live/sveltekit-adapter.mjs b/skill/scripts/live/sveltekit-adapter.mjs index 5cec5c5cb..e94c54f1e 100644 --- a/skill/scripts/live/sveltekit-adapter.mjs +++ b/skill/scripts/live/sveltekit-adapter.mjs @@ -7,6 +7,7 @@ * actual live UI remains the shared plain-DOM browser chrome. */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -14,6 +15,28 @@ export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot export const SVELTE_LAYOUT_MARKER_OPEN = ''; export const SVELTE_LAYOUT_MARKER_CLOSE = ''; export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';"; +// Matches the import at ANY revision (or none). [ \t]* bounds only, never +// \s*: a greedy \s* after the statement swallowed the next line's +// indentation on removal, leaving a formatting scar in user layouts. +const SVELTE_ROOT_IMPORT_LINE_RE = /^[ \t]*import ImpeccableLiveRoot from '\$lib\/impeccable\/ImpeccableLiveRoot\.svelte(?:\?[^']*)?';[ \t]*\r?\n?/gm; + +/** + * The import specifier carries a token-derived revision query. The adapter + * component embeds the helper token, and Vite (client AND SSR) can keep + * serving a stale compiled module after the file is rewritten on a helper + * restart; the browser then requests /live.js with a rotated-out token and + * gets a 401 with no picker. A changed specifier is a different module id, + * which no cache survives. + */ +export function svelteRootImportLine(rev) { + if (!rev) return SVELTE_ROOT_IMPORT; + return "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte?impeccable-live=" + rev + "';"; +} + +export function svelteAdapterRev(token) { + if (!token) return null; + return crypto.createHash('sha256').update(String(token)).digest('hex').slice(0, 8); +} export function detectSvelteKitProject(cwd = process.cwd(), config = null) { const appHtml = findSvelteKitAppHtml(cwd, config); @@ -50,7 +73,7 @@ export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, token, co fs.mkdirSync(path.dirname(layoutAbs), { recursive: true }); const layoutExisted = fs.existsSync(layoutAbs); const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout(); - const after = patchSvelteLayout(before); + const after = patchSvelteLayout(before, { rev: svelteAdapterRev(token) }); fs.writeFileSync(layoutAbs, after, 'utf-8'); return { @@ -94,15 +117,27 @@ export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null }; } -export function patchSvelteLayout(content) { +export function patchSvelteLayout(content, { rev = null } = {}) { let out = String(content || ''); - if (!out.includes(SVELTE_ROOT_IMPORT)) { - const scriptMatch = out.match(/]*)?>/i); - if (scriptMatch) { - const insertAt = scriptMatch.index + scriptMatch[0].length; - out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt); - } else { - out = `\n\n` + out; + const importLine = svelteRootImportLine(rev); + if (!out.includes(importLine)) { + // An import at an older revision is replaced in place, keeping its + // indentation; only a layout with no impeccable import gets an insert. + let replaced = false; + out = out.replace(SVELTE_ROOT_IMPORT_LINE_RE, (line) => { + if (replaced) return ''; + replaced = true; + const indent = (line.match(/^[ \t]*/) || [''])[0]; + return indent + importLine + '\n'; + }); + if (!replaced) { + const scriptMatch = out.match(/]*)?>/i); + if (scriptMatch) { + const insertAt = scriptMatch.index + scriptMatch[0].length; + out = out.slice(0, insertAt) + '\n ' + importLine + out.slice(insertAt); + } else { + out = `\n\n` + out; + } } } @@ -131,8 +166,8 @@ export function unpatchSvelteLayout(content) { 'g', ); out = out.replace(blockRe, '$1'); - out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), ''); - out = out.replace(/ + +{@render children()} +`; + + it('stamps the import with a token-derived revision and swaps it on rotation', () => { + const revA = adapter.svelteAdapterRev('token-a'); + const revB = adapter.svelteAdapterRev('token-b'); + assert.match(revA, /^[0-9a-f]{8}$/); + assert.notEqual(revA, revB, 'a rotated token must change the module specifier'); + + const patchedA = adapter.patchSvelteLayout(LAYOUT, { rev: revA }); + assert.ok(patchedA.includes(`ImpeccableLiveRoot.svelte?impeccable-live=${revA}'`), 'import carries the revision'); + + // A re-apply after helper restart replaces the import IN PLACE: exactly + // one import, at the new revision, same indentation. A stale specifier + // is a cached module with a rotated-out token, which 401s on live.js. + const patchedB = adapter.patchSvelteLayout(patchedA, { rev: revB }); + const importCount = (patchedB.match(/import ImpeccableLiveRoot/g) || []).length; + assert.equal(importCount, 1, 'rotation must not stack imports'); + assert.ok(patchedB.includes(`?impeccable-live=${revB}'`)); + assert.ok(!patchedB.includes(`?impeccable-live=${revA}'`)); + assert.match(patchedB, /\n import ImpeccableLiveRoot/, 'replacement keeps the original indentation'); + }); + + it('removal restores the layout byte-for-byte, including neighbor indentation', () => { + // The field failure: the old removal regex used \s* and swallowed the + // NEXT line's indentation, de-indenting the user's stylesheet import. + for (const rev of [null, adapter.svelteAdapterRev('some-token')]) { + const patched = adapter.patchSvelteLayout(LAYOUT, { rev }); + assert.notEqual(patched, LAYOUT, 'patch must change the layout'); + const restored = adapter.unpatchSvelteLayout(patched); + assert.equal(restored, LAYOUT, `removal must be byte-exact (rev=${rev})`); + } + }); + + it('removal of a created-from-scratch layout leaves no script husk', () => { + const patched = adapter.patchSvelteLayout('', { rev: adapter.svelteAdapterRev('t') }); + const restored = adapter.unpatchSvelteLayout(patched); + assert.doesNotMatch(restored, /ImpeccableLiveRoot|impeccable-live-svelte/); + assert.doesNotMatch(restored, /