// wasm/repl/repl.js // Lumbda interactive REPL with multi-tab sessions + encrypted persistence. import { openVault } from "./crypto.js"; const TIER_LABEL = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)", }; // ─── DOM ──────────────────────────────────────────────────────────── const lockScreen = document.getElementById("lock-screen"); const passwordEl = document.getElementById("password"); const unlockBtn = document.getElementById("unlock"); const freshBtn = document.getElementById("fresh"); const lockNote = document.getElementById("lock-note"); const tabsEl = document.getElementById("tabs"); const newTabBtn = document.getElementById("new-tab"); const resetTierBtn = document.getElementById("reset-tier"); const clearLogBtn = document.getElementById("clear-log"); const cancelBtn = document.getElementById("cancel"); const pauseBtn = document.getElementById("pause"); const lockBtn = document.getElementById("lock"); const replStream = document.getElementById("repl-stream"); const transcriptEl = document.getElementById("transcript"); const inputEl = document.getElementById("input"); const tierSelectEl = document.getElementById("tier-select"); const sendBtn = document.getElementById("send"); const portalBarEl = document.getElementById("portal-bar"); const portalSaveBtn = document.getElementById("portal-save"); const portalImportBtn = document.getElementById("portal-import"); const portalImportFileEl = document.getElementById("portal-import-file"); const portalScopeBtn = document.getElementById("portal-scope"); const portalChipsEl = document.getElementById("portal-chips"); // Whether the chip strip shows every tab's checkpoints (true) or just // the active tab's (false). UI-only — never persisted, so a fresh // session always starts in the focused per-tab view. let showGlobalCheckpoints = false; // ─── State ────────────────────────────────────────────────────────── const state = { vault: null, // { read, write, destroy, vaultId } or null (ephemeral) tabs: [], // [{ id, name, tier, transcript: [{kind, text, results?}] }] activeTabId: null, workers: {}, // { [tabId+":"+tier]: Worker } pending: {}, // same key → { runId, reject } nextRunId: 1, nextTabId: 1, saveTimer: null, // SharedArrayBuffer the tier's eval loop polls for a cooperative // pause signal. C tier reads via Atomics in the JS-library import; // Python via globals + sys.settrace. Browsers refuse the SAB // constructor without COOP/COEP cross-origin-isolation headers, // so this stays null on a plain http.server and the ⏸ pause // button surfaces a helpful tooltip instead of crashing. pauseFlag: (() => { try { return new SharedArrayBuffer(4); } catch (e) { return null; } })(), pausing: false, // true while a pause-and-snapshot flow is mid-air }; // Eager view over the pause flag so the button handler doesn't have to // re-wrap on every click. Null when SAB isn't available. const pauseFlagView = state.pauseFlag ? new Int32Array(state.pauseFlag) : null; // ─── Vault ────────────────────────────────────────────────────────── async function saveSoon() { if (!state.vault) return; if (state.saveTimer) clearTimeout(state.saveTimer); state.saveTimer = setTimeout(async () => { try { await state.vault.write({ tabs: state.tabs, activeTabId: state.activeTabId, savedAt: Date.now(), }); } catch (e) { console.error("vault write failed:", e); } }, 400); } async function tryUnlock() { const pw = passwordEl.value; if (!pw) { lockNote.textContent = "password required (or click ephemeral)"; return; } lockNote.textContent = ""; try { state.vault = await openVault(pw); const data = await state.vault.read(); if (data && data.__decryptionFailed) { // Wrong password for an existing vault under this id. lockNote.textContent = "vault exists but password is wrong"; state.vault = null; return; } if (data && Array.isArray(data.tabs) && data.tabs.length > 0) { state.tabs = data.tabs; state.activeTabId = data.activeTabId || data.tabs[0].id; state.nextTabId = Math.max(...state.tabs.map((t) => t.id)) + 1; } else { state.tabs = []; state.activeTabId = null; state.nextTabId = 1; } enterRepl(); } catch (e) { lockNote.textContent = "unlock failed: " + e.message; } } function enterEphemeral() { state.vault = null; state.tabs = []; state.activeTabId = null; state.nextTabId = 1; enterRepl(); } function enterRepl() { lockScreen.hidden = true; replStream.hidden = false; if (state.tabs.length === 0) newTab(); else renderAll(); inputEl.focus(); } function relock() { // Terminate all workers, clear in-memory state, return to lock screen. for (const k of Object.keys(state.workers)) { try { state.workers[k].terminate(); } catch {} } state.workers = {}; state.pending = {}; state.vault = null; state.tabs = []; state.activeTabId = null; transcriptEl.innerHTML = ""; tabsEl.innerHTML = ""; replStream.hidden = true; lockScreen.hidden = false; passwordEl.value = ""; passwordEl.focus(); } // ─── Tabs ─────────────────────────────────────────────────────────── async function newTab(name) { const id = state.nextTabId++; name = name || `session ${id}`; state.tabs.push({ id, name, tier: "c", transcript: [], inputDraft: "" }); // Route through setActiveTab so the outgoing tab's auto-pause // fires and the toolbar buttons sync to the freshly-created // (idle) tab. Without this, opening a new tab during an eval // leaves the send button disabled and the eval orphaned. await setActiveTab(id); } function closeTab(id) { const idx = state.tabs.findIndex((t) => t.id === id); if (idx < 0) return; // Terminate this tab's workers. for (const k of Object.keys(state.workers)) { if (k.startsWith(id + ":")) { try { state.workers[k].terminate(); } catch {} delete state.workers[k]; } } state.tabs.splice(idx, 1); if (state.activeTabId === id) { state.activeTabId = state.tabs.length ? state.tabs[Math.max(0, idx - 1)].id : null; } if (state.tabs.length === 0) newTab(); else renderAll(); saveSoon(); } async function setActiveTab(id) { if (id === state.activeTabId) return; // Re-entry guard: rapid clicks on tab labels can fire setActiveTab // before the previous autoPause/autoResume cycle finishes. Without // this, two pause flows race on the same outgoing tab and the // second one captures torn state. Drop the later click silently // (cheaper than queueing — the user can click again once they // see the active tab settle). if (state.switching) return; state.switching = true; try { // Capture the outgoing tab's current input as its draft before // we swap tabs — otherwise switching away from a half-written // form and back loses everything between the last input event // and the next saveSoon flush. const out = activeTab(); if (out) { out.inputDraft = inputEl.value; // Auto-pause + portal-save when the user leaves a tab // whose tier is mid-eval. Without this, switching tabs // during a long search abandons the state silently. // autoPauseTab snapshots the env (SAB-poll path for C/python, // transcript replay for asm), stashes the input that // started the eval, and terminates the worker so the // heap is released. autoResumeTab on the way back // hydrates and re-fires the input. if (isEvalInFlight(out)) { await autoPauseTab(out); } } state.activeTabId = id; renderAll(); // Sync the global toolbar button states to the incoming tab — // send/cancel/pause are not tab-scoped DOM elements, so a // switch-away during one tab's eval would otherwise leave the // newly-active (idle) tab with send disabled. The active tab // is idle iff nothing in state.pending matches its id. const incoming = activeTab(); if (incoming) { const evalRunning = isEvalInFlight(incoming); sendBtn.disabled = evalRunning; cancelBtn.disabled = !evalRunning; pauseBtn.disabled = !evalRunning || !pauseFlagView; } saveSoon(); if (incoming && incoming.autoPause) { await autoResumeTab(incoming); } } finally { state.switching = false; } } function isEvalInFlight(tab) { for (const k of Object.keys(state.pending)) { if (k.startsWith(tab.id + ":")) return true; } return false; } async function waitForPending(tabId, tier, timeoutMs) { const key = workerKey(tabId, tier); const start = performance.now(); while (state.pending[key] && (performance.now() - start) < timeoutMs) { await new Promise((r) => setTimeout(r, 30)); } } // Race a promise against a wall-clock timeout. Returns null when the // promise didn't settle in time so the caller can move on rather than // hang. Used in autoPauseTab so a worker that won't yield to the // pause flag (e.g. stuck in sync XHR, or eval-loop poll never fires // because we're outside the C poll site) can still be terminated. function withTimeout(p, ms) { return Promise.race([ p.catch(() => null), new Promise((r) => setTimeout(() => r(null), ms)), ]); } // Auto-pause hook for tab-switch-away. The tier may be deep inside a // (let loop ...) — set the SAB pause atomic, wait for the eval-loop // poll to raise lisp_error("paused"), then snapshot env via the // regular portal-save path so the state survives until the user // switches back. Without SAB we still capture the in-flight input // so the resume can re-eval it cold. async function autoPauseTab(tab) { const tier = tab.tier === "all" ? "c" : tab.tier; const activeInput = tab.activeInput || null; let blob = null; if (pauseFlagView && (tier === "c" || tier === "python")) { // SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested, // Python tier's lumbda.py polls _lumbdaPyPauseRequested. Both // raise their own "paused" error (lisp_error / LispErr) so the // module-global env survives intact. Asm tier has no in-eval // poll site — falls through to hard cancel. Atomics.store(pauseFlagView, 0, 1); await waitForPending(tab.id, tier, 2000); Atomics.store(pauseFlagView, 0, 0); // If the eval is still in flight after 2s, the worker isn't // yielding to the SAB flag (stuck in sync XHR, native builtin // without a yield point, etc). Skip the snapshot attempt and // drop straight to the unconditional terminate below — the // user gets the cancel-and-replay fallback instead of a hang. if (!isEvalInFlight(tab)) { const snapName = `_autopause_t${tab.id}`; try { const snapOk = await withTimeout( evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`), 2000, ); if (snapOk !== null) { const w = state.workers[workerKey(tab.id, tier)]; if (w) { const runId = state.nextRunId++; const reply = await withTimeout( bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"), 1500, ); if (reply && reply.blob) blob = reply.blob; } } } catch (e) { /* no blob — resume will re-eval from scratch */ } } } if (isEvalInFlight(tab)) { // SAB unavailable, asm tier, OR SAB pause timed out without // the eval unwinding — hard-cancel the in-flight eval(s) so // the unconditional terminate loop below actually kills them. for (const k of Object.keys(state.pending)) { if (!k.startsWith(tab.id + ":")) continue; try { state.pending[k].reject(new Error("tab paused")); } catch {} delete state.pending[k]; } } if (activeInput) { // Asm tier has no SAB poll site, so we can't snapshot env mid-eval. // Fall back to replay mode: every successful prior input gets // re-eval'd on resume to rebuild the env, then the active input // is sent fresh. Same pattern the manual asm portal-save uses. const replayInputs = (tier === "asm") ? tab.transcript.filter((e) => e.kind !== "error" && e.input !== activeInput).map((e) => e.input) : null; tab.autoPause = { tier, blob, replayInputs, inputSrc: activeInput, savedAt: Date.now() }; } // Terminate the worker(s) so the heap is reclaimed while the tab // is dormant. autoResumeTab spawns a fresh one. for (const k of Object.keys(state.workers)) { if (!k.startsWith(tab.id + ":")) continue; try { state.workers[k].terminate(); } catch {} delete state.workers[k]; } } async function autoResumeTab(tab) { const ap = tab.autoPause; if (!ap) return; tab.autoPause = null; // consume up-front so a re-pause picks fresh tab.tier = ap.tier; tierSelectEl.value = ap.tier; rebootTier(tab.id, ap.tier); if (ap.replayInputs && ap.replayInputs.length) { // Asm tier replay path — re-eval every successful prior input // in order so the env is reconstructed before sendInput fires // the active one. Errors mid-replay are swallowed; restoring // from a transcript that defined-on-error is the user's call. for (const src of ap.replayInputs) { try { await evalInTier(tab.id, ap.tier, src); } catch (e) { /* keep going */ } } tab.transcript.push({ input: `; resumed asm tier — replayed ${ap.replayInputs.length} prior inputs`, results: [{ tier: ap.tier, output: "#t" }], kind: "ok", }); } else if (ap.blob) { // Round-trip through the tier so portal-load! has the blob // to read. The bare 'init eval forces the tier to bootstrap // before we push the blob into MEMFS. try { await evalInTier(tab.id, ap.tier, "'init"); } catch (e) { /* harmless */ } const w = state.workers[workerKey(tab.id, ap.tier)]; if (w) { const snapName = `_autopause_t${tab.id}`; const runId = state.nextRunId++; await bridgeWorker(w, { kind: "portal-load", runId, tier: ap.tier, name: snapName, blob: ap.blob }, "portal-load"); try { await evalInTier(tab.id, ap.tier, `(portal-load! "${snapName}")`); } catch (e) { /* surface as resume failure but keep going */ } } } // Re-run the original input. The transcript already shows the // prior partial run; this appears as a new entry so the user // sees the resumption explicitly. inputEl.value = ap.inputSrc; autosizeInput(); await sendInput(); } function activeTab() { return state.tabs.find((t) => t.id === state.activeTabId); } // ─── Workers ──────────────────────────────────────────────────────── function workerKey(tabId, tier) { return `${tabId}:${tier}`; } function spawnWorker(tabId, tier) { const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" }); state.workers[workerKey(tabId, tier)] = w; // Hand the worker its pause flag. Worker stashes the SAB so the // tier loader can install an Int32Array view when it boots. We // skip bendUrl here so the worker's previous bend setting (if // any) isn't clobbered to null. w.postMessage({ kind: "config", pauseFlag: state.pauseFlag }); return w; } function ensureWorker(tabId, tier) { const k = workerKey(tabId, tier); if (!state.workers[k]) spawnWorker(tabId, tier); return state.workers[k]; } function evalInTier(tabId, tier, src, onChunkText, onChunkEol) { return new Promise((resolve, reject) => { const w = ensureWorker(tabId, tier); const runId = state.nextRunId++; const pendingKey = workerKey(tabId, tier); state.pending[pendingKey] = { runId, reject }; const handler = (e) => { if (e.data.runId !== runId) return; if (e.data.kind === "chunk-batch") { // Worker now batches chunks into 4KB/64-newline windows // so a tight (display) loop doesn't drown the main // thread in postMessage events. Split the batch text // back into individual text + eol calls so the live // streaming row sees the same shape as before. if (!onChunkText && !onChunkEol) return; const t = e.data.text; let start = 0; for (let i = 0; i < t.length; i++) { if (t.charCodeAt(i) === 10) { if (i > start && onChunkText) onChunkText(t.slice(start, i)); if (onChunkEol) onChunkEol(); start = i + 1; } } if (start < t.length && onChunkText) onChunkText(t.slice(start)); } else if (e.data.kind === "chunk-text") { onChunkText && onChunkText(e.data.text); } else if (e.data.kind === "chunk-eol") { onChunkEol && onChunkEol(); } else if (e.data.kind === "done") { w.removeEventListener("message", handler); // Only clear pending if it's still our runId — a // cancel-then-resubmit cycle can leave state.pending[k] // pointing at a fresh eval before our terminated worker // gets around to having its last-queued "done" delivered // on the main side. if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) { delete state.pending[pendingKey]; } resolve(e.data.output); } else if (e.data.kind === "error") { w.removeEventListener("message", handler); if (state.pending[pendingKey] && state.pending[pendingKey].runId === runId) { delete state.pending[pendingKey]; } reject(new Error(e.data.message)); } }; w.addEventListener("message", handler); w.postMessage({ kind: "eval", runId, tier, src }); }); } // Streaming live block — attached to a tier-result span after the // initial renderAll so chunks arrive directly into the DOM without // re-rendering the whole transcript (which would clobber sibling // tiers still streaming). Same per-line-div pattern the playground // uses: each chunk-eol closes a block-level div, sibling lines // stack vertically regardless of 's inline default. function attachStreaming(resultSpan, metaSpan, tier) { // Single Text node accumulator — appending to one Text node's // nodeValue and letting `white-space: pre` on the parent render // newlines is roughly 1000x faster than creating one
per // line for high-volume streams. 500K display lines used to choke // the main thread for ~12s of DOM mutation alone; a single Text // node renders the same content with near-zero per-line cost // (browsers don't re-layout a pre-wrap text node line-by-line). const makeErrLine = (txt) => { const d = document.createElement("div"); d.style.display = "block"; d.style.whiteSpace = "pre"; d.className = "err-line"; d.textContent = txt; return d; }; resultSpan.textContent = ""; resultSpan.style.whiteSpace = "pre"; const textNode = document.createTextNode(""); resultSpan.appendChild(textNode); metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`; let buffered = ""; let rafPending = false; function flush() { rafPending = false; if (!buffered) return; textNode.appendData(buffered); buffered = ""; maybeAutoscroll(); } function schedule() { if (rafPending) return; rafPending = true; requestAnimationFrame(flush); } return { flush, // exposed so finalize can drain synchronously appendText(t) { if (!t) return; buffered += t; schedule(); }, appendNewline() { buffered += "\n"; schedule(); }, finalize(r) { // Drain any buffered chunks that haven't been flushed yet // so streamedText below sees the most recent content // (otherwise the success-with-streaming match check is // racy against the last RAF batch). if (rafPending) flush(); const text = r.output != null ? r.output : ""; const errText = r.error ? "error: " + r.error : ""; // textContent here is the live Text node's accumulated // stream (the per-line-div era used Array.from(children); // switched to a single Text node for high-volume streaming // perf, so children is empty now). const streamedText = textNode.nodeValue.replace(/\n$/, ""); const expected = text.replace(/\n$/, ""); // Preserving partial output on cancel/error: a long-running // (display ...) loop that fox cancels half-way through, or // an asm tier that hits "index out of bounds" 5000 lines // into a sieve, used to lose every line that already // streamed. Now: // * success, streamed matches full output → keep DOM. // * success, no streaming happened → rebuild from // the final text. // * error with prior streaming → KEEP the // streamed divs, append a final divider + error line. // * error with no streaming → show error. if (!errText) { if (streamedText !== expected) { // Streamed buffer doesn't match final output — // refresh the text node with the canonical value. textNode.nodeValue = expected; } } else if (streamedText) { // Append the error AFTER what's already on screen so // the user keeps every line they were watching scroll. if (!textNode.nodeValue.endsWith("\n")) { textNode.appendData("\n"); } resultSpan.appendChild(makeErrLine(errText)); } else { resultSpan.textContent = ""; resultSpan.appendChild(makeErrLine(errText)); } metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`; }, }; } // ─── Portal save / resume ─────────────────────────────────────────── // Cooperative checkpoints: the tier exposes (portal-snapshot! NAME) and // (portal-load! NAME) which write/read /tmp/NAME.portal inside MEMFS. // JS reaches into MEMFS via the worker bridge, encrypts the blob into // the vault, and on restore reverses the flow. Names are alphanumeric; // the timestamp format we generate ourselves never embeds slashes. function safePortalName(s) { return (s || "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40) || "snap"; } function bridgeWorker(w, msg, matchKind) { return new Promise((resolve) => { const handler = (e) => { if (e.data.kind !== matchKind || e.data.runId !== msg.runId) return; w.removeEventListener("message", handler); resolve(e.data); }; w.addEventListener("message", handler); w.postMessage(msg); }); } // Tier-specific save strategies: // * c, python — call the tier's (portal-snapshot! NAME) so portal.c / // lumbda.py's portal_save dumps env+RNG to /tmp/.portal JSON, // then JS reads the blob out of MEMFS. Faithful reproduction. // * asm-wat — no portal subsystem in the tier yet (it'd need a // Cheney-aware serializer). Instead snapshot the transcript and // replay on restore. Works for the REPL's actual workflow of // successive defines because every closure rebuilds from source. async function saveCheckpoint() { const tab = activeTab(); if (!tab) return; const tier = tab.tier === "all" ? "c" : tab.tier; const ts = new Date(); const defaultName = `snap-${ts.getHours().toString().padStart(2, "0")}` + `${ts.getMinutes().toString().padStart(2, "0")}` + `${ts.getSeconds().toString().padStart(2, "0")}`; const name = safePortalName(prompt("checkpoint name:", defaultName) || ""); if (!name) return; // Overwrite guard — saving with an existing name silently nuked // the old blob, which has cost more than one "where did my snapshot // go?" moment. Ask first. if (tab.checkpoints && tab.checkpoints[name]) { const old = tab.checkpoints[name]; const when = new Date(old.savedAt).toLocaleString(); if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return; } let entry; if (tier === "asm") { // Replay mode — capture every successful prior input. Errors // get dropped because re-running them on restore would just // crash the tier mid-replay. const inputs = tab.transcript .filter((e) => e.kind !== "error") .map((e) => e.input); entry = { tier: "asm", mode: "replay", inputs, savedAt: Date.now() }; } else { // C / Python — portal-snapshot! lands a JSON blob in MEMFS. try { await evalInTier(tab.id, tier, `(portal-snapshot! "${name}")`); } catch (e) { alert("portal-snapshot! failed: " + e.message); return; } const w = state.workers[workerKey(tab.id, tier)]; if (!w) { alert("worker missing"); return; } const runId = state.nextRunId++; const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name }, "portal-save"); if (!reply.blob) { alert("portal blob empty — snapshot didn't land in MEMFS"); return; } entry = { tier, mode: "blob", blob: reply.blob, savedAt: Date.now() }; } tab.checkpoints = tab.checkpoints || {}; tab.checkpoints[name] = entry; renderAll(); saveSoon(); } // Restore a checkpoint into the active tab. The optional sourceCp // override lets the global-checkpoints view pass in a cp from a // different tab — the restore still operates on activeTab() so the // user lands on the tab they were working in, with that tab's tier // state reset to the snapshot. async function restoreCheckpoint(name, sourceCp) { const tab = activeTab(); if (!tab) return; const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]); if (!cp) return; if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return; if (tab.tier !== cp.tier) { tab.tier = cp.tier; tierSelectEl.value = cp.tier; renderAll(); } // Reboot the tier so we start from a clean global env — either // replay needs it, or portal_resume's "merge into _env" semantics // would otherwise compound on top of whatever's already defined. rebootTier(tab.id, cp.tier); if (cp.mode === "replay") { // Re-eval every saved input on the fresh worker, in order. for (const src of (cp.inputs || [])) { try { await evalInTier(tab.id, cp.tier, src); } catch (e) { /* keep going */ } } tab.transcript.push({ input: `; replayed ${cp.inputs.length} inputs from '${name}'`, results: [{ tier: cp.tier, output: "#t" }], kind: "ok" }); } else { // Blob mode — hydrate MEMFS, then ask the tier to load it. try { await evalInTier(tab.id, cp.tier, "'init"); } catch (e) { /* harmless */ } const w = state.workers[workerKey(tab.id, cp.tier)]; if (!w) { alert("worker missing"); return; } const runId = state.nextRunId++; await bridgeWorker(w, { kind: "portal-load", runId, tier: cp.tier, name, blob: cp.blob }, "portal-load"); try { await evalInTier(tab.id, cp.tier, `(portal-load! "${name}")`); tab.transcript.push({ input: `(portal-load! "${name}")`, results: [{ tier: cp.tier, output: "#t" }], kind: "ok" }); } catch (e) { alert("portal-load! failed: " + e.message); return; } } renderAll(); saveSoon(); } function renameCheckpoint(oldName) { const tab = activeTab(); if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return; const raw = prompt(`rename '${oldName}' to:`, oldName); if (raw === null) return; const newName = safePortalName(raw); if (!newName || newName === oldName) return; if (tab.checkpoints[newName]) { const old = tab.checkpoints[newName]; const when = new Date(old.savedAt).toLocaleString(); if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return; } tab.checkpoints[newName] = tab.checkpoints[oldName]; delete tab.checkpoints[oldName]; renderAll(); saveSoon(); } // Export a checkpoint to a downloadable .portal.json file. The blob // itself is opaque JSON for c/python (portal_save's wire format) and a // {tier, mode: "replay", inputs} object for asm. Either way the file // holds everything importCheckpoint needs to reconstruct the entry; no // vault-side metadata bleeds out. function exportCheckpoint(name) { const tab = activeTab(); if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return; const entry = tab.checkpoints[name]; const payload = { __portal: 1, name, ...entry }; const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(data); const a = document.createElement("a"); a.href = url; a.download = `${name}.portal.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); } // Import a checkpoint File (from ). Validates the // shape, prompts on name collision, lands the entry in the active // tab's checkpoints map. The imported entry lives on whatever tier it // was saved under — restoring it will switch the active tab to that // tier automatically (restoreCheckpoint already does the swap). async function importCheckpoint(file) { const tab = activeTab(); if (!tab) return; let payload; try { const text = await file.text(); payload = JSON.parse(text); } catch (e) { alert("import failed — not valid JSON: " + e.message); return; } if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) { alert("import failed — file isn't a portal snapshot (missing __portal/tier/savedAt)"); return; } const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported"; let name = baseName; tab.checkpoints = tab.checkpoints || {}; if (tab.checkpoints[name]) { const old = tab.checkpoints[name]; const when = new Date(old.savedAt).toLocaleString(); if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) { // Auto-suffix on decline so the user gets to keep both. let i = 2; while (tab.checkpoints[`${baseName}-${i}`]) i++; name = `${baseName}-${i}`; } } const entry = { tier: payload.tier, savedAt: payload.savedAt }; if (payload.mode === "replay") { entry.mode = "replay"; entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : []; } else { entry.mode = "blob"; entry.blob = payload.blob || ""; } tab.checkpoints[name] = entry; renderAll(); saveSoon(); } // Manual pause-and-snapshot — the user wants to inspect or save the // state of a long-running eval without leaving the tab. Signals the // SAB pause flag, waits for the in-flight eval to settle, then runs // saveCheckpoint so the snapshot lands in the visible chip strip // with a user-chosen name. Workers stay alive so the next input // runs immediately. async function pauseAndSnapshot() { if (!pauseFlagView) { alert("pause requires COOP/COEP headers — start with `make serve-repl`, or for production set Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp on the deploy"); return; } const tab = activeTab(); if (!tab) return; const tier = tab.tier === "all" ? "c" : tab.tier; if (tier === "asm") { alert("manual pause not supported on asm tier yet — use cancel + portal-save when the eval is idle"); return; } pauseBtn.disabled = true; Atomics.store(pauseFlagView, 0, 1); await waitForPending(tab.id, tier, 2000); Atomics.store(pauseFlagView, 0, 0); if (isEvalInFlight(tab)) { // The worker didn't yield (stuck in sync XHR, native builtin // without a poll site, etc). Bail out cleanly rather than // trying to snapshot on a still-busy worker — that would // queue portal-snapshot! behind the running eval and lock the // UI until the eval finishes on its own. alert("pause request timed out — the eval is in code without a yield point. cancel + reboot if you need to stop it."); return; } // Reset activeInput marker so a follow-up tab switch doesn't try // to "auto-resume" what the user has now explicitly saved. tab.activeInput = null; await saveCheckpoint(); } function deleteCheckpoint(name) { const tab = activeTab(); if (!tab || !tab.checkpoints) return; delete tab.checkpoints[name]; renderAll(); saveSoon(); } function rebootTier(tabId, tier) { const k = workerKey(tabId, tier); if (state.workers[k]) { try { state.workers[k].terminate(); } catch {} delete state.workers[k]; } if (state.pending[k]) { try { state.pending[k].reject(new Error("rebooted")); } catch {} delete state.pending[k]; } } function cancelAllPendingInActiveTab() { const tab = activeTab(); if (!tab) return; for (const k of Object.keys(state.pending)) { if (k.startsWith(tab.id + ":")) { try { state.pending[k].reject(new Error("cancelled")); } catch {} try { state.workers[k].terminate(); } catch {} delete state.workers[k]; delete state.pending[k]; } } // Synchronously re-enable send so the user can submit a new job // immediately, without waiting for the cancelled promise chain's // microtasks to drain. The .then below also rechecks // isEvalInFlight so a follow-up sendInput's button state isn't // clobbered when those microtasks eventually fire. sendBtn.disabled = false; cancelBtn.disabled = true; pauseBtn.disabled = true; tab.activeInput = null; } // ─── Render ───────────────────────────────────────────────────────── function renderAll() { // Tabs tabsEl.innerHTML = ""; for (const t of state.tabs) { const el = document.createElement("div"); el.className = "tab" + (t.id === state.activeTabId ? " active" : ""); const name = document.createElement("span"); name.textContent = t.name; name.addEventListener("click", () => setActiveTab(t.id)); name.addEventListener("dblclick", (e) => { e.stopPropagation(); const newName = prompt("rename tab:", t.name); if (newName) { t.name = newName.slice(0, 40); renderAll(); saveSoon(); } }); const close = document.createElement("span"); close.className = "tab-close"; close.textContent = "×"; close.title = "close tab"; close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(t.id); }); el.appendChild(name); el.appendChild(close); tabsEl.appendChild(el); } // Transcript — terminal-style: prompt line, then output lines underneath // indented under the prompt with a tier-tag suffix as a Lisp comment. transcriptEl.innerHTML = ""; const tab = activeTab(); if (!tab) return; tierSelectEl.value = tab.tier; // Restore the active tab's saved draft into the textarea so a page // refresh (or tab switch) lands the user back on the half-written // form they had before. The guard avoids fighting an active input // event mid-stream — if the textarea already matches the draft // there's nothing to do. const draft = tab.inputDraft || ""; if (inputEl.value !== draft) { inputEl.value = draft; autosizeInput(); } for (const entry of tab.transcript) { const block = document.createElement("div"); block.className = "entry" + (entry.kind === "error" ? " error" : ""); // Render the input across one or more lines (preserve newlines). const inputLines = entry.input.split(/\n/); for (let i = 0; i < inputLines.length; i++) { const line = document.createElement("div"); line.className = "prompt-line"; const sigil = document.createElement("span"); sigil.className = "sigil"; sigil.textContent = i === 0 ? "λ>" : ".."; line.appendChild(sigil); line.appendChild(document.createTextNode(inputLines[i])); block.appendChild(line); } for (const r of (entry.results || [])) { const row = document.createElement("div"); row.className = "tier-output"; const result = document.createElement("span"); result.className = "tier-result"; const text = (r.output != null ? r.output : ""); const errText = r.error ? "error: " + r.error : ""; result.textContent = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, ""); const meta = document.createElement("span"); meta.className = "tier-meta"; meta.textContent = `${TIER_LABEL[r.tier] || r.tier} · ${(r.elapsed | 0)}ms`; row.appendChild(result); row.appendChild(meta); block.appendChild(row); } transcriptEl.appendChild(block); } // Portal chips — when showGlobalCheckpoints is off, one per saved // checkpoint in the active tab. When on, gather every checkpoint // across every tab so the user can restore a snapshot saved on // tab A into tab B. The label gains a "· tabName" suffix in // global mode so it's clear where each chip came from. portalChipsEl.innerHTML = ""; // Always lead with "scope:" so the visible text reads as a state // label rather than a target ("this tab" alone was ambiguous — // is it telling me the current scope, or what I'd switch to?). portalScopeBtn.textContent = showGlobalCheckpoints ? "scope: all tabs 🌐" : "scope: this tab 📂"; portalScopeBtn.title = showGlobalCheckpoints ? "showing checkpoints from every tab — click to show only this tab" : "showing only this tab's checkpoints — click to show every tab"; const chipRows = []; if (showGlobalCheckpoints) { for (const t of state.tabs) { const cps = t.checkpoints || {}; for (const name of Object.keys(cps)) { chipRows.push({ tab: t, name, cp: cps[name] }); } } chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt); } else { const cps = tab.checkpoints || {}; for (const name of Object.keys(cps).sort()) { chipRows.push({ tab, name, cp: cps[name] }); } } for (const { tab: cpTab, name, cp } of chipRows) { const chip = document.createElement("span"); chip.className = "chip"; const when = new Date(cp.savedAt).toLocaleString(); chip.title = showGlobalCheckpoints ? `${name} · ${cp.tier} · saved ${when} on tab '${cpTab.name}' — click to restore into THIS tab` : `${name} · ${cp.tier} · saved ${when} — click to restore, dbl-click to rename`; const label = document.createElement("span"); label.textContent = showGlobalCheckpoints ? `${name} · ${cpTab.name}` : name; // Restore always operates on the active tab; in global mode // the snapshot's cp object is passed so we don't depend on // the active tab having a checkpoints[name] match. label.addEventListener("click", () => restoreCheckpoint(name, cp)); if (!showGlobalCheckpoints) { label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); }); const exportBtn = document.createElement("span"); exportBtn.className = "chip-export"; exportBtn.textContent = "⇣"; exportBtn.title = "download checkpoint as .portal.json"; exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); }); const close = document.createElement("span"); close.className = "chip-close"; close.textContent = "×"; close.title = "delete checkpoint"; close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); }); chip.appendChild(label); chip.appendChild(exportBtn); chip.appendChild(close); } else { chip.classList.add("chip-global"); chip.appendChild(label); } portalChipsEl.appendChild(chip); } // Body owns the scroll now; jump to the bottom after layout settles. // requestAnimationFrame lets the just-mounted DOM contribute to // scrollHeight before we measure — otherwise on first load the page // sticks at the top because the transcript hasn't been laid out yet. // renderAll fires on user-initiated actions (send, tab switch, new // entry) where they expect the most-recent content, so the jump // here is unconditional and resets scrollPinned to true so streamed // follow-ups continue to autoscroll. requestAnimationFrame(() => { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); scrollPinned = true; }); } // ─── Sticky autoscroll ────────────────────────────────────────────── // During a long streaming eval the page should track the bottom UNLESS // the user has deliberately scrolled up to read older output. We sample // "is the viewport at the bottom" on every user scroll, then streamed // appendText/appendNewline only force the scroll when scrollPinned is // still true. A 64px tolerance covers fractional-pixel scroll positions // and short header offsets — anything farther than that is treated as // "user wanted to look back" and autoscroll stops until they return. let scrollPinned = true; const SCROLL_BOTTOM_TOLERANCE = 64; function isViewportAtBottom() { return (window.innerHeight + window.scrollY) >= (document.documentElement.scrollHeight - SCROLL_BOTTOM_TOLERANCE); } function maybeAutoscroll() { if (scrollPinned) { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); } } window.addEventListener("scroll", () => { scrollPinned = isViewportAtBottom(); }, { passive: true }); // ─── History navigation (readline-style up/down) ──────────────────── // Each tab owns its own history; the active tab's draft is preserved so // that walking back into history doesn't eat what the user was typing. const history = { idx: null, draft: "" }; function historyEntries() { // Transcript's `input` fields, deduplicated against the immediate // predecessor — pressing up shouldn't make you hit the same line // twice in a row when you just submitted it. const tab = activeTab(); if (!tab) return []; const out = []; for (const e of tab.transcript) { if (out.length && out[out.length - 1] === e.input) continue; out.push(e.input); } return out; } function historyPrev() { const entries = historyEntries(); if (entries.length === 0) return; if (history.idx === null) { history.draft = inputEl.value; history.idx = entries.length - 1; } else if (history.idx > 0) { history.idx--; } inputEl.value = entries[history.idx]; inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); autosizeInput(); } function historyNext() { const entries = historyEntries(); if (history.idx === null) return; if (history.idx >= entries.length - 1) { history.idx = null; inputEl.value = history.draft; history.draft = ""; } else { history.idx++; inputEl.value = entries[history.idx]; } inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); autosizeInput(); } function resetHistory() { history.idx = null; history.draft = ""; } // Auto-grow the textarea to fit its content (up to the max-height the // CSS pins it at, after which the textarea scrolls internally). Paste // a 40-line program and the prompt-bar expands instead of leaving you // editing the program through a one-line keyhole. Fires on every input // event and every programmatic value set (sendInput, history, etc.). function autosizeInput() { inputEl.style.height = "auto"; inputEl.style.height = inputEl.scrollHeight + "px"; } // ─── Input handling ───────────────────────────────────────────────── async function sendInput() { const src = inputEl.value.trim(); if (!src) return; const tab = activeTab(); if (!tab) return; tab.tier = tierSelectEl.value; const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier]; // Pre-populate placeholder results so the initial renderAll lays // out the tier-output rows we'll stream into. Each placeholder // carries output="" and a streaming:true flag so renderAll // (if invoked later for any reason) renders an empty row rather // than missing the tier. Real values land via the streaming // attach below, finalized when each tier's promise resolves. const entry = { input: src, results: tiers.map((t) => ({ tier: t, output: "", streaming: true })), kind: "ok", }; tab.transcript.push(entry); inputEl.value = ""; tab.inputDraft = ""; // Remember the input string while it's evaluating so that auto- // pause-on-tab-switch can re-fire it on resume. Cleared on completion. tab.activeInput = src; autosizeInput(); resetHistory(); sendBtn.disabled = true; cancelBtn.disabled = false; pauseBtn.disabled = !pauseFlagView; renderAll(); saveSoon(); // Grab the DOM rows we just rendered for this entry so streaming // chunks land directly into them. transcriptEl.lastElementChild // is the block we just added (entries render in order). const entryEl = transcriptEl.lastElementChild; const rowEls = entryEl ? Array.from(entryEl.querySelectorAll(".tier-output")) : []; const liveRows = {}; for (let i = 0; i < tiers.length && i < rowEls.length; i++) { const row = rowEls[i]; const resultSpan = row.querySelector(".tier-result"); const metaSpan = row.querySelector(".tier-meta"); if (resultSpan && metaSpan) { liveRows[tiers[i]] = attachStreaming(resultSpan, metaSpan, tiers[i]); } } const startTimes = {}; for (const t of tiers) startTimes[t] = performance.now(); const promises = tiers.map((t) => { const live = liveRows[t]; return evalInTier( tab.id, t, src, live ? (text) => live.appendText(text) : null, live ? () => live.appendNewline() : null, ) .then((output) => ({ tier: t, output, elapsed: performance.now() - startTimes[t] })) .catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] })); }); let remaining = promises.length; for (const p of promises) { p.then((r) => { // Mutate the placeholder result in place — never push a // second entry per tier or we'd render twice. Mark // streaming=false so a later tab switch redraws via the // normal (full text) path instead of leaving it blank. const idx = entry.results.findIndex((x) => x.tier === r.tier && x.streaming); if (idx >= 0) { entry.results[idx] = { tier: r.tier, output: r.output != null ? r.output : "", error: r.error, elapsed: r.elapsed, }; if (r.error) entry.kind = "error"; } // Update the DOM in place (don't call renderAll — it'd // clobber sibling tiers still streaming). finalize swaps // the streaming divs for the canonical text if they // diverge and writes the elapsed-ms meta. const live = liveRows[r.tier]; if (live) live.finalize(r); saveSoon(); remaining--; if (remaining === 0) { // Don't clobber the toolbar / activeInput marker if // another sendInput started while THIS one was still // unwinding. The cancel-then-submit-immediately race // used to leave send re-enabled mid-eval because the // first run's .then fired after the second's sendInput // had already disabled the buttons. Check the live // pending state instead of trusting "remaining===0". if (!isEvalInFlight(tab)) { sendBtn.disabled = false; cancelBtn.disabled = true; pauseBtn.disabled = true; inputEl.focus(); tab.activeInput = null; } } }); } } // ─── Heap pressure indicator ─────────────────────────────────────── // Polls each loaded tier in the active tab and prints a compact // "py 12M · c 32M · asm 4M" string next to the tabbar buttons. const heapEl = document.createElement("span"); heapEl.className = "heap-pressure"; heapEl.title = "tier worker memory — \"reboot tier\" reclaims on demand"; function formatBytes(n) { if (n < 1024) return `${n}B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}K`; return `${(n / (1024 * 1024)).toFixed(1)}M`; } async function pollHeap() { const tab = activeTab(); if (!tab) return; const tiers = ["python", "c", "asm"]; const parts = []; for (const t of tiers) { const k = workerKey(tab.id, t); const w = state.workers[k]; if (!w) continue; const runId = ++state.nextRunId; const stats = await new Promise((resolve) => { const handler = (e) => { if (e.data.kind !== "heap" || e.data.runId !== runId) return; w.removeEventListener("message", handler); resolve(e.data.stats); }; w.addEventListener("message", handler); w.postMessage({ kind: "heap", runId, tier: t }); // Timeout safety — eval-busy workers won't reply. setTimeout(() => { w.removeEventListener("message", handler); resolve(null); }, 200); }); if (stats && stats.used != null) { parts.push(`${t.slice(0, 3)} ${formatBytes(stats.used)}`); } } heapEl.textContent = parts.length ? parts.join(" · ") : ""; } setInterval(pollHeap, 2000); // ─── Wire up ──────────────────────────────────────────────────────── unlockBtn.addEventListener("click", tryUnlock); freshBtn.addEventListener("click", enterEphemeral); passwordEl.addEventListener("keydown", (e) => { if (e.key === "Enter") tryUnlock(); }); newTabBtn.addEventListener("click", () => newTab()); resetTierBtn.addEventListener("click", () => { const tab = activeTab(); if (tab) rebootTier(tab.id, tab.tier); }); clearLogBtn.addEventListener("click", () => { const tab = activeTab(); if (tab) { tab.transcript = []; renderAll(); saveSoon(); } }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); pauseBtn.addEventListener("click", pauseAndSnapshot); portalSaveBtn.addEventListener("click", saveCheckpoint); portalImportBtn.addEventListener("click", () => portalImportFileEl.click()); portalImportFileEl.addEventListener("change", async (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; await importCheckpoint(file); e.target.value = ""; }); portalScopeBtn.addEventListener("click", () => { showGlobalCheckpoints = !showGlobalCheckpoints; renderAll(); }); lockBtn.addEventListener("click", relock); // Insert heap pressure indicator into the tabbar after the spacer. document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn); inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendInput(); return; } // Up/Down navigate per-tab history when the caret is in the only // (or first/last) row — otherwise they belong to the textarea's // natural multi-line navigation. if (e.key === "ArrowUp") { const before = inputEl.value.substring(0, inputEl.selectionStart); if (!before.includes("\n")) { e.preventDefault(); historyPrev(); } return; } if (e.key === "ArrowDown") { const after = inputEl.value.substring(inputEl.selectionStart); if (!after.includes("\n")) { e.preventDefault(); historyNext(); } return; } }); inputEl.addEventListener("input", () => { autosizeInput(); // Persist the in-progress input so a refresh, lock/unlock, or tab // switch lands the user back where they were. saveSoon debounces // to 400ms so paste storms don't thrash the vault writer. const tab = activeTab(); if (tab) { tab.inputDraft = inputEl.value; saveSoon(); } // Any keystroke that isn't an arrow drops history navigation — // edits are now the user's own draft, not the historical entry. if (history.idx !== null && document.activeElement === inputEl) { // We can't reliably distinguish arrow-induced updates here, so // we only invalidate when the buffer has actually diverged from // the historical entry the cursor was on. const entries = historyEntries(); if (entries[history.idx] !== inputEl.value) { history.draft = inputEl.value; history.idx = null; } } }); sendBtn.addEventListener("click", sendInput); tierSelectEl.addEventListener("change", () => { const tab = activeTab(); if (tab) { tab.tier = tierSelectEl.value; saveSoon(); } });