npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 (createevaldrainJobsdispose). 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.

  • memoryLimit per VM — passed straight through to QuickJSOptions.memoryLimit (quickjs-wasi built-in). Exceeding it fails allocations with InternalError: out of memory (an EvalErrorInfo with outOfMemory: 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.
  • interruptHandler per eval and per settlement drain — quickjs-wasi's interruptHandler is 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 that evalCode arms for the duration of the eval and its drain, and drainJobs({ interruptHandler }) arms for the duration of a standalone settlement drain, then restores. Handlers never leak across operations. Returning true aborts with InternalError: 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 a JSException whose constructor performs guest-visible [[Get]] reads of name/message/stack on the guest exception — a getter installed on SyntaxError.prototype.name runs during error construction, before any host catch. The engine instead drives the same raw qjs_eval export (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 on SyntaxError.prototype.name/message/stack, Error.prototype.name, and TypeError.prototype.name never fire, and a thrown proxy reports a trap-free [Proxy] marker (proxies fire traps on descriptor/prototype reads — every such read is isProxy-guarded, including the prototype of an error whose prototype was replaced with a proxy via Object.setPrototypeOf).
  • JSValueHandle.getOwnPropertyDescriptor() throws a JSException when 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 drives qjs_get_own_property_descriptor directly, takes a failed read's exception value out of the runtime and frees it (no JSException is ever constructed), and reads the engine-created descriptor object's own data properties through raw qjs_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 through exc.toString() — a JavaScript string conversion that executes guest code. The engine's drainJobs() 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 a DrainJobError carrying EvalErrorInfo; evalCode converts 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/set handles 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 name data property when instances carry none (quickjs-ng stores name on 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 finally blocks, and accessor descriptors' get/set handles 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') reports Symbol, never the fabricated NaN the default number conversion produced. The description is deliberately NOT read — the raw qjs_get_symbol_description export invokes guest Symbol.keyFor (FORMAT.md §1.1), a forbidden seam, so Symbol(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")). modelSpec is the backend-routing spec; task the 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 methods followUp(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 values acp-agents surfaces in its steering events) — plus id (the stable call id "c1", … used by status/interrupt).
  • checkpoint(question, options?) → Promise and checkpoint.answer(callId, value) → boolean — the data plane interrupting the intent plane. The answer enters the data plane only through checkpoint.answer (the __host_checkpoint trailing-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 $N global, then forwarded as { refs, args } to __host_console.
  • parallel / pipeline / verify / judgePanel / gate / retry / loopUntilDry — pure JavaScript layered on agent(), following packages/workflows/src/dsl.d.ts semantics. A rejection with recoverable: false halts the surrounding orchestration; any other rejection is recoverable (a null slot in parallel/pipeline, reported via console.warn). There is no budget surface: no budget() global, no ledger, no caps vocabulary — resource limits are server configuration, invisible to the guest (the host's non-recoverable signal is exclusively recoverable: 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 xawait 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's openSession with the routing grammar, model spec and per-call cwd (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). schema is a JSON Schema object validated by acp-agents' own structured-output ladder (resolveStructuredOutput driven over the session: convert/check, native + prose extraction, re-prompt, SCHEMA_NONCOMPLIANCE — the one divergence from run(): 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 with keepSession: true, so the ACP session persists on the backend for the restore path's lazy re-attach.
  • Six concurrent subagents per workspace (doc-settled; maxConcurrentAgents configurable — 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 recoverable ConcurrencyLimitError; nothing queues and nothing is hidden.
  • Steering resolves with what actually happened (the doc's "nothing is hidden, nothing hard-errors"): followUp/steer settle with acp-agents' steering-outcome vocabulary where the backend advertises _session/steering, with the broker's honest queued marker where it does not (the per-backend steering mechanism table is the GENERATED artifact in docs/steering-mechanism-table.md — see src/steering-table.ts and its gate test). Steering calls NEVER hard-error: backend/wire failures resolve failed; the only rejections are guest protocol violations. cancel() resolves cancelled (turn in flight), idle (nothing running) or failed; a cancelled call rejects with the RECOVERABLE AGENT_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. InMemoryCallStore for tests and ephemeral hosts; JsonlCallStore is 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 prefixed warn:/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 the checkpoints list). Eval errors render as plain Name: message lines in output.
  • Suspended-eval semantics (transfer lesson 3): top-level await accepted; 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-level return stays a syntax error.
  • Checkpoints (transfer lesson 4): checkpoint(question) parks a promise and records the dispatch; the question appears in the tool result's checkpoints list 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_handler path. 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_result export instead of the shim's resolvePromise() (whose host promise yields even when already settled). This makes dispose() structurally un-raceable and serializes all VM operations, which in turn makes the interrupt-slot save/restore concurrency-safe (an opDepth reentrancy 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 through toString().
  • A failed eval's exception value is freed immediately (in a finally), and accessor descriptors' get/set handles 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/WasmModule from src/types.ts) instead of the DOM-lib BufferSource/WebAssembly.Module names, so the published declarations compile under the repo's non-DOM lib with skipLibCheck: false — and WasmModule is opaque, so only loadShippedWasm() 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). dispose during an in-flight create cancels it — the created VM is torn down without materializing, the waiting get rejects, and a later get starts 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 reaches qjs_get_symbol_description, which invokes guest Symbol.keyFor (FORMAT.md §1.1). A guest that replaces Symbol.keyFor must not be able to forge error rendering (pinned by test).
  • Realpath validation of projectDir is deliberately NOT here: that is the daemon's project-registry concern (the repl tool'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-shot run() calls: the live-handle contract (followUp/steer/cancel on a settled call) requires a session that outlives the call. Sessions are opened with keepSession: 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. schema calls drive acp-agents' own resolveStructuredOutput over the session (tryNative = raw structured output, else the generic parse-final-JSON dialect) — the one divergence from run(): 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 ConcurrencyLimitError with NO code — the doc deletes the budget vocabulary (AGENT_LIMIT_EXCEEDED/BUDGET_EXHAUSTED have no counterpart here), and recoverable: true is the one signal the guest needs.
  • The steering mechanism table (the doc's spec-owed decision): extension backend + turn in flight → live _session/steering wire 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, resolving queued IMMEDIATELY (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 resolves failed — steering never hard-errors. cancel resolves cancelled (a turn was in flight and ACP session/cancel completed; the cancelled call itself rejects with the recoverable AGENT_CANCELLED — never a halt signal for the orchestration owning it), idle (nothing was running), or failed. The outcome surface is acp-agents' SteeringOutcome plus the honest queued/cancelled/idle additions — 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. admitted is deliberately absent from the record shape — it was the budget ledger's bookkeeping, and the ledger is deleted vocabulary.
  • completed excludes checkpoint answers (the harness's pump convention): an answered id leaves the checkpoints list — that is its visibility; completed reports delegated work (pump deliveries + dispatch-time refusals).
  • result is 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 plain Name: message line in output (the harness's "thrown-exception message" convention; late uncaught top-level rejections are the error:-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.restore are the raw snapshot seams (the daemon layer wraps the identity envelope later); Workspace.rehost is 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 against wasmSha256Of of the binary it restores with and REFUSES LOUDLY naming both hashes. The format version is a second refusal axis (version-bump test included).
  • loadShippedWasm records the shipped binary's hash against the compiled modulewasmSha256Of(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 a reset().
  • Restore-time corruption is contained in the same refusal family (SnapshotRestoreError, code RESTORE_CORRUPT — a SnapshotEnvelopeError subclass, 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 like contextPtr: 0xfffffff0 refuses as CORRUPT_PAYLOAD at 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 from Workspace.restore naming 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; reset clears it) — never a raw RuntimeError retry loop into garbage.
  • The safe-re-issue fence is re-checked after every awaited release (reissueReattached and the reconcile catch arm): the loaded session's release() 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 reports isDrained; 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) and flush() at the end of each serialized operation; the store's snapshotWriter debounces the burst into one atomic write. SnapshotWriteOptions.debounceBursts (default true) and fsync (default true) are the decided knob names; ReplStoreOptions.persistenceRoot/ env override 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 own loadSession (acp-agents' supportsLoadSession — a custom backend that omits it degrades through the same gate, surfaced guest-visibly).
  • BrokerSession.awaitCurrentTurn is 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_turn vendor 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/load obliges 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/query whether 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 (stopReason synthesized end_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/ended notification (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 by AGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS (default 15 min — the "never hang unobserved" backstop). A backend WITHOUT the _session/loaded_turn extension — 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). A running turn past the max-wait bound rejects with LoadedTurnStillRunningError: 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 with LoadedTurnFailedError (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-party BrokerSession adapter 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 honest failed outcome); 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 passes SESSION_IDLE_TTL_MS; a turn that OVERRUNS the bound is force-cancelled — the honest bounded teardown, settled as the recoverable AGENT_CANCELLED), then every idle child closes (keepSession keeps 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 (openSession parked — an opening call has no session entry yet, so a drain that considered only registered busy sessions returned true immediately 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 recoverable AGENT_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-id interrupt, 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 its known set) 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 founding agent() call's task text for worker cN and handle bindings, capped at 200 chars) and provenanceAtMs (the attribution wall clock; phase-D review round 3: bindings used to carry only the label and an internal timestamp). The $N log-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.call is null on that path; answers settle through the reconciliation surface). Reconcile is idempotent (an isTracked guard 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 recoverable ConcurrencyLimitError).

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 .then registered 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 is min(50, deadline - now) (the carried defect: the unconditional 50 ms sleep made every sub-50 ms timeoutMs take ~51 ms, violating the bounded-wait contract). The disconnect drain's pumps already did this; the wait now matches. A zero timeoutMs still 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 reported drained: false and 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 to undefined in the broker's id lists (a hole in the tool's structured pending). readValue still bounds the general preview read (default 256); the host-owned metadata surfaces (readValueComplete — the pending registry, the await log, the provenance registry's read() 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 awaiting q ran 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, then targetDone: 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 after await q completes (await deferred resolves sibling: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 await iterable in __replAwait, whose promise result made for await (const x of [1, 2]) throw TypeError: not a function instead of iterating. The 0.3.1 surface adds __replAwaitIterable(value, token): an ASYNC-ITERABLE wrapper (resolved exactly like for await resolves an iterable — @@asyncIterator then @@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 new supportsIterableLease surface 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 with object type and eval 1 provenance.

Phase B decisions (the guest library, bridge, previewer):

  • repl.guest as the surface key (Symbol.for("repl.guest"), marker global __REPL_GUEST_VERSION) — a fresh namespace for this product's own library (the harness's agentprism.guest/__AGENTPRISM_GUEST_VERSION are its sibling project's).
  • Four host callbacks, no budget function: __host_agent, __host_checkpoint, __host_console, __host_agent_steer. The harness's __host_budget is deleted with the budget surface; __host_agent_steer carries 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_agent verbatim 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 (or null for cancel) — the host interprets them; the guest passes the settlement value (the steering outcome) through verbatim, mirroring the outcome values acp-agents surfaces in its steering events.
  • A pending steer is snapshot-reconcilable: __host_agent_steer receives 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/judgePanel spawn their reviewers/graders through agent("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 invented opts.model was 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-enumerable id/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). No agent.start/agent.continue variants (the doc does not carry them; followUp is the continuation vector).
  • Non-recoverable = recoverable: false exclusively — the harness's reserved BUDGET_EXHAUSTED/AGENT_LIMIT_EXCEEDED codes are budget vocabulary, deleted per the doc.
  • retry mirrors the workflow engine exactly: without until, the FIRST attempt's result is returned (workflow.ts: if (!opts.until || opts.until(last)) return last — "stopping early once until(result) holds" holds trivially when there is no predicate); with until, attempts run until the predicate holds or attempts are exhausted, and the last result is then returned for the caller to inspect. Review regression: the guest used to run every attempt without until, diverging from the repository DSL.
  • loopUntilDry dedupes within rounds too (the harness dedupes across rounds only) — "collecting fresh (deduped by key) item