@automatalabs/repl-engine
v0.2.3
Published
The engine package of the **REPL orchestrator** (see [`docs/roadmap/repl-orchestrator.md`](../../docs/roadmap/repl-orchestrator.md)): a persistent JavaScript REPL in a capability-free QuickJS-in-WASM VM. One VM per workspace; the workspace object owns the
Downloads
1,477
Readme
@automatalabs/repl-engine
The engine package of the REPL orchestrator (see
docs/roadmap/repl-orchestrator.md): a persistent
JavaScript REPL in a capability-free QuickJS-in-WASM VM. One VM per workspace; the workspace
object owns the VM lifecycle (create → eval → drainJobs → dispose). The repl MCP
tool that registers in mcp-server (the daemon wiring below) is built directly on this
engine's Broker + Workspace + per-project ReplWorkspaceStore — with its own tool-level
input/output schemas, an action discriminator, output caps, and the client-presence drain —
not a thin WorkspaceRegistry wrapper; this package is the engine
tier it sits on.
import { Workspace } from '@automatalabs/repl-engine';
const ws = await Workspace.create('/path/to/project');
const first = await ws.eval('const findings = [1, 2, 3]; findings.map(x => x * 2)');
// first = { kind: 'value', value: [2, 4, 6] } — state persists in the VM
const second = await ws.eval('findings.length');
// second = { kind: 'value', value: 3 }
ws.dispose();Engine posture
The runtime shim is quickjs-wasi used as-is,
including the npm package's shipped quickjs.wasm binary — the roadmap doc's mapping table
is followed verbatim, and we never build our own binary. loadShippedWasm() resolves the
binary through the package export map and compiles it once per process into a reusable
WebAssembly.Module. The engine pins quickjs-wasi at an exact version to keep the shipped
quickjs.wasm byte-identical across installs — but snapshot compatibility (phase D's envelope +
restore) is enforced on the binary itself, not the package version: the envelope records and
compares the quickjs.wasm SHA-256 plus the envelope format version. An upgrade that
changes that binary's hash — or a format-version bump — refuses old snapshots loudly (both hashes
named), never restoring them silently; a package bump that ships the same binary keeps old
snapshots restorable.
memoryLimitper VM — passed straight through toQuickJSOptions.memoryLimit(quickjs-wasi built-in). Exceeding it fails allocations withInternalError: out of memory(anEvalErrorInfowithoutOfMemory: true); the VM stays usable. Default when unconfigured: 64 MiB (ReplVm.DEFAULT_MEMORY_LIMIT) — generous for data-plane state while still bounding what a single workspace can make the daemon hold.interruptHandlerper eval and per settlement drain — quickjs-wasi'sinterruptHandleris a per-VM create-time option, so the engine composes per-operation semantics on top of the built-in: one VM-level handler delegates to a per-operation slot thatevalCodearms for the duration of the eval and its drain, anddrainJobs({ interruptHandler })arms for the duration of a standalone settlement drain, then restores. Handlers never leak across operations. Returningtrueaborts withInternalError: interrupted(EvalErrorInfo.interrupted === true). Note the interrupt budget is instruction-based (quickjs's built-in check interval), so against a tiny loop body the handler fires comparatively rarely — that is the shim's native behavior. Why the drain takes its own handler: a suspended eval's handler is removed when the eval returns, and a settlement drain that later resumes a runaway continuation (a continuation left queued by an interrupted drain, or resumed by host-side settlement) would run unguarded — the drain boundary therefore carries its own interrupt signal.
Eval semantics
ReplVm.evalCode (and Workspace.eval) evaluates with EvalFlags.ASYNC — the script-global
REPL mode the harness pinned: bindings persist across evals (var lands on globalThis,
let/const/class/function in the shared global lexical environment), sloppy mode,
completion value = last expression, top-level await accepted, and top-level return
stays a syntax error (the parser's "return not in function" check is independent of the
async flag — pinned by test). The eval returns a promise; the engine drains the job queue
(quickjs-wasi's built-in executePendingJobs(), with the per-eval interrupt still armed, so a
runaway microtask loop is bounded) and reports one of:
| Outcome | Meaning |
|---|---|
| { kind: 'value', value } | completion promise fulfilled within the drain |
| { kind: 'pending' } | suspended on an unsettled promise — no fabricated value; the continuation resumes at settlement like a .then |
| { kind: 'error', error } | threw (synchronously, via a rejected completion promise, or via a job error during the drain — the canonical drain error is the per-eval interrupt firing inside a resumed continuation) |
The eval promise is fulfilled synchronously — the completion is read straight from the
runtime through the raw qjs_promise_result export, never through resolvePromise() (whose
host promise yields through the microtask queue even when already settled). This makes an eval
structurally un-raceable by dispose(): const p = ws.eval('6*7'); ws.dispose(); await p
returns 42 (review regression: the yielding completion read crashed on nulled WASM exports).
All VM operations serialize for the same reason, so concurrent evals can never reorder the
interrupt-slot save/restore (review regression: a stale handler stayed armed).
Trap-free rendering (from day one)
Rendering guest state is adversarial territory (roadmap doc transfer lesson R69: a single
[[Get]] on the completion wrapper let Object.prototype.value pollution hijack every eval
result). This package follows the rule from its first line of engine code, and it is enforced
structurally — two quickjs-wasi paths that would violate it are never taken:
QuickJS.evalCode()wraps synchronous failures (parse errors) in aJSExceptionwhose constructor performs guest-visible[[Get]]reads ofname/message/stackon the guest exception — a getter installed onSyntaxError.prototype.nameruns during error construction, before any hostcatch. The engine instead drives the same rawqjs_evalexport (through the package's public_getExports()/_writeString()accessors) and reads a synchronous exception own-property-descriptor-wise itself; the exception value is freed immediately after. Adversarial tests pin this: getters onSyntaxError.prototype.name/message/stack,Error.prototype.name, andTypeError.prototype.namenever fire, and a thrown proxy reports a trap-free[Proxy]marker (proxies fire traps on descriptor/prototype reads — every such read isisProxy-guarded, including the prototype of an error whose prototype was replaced with a proxy viaObject.setPrototypeOf).JSValueHandle.getOwnPropertyDescriptor()throws aJSExceptionwhen the C descriptor read fails (allocation edge) — and that constructor runs the same guest-visible getters. The engine's descriptor path (readOwnDataProperty) never calls it: it drivesqjs_get_own_property_descriptordirectly, takes a failed read's exception value out of the runtime and frees it (noJSExceptionis ever constructed), and reads the engine-created descriptor object's own data properties through rawqjs_get_prop_value. A regression test forces every descriptor read to fail C-side and asserts zero guest getter executions and a still-usable VM.QuickJS.executePendingJobs()renders a failed job's exception throughexc.toString()— a JavaScript string conversion that executes guest code. The engine'sdrainJobs()runs the same built-in pending-job loop (qjs_is_job_pending/qjs_execute_pending_job, which is all the built-in is) but reads the exception trap-free and throws aDrainJobErrorcarryingEvalErrorInfo;evalCodeconverts that into the error outcome.- The engine-created
{ value }completion wrapper is unwrapped via own-property-descriptor reads (getOwnPropertyDescriptor), never[[Get]]. - Completion values and error info are read the same way: own data properties only,
accessors skipped (and their
get/sethandles disposed — a leaked accessor handle pins guest memory; review measured a 1 MiB VM exhausting after ~3,128 accessor-valued completions), proxies and branded objects ([Promise],[Date],[Map], …) rendered as markers, depth ≤ 4, ≤ 256 properties per level, cycle-guarded. This shallow read is the conservative seed of the ObjectPreview rendering a later phase owns; the tool-result caps live there. - Error names come from the error prototype's own
namedata property when instances carry none (quickjs-ng storesnameon the prototype) — still trap-free; a guest-installed accessor or proxy prototype is skipped and the name falls back to'Error'. - No handle is ever leaked from a failed path: the exception values of failed evals, failed
jobs, and failed descriptor reads are disposed in
finallyblocks, and accessor descriptors'get/sethandles are disposed on the spot — long-lived VMs must not accumulate guest memory from error paths (both leaks were measured during adversarial review and are pinned by bounded-memory regression tests). - Error rendering converts symbols natively (the bare brand, FORMAT.md §5.7): a
thrown
Symbol('x')reportsSymbol, never the fabricatedNaNthe default number conversion produced. The description is deliberately NOT read — the rawqjs_get_symbol_descriptionexport invokes guestSymbol.keyFor(FORMAT.md §1.1), a forbidden seam, soSymbol(x)is unimplementable trap-free, not merely unimplemented (review regression, pinned by test).
The published type graph is also self-contained: the public options take WasmInput — a
locally declared stand-in for WebAssembly.Module | BufferSource (ArrayBuffer |
ArrayBufferView | WasmModule, see src/types.ts) — because the repo's tsconfig has no DOM
lib and the ambient declarations the package compiles against are source-only (never
published; the package ships dist only). WasmModule is opaque/branded: its only
producer is loadShippedWasm(), so accidental values ({ wasm: 42 }, a plain object, a
string) are compile-time errors — pinned by @ts-expect-error negative cases in the
consumer fixture (review regression: WasmModule used to be an empty interface that
satisfied every non-null value). Custom WASM is accepted as raw bytes (ArrayBuffer /
ArrayBufferView). A consumer check with the repo's non-DOM lib and
skipLibCheck: false is part of the test suite (test/public-types.test.ts).
The guest library and the bridge (phase B)
At VM creation the host installs the guest-side library — a version-marked plain script
evaluated exactly once in the realm — plus the four __host_* callbacks that are the realm's
entire effect surface. The library is this package's fresh implementation (not a vendor of the
harness's guest/dsl.js); its source is src/guest/guest-library.ts, its version is
GUEST_LIBRARY_VERSION (marker global __REPL_GUEST_VERSION), and its semantics follow the
roadmap doc's DSL split: only a sliver needs host effects, everything else is pure JS.
Sandbox globals
agent(modelSpec, task, options?) → Promise— the delegation primitive, per the roadmap doc's own example (agent("pi/deepseek-v4-flash-max", "research X")).modelSpecis the backend-routing spec;taskthe worker's prompt;options(structured-output schema, cwd, backend config) cross the bridge as JSON. The returned promise is the live handle: it may sit in a variable across evals, and it carries own non-enumerable handle methodsfollowUp(prompt, opts?)/steer(prompt, opts?)/cancel()— each resolving with what actually happened (the host settles with the steering outcome, live injection vs queued delivery, mirroring the outcome valuesacp-agentssurfaces in its steering events) — plusid(the stable call id"c1", … used bystatus/interrupt).checkpoint(question, options?) → Promiseandcheckpoint.answer(callId, value) → boolean— the data plane interrupting the intent plane. The answer enters the data plane only throughcheckpoint.answer(the__host_checkpointtrailing-argument answer mode); it returns whether a pending checkpoint with that id was answered.console.{log,info,warn,error,debug}— the bridge: every argument is frozen (structuredClone via the shipped quickjs-wasi extension, with an iterative marker-copy fallback) into a real$Nglobal, then forwarded as{ refs, args }to__host_console.parallel/pipeline/verify/judgePanel/gate/retry/loopUntilDry— pure JavaScript layered onagent(), followingpackages/workflows/src/dsl.d.tssemantics. A rejection withrecoverable: falsehalts the surrounding orchestration; any other rejection is recoverable (anullslot inparallel/pipeline, reported viaconsole.warn). There is no budget surface: nobudget()global, no ledger, no caps vocabulary — resource limits are server configuration, invisible to the guest (the host's non-recoverable signal is exclusivelyrecoverable: false).phase()is deleted per the doc.
Guest library ⇄ host contract
| Function | Meaning |
|---|---|
| __host_agent(callId, modelSpec, task, optionsJson) | Kick off one worker run against the backend routed by modelSpec. May return a thenable (the bridge's GuestCall promise) — the guest chains onto it — or undefined (settle later via the surface). |
| __host_checkpoint(callId, question, optionsJson, answerJson?) | Question mode: three arguments, like __host_agent. Answer mode: a PRESENT fourth argument (the JSON-encoded answer) — the host settles the pending checkpoint and returns a boolean synchronously; nothing new pends. |
| __host_agent_steer(callId, sessionId, action, payloadJson) | Steering: callId is the operation's OWN registry id (the settlement key), sessionId the FOUNDING call id of the session being steered (the dispatch and post-restore re-issue target); action is "followUp" | "steer" | "cancel" and payloadJson is { prompt, options } or null for cancel. The host settles with the steering outcome. |
| __host_console(level, payloadJson) | The console bridge, called synchronously after the guest froze each argument into $N. |
Settlement is first-wins idempotent by call id, through two always-valid routes: the live
GuestCall (a promise created via the raw qjs_new_promise export whose parts the call
owns and disposes completely — the TS analogue of the Rust reference broker's
new_promise_raw/Deferred; the shim's newPromise() Deferred is deliberately not used
because it pins the reject-function handle until VM dispose, measured to exhaust a 2 MiB
VM after ~5,000 resolved calls) or the reconciliation surface after a restore. The
surface — globalThis[Symbol.for("repl.guest")], read host-side via
readGuestSurface(vm) — exposes version, pending() (verbatim details for re-issuing
lost work, including sessionId — the founding session id for steering calls — and
modelSpec), settle(callId, outcome, value) and stats(); it is frozen, its binding
non-configurable, and its registry operations use captured intrinsics, so Map.prototype
pollution cannot corrupt settlement. The returned surface object pins NO guest memory:
every handle it needs is acquired per call and disposed on the spot. The pending-call
registry lives in the library's closure and travels inside snapshots; on restore the
host re-registers the four callbacks by name (registerGuestHostCallbacks) and
reconciles — the library itself is never re-evaluated (idempotence guard).
Eval-await tracking — the continuation lease (version 0.3.1) — the eval-break
targeting seam (the interrupt tool's no-id arm): the library defines
__replAwait(value, token) — the global the host's instrumentTopLevelAwaits rewrite
inserts around every TOP-LEVEL await of an eval (await x →
await this["__replAwait"](x, TOKEN); the this base is the engine's global-object
binding for the script's async wrapper, so the injected expression names no
shadowable identifier — the phase-E review round-5 hygiene regression: the old
instrumenter's guest-resolvable __replAwait identifier was shadowable by a lexical
declaration, changing program semantics). With a token the awaited value is WRAPPED in
a fresh promise. The CONTINUATION LEASE (the writable __replLease accessor global)
is set by a reaction registered on the WRAPPER itself — BEFORE the await machinery
registers its own reaction on the same wrapper — so the wrapper's settlement queues
the lease-setting job DIRECTLY BEFORE the machinery job that runs the eval's
continuation segment: the job after the lease-setting reaction IS the segment, and
NO job queued between the awaited value's settlement and the wrapper's settlement can
run with the lease set (round-6 rejection: the 0.3.0 reaction ran on the awaited
VALUE's settlement, so a sibling q.then(...) registered after the eval started
awaiting q ran between the lease set and the continuation, consumed the armed
signal, and the target's continuation ran later unprotected — the lease is
associated with the actual continuation job, not the next job). The host's drain
loop reads the lease between jobs: a job that starts with a lease set IS the armed
eval's continuation, and the lease is cleared after the segment ends — the armed
signal's genuine per-eval identity. An unawaited sibling .then registered before
the target's await runs first in the settlement drain (before the lease-setting
reaction) and can neither fire nor consume the signal; an indirect wait (await
Promise.all([q])) is targetable through the promise graph (the 0.2.0 log-only
targeting refused it); a never-settling local promise is refused at arm time (no
pending host call can ever resume it). The surface's supportsContinuationLease
reports the capability. For-await loops ride the same discipline through a second
global, __replAwaitIterable(value, token) (0.3.1): the instrumenter wraps every
top-level for await (... of <iterable>) ITERABLE in it, and the wrap returns an
ASYNC-ITERABLE — never a promise — that sets the lease per iteration, so the loop
iterates exactly like the un-instrumented program (for await (const x of [1, 2])
works — the 0.3.0 wrap returned a promise and made every loop throw TypeError: not
a function, the round-6 rejection) and stays breakable mid-iteration. The surface's
supportsIterableLease gates the for-await sites: a snapshot carrying the 0.3.0
library is served as-is with its for-await sites left unwrapped (native semantics,
no mid-loop targeting — the honest degradation). The broker's continuation-lease
availability check is VERSION-GATED on ≥ 0.3.1 (round-7 decision): a restored 0.3.0
copy reports supportsContinuationLease: true but its lease-setting reaction still
runs on the awaited VALUE's settlement — the sibling-reaction defect — so the host
serves it WITHOUT instrumentation and the eval-break interrupt refuses honestly
(the flag alone would re-arm the original defect on a supported older snapshot;
see Broker.continuationLeaseAvailable). The instrumentation surface is also
hardened against guest Promise sabotage (round-7 decision): __replAwait /
__replAwaitIterable (and the host-thenable forwarding in issueHostCall —
without it, a replaced Promise.prototype.then silently killed every settlement)
mirror values through CAPTURED pristine intrinsics (P/PResolve/PReject/pThen
— bound at installation, before any guest code runs), so replacing
Promise.prototype.then, overwriting Promise.resolve, or shadowing Promise
lexically cannot change the instrumentation's semantics (the instrumented
await 40 stays 40) or skip the continuation-lease setting. The for-await wrap
also follows GetIterator/GetMethod acquisition semantics exactly once (an
observable/throwing @@asyncIterator getter runs a single time and its ORIGINAL
error propagates — the old degrade-to-unwrapped made the machinery acquire a
second time) and passes SYNC-iterable results through
AsyncFromSyncIteratorContinuation (the result VALUE is awaited and unwrapped —
for await (const x of [Promise.resolve(1)]) yields 1, never the promise
object). A snapshot carrying the
0.1.0/0.2.0 library is served as-is (the version-compatibility
rule below): the host skips the instrumenter on it and the eval-break interrupt
degrades to the honest refusal (the 0.2.0 log-only targeting is the rejected
settled-call-ids identity). The transform is a pure source rewrite at exact AST
boundaries (acorn; nested function bodies are never touched — an await inside a
.then callback or a combinator thunk belongs to its own continuation, not the
eval's; for await (... of await y) needs no iterable wrap — the right expression's
own await is instrumented normally and the loop iterates the unwrapped value) and
injects nothing but the call sites (no helper binding — a top-level
const would persist in the realm's global lexical record and redeclare on the loop
idiom).
Version compatibility (the doc's evolution disciplines): the library is versioned with the
workspace, not the host — a host must serve any snapshot whose resident library is the same or
an older version, the host-call surface is append-only (new optional trailing arguments =
minor; new __host_* names = major), and the host discovers the resident version through the
surface rather than assuming. ReplVm.restore exists so the evolution discipline is testable
now: state, $N store, registry and marker survive a snapshot/restore round trip.
The console bridge and the previewer
Every console.log is truncated in the tool result but captured inside the VM as
$1, $2, … (what-you-saw-is-what-you-have: mutation after the log never changes $N), and
the rendered line carries the address, type and size — [$14 · object · 48kB] {sections:
Array(12), title: "Auth flow", …} — so the orchestrator slices deeper in a later eval
(console.log($14.sections.map(s => s.title))) instead of re-running work. The capture is a
structuredClone with an iterative marker-copy fallback: cloneable data (objects, arrays,
Map/Set/Date/RegExp/Error/ArrayBuffer/typed arrays) is preserved whole, while
functions, symbols, promises, weak collections, and hostile or otherwise unfreezable subgraphs are
stood in for by a typed marker ({ __unclonable__: <kind>, description? }) — so $N keeps the
value's shape rather than failing or losing the surrounding structure. Nothing cloneable is lost
by logging it; nothing floods the client's context by being logged.
The truncation format is the Chrome DevTools Protocol's ObjectPreview model, adopted as a
spec; the harness's previewer/FORMAT.md is the normative reference and this package imitates
its rules: one collapsed level, ≤ 8 properties / 8 leading array entries, 40-char property
strings (24+8 head/tail), 200-char top-level strings (120+40 head/tail), 120-char error
descriptions (72+24), a 400-char collapsed backstop, the overflow flag, head+tail elision
everywhere (errors live at the end), positional canonical-index rendering, and the byte-size
format with decimal units and the ≥ 999.95 promotion rule. Preview generation is
side-effect-free by construction: engine brand checks only, own-property-descriptor reads
only, proxies detected first and previewed as proxies (Proxy(Array), Proxy(revoked)),
typed-array elements via the language-guaranteed integer-indexed reads, and the key
materialization read back through descriptors with honest degradation on a corrupted
enumeration (FORMAT.md §6 — a corrupted typed-array key count degrades with overflow: true,
never a fabricated "no expandos"). The forbidden seams stay unwired: symbol descriptions are
never read (qjs_get_symbol_description invokes guest Symbol.keyFor — FORMAT.md §1.1), so
symbols render as the bare brand Symbol everywhere, including thrown-symbol error messages,
and qjs_get_array_buffer's raw data pointer is never passed to qjs_is_exception (a
guest-controlled buffer must not be able to forge a failed read). Every raw export that
returns heap-allocated JSValues (qjs_get_typed_array_buffer's backing buffer,
qjs_get_proxy_target's exception box) is disposed on every path. A $N slot rebound to an
accessor renders an explicit sabotage marker — the getter is never invoked.
Output caps
applyOutputCaps enforces the doc's limits — 256 lines or 10 KB per tool result, whichever
trips first — line-granular (a line that would trip either cap is not emitted at all) and
byte-counted in UTF-8 with \n separators (the canonical serialization). The 256-line cap
counts physical lines, not rendered entries: the previewer renders property names verbatim
(FORMAT.md §5.18), so a name carrying 300 line feeds reaches the tool result as 301 physical
lines inside ONE rendered line — both caps account for embedded newlines (the byte cap via
Buffer.byteLength, the line cap by splitting on \n; review regression, pinned by test).
Over-cap content remains reachable through the $N refs the capped lines carry: the cap
costs reads, never data.
The workspace (phase B)
Workspace.create installs the guest bridge at VM creation — the doc's injection discipline:
agent/checkpoint/the combinators are live from the first eval, never undefined. options.handlers
may supply custom bridge handlers; the default is a parking bridge (agent/checkpoint/steer calls
park — they pend in the guest registry, visible through surface()/parkedCalls(), and stay
unsolved until a later phase attaches real backends; parking never fabricates a result — console
events accumulate in consoleEvents()). The one deliberate exception is checkpoint.answer:
answering a parked question settles the matching pending checkpoint first-wins, so the data plane
can interrupt the intent plane even with no backends attached. The workspace also exposes the
rendering seam
(renderRef, inspectBinding) and the reconciliation surface (surface()) the repl tool layer
builds on. A later phase that wires real backends swaps handlers via registerGuestHostCallbacks
(the same re-registration the restore path uses) — the broker does exactly that through
Workspace.rehost. Workspace.snapshot() / Workspace.restore are the raw snapshot
seams; the identity envelope (wasm hash + format version + gzip) is the daemon layer's wrap
(ReplWorkspaceStore).
The broker (phase C)
Broker.attach(workspace, options) takes over a workspace's four __host_* callbacks (by-name
re-registration — the guest library and its pending-call registry are untouched) and implements
the doc's broker contract against real ACP sessions through @automatalabs/acp-agents:
agent(modelSpec, task, opts)dispatches a held-open ACP session — the runner'sopenSessionwith the routing grammar, model spec and per-callcwd(default: the workspace's project directory). The guest option bag is exactly{ schema, cwd, configOptions, mode, tier, label, toolNames, disallowedToolNames, meta, promptMeta, maxSchemaRetries, baseInstructions, developerInstructions }— any other key refuses the call (recoverable: false).schemais a JSON Schema object validated by acp-agents' own structured-output ladder (resolveStructuredOutputdriven over the session: convert/check, native + prose extraction, re-prompt,SCHEMA_NONCOMPLIANCE— the one divergence fromrun(): the client-hosted StructuredOutput MCP capture tool is not injected on the interactive path). Sessions stay open for the workspace's lifetime (the live-handle contract) and are opened withkeepSession: true, so the ACP session persists on the backend for the restore path's lazy re-attach.- Six concurrent subagents per workspace (doc-settled;
maxConcurrentAgentsconfigurable — server configuration, invisible to the guest). The cap counts live work: unsettled agent calls plus sessions running a queued-steer delivery turn. An over-cap dispatch is refused at dispatch time — recorded in the store (a refused call is never re-issued after a restore) and rejected with a recoverableConcurrencyLimitError; nothing queues and nothing is hidden. - Steering resolves with what actually happened (the doc's "nothing is hidden, nothing
hard-errors"):
followUp/steersettle with acp-agents' steering-outcome vocabulary where the backend advertises_session/steering, with the broker's honestqueuedmarker where it does not (the per-backend steering mechanism table is the GENERATED artifact indocs/steering-mechanism-table.md— seesrc/steering-table.tsand its gate test). Steering calls NEVER hard-error: backend/wire failures resolvefailed; the only rejections are guest protocol violations.cancel()resolvescancelled(turn in flight),idle(nothing running) orfailed; a cancelled call rejects with the RECOVERABLEAGENT_CANCELLED(one worker's cancellation never halts the surrounding orchestration). - The append-only call store (
src/store.ts, transfer lesson 1): every call's outcome is recorded by call id BEFORE it is settled into the guest.InMemoryCallStorefor tests and ephemeral hosts;JsonlCallStoreis the durable append-only JSON-lines file — every mutation one fsynced line, torn-tail repair on open (fragment sidecarred then truncated; unterminated- but-complete records kept; newline-terminated corruption refused), and appends heal to the acknowledged prefix after a failed write. The pump's delivery loop is record → settle → consume, with both sides first-wins idempotent — a crash between the store write and the guest settlement is healed by the next delivery, exactly once (pinned by the simulated-crash tests, including the snapshot/restore +reconcile()path). - The eval tool-result shape (
Broker.eval→{ output, outputTruncated, result?, pending, checkpoints, completed }): output lines (console events rendered through the previewer — one line per logged argument, non-log levels prefixedwarn:/error:/… — capped at 256 lines / 10 KB), the previewed completion value when the eval resolved (trap-free, from the live completion handle), the pending call ids when it suspended (no fabricated value), the raised checkpoints (previewed questions), and the call ids this operation settled (checkpoint answers deliberately excluded — an answered id leaves thecheckpointslist). Eval errors render as plainName: messagelines inoutput. - Suspended-eval semantics (transfer lesson 3): top-level
awaitaccepted; an eval whose completion resolves within its drain reports the previewed value; a suspension returns immediately with the pending call ids; the continuation resumes at settlement like a.then(its output lands in the next tool result); a late uncaught rejection surfaces as an error-level console line in the next tool result (the VM's rejection bridge, armed by the broker); top-levelreturnstays a syntax error. - Checkpoints (transfer lesson 4):
checkpoint(question)parks a promise and records the dispatch; the question appears in the tool result'scheckpointslist previewed through the top-level string rule (quoted, head+tail elided past 200 chars — guest-chosen text never crosses unbounded);checkpoint.answer(id, value)in a later eval records the answer and settles the parked promise within that eval — root-mediated by construction, first-wins, and the answer's continuation output lands in the delivering eval's own tool result.
The broker's public type surface is fully self-contained (structural BrokerRunner/
BrokerSession stand-ins — no acp-agents or quickjs-wasi types leak into the published
declarations; verified by the consumer fixture). Broker.eval/pump/reconcile/dispose
serialize, so overlapping tool calls can never interleave settlement bookkeeping.
Decisions for spec-owed details
These are the decisions this phase (the broker/engine tier) made where the roadmap doc left
room. The later phases that build on them — the daemon wiring and the repl MCP tool — have
since shipped (roadmap phase E; see Daemon wiring
below).
- Default memory limit: 64 MiB per VM (configurable per workspace and per registry).
- Per-eval and per-drain interrupts composed over the built-in per-VM handler (see
Engine posture) — this is the only composition quickjs-wasi's API allows, and it keeps the
whole interrupt mechanism on the built-in
qjs_set_interrupt_handlerpath. A standalone settlement drain arms its own handler because the suspended eval's handler is gone. <repl>as the default eval filename for guest stack traces.- Eval completion is synchronous: the completion value is read through the raw
qjs_promise_resultexport instead of the shim'sresolvePromise()(whose host promise yields even when already settled). This makesdispose()structurally un-raceable and serializes all VM operations, which in turn makes the interrupt-slot save/restore concurrency-safe (anopDepthreentrancy guard makes the serialization invariant structural). - Drain errors are authoritative eval errors: when a drained job throws (interrupt-in-job
is the canonical case), the eval reports that error; the guest exception has already been
consumed and cleared by the drain loop, so the VM stays usable. The drain is the built-in
pending-job loop, but the failed job's exception is read trap-free (see Trap-free
rendering) and thrown as
DrainJobError— never rendered throughtoString(). - A failed eval's exception value is freed immediately (in a
finally), and accessor descriptors'get/sethandles are disposed on the spot — long-lived VMs must not accumulate guest memory from error paths (both leaks were measured during adversarial review and are pinned by bounded-memory regression tests). - The public wasm surface uses self-contained, branded types (
WasmInput/WasmModulefromsrc/types.ts) instead of the DOM-libBufferSource/WebAssembly.Modulenames, so the published declarations compile under the repo's non-DOM lib withskipLibCheck: false— andWasmModuleis opaque, so onlyloadShippedWasm()can produce one (custom wasm goes in as raw bytes). - The registry dedupes the in-flight creation promise: concurrent first-touches of one
project key share a single creation, so exactly one VM is instantiated per workspace (the
first caller's options win).
disposeduring an in-flight create cancels it — the created VM is torn down without materializing, the waitinggetrejects, and a latergetstarts fresh. - Primitive error rendering follows native conversions for every primitive type, with
symbols as the one deliberate exception: a thrown symbol renders the bare brand
Symbol(FORMAT.md §5.7) — its description is not readable trap-free, because reading it reachesqjs_get_symbol_description, which invokes guestSymbol.keyFor(FORMAT.md §1.1). A guest that replacesSymbol.keyFormust not be able to forge error rendering (pinned by test). - Realpath validation of
projectDiris deliberately NOT here: that is the daemon's project-registry concern (therepltool's phase); the registry keys by the string it is given.
Phase C decisions (the broker, the call store, the eval tool-result semantics):
- The broker dispatches held-open sessions (
runner.openSession), not one-shotrun()calls: the live-handle contract (followUp/steer/cancel on a settled call) requires a session that outlives the call. Sessions are opened withkeepSession: true(the ACP session persists on the backend for the restore path's re-attach) and stay open while any MCP client is connected to the project; on last-client disconnect the daemon drives the client-presence drain (drainForDisconnect— in-flight turns drain to completion, then idle children close) and later followUp/steer/cancel re-attach the session lazily.schemacalls drive acp-agents' ownresolveStructuredOutputover the session (tryNative= raw structured output, else the generic parse-final-JSON dialect) — the one divergence fromrun(): the client-hosted StructuredOutput MCP capture tool is not injected on the interactive path. - The concurrency cap counts live work — unsettled agent calls plus sessions running a
queued-steer delivery turn (a follow-up turn is a subagent working). Over-cap dispatches are
refused AT DISPATCH TIME (nothing queues): recorded in the store (dispatched + rejected, so a
restore never re-issues them) and rejected with a recoverable
ConcurrencyLimitErrorwith NO code — the doc deletes the budget vocabulary (AGENT_LIMIT_EXCEEDED/BUDGET_EXHAUSTEDhave no counterpart here), andrecoverable: trueis the one signal the guest needs. - The steering mechanism table (the doc's spec-owed decision): extension backend + turn in
flight → live
_session/steeringwire call, resolving with the backend's verbatim outcome (injected/startedNewTurn/failed); extension backend + idle session → a new turn (startedNewTurn); no-extension backend + turn in flight → queued for next-turn delivery, resolvingqueuedIMMEDIATELY (the delivery happens at the next turn boundary; a delivery turn's failure surfaces as a warn-level line in the next tool result; a cancelled call drops its queue — both documented); no-extension backend + idle session → a new turn (startedNewTurn). Any wire failure resolvesfailed— steering never hard-errors.cancelresolvescancelled(a turn was in flight and ACPsession/cancelcompleted; the cancelled call itself rejects with the recoverableAGENT_CANCELLED— never a halt signal for the orchestration owning it),idle(nothing was running), orfailed. The outcome surface is acp-agents'SteeringOutcomeplus the honestqueued/cancelled/idleadditions — urgency delivery (injected) is always distinguishable from next-turn delivery (queued/startedNewTurn). - The store records refused calls too (dispatched + rejected with the refusal error):
without the record, a restore would re-issue a call that was deliberately refused.
admittedis deliberately absent from the record shape — it was the budget ledger's bookkeeping, and the ledger is deleted vocabulary. completedexcludes checkpoint answers (the harness's pump convention): an answered id leaves thecheckpointslist — that is its visibility;completedreports delegated work (pump deliveries + dispatch-time refusals).resultis the FORMAT.md collapsed rendering of the completion value (the bare body —42,"hello",{a: 1, …}), previewed from the live completion handle through the previewer's own trap-free machinery (the engine's internal eval-with-completion seam returns the unwrapped value handle; the published type graph stays clean). Eval errors render as a plainName: messageline inoutput(the harness's "thrown-exception message" convention; late uncaught top-level rejections are theerror:-prefixed console-bridge lines).- Checkpoint questions cross previewed via the previewer's top-level string rule
(
stringDescription: quoted, head+tail elided past 200 chars) — the harness's R74 rule; the id stays exact. - The broker serializes its async operations (eval/pump/reconcile/dispose share one promise chain): two overlapping tool calls can never interleave settlement bookkeeping or the eval's pump-before-eval ordering. The pump delivers ready outcomes one at a time (record → settle → consume), keeping a failed delivery staged for the next pump — both the store write and the guest settlement are first-wins idempotent, so the retry settles exactly once.
Workspace.snapshot()/Workspace.restoreare the raw snapshot seams (the daemon layer wraps the identity envelope later);Workspace.rehostis the by-name callback re-registration the broker uses to take a workspace over — the same re-registration the restore path uses.
Phase D decisions (snapshots + restore; see also the "Snapshots and durability" section):
- The envelope is a JSON header line + gzip of the shim's own
serializeSnapshot()output (its versioned QJSS binary with extension metadata) — the doc's "serializeSnapshot() output wrapped in the identity envelope" is followed verbatim; gzip is the shim-documented compression choice (JS runtimes decompress it natively). The header carries format name + format version + wasm sha256 + createdAtMs; the restore path compares the recorded hash againstwasmSha256Ofof the binary it restores with and REFUSES LOUDLY naming both hashes. The format version is a second refusal axis (version-bump test included). loadShippedWasmrecords the shipped binary's hash against the compiled module —wasmSha256Of(module)resolves through that registry; a module the engine did not load cannot be hashed (bytes are not recoverable from the compiled form) and refuses loudly (pass raw bytes instead).- The repl store reuses
@automatalabs/workflows' store-layout helpers verbatim (workflowProjectPaths— the mcp-server project registry's own helpers), so the store key derives from the project directory exactly as the workflow engine's and one project has one repl store. Files:repl/snapshot.bin+repl/calls.jsonl. - Atomic writes are tmp + rename + fsync (fixed-name
.tmp, single-writer discipline; best-effort directory fsync); a failed write removes the tmp and throws, leaving the previous snapshot untouched; the store directory self-heals on write after areset(). - Restore-time corruption is contained in the same refusal family
(
SnapshotRestoreError, codeRESTORE_CORRUPT— aSnapshotEnvelopeErrorsubclass, so the daemon's single containment catch covers the whole load path): the envelope's decode checks now include pointer-BOUNDS validation (runtime/context/stack pointers must be integers strictly inside the snapshot memory — a corrupted in-range VM header likecontextPtr: 0xfffffff0refuses asCORRUPT_PAYLOADat decode, before any VM exists), and a payload that passes every at-rest check yet cannot be materialized (a header patched to a wrong-but-in-bounds value, a guest surface that cannot be rehosted, a provenance registry that cannot bootstrap) refuses fromWorkspace.restorenaming the underlying failure — after DISPOSING the partially created VM. The daemon records the refusal as stable state (later touches surface it without re-attempting the restore;resetclears it) — never a rawRuntimeErrorretry loop into garbage. - The safe-re-issue fence is re-checked after every awaited release (
reissueReattachedand the reconcile catch arm): the loaded session'srelease()can park past the client-presence drain's bound (or a disposal's generation bump), during which the drain's forced stop settles the call durably and reportsisDrained; a re-issue that resumed after the release would record a reissue and open a FRESH child post-drain. The generation captured at entry is re-checked after the await — a fenced landing holds the call (no reissue recorded, nothing opened; the call stays as the drain/disposal left it). - The debounce is boundary-in/burst-out: the broker fires
boundary(kind)per doc-defined boundary (after each eval; after each settlement drain that changed VM state) andflush()at the end of each serialized operation; the store'ssnapshotWriterdebounces the burst into one atomic write.SnapshotWriteOptions.debounceBursts(default true) andfsync(default true) are the decided knob names;ReplStoreOptions.persistenceRoot/envoverride the workflow home. - The re-attach arm keys on a store-recorded backend session id (
recordAttached, written at session open BEFORE the prompt — a new append-only log event; overwrites on re-issue so a later restore re-attaches the CURRENT session). The capability gate is the runner's ownloadSession(acp-agents'supportsLoadSession— a custom backend that omits it degrades through the same gate, surfaced guest-visibly). BrokerSession.awaitCurrentTurnis REAL on the acp-agents adapter (the loaded session's founding-turn completion;InteractiveSession.awaitCurrentTurn, phase-D review round 1: the seam used to be absent, so every built-in backend loaded, released, and re-issued). Its completion evidence is the_session/loaded_turnvendor extension (phase-D review round 3: the quiet-grace heuristic — a settled stream with a trailing assistant chunk treated as completion, which durably settled an assistant PARTIAL as a completed-while-down turn when the next live chunk arrived later — and the blind re-issue fallback, which duplicated a still-running backend turn, were both rejected; an AUTHORITATIVE terminal channel is required).session/loadobliges the agent to replay the entire persisted conversation and only then resolve the load; the runner marks the LOAD BOUNDARY synchronously after the response, and the seam then asks_session/loaded_turn/querywhether the founding turn is still running RIGHT NOW. The backend answers one of three terminal classifications: (1)completed— the turn observably completed while the host was down, so the replay's trailing assistant message is its FINAL message and the seam resolves immediately with the REAL accumulated text (stopReasonsynthesizedend_turn— the protocol's replay carries none; the broker's result-shaping gates still apply); (2)interrupted— the turn ended without a terminal assistant message and no turn is running, so the seam rejects with the SAFE-RE-ISSUE class (nothing to duplicate); (3)running— the turn is still executing at the backend, so the seam KEEPS THE LOADED SESSION ATTACHED and waits for the authoritative_session/loaded_turn/endednotification (a quiet gap is only a progress-stream gap, never terminal evidence), absorbing the live update stream and settling with the turn's REAL accumulated text at the notification, bounded byAGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS(default 15 min — the "never hang unobserved" backstop). A backend WITHOUT the_session/loaded_turnextension — the built-in claude and opencode backends today — is classified by the seam's observation path instead (phase-F review round 2: the old degradation released the loaded session and blindly re-issued, which can duplicate a still-running backend turn — re-issue is now reserved for the observably-dead classes). The observation path is the post-load continuation watch (any content update after the load boundary is live continuation — the authoritative still-running signal, which flips to the keep-attached wait) plus the replay probe under the connection-death contract: the built-in ACP servers terminate in-flight turns when the client connection closes (live-verified) and their persisted transcripts hold only completed messages, so at restore the founding turn is never still running — the replay's trailing assistant message is the turn's terminal message (completed-while-down, settled from the replay), and anything else means it died mid-way (the safe-re-issue class, no duplication possible). Arunningturn past the max-wait bound rejects withLoadedTurnStillRunningError: the re-armable form re-arms the seam on the still-attached session (a later notification or a cancel still settles the call), and the non-re-armable form (a third-party seam that can NEVER observe the terminal state) is NOT re-invoked — the broker keeps the loaded session attached and waits for the terminal state from the session's own ended notification, the call's cancel, the session's release, or the client-presence drain's forced stop (a possibly-running call is never re-issued). A turn that failed at the backend rejects withLoadedTurnFailedError(a definite outcome, settled as an ordinary rejection, never re-issued); everything else (no user message in the transcript,interrupted, a dead process) is the safe-re-issue class. A handle that was never load-marked rejects immediately (without the boundary the completion is not observable and the seam never guesses). The broker arms the re-attached call on the seam WITHOUT blocking reconcile: reconcile returns immediately, the pump delivers the completion through the same record → settle → consume path as a live call. Only a third-partyBrokerSessionadapter WITHOUT the seam at all re-attaches the session and then degrades through the re-issue fallback — the seam absence is a capability omission.- Backend identity/pool routing is persisted (phase-D review round 2): the store
records the model spec VERBATIM (including the guest's
"default"sentinel) AND the RESOLVED backend id at session open (recordAttached— a backend id doubles as a model routing spec). The restore's re-attach, the lazy re-attach, and re-issues all route by the recorded pin — never by the CURRENT configured default, so a changed default across a restart can never load or re-issue on the wrong backend and miss a still-resumable original session. - Settled handles re-attach lazily (phase-D review round 2; the doc: "followUp
re-attaches the subagent session lazily via the capability matrix"): after the
client-presence drain (or a restore that left settled calls unattached),
followUp/steer/cancel on a settled handle load its recorded backend session through
the runner's own
loadSession— capability-gated exactly like the restore arm (a custom backend without the capability degrades through the same gate, surfaced guest-visibly as a warn line and the honestfailedoutcome); the loaded session serves the steering operation per the mechanism table. Concurrent lazy re-attaches of one session share a single load. - The client-presence drain (
Broker.drainForDisconnect, phase-D review round 2): in-flight turns DRAIN TO COMPLETION within the bound (each settlement boundary snapshots — a turn that finishes in time is never cancelled), bounded by the spec-owed concrete bound, which REUSES the daemon's session-eviction TTL (the daemon passesSESSION_IDLE_TTL_MS; a turn that OVERRUNS the bound is force-cancelled — the honest bounded teardown, settled as the recoverableAGENT_CANCELLED), then every idle child closes (keepSessionkeeps the backend sessions re-openable; queued-but-undelivered steers are re-queued durably against their founding session ids and delivered by the next re-attach exactly once). The workspace and broker stay alive; the next client's followUp/steer/cancel lazily re-attaches. Phase-D review round 3 hardens both edges: the drain WAITS for calls still OPENING (openSessionparked — an opening call has no session entry yet, so a drain that considered only registered busy sessions returnedtrueimmediately and let the child open and run after the last client disconnected) and in-flight lazy re-attaches, and a parked open that outlives the bound is STOPPED (the late child is closed before it ever prompts, the call settles as the recoverableAGENT_CANCELLED, queued steers are dropped durably); and the outer bound is ABSOLUTE — every post-deadline cancel/release await races the remaining time, so a hung backend can never block disconnect/shutdown past the eviction TTL. - The per-eval wall-clock deadline (
BrokerOptions.evalTimeoutMs, default 30 s,AGENTPRISM_REPL_EVAL_TIMEOUT_MS; phase-D review round 2): every eval and settlement drain runs under a deadline enforced by the quickjs interrupt handler, COMPOSED with the configured signal handler — so a runaway eval can never hang the workspace forever: the deadline ALWAYS bounds it (a synchronous eval blocks the event loop before a later interrupt request could arm the signal, so the deadline, not the signal, is the last-resort bound; phase F adds the out-of-band relay that breaks a synchronous runaway before the deadline). This is distinct from the interactive no-idinterrupt, which breaks a yielding eval promptly but honestly refuses (refused-idle) the cases it cannot key a resumption to — a never-settling local promise, or a restored older guest that predates the continuation-lease seam. The VM stays usable after an interruption. - The workspace manifest (
Broker.workspaceManifest(), phase-D review round 2; the doc's status surface): top-level USER bindings (fresh-realm baseline set difference — the baseline is captured once per process from a throwaway VM provisioned exactly like a real workspace, and the engine-versioned library never grows the realm's baseline) with structure-only tokens ({2 keys} · 1.2kB,string · 10B,number,Array(3) · …— metadata, never content: no value fragments, no nested names), provenance labels (via eval N/via worker cN/session restore— from the in-realm provenance registry, which is HOST policy (bootstrap-installed with the baseline as itsknownset) so it travels inside snapshots without touching the guest library; the maintenance pass runs after every eval and settlement drain, trap-free descriptor reads only, sanitized at render), and live-handle status (agent handle · pending|settled · call cN— the call id maps to the task and timestamps in the store) and the doc's full provenance surface —task(the foundingagent()call's task text forworker cNand handle bindings, capped at 200 chars) andprovenanceAtMs(the attribution wall clock; phase-D review round 3: bindings used to carry only the label and an internal timestamp). The$Nlog-ref globals render as a range (logs: $1…$4 (4 values)). - Pending steers whose wire call died with the process resolve
failed(recorded + settled + warned): their outcome is unknowable and re-injecting would duplicate; the one exception is queued-but-undelivered steers, whose payload is in the store (the phase-C queue rebuild). Pending checkpoints re-surface into the broker's checkpoint table (PendingCheckpoint.callis null on that path; answers settle through the reconciliation surface). Reconcile is idempotent (anisTrackedguard never re-attaches/re-issues twice) and adopts store-unknown entries (foreign snapshot / wiped store) so the replay ledger stays complete. Re-issues respect the concurrency cap (over-cap re-issues refuse with the recoverableConcurrencyLimitError).
Phase E review round 3 decisions (the carried review's three defects, as re-verified in round 5):
- The eval-break signal is keyed to the armed target's CONTINUATION, not to whichever drain runs next. The carried defect: the drain-phase interrupt handler was installed on every later eval's drain without checking whether that drain resumed an armed target — an unrelated finite eval B (or an unrelated settlement drain) consumed the signal and the interrupted-drain release cleared the target's tracking while its checkpoint stayed pending and uninterruptible. The armed identity is the target's CONTINUATION TOKEN (round 5): the guest library's
__replAwait(value, token)wrap sets the continuation lease to the eval's token in the job immediately before the eval's continuation segment, the drain loop mirrors the lease per job, and the signal fires only while the executing JOB holds an armed token — the executing job IS the target's continuation. An unrelated drain — and an unrelated JOB inside a drain that settled a target's call (an unawaited sibling.thenregistered before the target's await runs first, before the lease-setting reaction: it can neither fire nor consume the signal) — leaves the armed state intact; an indirect wait (await Promise.all([q])) is targetable through the promise graph (round 5's regressions). The interrupted-drain release (releaseInterruptedEval) is exact the same way: the interrupted job's lease names the eval whose continuation was actually executing — exactly that eval is released (a deadline-broken resumed runaway releases its tracked eval even when no signal was armed — a stale target would make a later arm target a dead eval); an unrelated interrupted drain leaves the armed state and every tracked eval intact. A no-id interrupt with NOTHING BREAKABLE — no eval in flight, or every in-flight eval suspended with NO pending host call (a never-settling local promise — no execution can ever resume it; a suspended eval's continuation is always queued by a pending call's settlement, directly or through any promise chain) — REFUSES and arms nothing. - The bounded wait sleeps only for the REMAINING budget:
waitForCalls's inter-pump sleep ismin(50, deadline - now)(the carried defect: the unconditional 50 ms sleep made every sub-50 mstimeoutMstake ~51 ms, violating the bounded-wait contract). The disconnect drain's pumps already did this; the wait now matches. A zerotimeoutMsstill performs ONE immediately available state read (round 5's regression: the chain acquisition used to return unacquired with the deadline already past, so an idle workspace reporteddrained: falseand a pending call's surface read as empty). - The pending surface reports the WHOLE guest registry: the trap-free reader's generic 256-element array cap silently truncated the guest surface's
pending()list, and its[ArrayTruncated]marker mapped toundefinedin the broker's id lists (a hole in the tool's structuredpending).readValuestill bounds the general preview read (default 256); the host-owned metadata surfaces (readValueComplete— the pending registry, the await log, the provenance registry'sread()result) read with NO array-length or object-key cap: they are the frozen guest library's own metadata, bounded by the VM's memory like the metadata itself.
Phase E review round 6 decisions (the carried review's three defects):
- The lease is associated with the ACTUAL CONTINUATION JOB, not the next job. The carried defect: the 0.3.0 lease-setting reaction ran on the awaited VALUE's settlement (inside the job that resolved the wrapper), so a sibling
q.then(...)registered AFTER the eval started awaitingqran between the lease set and the continuation — the drain attributed the lease to the SIBLING job, fired the armed signal on it, and the target's continuation ran later unprotected (repro:siblingDone: false, thentargetDone: true). The 0.3.1 reaction is registered on the WRAPPER promise itself, immediately before the await machinery's own reaction: the wrapper's settlement queues [lease-setting, machinery] adjacently, so the job after the lease-setting job IS the continuation, and no job queued between the value's settlement and the wrapper's settlement can run with the lease set. Regression: a deferred sibling reaction registered afterawait qcompletes (await deferredresolvessibling:resumed) while the target's own continuation is the job broken mid-run. - The for-await iterable wrap preserves the iterable protocol. The carried defect: the 0.3.0 instrumenter wrapped every top-level
for awaititerable in__replAwait, whose promise result madefor await (const x of [1, 2])throwTypeError: not a functioninstead of iterating. The 0.3.1 surface adds__replAwaitIterable(value, token): an ASYNC-ITERABLE wrapper (resolved exactly likefor awaitresolves an iterable —@@asyncIteratorthen@@iterator; a promise iterable throws the same TypeError) whose per-next()results are lease-wrapped promises (registered before the machinery's own reactions), so the loop iterates natively and remains breakable mid-iteration.for await (... of await y)is not wrapped at all (the right expression's own await is instrumented normally). The instrumenter gates the for-await sites on the newsupportsIterableLeasesurface flag; a 0.3.0 snapshot's loops run unwrapped (native semantics, honest degradation). Regressions: array/async-generator/awaited-iterable iteration through the broker, and a mid-loop break. - Same-type baseline-global overwrites are tracked and attributed. The carried defect: baseline-global rebinding was detected only when the value's TYPE TOKEN changed, so
Math = { userOwned: true }(both values objects) stayed absent from the manifest with no provenance. The provenance registry now captures the ORIGINAL baseline VALUES at creation (descriptor reads in the pristine realm; they travel inside snapshots and are never updated on attribution) and tracks the last-attributed value per known name: the record pass re-attributes on SameValue difference (a second same-type rebind re-attributes to its own eval; a pre-snapshot rebind is not re-attributed by the first post-restore pass), and the registry's read reports the changed-known list (current value no longer SameValue to the ORIGINAL baseline, or token changed) which the manifest's filter consults alongside the host-side token check. In-place mutation of a rebound value still does not re-attribute (the documented stance). Regression:Math = { userOwned: true }is listed withobjecttype andeval 1provenance.
Phase B decisions (the guest library, bridge, previewer):
repl.guestas the surface key (Symbol.for("repl.guest"), marker global__REPL_GUEST_VERSION) — a fresh namespace for this product's own library (the harness'sagentprism.guest/__AGENTPRISM_GUEST_VERSIONare its sibling project's).- Four host callbacks, no budget function:
__host_agent,__host_checkpoint,__host_console,__host_agent_steer. The harness's__host_budgetis deleted with the budget surface;__host_agent_steercarries the doc's handle methods (followUp/steer/cancel) as a new host-callback name in the initial major. agent(modelSpec, task, opts?)carries the model spec as a first-class argument — the roadmap doc's own signature (agent("pi/deepseek-v4-flash-max", "research X")). The spec crosses the bridge to__host_agentverbatim and is recorded in the pending-call registry entry (modelSpec) so a restore can re-issue the call against the same routing.- Steering payloads are
{ prompt, options }JSON (ornullfor cancel) — the host interprets them; the guest passes the settlement value (the steering outcome) through verbatim, mirroring the outcome valuesacp-agentssurfaces in its steering events. - A pending steer is snapshot-reconcilable:
__host_agent_steerreceives the operation's OWN registry id first (the settlement key) and the founding session id second, and the registry entry records both (id+sessionId) in the pending manifest — the host can durably settle (by registry id) or re-issue (to the session) a pending steer after a restore (review regression: the entry used to omit the founding id). - Combinator model specs:
verify/judgePanelspawn their reviewers/graders throughagent("default", …)— the DSL options are exactly{ reviewers, threshold, lens }and{ judges, rubric }(packages/workflows/src/dsl.d.ts); there is no per-call model option (an inventedopts.modelwas removed in review). The"default"sentinel is host-routed to the configured default backend (mirrors dsl.d.ts, where reviewers inherit the run's default model when none is given). - The handle is the promise:
agent()returns the promise itself with own non-enumerableid/followUp/steer/cancel— started-not-awaited handles come free with top-level await, per the doc (const research = agent(...); end the eval; check in next call). Noagent.start/agent.continuevariants (the doc does not carry them;followUpis the continuation vector). - Non-recoverable =
recoverable: falseexclusively — the harness's reservedBUDGET_EXHAUSTED/AGENT_LIMIT_EXCEEDEDcodes are budget vocabulary, deleted per the doc. retrymirrors the workflow engine exactly: withoutuntil, the FIRST attempt's result is returned (workflow.ts:if (!opts.until || opts.until(last)) return last— "stopping early onceuntil(result)holds" holds trivially when there is no predicate); withuntil, attempts run until the predicate holds orattemptsare exhausted, and the last result is then returned for the caller to inspect. Review regression: the guest used to run every attempt withoutuntil, diverging from the repository DSL.loopUntilDrydedupes within rounds too (the harness dedupes across rounds only) — "collecting fresh (deduped bykey) item
