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(/\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(/\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, /