Durable — making room, not building to it
Written 2026-10-01 by Claude (Sonnet 5.5) at Daniel’s request, for the Enso project.
A decision record and a pivot account. The decision: Enso will not write contracts shaped like pi Durable’s, and it will keep a move to Durable cheap enough to make later, on evidence. This page says what that costs today, what keeps it cheap, and the ways a move could be done.
Evidence is the source of pi 1.0.0 (earendil-works/pi, release commit a13d35a74, read from a local
checkout) and this repository at c53bb39. Where a claim rests on reasoning, not on a file read, it
says [inference]. Nothing here was run: no Durable code was executed, and nothing was built against it.
Decided 2026-10-12 (Daniel, #767 → #860): build toward Durable now, as way B below — a Durable
thread kind beside pi, opt-in behind ENSO_EXPERIMENTAL_DURABLE, implemented under the existing seams as
rule 7 asks, with Durable’s imports in one gated directory and an exact pin. The rules under What keeps
the move cheap stand; Why not now is the epic’s work list rather than a reason to wait. The #767 spike
ran Durable 1.1.0 (a guard through beforeTool, OAuth through pi-coding-agent’s ModelRuntime, resume
after SIGKILL) and found two hazards Durable leaves to its host, #863: a tool’s process outlives a killed
host, and a second opener of one store runs live work twice.
Paused 2026-10-12 (Daniel, #860): no Durable runtime until one can be added without weakening Enso’s
security posture. Building the Durable thread (#862) found that Durable’s beforeTool can block but not
end the turn, so a refusal that needs the user’s authorization would let the run go on where pi stops it.
It also found that a hook cannot commit, so a refusal’s record could land after the result it explains
(read from the docs, not run). And a second runtime means a second log, which every security reader would
have to agree with. The guard moved out of pi stays (#861): it changed no behaviour, and a later runtime
still gets it. The decision above is suspended, not reversed; the rules below still hold. Revisit when a
Durable hook can commit a record or end the turn with its verdict, when one log can serve both runtimes
(for example pi-coding-agent itself on Durable), and when Durable’s API stops breaking between releases.
What Durable is
Section titled “What Durable is”@earendil-works/pi-durable 1.0.0, shipped 2026-10-01 with pi 1.0. Its README opens with
“Experimental. The API changes without notice between releases.”
A harness over a storage backend (memory, SQLite, JSONL). Conversations are transcripts of immutable entries. Every model request and every tool call is a task that checkpoints before it moves on, so a restarted process continues from the last checkpoint. State beyond the transcript lives in typed documents committed atomically with it. A fork shares its parent’s history without copying it. An extension is a bundle of tools, system-prompt sections, hooks, wraps and tasks, installed in a registry the host process owns.
It is not the pi coding agent’s new core. pi 1.0’s coding-agent is still AgentSession plus
ExtensionAPI. packages/coding-agent/src/experimental/ (about 10k lines: the durable demo, the
vacation planner, and the server, worker, coordinator and client TUI) is built on Durable, and a search
of it for core/extensions, ExtensionAPI, discoverAndLoadExtensions and AgentSession finds
nothing. It is gated behind PI_EXPERIMENTAL=1 and source-only. So the two have separate extension
models with no bridge between them, and the new surface work (remote clients, multiple steerers) is on
Durable’s side. That last clause is [inference] from where the code is; no document states the
direction.
Why it fits
Section titled “Why it fits”| What Enso needs | What it does today | In Durable |
|---|---|---|
| A torn thread continues from exactly its stored state (#436) | host/continuation.ts restates agent.continue()’s rules and writes one synthetic isError result per open call |
per-tool replay: "safe"; otherwise the model gets an interrupted result with the output so far; checkpoints at every step |
| Run provenance that a fork inherits | active_agent, enso-run-context, enso-guard-denial, enso-incident-*, enso-system-prompt as custom entries in pi’s JSONL |
documents with fork: "asOf", committed atomically with the transcript |
Subagent transcripts in one place (open question 3 in architecture.md) |
separate files, collected at two levels | a subagent is a conversation in the same store, owned by the call that made it |
| An unattended render target (open question 2) | not built | headless by design |
| A live view for the web follow | follow.ts and map-events.ts over pi events |
viewState() / watch(); watchEvents() for coding-agent-style events |
| Context view and edit (the Context pane) | projections over SessionEntry |
ContextEdit omit and replace, plus head |
| Snapshots and worktrees (#146, #147) | open | forks and rewindable documents cover #146’s shape; #147 is not covered |
Why not now
Section titled “Why not now”- The TUI.
bun run ensospawns realpi(scripts/enso.ts) and the terminal page says “You use pi’s own interface, keys and commands.” Durable has no TUI of its own. The experimental one is a 655-line demo that borrows pi’s components; it has no/login, no skills UI, no/tree, no extensions. The end state asks the TUI to reach parity with pi, Claude Code and omp. So a move means building and owning a terminal surface, or running two runtimes that cannot share a store. - Tools and resources. Durable’s
CodingToolsareread,write,edit,bash; its README says reading images is not supported. Nogrep,find,ls,powershell. No MCP or codemode. Provider login and OAuth stay in coding-agent’sModelRuntime, which the experimental app imports as a library. - No dialog primitive. A grep of Durable’s README for UI and dialog terms finds none.
ask_user, the questionnaire card andextension-ui-context.ts(585 lines) have nothing to map to. It would be built frommemo, a document and a route. - Re-execution and concurrency. A Durable hook can run again after a crash and must be idempotent
(
memo: the first write wins). Tool rounds run in parallel by default (toolExecution: "parallel";"sequential"exists).harness/core/session-security.tsstates that pi prepares a batch’s calls one at a time, and the guard depends on it. - Maturity and the freeze. Released the day this was written and experimental; the source shows no third-party user of it. A runtime swap is the opposite of the feature freeze.
What a pivot touches today
Section titled “What a pivot touches today”The repository already states a pivot cost per seam, and those sections are the source for this table:
thread-runtime.md, storage.md,
guards.md and permission-mode.md.
The vendor gate allows @earendil-works/* in two roots only, packages/web/src/host and
packages/harness/extensions — 27 non-test files, about 9k lines (whole files, 17 in host/ and 10 in extensions/), counted on 2026-10-01 — and
.fallowrc.jsonc checks the same rule on the import graph. packages/core imports nothing from the
vendor. Everything above the host runs against fakes.
A generic runtime swap is the cost those pages state. A move to Durable has four more, which they do not:
- The custom-entry envelope was read in
core(closed by #557). The foldsrecordedPermissionMode,sessionIncidentsOf,incidentEntriesOfandriskEventsOfEntriestookreadonly unknown[]and matched pi’s{ type: 'custom', customType, data }shape. L2 closed the transcript side (core/src/transcript.tsis ours), but these folds still read the runtime’s envelope. A Durable store has{ kind, data }entries and documents. The fold logic ports; the envelope does not. Now the folds takeEnsoTranscriptEntry[]and the envelope is read incore/src/runtime-entries.tsalone (rule 4 below says why there). - All eleven declared extensions load through pi’s manifest.
packages/harness/package.jsonpi.extensionslists them, including two third-party ones (@gotgenes/pi-permission-system,pi-multi-account). Durable installs extensions in a registry, and the permission engine talks over pi’s event bus. Each of the nine first-party extensions is(pi: ExtensionAPI) => void. - Guard state is closures. The files-read set, stale-write stamps and session risk live in a
session-scoped closure — since #861 a runtime-free guard session in
harness/core(guard-session.ts), which any runtime’s hooks call through a port; the pi extension is glue. - The terminal is a spawn of pi, not a surface Enso owns.
What keeps the move cheap
Section titled “What keeps the move cheap”Rules for new work. None needs a new abstraction, and none is Durable-shaped; each is correct on pi today.
- Policy goes in
coreorharness/coreas pure functions over our own records. The extension is glue. The repository already does this (harness/core/takes strings, paths and a cwd;decidereturns aGuardDenialand the handler converts it). Keep that direction. - New session-scoped state is derived from records, not held in a closure. It then survives a restart, a fork and a different runtime alike.
- A gate is pure, idempotent and independent of the order calls arrive in. pi does not need it. A hook that reruns after a crash, or runs beside its sibling, does.
- Hand folds our record type, not
unknown[]. The host decodes the runtime’s entries into{ kind, data }of ours before the folds run, the way it already decodes the transcript. This is the one concrete change the account above calls for; it removes the envelope from the folds (item 1). Done in #557. Every fold takesEnsoTranscriptEntry[]. The decoder is ONE module,core/src/runtime-entries.ts(transcriptOf) — incore, not the host, because three zones need it andcoreis the only one all three reach: the host (which adds the vendor-typed refusal and descriptor on top), the extensions onctx.sessionManager.getBranch(), and the corpus scripts over the omp corpus. So the envelope is named in one swappable file incorerather than in no file incore; a move replaces that file and the folds do not move. - Key new features to our ids and our records. #146 as written keys a snapshot to “pi’s session
entry id”. Key it to an Enso thread and entry id held in an Enso record, so
/treeis not the mechanism. Same for #147. - No new importer of the runtime outside the two roots, and a baseline that only shrinks inside
them. The vendor gate holds both halves. Outside the roots,
VENDOR_IMPORT_ALLOWED; inside them,VENDOR_IMPORTERS_ALLOWED_INSIDE_ROOTS(#558), the files underhost/andextensions/that may import the vendor. A new importer fails by name, and a file that stops importing leaves the list in the same PR, so the pivot cost can no longer grow without a gate noticing. - Do not add a port that copies Durable’s contracts. Designing against a runtime that changes without notice, from one implementation, gets the shape wrong in both directions. The seams exist; use them.
Ways to do it
Section titled “Ways to do it”Each is a way, not a plan. None is chosen.
A. Swap behind the seams. A ThreadRuntime over a Durable Harness; the storage seam over its
SQLite or JSONL store; session-events and map-events fed from watchEvents(); the nine extensions
re-expressed as Durable extensions; the continuation logic deleted in favour of replay. The server,
routes, browser and every fake-driven test stay. This is the cost in What a pivot touches today,
plus the four extras, plus whatever the terminal decision is. It is the largest and only one-way move.
B. Side by side, new surfaces first. pi stays the interactive runtime. Durable runs the surface pi
cannot serve well: unattended and factory runs (open question 2), where there is no TUI to lose. Both
runtimes share what is already runtime-free: core folds, harness/core matchers, the wire schemas.
The cost is two stored-thread readers behind live-or-stored. The first half of A (a Durable
ThreadRuntime) is built under B, so B does not close A off. Of the four, this is the one that earns
evidence at the least risk [inference].
C. pi as a library under Durable. What the experimental app already does: Durable’s harness, pi’s
ModelRuntime, auth, settings, system prompt and TUI components. It keeps the login and credential
flow shared with bun run enso and keeps pi in the dependency tree. Compatible with A or B.
D. Durable’s own transport. pi-server, pi-client and pi-protocol (CBOR frames over a Unix
socket, Chord facets) route clients to Durable sessions. Enso’s server already has the shape these
solve: one process owns the state, clients attach. Not adopting this is the default; it would matter
only if Enso wanted pi’s own remote clients to attach to Enso sessions. No evidence yet that it does.
Stored threads, for any of them. No importer from pi session files into Durable storage exists. I
searched durable/src, durable/docs and experimental/ and found none [inference: absence of a
search hit]. Two ways: replay-import a pi branch into Durable entries (one-way, lossy at the
envelope), or leave old threads readable through the cold path that already exists
(readStoredThread → our transcript). The second needs no import and no new code. It is the one I
would take.
How to find out
Section titled “How to find out”A spike, in a worktree, thrown away after the answer is written down. Pass criteria, written first:
- Kill the process mid-
bash, reopen, and see the model get the interrupted result. The result replaceshost/continuation.ts. secret-readandread-before-writeasbeforeToolhooks with state in a document, run once sequentially and once in parallel, with the same verdicts.- A fork keeps a run-context document as of its fork point.
ask_useras a document plus a route, answered from the web.viewState()driving the existing follow throughwatchEvents()intomap-events.- The effort to put a terminal on it, measured against the 655-line demo.
If 1 to 5 pass and 6 is acceptable, A or B is justified. If 4 or 6 do not, B is the whole of it.
Revisit when
Section titled “Revisit when”- the server and client path no longer needs
PI_EXPERIMENTAL; - an
ExtensionAPIbridge exists, or the coding agent itself runs on Durable; - Durable has
grep,find,ls,powershelland image reads, and a session import; - a second external adopter has run it for longer than a release.
Not verified
Section titled “Not verified”- How many threads a single
Harnesscarries, and whether the web host wants one store per project, per thread or one overall. The experimental app uses one SQLite per session with a lock. - Durable’s JSONL storage format, and whether it is greppable the way pi’s session log is.
- How a third-party pi extension would be re-hosted, if it can be at all.
- Real cost. The “about 9k lines” is a count of the files that name the vendor, not an estimate of work, and the four extras are unsized.
