Durable REPL
Status summary#
Implementation complete; CI fully green (incl. preview e2e after main's
mobile-approvals fix merged); all four Bugbot threads fixed and resolved.
Live-verified: runs execute server-side via
runScript in /repl/<user-id>, history restores from the scope stream on
reload (verified in a real browser, incl. a typecheck-gate error entry and a
results[0].data continuity run), all checks green. Remaining: review.
Make the product REPL run scripts through the capability-host script runner on
a dedicated per-user scope, replacing in-browser evaluation. That is the same
path agent scripts take (itx.capabilityHost.runScript(code)): typechecked
against scope capability types, journaled as
script-run-requested/script-run-settled on the scope stream, outcomes
feeding the scope's derived results preamble.
Settled decisions (final)#
- REPL Runs execute as real scope scripts; each Run settles on a stream and
feeds the same derived
resultspreamble agents get (results[0].datainline,await results[0].load(itx)for large — capability-host-preamble.ts). - NO live persistent-isolate REPL. Continuity is derived-and-durable only.
- ONE REPL type. DELETE the session REPL: browser-eval (src/itx/browser-repl.ts) goes away wholesale; the REPL always has a project context. Session-context catalogue examples repointed or retired (audited below).
- Runs execute in SESSION STREAMS
/repl/<timestamp-slug>(REVISED by Misha a second time — supersedes the shared-singleton/repl, which itself superseded per-user/repl/<user-id>): now that REPLs are real streams they get full agent-style paths (the/agents/web/<timestamp>convention, see ~/lib/repl-session.ts), the URL carries the suffix (/projects/<slug>/repl/<timestamp-slug>— the URL IS the stream path, so sharing it shares the console), and/repljoins the sidebar's path-style surfaces. Bare/replresumes the MOST RECENT session (a console should be where you left it), minting a fresh stream only when none exists (routerreplace, so back-button behavior stays sane); a "New REPL" header button mints a fresh session explicitly. Birth is unchanged: the standardcapabilityHosts.get(path).create()batch with the one-hop fallback to the project root host, so capability resolution is identical.resultscontinuity is per-session — a fresh console is a fresh Out[n] — and stable byOffset addressing is per-stream, unaffected. - History is STREAM-DERIVED: the entry list renders from the scope's
script-run-requested/script-run-settledevents plus local in-flight state for the pending Run only. Reload restores the session. No useEffect, no useState where tanstack-query derivations work (the raw stream connection buffer uses the blesseduseStreamConnectioncallback pattern from itx-activity-tail.tsx — a push stream is not a query).
Recommendations (implement, keep cheap)#
- In-editor TS: feed the scope's assembled preamble (
getPreamble()) into the codemirror TS worker as a virtual module soresultsautocompletes with real types. Best-effort; any failure falls back to today's itx-only types. - Console capture dropped for v1 — return values are the output. Console UI affordance removed.
- Typecheck gate as-is (agent parity). Run latency now includes typecheck + dynamic-worker spin-up; noted, not fixed here.
/_app/itx-replbecomes a minimal project chooser linking to per-project REPLs. Sidebar link stays pointed at it.Stable addressing (Pulled INTO the task with the shared-scope revision: every results row now carries its settlement's streamresults.byOffset(n)) out of scope — follow-up.offset, the assembled array wearsresults.byOffset(n)(throws outside the retained window), and the REPL labels each entry#<offset>. Lives in capability-host-preamble.ts, so agents get it too; documented in that file's header.
Assumptions added while fleshing out (delineated — not from the grill)#
- Explicit
return:runScriptexecutesasync (itx) => { ... }bodies; the browser REPL's "last expression is the result" magic dies with the in-browser evaluator. The REPL wraps the typed body asasync (itx) => {\n<body>\n}(injectingconst vars: Record<string, any> = {}only when the body doesn't declare its ownvars), and the default snippet and help copy now use explicitreturn. History unwraps the deterministic wrapper for display. $_/_die: the mutable browser scope is deleted; continuity isresults[0].data/results[N].load(itx)— same as agents.- npm imports die: the old REPL rewrote
importlines to esm.sh in the browser; server-side scripts have no module loader (agent parity — agents can't import npm packages in scripts either). No catalogue example used top-level imports. /admin/repldeleted: it was a session REPL for admins; admins use per-project REPLs like everyone else. Admin sidebar link removed./_app/itx-repldeleted (revision follow-through): briefly a project chooser; with the route settled as/projects/<slug>/repl(matching the/mediaconvention) the session-level page and its global sidebar entry went away — the project sidebar's/replentry (path-style group, alongside /repos, /agents, /integrations; OS app only, NOT the mobile drawer) is the way in.- Bare-
/replresume policy (delineated, per Misha's stated assumption): most-recent-session resume, fresh mint only when the project has none. No session switcher for now (the resolver + New REPL cover the flows; a listing UI is a follow-up if sessions proliferate). - Examples audit outcome:
- Session-context entries (
whoami,list-projects) lose thebrowserruntime (node/cli reading material; the examples sheet marks them not-runnable-here). - Live-capability entries (
provide-live-capability,provide-live-flattened) lose thebrowserruntime: Runs execute server-side, so the browser can no longer be the live provider process from the REPL. Node/cli e2e matrix coverage keeps them proven. specs/repl-examples.spec.tsfilters to browser-runnable cases instead of throwing on non-browser cased examples; the matrix meta-assertion is relaxed the same way.
- Session-context entries (
- Entry statuses: requested-without-settlement renders as running; settlement renders success (settlement.result) or error (settlement.error). The stream is the source of truth as soon as the request event lands; the local pending row exists only for the append round-trip window.
Checklist#
- Task file committed, draft PR opened initially as
repl-on-script-door; renamed todurable-repl(jargon purge), PR reopened -
ItxScopeReplcontainer: per-user scope path, hostcreate()via suspense query,useStreamConnectionhistory buffer, run mutation throughcapabilityHosts.get(path).runScript(wrapped)apps/os/src/components/itx-scope-repl.tsx; pure derivation in itx-scope-repl-entries.ts - Presentational
itx-repl.tsxreworked: stream-derived entries, running/success/error states, console affordance removed, copy updated (return,results[0]) same testids kept so the Playwright specs' contract held - Delete
src/itx/browser-repl.ts+browser-repl.test.ts; REPL routes re-wired (/projects/$slug/replproject REPL,/_app/itx-replproject chooser,/admin/replremoved) routeTree regenerated; admin sidebar entry dropped - Editor scope types: worker
setScopeContext+ preamble query feeding a virtual module;resultsautocompletes; failure falls back silently replScopeModules in itx-repl-types.ts; preamble refetched after every settled run - Examples audit implemented (runtimes updated, generated file regenerated
via
pnpm generate:itx-examples) LIVE_SESSION_RUNTIMES → node/cli; new INTERACTIVE_RUNTIMES keeps browser for model/account reading material - Specs updated: repl-examples filters to browser-runnable; forged-session spec still passes on the new run path; matrix meta-assertion relaxed 90s budget for the cold path; spinner-waiter bypassed for the run wait
- Unit tests: entry derivation (requested/settled interleavings, unwrap, pending dedupe) + repl-types test updated itx-scope-repl-entries.test.ts (8 tests); repl-types tests cover the typed results modules
- Live verification: run entries, reload restores history local dev + real browser: describe run, results[0].data continuity run, typecheck-gate error entry; hard reload restored all three from the stream. forged-session-repl + describe-project/run-script playwright specs green against dev. Re-verified after the shared-scope revision: entries labeled #16/#21,
results.byOffset(16)returned the first run's data from the second run, reload restored both -
pnpm typecheck && pnpm lint && pnpm knip && pnpm format && pnpm testall green locally (2743 vitest passes in apps/os)
Follow-ups (deliberately out of scope)#
- Scope picker: let the REPL attach to other scopes — most notably an
agent's scope, making the REPL a shared console with that agent (you see its
results, it sees yours). - Stable addressing:
results.byOffset(n)or similar, so an entry can be referenced durably instead of by newest-first index. - Journaled console output: capture console from script execution into the settlement (or a sibling event) so the REPL can render logs again — needs a settlement schema change, punted with the console UI affordance.
- Run latency: each Run pays typecheck + dynamic-worker spin-up. If it feels bad, consider a warm worker per scope or an optimistic skip-typecheck-for-repl mode (explicitly NOT done here — agent parity).
Implementation log#
- Studied surfaces on origin/main post-#2395: runScript lives on
CapabilityHostRpcTarget(rpc-targets.ts ~5927) overrunCapabilityHostScript(capability-host-script-run.ts); host birth =capabilityHostCreationEvents(capability-host-defaults.ts); preamble assembly in capability-host-preamble.ts; settlement schema in packages/shared/src/script-execution.ts (succeeded.resultcarries the full JSON result — the REPL renders it straight off the settled event). - Stream reading:
useStreamConnection((itx) => itx.streams.get(path).openConnection({replayAfterOffset: 0, processEventBatch}))per itx-activity-tail.tsx; offsets dedupe replay overlap on reconnect. - Renamed from
repl-on-script-doortodurable-replmid-flight: "script door" jargon banned; prose now says what it is — runs execute as real scope scripts viarunScript, the same path agent scripts take. - Live verification notes: nested
runScriptfrom a REPL run works (the "run-script" catalogue example passes through the REPL in 6.5s — no deadlock); a provable typo (awaitt) settles as a journaled error entry carrying the compiler diagnostics; the dev-server OOM auto-restart (dev.ts, PR #2401) tripped once mid-verification — unrelated to this change, restart resumed cleanly. - Shared-scope revision (Misha, mid-review): scope
/repl(not per-user), route stays/projects/<slug>/repl, stable addressing pulled in. byOffset is rendered by the preamble assembler asObject.assign(__resultRows, { byOffset })so the tuple keeps per-index literal types while the helper rides the same value; the settled event's offset doubles as the UI label. - Bugbot round (all four real, all fixed): (1) cross-project state leak —
ItxScopeReplConnected + ItxActivityTail now key by projectId:scopePath;
(2) same-code rerun swallowed the pending row — the run mutation carries a
submit-time stream-offset anchor; (3) byOffset union types — now generic
over the tuple's literal offsets (Extract per offset), proven through the
real tswasm gate; (4) activity tail pointed at /repl with script-run
friendly renderers. Bonus find while re-verifying with the dev typechecker
sidecar down: the injected vars line was TS syntax, a SyntaxError on the
no-emit fallback path — now
const vars = JSON.parse("{}")(JS-safe, any-typed so vars-reading snippets stay clean through the gate). - REPL-echo round (Misha, live-testing the preview): bare expressions
returned null because the wrapper only surfaced explicit
return. Fixed in the wrap decision (itx-scope-repl-entries.ts): the whole input parsing as one expression auto-returns parenthesized ({ a: 1 }stays an object literal); otherwise a Node-REPL-style trailing-line-run rewrite (const x = 5;\nx * 2answers 10); otherwise run-as-written.new Functionis the parser — synchronous, dependency-free, the deleted browser evaluator's own trick; TS-only syntax falls back conservatively. Display unwraps the echo rewrite so history shows what was typed. Also value-less successes now render "undefined (nothing returned)" instead of a dishonest null (the settlement carries noresultkey for undefined; null is preserved as null). Live-verified:1 + 1→ 2, bareresults[0]echoes the retained row, trailing expression after statements works, value-less run renders the undefined note. - Session-streams revision (Misha, third scope shape): /repl/
session streams with the web-agent slug convention; URL = stream path; bare /repl resumes newest (itx.streams.list() filtered on the /repl/ prefix — streams register in project state via stream/created); New REPL button mints; sidebar /repl moved into the path-style nav group (fuzzy match keeps it active on session URLs; mobile drawer deliberately untouched). Live-verified end to end: mint → URL replace → reload resume → bare-/repl resume → two tabs sharing one console live → New REPL fresh stream → sidebar navigation. - Laziness revision (Misha): nothing exists until the first Run. Key fact:
ANY wake of a Stream Durable Object births it (stream/created on first
boot), so pre-birth the page makes NO session-stream calls — existence
comes from itx.streams.list() (a project-root read, non-suspending with a
30s staleTime), and the stream connection, preamble query, and activity
tail are all gated on
born(exists || this component submitted a Run || events already buffered). The Run mutation is the ONE creating code path: mint path (bare visits mint at Run time, so the timestamp reflects when work started) → idempotent create (identical default batch — a racing second tab dedupes and both proceed) → prime the known-streams cache → router-replace the URL (bare case) → runScript. Pre-birth editor typing falls back to itx-only types (preamble query disabled until born).- Delineated deviation from the suggested New-REPL shape: New REPL
navigates to a fresh /repl/
URL (still unborn — a URL costs nothing) rather than bare-/repl-plus-ephemeral-intent. Same laziness guarantee, no intent state, and it makes the not-yet-born shared-link case (open a session URL before anyone ran) first-class; reloading an unborn URL keeps you on that empty console instead of resume-latest — arguably truer to "where you left it" than the suggested fallback. - Platform note: the first child birth also registers the PARENT /repl stream (stream/child-stream-created) — that is platform bookkeeping after the first Run, not an early wake; the lazy spec's prefix filter accounts for it.
- specs/repl-lazy.spec.ts proves it end to end: visit + New REPL create nothing (settle window + streams.list []), first Run births exactly one stream at the URL's path.
- Delineated deviation from the suggested New-REPL shape: New REPL
navigates to a fresh /repl/
- Laziness Bugbot rounds (4 threads fixed, 1 rebutted): ASI continuation
guard in the echo split (
const total = anewline+ bis ONE statement — never auto-return+ b; guard lifts on;/}, matching real JS); the bare-/repl URL establish moved to settle time (mid-run replace remounted the console); a failed create() neither establishes nor opens the read connection (which would itself birth the stream) — bornPaths, a local twin-record signal (ref for settle callbacks, state for the born gate), recorded only after create() resolves, gates both. Rebutted: "ASI guard blocks valid echoes" — the guard refuses exactly where JavaScript reads a continuation; echoing there would change program meaning.