Based on AgentRun release · Synced .
@parcha/pi-durable-agent
An agent home for pi agents. The home is one directory that keeps what makes an agent the same agent from one run to the next: its definition, its skills, its saved sign-ins and every run it has made. A run starts from the home. When it ends, the sign-ins it made go back to the home, so the agent's next run starts signed in. The home moves as that directory, or as one file, a pack, which leaves the sign-ins behind.
Part of AgentRun. Node 22.19 or later. The agent store and each run's store are
pi-durable stores, read and written with pi's own Session, so
there is no second format. This package opens its own at synchronous = FULL.
What it does
- Keeps the agent.
agent.sqliteholds the definition (name, instructions, and the names of its skills and tools), the skill files with their SHA-256, the saved sign-ins and an index of the runs. - Starts a run from the home.
startRunmakes the run's directory, a pi-durable-disk run directory, with the agent's skills in its workspace and its saved sign-ins in its store. The host opens its own pi-durable Harness on that store. - Takes the run back.
carryBackcopies the sign-ins the run made into the home and records how the run ended, so the next run starts signed in. - Moves the home.
packHomewrites the home as one tar file,readPackjudges one, andimportPackwrites one as a home. A pack and an export leave the saved sign-ins behind, so a new machine asks once. - Reads a run as a timeline.
@parcha/pi-durable-agent/timelineturns a run's entries or live events into plain rows that a page can draw. - Shows a home or a store.
npx @parcha/pi-durable-agent serve <dir>opens a local page over a home's files, read-only (Looking at a home), or over an agent store's agents, revisions and runs (Looking at a store).
It does not run the agent. The host keeps what a run needs from outside: the model, the thinking level, the tools, and
where each tool runs. The definition names tools; the host binds them. It does not drive a browser either:
pi-browser does, through the
ProfileStore this package gives it.
Install
npm install @parcha/pi-durable-agent@latest @earendil-works/pi-durable@1.5.0 @earendil-works/pi-ai@1.5.0 @earendil-works/chord@1.5.0
The package needs Node 22.19 or later, and @earendil-works/pi-durable and @earendil-works/chord as peers
(Requirements); @earendil-works/pi-ai gives the host's Harness its models. The pi packages are pinned
to 1.5.0, the one version its peers take. It does not need a browser, a key or a network.
To try a version that is not on npm yet, npm pack -w @parcha/pi-durable-agent in a built checkout writes
parcha-pi-durable-agent-<version>.tgz: install that file's path in place of @parcha/pi-durable-agent@latest.
Quickstart (no key, no network)
Two runs of one agent on this machine. The first signs in, using a stand-in for the provider, and the second finds that
sign-in already saved. The reference it prints, ctx_1, is a stand-in; a real one is secret.
In an empty directory, install the package and the pi packages it runs with (pinned to 1.5.0), save this as quickstart.mjs, and run
node --no-warnings=ExperimentalWarning quickstart.mjs (Node's SQLite is marked experimental and says so):
npm install @parcha/pi-durable-agent@latest @earendil-works/pi-durable@1.5.0 @earendil-works/pi-ai@1.5.0 @earendil-works/chord@1.5.0
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { SqliteStorage } from "@earendil-works/pi-durable/storage/sqlite";
import { openNodeSqliteDatabase } from "@earendil-works/pi-durable/storage/sqlite/node";
import { carryBack, createAgentHome, durableProfileStore, readAgentHome, startRun } from "@parcha/pi-durable-agent";
const home = await createAgentHome("./acme", {
definition: { version: 1, name: "Acme researcher", instructions: "Cite every claim.", skills: ["cite"], tools: ["browser"] },
skills: { "cite/SKILL.md": "# Cite\n\nQuote the page you read.\n" },
});
async function oneRun(runId) {
const run = await startRun(home, { runId, host: "laptop" });
// The host's own Harness on the run store: its models, its registry, its tools.
const database = await openNodeSqliteDatabase(run.storePath);
await database.exec("PRAGMA synchronous = FULL");
const harness = await Harness.open(await SqliteStorage.open(database), { models: createModels(), registry: createRegistry() }, ctx);
await harness.root(ctx, { agent: { instructions: run.agent.instructions } });
// pi-browser takes this as its ProfileStore; here a stand-in makes the sign-in.
const profiles = durableProfileStore(harness);
const ref = await profiles.resolve("acme.example", async () => { console.log(`${runId}: signing in`); return "ctx_1"; });
console.log(`${runId}: profile ${ref}, save lease: ${run.leases.includes("acme.example")}`);
await harness.close(ctx);
const back = await carryBack(home, run, { outcome: "completed" });
console.log(`${runId}: carried back ${back.profiles.join(", ") || "nothing"}`);
}
await oneRun("run-1");
await oneRun("run-2");
console.log((await readAgentHome(home)).profiles);
run-1: signing in
run-1: profile ctx_1, save lease: false
run-1: carried back acme.example
run-2: profile ctx_1, save lease: true
run-2: carried back acme.example
{
'acme.example': { provider: 'browserbase', ref: 'ctx_1', state: 'ready' }
}
Run 1 signs in once. It starts with no save lease, because the agent has no sign-in to lease yet; a new sign-in goes to the first run that carries it back. Run 2 starts with the lease, finds the sign-in ready and never makes one.
A saved sign-in
A sign-in is kept as the provider's reference to a signed-in browser: a Browserbase context id, or, for a local Chrome,
the profile directory under chrome/ in the home, which holds its cookies (the runs app does this). It sits in a
browser.profiles document in agent.sqlite, and in the store of each run that was given it. It is not encrypted. Treat
it like a password, because whoever holds it can open the signed-in session.
- A run gets it from the host.
startRuncopies the agent's sign-ins into the run's store, anddurableProfileStorehands the reference to pi-browser. The model never sees it, and no tool reads or returns it. A sign-in another live run holds is copied without its reference. - One run saves it. Only the run that holds a sign-in's save lease gets its reference, and only that run's changes go back (The save lease).
- A pack and an export leave it behind.
SECRET_KINDSnames the kinds that never leave a home:browser.profilesamong them. A pack's manifest lists the sign-ins the source had (their keys and providers), andreadPackwarns that they are to redo at the target. The first run on the new machine signs in again, and its carry back saves the new sign-in. - A copy of the directory takes it along. Copy the home as a directory only to a place you would trust with its sign-ins. A pack is the way to move a home without them.
A signed-in browser session can also be kept in the session vault, as ciphertext that only the host's key opens, and it never travels with a home (Saved browser sessions). Saved credentials, such as passwords, are not built yet (Not yet).
The agent home
acme/ the agent home
agent.sqlite the agent store: identity, definition, skills, sign-ins, the runs index
owner.lock the writer lock (below); never open, copy or move it with the home
chrome/<key>/ a local Chrome's profile, made by a host such as the runs app, not by this package;
it holds a sign-in too, and a pack never reads it
runs/<run id>/ a pi-durable-disk run directory
store/run.sqlite that run's pi-durable store; the host opens its Harness on it
work/ that run's files, with the agent's skills under .agents/skills/
tmp/, commands/ written by pi-durable-disk when it runs the run; carryBack reads only the store
runPaths(home, runId) gives a run's dir, storePath and workspace. To run it under pi-durable-disk, start it with
startRun first, then pi-durable-disk run --dir <dir> --app MODULE (the run id defaults to the directory's name), or
openDurableRun(ref, { ...localRun(dir), harness }) from code, and call carryBack after the instance released it.
Every call on a home but homeRevision opens agent.sqlite as its only writer and closes it before it returns, so an
AgentHome handle holds nothing open. Writers queue in the process, then take owner.lock: an exclusive SQLite lock on
an empty file, which the kernel drops when its holder dies, so a writer that crashed never leaves it behind. A writer
waits up to 5 seconds for another process's, then fails with AgentHomeError (busy). On a disk that one machine claims
at a time, the host passes the claim as a HomeAccess and the lock is taken inside it
(An agent home on a disk).
A run
startRun(home, { host, runId?, prompt?, now?, leaseMs? })createsruns/<runId>/(the id defaults to a UUIDv7), writes the skills into the workspace (each checked against its SHA-256), takes the save lease for each profile no live run holds, copies the agent'sbrowser.profilesinto the run store, and records the run inagent.runs. A profile another run holds the lease for is copied without itsref(state: "held",heldBynaming that run). It returns{ runId, dir, storePath, workspace, agent: { instructions, tools }, leases }, whereleaseslists the profiles this run can write to. Seeding is this host step; nothing here is a tool, and the model never sees a sign-in.promptis the prompt variant the host chose for the run,{ id, reason, confidence? }, recorded on its entry; an id that is not one of the agent's variants is refused, and so is aconfidencethat is not a number from 0 to 1.- The host opens its own Harness on
storePath, itself or through pi-durable-disk ondir, and creates the root conversation withagent.instructions, its chosen model and thinking level, and its tools for the names inagent.tools. durableProfileStore(harness)is pi-browser'sProfileStoreover the run store'sbrowser.profiles:resolve(key, make)returns a ready profile'sref, or callsmakeonce (one caller at a time per key, outside any commit) and commits the newrefasready. Aheldprofile has noref, so the run starts signed out of that site andmakegives it a context of its own, which is never carried back.- After the run's Harness closes,
carryBack(home, run, { outcome, finalAnswer?, cost?, now? })copies back, in one commit, each profile the run holds the lease for and each profile new to the agent that no live run holds, releases the run's leases, and completes itsagent.runsentry. A second call for the same run changes nothing.
The save lease. Two runs of one agent can run at once, and neither Browserbase nor Kernel locks a saved context:
the last writer wins. So one run at a time holds a profile's save lease: only that run gets the saved sign-in, and only
its changes go back. pi-browser's Browserbase provider opens every saved context it is handed with saving on, so a run
without the lease never gets the ref; reusing a sign-in read-only waits for pi-browser to let the host open a saved context without saving.
A new profile goes to the first run that carries it back. A lease expires after leaseMs (default 6 hours), so a run that
died does not hold a profile forever; a run whose lease passed to another saves nothing back.
Following the agent's repo
An agent's definition and skills can live in a git repo, with the agent home as their cache. The package does not read git: the host reads the definition and the skill files at a commit and passes them in with its sha (40 or 64 lowercase hex digits, a SHA-1 or a SHA-256 commit).
createAgentHome(dir, { definition, skills, sha })andrefreshAgentHome(home, { sha, definition, skills })write the files andagent.source({ sha, refreshedAt }) in one commit, so the sha always names the cached files. A refresh replaces the definition and the skill set: a skill path not in the new set is removed. The definition'sid, when given, must be the agent's, andidentity.namefollows itsname. A refresh at the sha the home already has changes nothing ({ changed: false }): a sha names one content.refreshAgentHome(home, { sha, from, ... })is a compare-and-swap: the home must be atfrom(null: no sha yet), or the refresh is refused (stale) before anything is written. A host that read an older commit then cannot move the cache back over a newer one; whether one commit follows another is for the host to judge, from git.refreshAgentHome(home, { ..., ifRevision })is a compare-and-swap onhomeRevision: a home whose head commit is notifRevisionis refused (stale) before anything is written. An API checks anIf-Matchthis way, under the home's lock, instead of racing a separate read. The order is: the input,ifRevision,from, the same-sha no-op.startRunrecords the home's sha on the run (agent.runs, andshaon what it returns), read under the same lock as the skills it copies into the workspace. A run already started keeps its sha and its copy of the skills when the home is refreshed.startRun(home, { host, sha })refuses withAgentHomeError(stale) when the home's cache is at another commit, or at none, so a run never starts on skills older than the host asked for: refresh first.
A home with no agent.source (every home made before this) is not tied to a repo, and its runs record no sha.
Prompt variants
An agent can keep prompt variants in its home: one base, one generic, overlays, and at most one question. Each
overlay's when is the rule for picking it: the question's id, the option the overlay needs, and the least probability
for that option. The question's text is a Jev choice question, and its criteria describe options by name; every
question also has the option other, for an input no overlay fits.
choosePrompt(prompts, input, { judge, lookup?, signal? }) picks one before a run starts. judge is a Jev runner
(createJevRunner from @parcha/agentrun-jev), or any function of that shape; this package imports no Jev. The judge
answers the question about { input }, with the overlays' options and other as the choices. The overlay whose
option's probability is at least its threshold is chosen, the highest such probability winning. When none is and
lookup is given, the lookup runs once on the input's words, and the judge answers again about { input, lookup }
(its text cut to LOOKUP_TEXT_MAX, 3,000 characters) by the same rule. Otherwise the run gets the generic. A judge or
lookup failure never throws: the run gets the generic, and reason says why. An answer whose choice is not one of the
options, that gives a probability for another option, or whose probability or confidence is not a number from 0 to 1
is a judge failure; the probabilities' sum is Jev's to check. An error's message is cut to 500 characters, so reason
stays within the 2,000 startRun takes.
await setPrompts(home, [
{ id: "base", role: "base", label: "Company research", text: "Research the company in the input. Cite every claim." },
{ id: "kind", role: "question", label: "Kind of organization", text: "What kind of organization is this?",
criteria: { public_company: "Its shares trade on a public exchange." } },
{ id: "public", role: "overlay", label: "Public company", text: "Start from its latest annual report.",
when: { question: "kind", option: "public_company", threshold: 0.9 } },
{ id: "generic", role: "generic", label: "Not sure", text: "Say what kind of organization it seems to be, and why." },
]);
const { prompts } = await readAgentHome(home); // keyed by id
const choice = await choosePrompt(prompts, "Caterpillar Inc.", { judge, lookup });
// { variant: "public", instructions: base + "\n\n" + overlay, passes, lookup: { used, chars, sources }, reason }
await startRun(home, { host: "laptop", prompt: { id: choice.variant, reason: choice.reason } });
instructions is the base's text, a blank line, and the chosen variant's text: what the host gives the run's root
conversation. passes holds each answer (choice, probabilities, confidence, model, cost_usd, latency_ms).
lookup says whether one ran, how many characters the judge read and the sources it named, never the text. lookup
is (query, { input, signal }) => text | { text, sources? } | null, query being the input's words.
setPrompts replaces the whole set in one commit and refuses one that is not exactly one base and one generic, with
ids that are one URL segment and given once, a label of at most 200 characters, a text of at most 64 KiB, a threshold
from 0 to 1, and rules that name the set's question and an option no other overlay needs, never other (invalid);
ifRevision makes it a compare-and-swap on homeRevision (stale). choosePrompt refuses the same sets. Prompts are
exported and packed with the agent; they are not secrets.
Saved browser sessions: the vault
A browser session the person signed in to can be kept for the agent's next run, as ciphertext only.
sessionVault(home, { hostKey }) is the session vault a browser host uses (SessionVaultPort): capture at release,
restore at the next create, before the driver attaches, and forget. A session is a local Chrome's cookies and
localStorage (kind: "cdp"), or a provider's context id (kind: "provider-context", Browserbase or Kernel, which keep
the state on their side).
const vault = sessionVault(home, { hostKey: localHostKey("laptop-1", keyBytes) });
await vault.capture({ sessionKey: "acme", origins: ["https://app.acme.example"], state }); // at release
const saved = await vault.restore({ sessionKey: "acme", origins: ["https://app.acme.example"] }); // at create, or null
- Ciphertext, under keys the home never holds in clear. Each session is AES-256-GCM in
agent.vaultunder its own item key. The item keys are wrapped by the home's vault key, and that key by the host's key (HostKey:{ id, wrap, unwrap }, a KMS key, an OS keychain item or a 1Password item;localHostKey(id, bytes)is one held in this process). The wrapped keys are invault.keys, owner-only, besideagent.sqlite, and not in the store: a pi-durable store keeps every revision of a document, so a key kept there could never be destroyed. Each ciphertext is bound to its agent, session and kind, so it cannot be moved to another row. It also holds the session's origins and capture time, and a restore goes by those: a row whose clearoriginsorcapturedAtdiffer from them was changed, and is refused (format). - One host key. The first capture binds the vault to
hostKey.id; another key, or the same id with other bytes, is refused (AgentHomeErrorkey). A copy of the home opens on another machine with the same host key. - Revoke destroys the key.
forget(sessionKey)removes the session and its item key, so the session's earlier revisions, which the store keeps, can never be read. A capture mints a new item key and destroys the one before.forgetAll()destroys every key and the vault key itself. - What comes back. A capture keeps only the cookies and localStorage of the origins it names. A restore returns
what was captured for the origins asked for, never more. A cookie counts for an origin the browser would send it to:
a host-only cookie (no leading dot in its domain) for its own host only, a domain cookie for its host and every host
under it. A provider context cannot be cut down to fewer origins, so it comes back only to a restore that names every
origin it was captured for; a restore for some of them gets
null. A context's ownorigins, when the browser host records them, must lie within the capture's (a capture that records another is refused,invalid), are sealed with it, and are given back as they came; a restore must name them too. A persistent cookie keeps its own expiry; a session cookie comes back for at most 24 hours after its capture (maxSessionCookieAgeMslowers it; it cannot go higher). A capture is dated by its state'scapturedAt(a time still to come counts as now), and one older than the session already kept is ignored, so a late capture never replaces a newer one. WithrunId, a capture is refused (busy) while another live run holds that session's save lease. - Never anywhere else.
agent.vaultis onSECRET_KINDS: never exported, packed or seeded into a run; a pack's manifest names the sessions (vault), never a value, andvault.keysnever travels. No value is in an error or a log.handedOut()lists every valuerestorereturned, for a redactor such as pi-browser'sredact.values.
On a disk, access (a HomeAccess) claims the directory that holds agent.sqlite, and vault.keys is read and written
there, inside the claim.
Stored kinds
Every document is session-scoped, version 1, in the store named. Times are ISO 8601 strings in UTC. The kinds and their fields are the stored format.
| Kind | Store | Key | Value |
|---|---|---|---|
agent.identity |
agent | { id, name, formatVersion }. id is the definition's id, or a UUIDv7 minted at creation; never a host's id. A given id is one URL segment, ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ |
|
agent.source |
agent | { sha, refreshedAt }: the repo commit the definition and skills were read from, written in their commit; absent for a home not tied to a repo |
|
agent.definition |
agent | { id, version, name, description?, instructions, skills, tools, input_schema?, output_schema?, workflow?, max_cost_usd? }. skills and tools are names; workflow is an AgentRun workflow document. No model, no thinking level |
|
agent.skills |
agent | path under .agents/skills/ |
{ content, sha256 } |
agent.prompts |
agent | id | { role, label, text, when?, criteria? }: role is base, overlay, generic or question, one base, one generic and at most one question per set; when (an overlay's only) is { question, option, threshold }, the question's id, the option the variant needs and its least probability, 0 to 1; criteria (a question's only) describes options by name |
agent.aliases |
agent | host | { externalId }: the agent's id in that host |
agent.bindings |
agent | host | { [tool]: target }: where each tool runs in that host |
agent.runs |
agent | run id | { path, host, startedAt, sha?, prompt?, endedAt?, outcome?, finalAnswer?, cost? }; path is relative to the home; sha is the home's agent.source when the run started; prompt is { id, reason, confidence? }, the variant the host chose and how sure the choice was (0 to 1) |
agent.leases |
agent | profile key | { runId, expiresAt } |
agent.vault |
agent | session key (1 to 128 of letters, digits, _, ., : and -, starting with a letter or digit, as pi-browser takes it) |
{ kind, origins, capturedAt, provider?, keyId, iv, ciphertext, alg, v }: a saved browser session as AES-256-GCM ciphertext; keyId names its item key in vault.keys, beside the store (the vault) |
browser.profiles |
agent and run | profile key | { provider, ref, state, startUrl?, allowedOrigins?, verifiedAt?, heldBy? }; ref is the provider's saved sign-in (a Browserbase context id, or a local Chrome's profile directory), state is ready once it names one, or held in a run store whose run has no lease on it (ref empty, heldBy the run that has) |
A profile's ref opens a signed-in session, so it is as secret as the password behind it: it reaches a run only
through the host, and no tool reads or returns a profile.
Exporting a home
An exporter (a copy of a home for a viewer, or for someone else to run) copies only the kinds on EXPORTABLE_KINDS:
agent.identity, agent.definition, agent.source, agent.skills, agent.prompts and agent.runs. Every other kind is dropped, a kind this release
does not know included. SECRET_KINDS names what never leaves the home: browser.profiles (saved sign-ins), agent.vault (saved sessions),
agent.leases, and agent.bindings and agent.aliases (each host's own names for where the tools run and for the
agent). Every kind this package defines is on exactly one of the two lists, and a test fails on a kind added to
neither.
projectForExport(kind, value) is what an exporter writes for one stored document: a deep copy, with host removed
from an agent.runs record (it is the caller's own name for itself), or undefined when the kind is not on
EXPORTABLE_KINDS. Kinds this package does not define, pi's own documents and another extension's, are not on the
list, so they are dropped too.
The three are also the subpath @parcha/pi-durable-agent/export, which imports no module: a writer that only filters
kinds (a replay of exported stores, for one) loads them with node:sqlite alone, not the root, which loads
pi-durable, chord and pi-ai.
Moving a home
A home moves as its directory or as a pack. Copying the directory takes everything with it, the saved sign-ins
included (A saved sign-in). A pack takes the agent, the index of its runs and each finished run's
files, and a finished run's store only through the exportRunStore hook you give it. It leaves the sign-ins behind, and
it can be made while a run is live: that run travels as an index entry only.
One packer moves a home between a local server and a hosted one. packHome(home, { out }) writes the pack,
readPack(file) judges one without writing a home, and importPack(file, dir) writes one as a home.
A pack is a tar (POSIX ustar, with pax headers for a name over 100 bytes or beyond ASCII), and it holds these members
and nothing else, in this order, sorted, owned by 0/0, mode 0644 or 0755, stamped with the manifest's created_at:
| Member | Holds |
|---|---|
manifest.json |
format agentrun.home/1, agent_id, definition, source (sha, refreshed_at, or null), skills (path, sha256, size_bytes), sign_ins (key, provider: what the source had, none travels), vault (names: the saved sessions' keys, never a value; none travels), runs (count, live), size_bytes (the pack's own), created_at |
agent.sqlite |
A new agent store with only EXPORTABLE_KINDS, each through projectForExport, then VACUUM INTO one clean file: no -wal, no -shm, no free page with an old row. Nothing is copied from the live file |
runs/<id>/store/run.sqlite |
Only through exportRunStore(storePath, outPath); without it no run store travels |
runs/<id>/work/... |
Regular files and directories whose names do not start with .. Whatever the reader would refuse is left out and listed in skipped with its reason: a link, a hard link, a device, a dotfile, a *.sqlite-wal side file, a text file named *.sqlite |
A run that was still live (no endedAt) travels as its agent.runs entry only: neither its store nor its work. The
home's vault.keys, chrome/, repo/, owner.lock, tmp/ and everything else are never read. The packer refuses (check) a
SQLite file that holds a SECRET_KINDS document or has a -wal, -shm or -journal file beside it, whatever its name:
it looks at the bytes, so a store hiding in work/ is found. A work directory it cannot read fails the pack, naming it,
rather than packing less than the run has. Each work file is opened without following a link and must be the file that
was listed (device, inode, size), alone, and inside the run's work directory, so a file or a parent directory swapped
for a link while the pack is made is left out (or stops the pack, if it happens while it is written). maxBytes and
maxMembers refuse (too_large) before anything is written. The home is read under its owner lock, the pack is written
to a .part file, read back with readPack, and renamed into place only when its own reader accepts it.
readPack returns { ok, manifest, checks }. The checks use the contract's names for what the package can judge:
format (the manifest first, agentrun.home/N with N at most this version's, every member on the allowlist and none
inside a file, no .., no absolute path, no link or device, no store with a sign-in kind, at most maxMembers, no global
pax header, at most one pax header of at most 64 fields before a member, nothing after the end, and prompts that are none
or a set setPrompts takes, so an import never leaves a home whose choosePrompt refuses its set), definition (the
definition is valid and is the one in agent.sqlite, the agent id is the same everywhere, each skill matches its
sha256 and size), sign_ins (warn when the source had sign-ins: they are to redo at the target), size (the pack is
the size its manifest says, and within maxBytes) and live_runs (warn when runs travel as index entries only).
Tools, vault and quote are the host's to judge. It writes only temporary files.
importPack judges the pack and extracts it to a directory beside dir. A pack whose file is over maxBytes is refused
on its size, before a byte of it is parsed; one whose manifest says it is over is refused before anything is extracted. A
dir that is absent or empty becomes a new home: the extracted directory is renamed into place, after an empty dir is
removed with rmdir. The target is never deleted recursively: whatever another process has put there since the check
makes the import refuse (exists for a home, invalid otherwise) and leaves it as it is. A dir that is already a home needs replace, must hold the same agent, and must
have no live run (check, with live_runs failed); ifRevision must still be its homeRevision (stale).
A home is changed in place, under its own owner lock: the lock, agent.sqlite, chrome/, repo/, the home's
sign-ins, aliases and bindings, and its runs the pack does not have stay where they are. One store commit replaces the
definition, the skills, the prompts, the source and the pack's runs, so homeRevision only goes up (an import that
changes nothing leaves it as it is). A prompt variant the pack does not have is removed, and so is the whole set when
the pack carries none. A run id in both takes the pack's entry and directory. A run that was live at the source arrives
as an ended entry with outcome not_carried_back.
The pack's run directories move into place before the commit, each old one set aside first in the staging directory
beside the home, in the order of a journal, .import-journal (mode 0600), written first. A failure puts every one back.
The journal has three phases, moving, then committed or rolled-back, and a settled one is written (beside the
journal, synced, renamed over it, the directory synced) before any evidence is removed. As soon as the store's commit
returns, the import writes committed; recovery writes rolled-back after every run directory is back, and committed
for a journal that was still moving when the store's head had moved. A crash leaves the journal for the next process
that takes the home's lock, which settles it before anything else. A committed or rolled-back journal is never
judged again: only what is left of its evidence is removed, the staging directory first and the journal last, so
recovery can be stopped at any point and run any number of times. Only a moving journal is judged, once: a store head
past the journal's means the commit landed (a commit that changes nothing in the store does not move it, which is why the
import writes committed itself, at once, and does not leave that to the head); otherwise every directory goes back. Recovery never deletes a run directory it cannot account for: when one it would
restore, or one that should have arrived, is in neither of its places, it changes and removes nothing, keeps the journal
and the staging directory, and says so; the import that failed does not remove them either. So a home is the old one or
the new one, never a mix, and no old directory is deleted before the commit. Every refusal leaves the home, and the
directory beside it, as they were.
import { homeRevision, importPack, openAgentHome, packHome, readPack } from "@parcha/pi-durable-agent";
const home = await openAgentHome("./acme"); // the quickstart's home
const { manifest } = await packHome(home, { out: "acme.tar", maxBytes: 200 * 1024 * 1024 });
const { ok, checks } = await readPack("acme.tar");
console.log(manifest.format, ok, checks.map((check) => `${check.name} ${check.status}`).join(", "));
if (ok) await importPack("acme.tar", "./acme-copy"); // a fresh directory, or { replace: true, ifRevision: await homeRevision(home) } over a home
The timeline
@parcha/pi-durable-agent/timeline turns a run into one human-readable timeline. It has no dependency and uses no Node
built-in, so a page, a viewer and a harness can import the same code. A row has Grep's TimelineEvent fields
(kind, summary, tool_name, tool_use_id, tool_status, the previews, text, with Grep's caps), plus optional
seq, conversation_id, round, parent_tool_use_id, session, path and usage, and the kinds user_message, waiting
and usage.
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, Harness } from "@earendil-works/pi-durable";
import { SqliteStorage } from "@earendil-works/pi-durable/storage/sqlite";
import { openNodeSqliteDatabase } from "@earendil-works/pi-durable/storage/sqlite/node";
import { groupTimeline, renderText, timelineOf, timelineReducer } from "@parcha/pi-durable-agent/timeline";
// A released run: the entries of its root conversation, in log order (here the quickstart's run-1).
const harness = await Harness.open(await SqliteStorage.open(await openNodeSqliteDatabase("./acme/runs/run-1/store/run.sqlite")), { models: createModels(), registry: createRegistry() }, ctx);
const { items: entries } = await (await harness.root(ctx)).entries({}, 1000, undefined, ctx);
console.log(renderText(timelineOf(entries, { settled: true })));
await harness.close(ctx);
// A live run: `events` are the `snapshot` event, then each batch of `watchEvents` (what pi-durable-disk serves on GET /events).
const reducer = timelineReducer();
const { rows, drop } = reducer.apply(events); // upsert rows by id, delete the drop ids, sort by seq
const groups = groupTimeline(reducer.rows()); // rounds, folds and nested calls for a page
Both paths read committed entries through one function, so live and replay give equal rows. A call and its result pair
by tool_use_id, never by time. A row is built from pi entries and events only: it never carries a live-view URL, a
browser context id or a cookie, and every summary, preview and text (a person's own message excepted) keeps a URL's
origin, path and query keys but drops its query values, fragment and userinfo. registerLabel(tool, label) adds the label of a host's own tool, and registerTurnEnding(tool) names one that ends its turn.
Looking at a home
pi-durable-agent serve <dir> serves a page about the agent home in <dir> and prints its address. It opens nothing
itself, and it writes nothing to the home.
npx @parcha/pi-durable-agent serve ./agent-home
# http://127.0.0.1:41297/
--port <n> takes a port (the default is a free one). From code, serveHome(home, { port?, signal? }) from
@parcha/pi-durable-agent/serve does the same and returns { url, close() }.
The page shows the agent (its identity, definition, the repo commit it came from, its skills' names and sizes, and its prompt variants), its runs (newest first, with the variant each loaded, why, and how sure the choice was), and each run's timeline. A live run is polled. The files are read directly, with no lock, so a home can be watched while a run writes it. Reads open the stores read-only; SQLite may create its shared-memory side files beside them. The agent store is copied by SQLite's backup (a consistent snapshot) into a private temporary directory, read there with the package's own reader, and the directory is removed; a run's store is read for its entries with one query. A store from a newer pi-durable than this package reads (a schema version other than 1) is refused, not guessed at.
It sends only what an exporter may copy (EXPORTABLE_KINDS: identity, definition, source, skills, prompts and the runs
index without each run's host), so a saved sign-in, saved session, lease, binding or alias is never in an answer. It
sends no skill's text and no run's final answer. It listens on 127.0.0.1 only, and there is
no option to change that; it answers 421 to a request whose Host is not 127.0.0.1:<port> or localhost:<port>, and
405 to anything but GET and HEAD; it sends no CORS header, and the page's content security policy allows only its
own files. The data is JSON:
| Request | Answer |
|---|---|
GET /api/agent |
{ identity, definition, source, skills: [{ path, sha256, size }], prompts, counts: { skills, prompts, questions, runs, live } }; prompts are the variants and the question row, with their id, role, label, text, when and criteria, and counts.prompts counts the variants only |
GET /api/runs |
[{ id, startedAt, endedAt?, outcome?, cost?, prompt?: { id, reason, confidence? }, live }], newest first; live is a run with no endedAt |
GET /api/runs/:id |
{ run, rows }: the run as above, and its entries as the timeline's rows (settled for a finished run, so a call with no result reads as interrupted, and open for a live one). 400 for an id that is not letters, digits, ., _ or -, 404 for a run the index does not list |
An unreadable store is a 500 with { error } that says what is wrong with it and not where it is.
Agents in a folder: the agent store
One folder holds many agents. An agent has revisions, written once and never edited; a run says which revision it ran. Copy the
FOLDER (never store.sqlite alone: a writer killed mid-write leaves a journal beside it) and every agent, revision and run comes
with it. The agents are rows in store.sqlite, not one home each; the beta.24 home API above is unchanged, and store.importHome(dir)
turns a home into an agent.
my-agents/store.sqlite agents, revisions, skills, runs, archive_changes, run_imports, run_metrics, run_tool_metrics, run_origins
my-agents/runs/<run id>/ each run's own store and work directory
CREATE TABLE agents (id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL, current_revision TEXT REFERENCES revisions(id));
CREATE TABLE revisions (id TEXT PRIMARY KEY, agent_id TEXT NOT NULL REFERENCES agents(id), parent_id TEXT REFERENCES revisions(id),
kind TEXT NOT NULL CHECK (kind IN ('agentic', 'workflow')), body_json TEXT NOT NULL, note TEXT, created_at TEXT NOT NULL);
CREATE TABLE skills (sha256 TEXT PRIMARY KEY, content TEXT NOT NULL);
CREATE TABLE runs (id TEXT PRIMARY KEY, agent_id TEXT NOT NULL REFERENCES agents(id), revision_id TEXT NOT NULL REFERENCES revisions(id),
state TEXT NOT NULL CHECK (state IN ('running', 'completed', 'failed', 'stopped', 'escalated')),
started_at TEXT NOT NULL, ended_at TEXT, cost_usd REAL, summary TEXT);
CREATE TABLE archive_changes (seq INTEGER PRIMARY KEY, kind TEXT NOT NULL CHECK (kind IN ('agent', 'run')), id TEXT NOT NULL,
archived INTEGER NOT NULL CHECK (archived IN (0, 1)), at TEXT NOT NULL);
CREATE TABLE run_imports (run_id TEXT PRIMARY KEY REFERENCES runs(id), source TEXT NOT NULL CHECK (source IN ('claude-code', 'codex', 'pi')),
session TEXT NOT NULL, at TEXT NOT NULL, UNIQUE (source, session));
CREATE TABLE run_metrics (run_id TEXT PRIMARY KEY REFERENCES runs(id), input INTEGER NOT NULL, cache_read INTEGER NOT NULL,
cache_write INTEGER NOT NULL, output INTEGER NOT NULL, requests INTEGER NOT NULL, unmetered INTEGER NOT NULL,
active_ms INTEGER, effort TEXT, model TEXT, models_json TEXT NOT NULL, cost_usd_est REAL, cost_basis TEXT, at TEXT NOT NULL);
CREATE TABLE run_tool_metrics (run_id TEXT NOT NULL REFERENCES run_metrics(run_id), tool TEXT NOT NULL, calls INTEGER NOT NULL,
errors INTEGER NOT NULL, PRIMARY KEY (run_id, tool));
CREATE TABLE run_origins (key TEXT PRIMARY KEY, run_id TEXT NOT NULL UNIQUE REFERENCES runs(id), fingerprint TEXT, at TEXT NOT NULL);
const store = openStore("my-agents");
const agent = store.createAgent({ name: "Company researcher" });
await store.commitRevision(agent.id, { kind: "agentic", instructions: "Research the company." }); // A
await store.commitRevision(agent.id, { kind: "agentic", instructions: "Research the company; cite sources." }); // B
await store.commitRevision(agent.id, { kind: "workflow", workflow }, { setCurrent: true }); // C: the DSL's JSON
const run = await store.startRun({ agent: agent.id }); // pinned to the current revision; { revision } picks another
store.carryBack(run.runId, { state: "completed", costUsd: 0.12, summary: "A housing charity" });
A revision's id is the SHA-256 of the canonical JSON of { agent, parent, kind, body }; the database refuses an UPDATE of one. Every
run of every revision is one join:
SELECT runs.id, agents.name, revisions.id AS revision, revisions.kind, runs.state, runs.started_at, runs.cost_usd, runs.summary
FROM runs JOIN revisions ON revisions.id = runs.revision_id JOIN agents ON agents.id = runs.agent_id ORDER BY runs.started_at DESC;
An agent or a run is archived, never deleted: store.setArchived("agent" | "run", id, archived) appends one row to
archive_changes when its state changes (none when it does not) and returns when it was archived, or null. The rows are written
once, like revisions: the database refuses an UPDATE or a DELETE. An item's state is its newest row; the reads carry
archivedAt while it is archived, and a run's is its own, else its agent's. The table came without a new store version: a store an
earlier version wrote opens and reads with nothing archived, gets the table from the first openStore, and an earlier version still
opens a store that has it and ignores it.
store.stats() counts the whole store in one read: agents, runs by state and in all, each agent (by name, then id) with
its run count, the cost summed over the runs that ended with one (usd, null when none did, never 0) beside how many runs
have no known cost (running, or ended without one), and the ids of the running runs. What is archived is left out of every
number but archivedAgents and archivedRuns. Each number is an SQL aggregate over store.sqlite through its indexes
(runs_by_agent, runs_by_state); nothing under runs/ is opened. runs_by_state came, like archive_changes,
without a new store version.
A caller that may ask for the same run twice (a retry after a crash, a firing driven again) names its origin:
store.startRun({ agent, origin: { key, fingerprint? } }) writes the run_origins row in the transaction that writes the run's,
and the same key never starts a second run. Asked again, or by two starts at once, it returns the run the key started, with
existing: { fingerprint? } (the fingerprint recorded with it, for the caller to tell a retry from another request under the
key), and writes nothing; a start that lost the race removes the directory it seeded. store.runOfOrigin(key) reads it without
starting anything. An origin is written once, and, like archive_changes, the table came without a new store version.
A session another harness ran on this machine comes in as a run that has already ended:
store.importRun({ agent, revision, state, startedAt, endedAt, costUsd?, summary?, source, session }) writes its runs row at the
source's own times and its run_imports row (the source, claude-code, codex or pi, and the session's own id) in one
transaction, and returns { runId, dir, storePath }: the store mints the id, and makes the run's directory and its store directory
0700. Nothing ran here, so there is no work directory; the caller writes the transcript at storePath. The same source and session
imports once: again, it returns undefined and writes nothing. The reads carry importedFrom: { source, session }. An imported run
is read-only: reopenRun and carryBack refuse it by name, and a trigger in the file refuses any UPDATE of its row, so a version
that ignores run_imports cannot change it either. Like archive_changes, the table came without a new store version.
A run's end time is carryBack's endedAt when its host gives one, an ISO time in UTC it chose before writing the end
anywhere, so the row and whatever the host announces of the end name one time; with none, it is the time of the call.
A run's metrics sit beside its row: metrics on carryBack(runId, { state, costUsd?, summary?, metrics?, endedAt? }) and on importRun
are written in the transaction that writes the run's row, and store.runMetrics(runId) reads them back with at, when they were
written (undefined for a run with none). runMetricsOf(records) from @parcha/pi-durable-agent/timeline makes them from the
entries of every conversation of the run's store (SELECT record FROM entries ORDER BY id), for a run this machine ran and an
imported one alike; runMetricsWithDecisions(records, decisions) adds a workflow run's decision receipts (each a request of
its own, its usage in the token sums, one with no usage or cancelled in unmetered); the host adds what only it knows: activeMs, how long the run was at work, effort, model (the model it
ran; runMetricsOf names the one with the most requests), and costUsdEst with costBasis, an estimate of its cost apart from
cost_usd and how it was made. The tokens are pi's: input is the input neither read from nor written to the cache,
cache_read the cached input read, cache_write the input written to the cache, so the whole input is
input + cache_read + cache_write, and the cached input is cache_read; output is the output. requests counts the model
requests as the timeline's usage row does, and unmetered those of them whose tokens are unknown (cut before their end, a usage
with no token counts, or one that reports no input at all: a request always sends some, so that is a writer's zeros for a request
it did not measure, as an imported transcript's are): they are in requests and in no token sum, never read as 0. models_json counts requests by
provider/model. run_tool_metrics holds a row per tool: its calls, and how many failed (an error result, or no result at all:
interrupted). The names come from the run's entries, which a model writes, so runMetricsOf keeps them to what the store takes
(METRIC_NAMES): a name longer than 256 characters is cut to its first 255 and "…", the same name always to the same key; a
run keeps its 100 busiest tools and models, and sums the rest under …other tools and …other models; a tool result with no
name counts under …unnamed tool. No entry's name is one of those three: one that starts with "…" gets another. A run opened again keeps its metrics while it runs; its next end replaces them, and an end with none leaves it none,
as an end with no cost leaves its cost unknown. They are in store.sqlite, so a sum over them opens nothing under runs/.
Like archive_changes, the two tables came without a new store version: a store an earlier version wrote opens with no run's
metrics and gains the tables from the first openStore, and an earlier version still opens a store that has them, ignores them
and reads every run and stats as before (one that ends a run opened again leaves its metrics as they were).
store.analytics({ from?, to?, agent?, pending? }) sums the metrics in one read, over the runs not archived that started in
[from, to) (ISO times in UTC) and are agent's, each bound optional: totals (the runs, how many completed, an
escalated run among them, how many of those need review (needsReview, the escalated ones), how many failed, how
many have metrics, their tokens and token kinds, requests and unmetered requests, and their cost as stats sums it),
time (ended minus started over the ended runs, and activeMs over those whose metrics have one),
byHarnessEffort (the revision's harness, pi when it names none and workflow for a workflow revision, then
the metrics' effort, null when unset), byDay (the UTC date a run started), bySource (live, or the source an imported
run came from) and tools (the 20 with the most calls, by name on a tie, with their errors). Each group has runs beside
runsWithMetrics, so a run with no metrics counts as a run and never as 0 tokens; a running run's metrics are its last
end's, so it counts as one without them. pending names the running runs whose end the caller holds and the store does
not have yet, each by the state its end will have (PendingEnd: completed, escalated, failed or stopped): such a
run counts at that state in totals' counts, at no cost (known or unknown), and in no group or metric until its end is
written; a run the store has ended already is read as it is. It reads run_metrics, run_tool_metrics, runs and
revisions alone, in one snapshot; a store without the metrics tables answers its runs with no metrics.
A workflow revision whose call nodes name a tool that is not a tool call passes the commit check on purpose: whether it runs is the host's to decide. openStoreReader(folder) reads store.sqlite in place, read-only: it writes nothing and makes no copy. On a store a run was stopped in the middle of writing, it says so in one line and changes no file; open the store once with write access to roll the write back.
Looking at a store
npx @parcha/pi-durable-agent serve ./my-agents
# http://127.0.0.1:41297/
On a folder holding a store.sqlite, the same command serves the store's page: its agents (archived ones behind Show
archived, and marked archived wherever they show); each agent's revisions, newest first, an agentic one's prompt diffed
line by line against its parent's and a workflow's graph with what changed; its runs across revisions, filtered by
revision; and each run, an agentic run's timeline or a workflow run's path lit on its graph. It reads store.sqlite and the runs' own stores in place, read-only, and never copies them. A run that
has ended is read as an immutable file, so serving adds no file beside it or to store.sqlite; a running run's store
is read the way SQLite reads a live file. From code, serveStore({ dir }, { port?, signal? }) from
@parcha/pi-durable-agent/serve does the same.
A workflow revision
A revision of an agent can be a DSL workflow instead of a prompt. Its run executes the workflow with agentrun-pi's
durable workflow task on the run's own store, not the agent loop. @parcha/pi-durable-agent/workflow holds the three
steps; the root never imports it, and only these functions load @parcha/agentrun-dsl and @parcha/agentrun-pi.
- Before a store commits a workflow revision,
checkWorkflowBody(body)checks it: the DSL's structural validation, what the durable runner refuses before any step, and thattoolslists every tool a call node uses. - To run it, open a Harness on the run's
storePathwith agentrun-pi'screateWorkflowExtensioninstalled (it holds your tools), and callrunWorkflowRevision({ harness, workflows, context, run, input, tools }). It returns the DSL'soutcomeand acarry,{ state, summary }, for the store'scarryBack. Inside api-durable-disk run, whose process holds the store file until it exits, callstartWorkflowRevision(the same options) in the app, andworkflowCarry(run, outcome)on the host after the run releases and its process exits. readWorkflowRun(run)reads the run back for a page, read-only: the workflow's graph, each node's state, and, once the run ended, how it ended, where it escalated and its output.revisionGraph(revision, parent?)draws a revision's graph, and what changed from a parent that is a workflow too.
Library
Four entry points: @parcha/pi-durable-agent (everything below but the timeline), @parcha/pi-durable-agent/export
@parcha/pi-durable-agent/timeline and @parcha/pi-durable-agent/serve. A function that opens a home can also fail with the home's own errors:
missing (the directory is not a home), format (a store this version does not read) and busy (another writer held
the home for more than 5 seconds). busy applies to everything that writes the home, and to openAgentHome and
readAgentHome, which take the writer lock to read. The Throws column does not repeat these except where a function
exists to check them. Errors says what each code means.
@parcha/pi-durable-agent
| Export | Does | Throws |
|---|---|---|
createAgentHome(dir, { definition, skills?, sha?, now? }) |
A new agent home in dir: the identity (the definition's id, or a new UUIDv7), the definition, the skill files and, with sha, agent.source, in one commit. Returns an AgentHome, { dir, identity } |
invalid: a definition with a field it does not keep (a model, for one) or with a bad field, an id that is not one URL segment, a skill path that is not relative, a sha that is not a commit sha. exists: dir already holds a home. Nothing is created on either |
openAgentHome(dir) |
The home in dir, checked to be this format |
missing, format |
readAgentHome(home, { access? }) |
Everything the home holds, read in one pass: an AgentHomeState |
missing, format |
homeRevision(home, { access? }) |
The agent store's head commit number: every write that changes the home moves it, reads do not. With the identity's id it is the home's version, an ETag. One read-only query, under no lock by default | missing (not a home, without access), format (no readable head commit) |
setAlias(home, host, externalId) |
The agent's id in a host | invalid: an empty host or externalId |
setBindings(home, host, bindings) |
Where each tool name runs in a host; replaces that host's bindings | invalid: an empty host, or bindings that are not tool names to non-empty targets |
setPrompts(home, variants, { ifRevision?, access? }) |
The agent's prompt variants replaced by variants (each with its id), in one commit; an equal set writes nothing. Returns { changed } (Prompt variants) |
invalid: not exactly one base and one generic, more than one question, an id that is not one URL segment or is given twice, a label, text or rule over its limits, a rule that names no question row or an option another overlay needs, or a field a row does not keep. stale: the home is not at ifRevision. Nothing is written on either |
choosePrompt(prompts, input, { judge, lookup?, signal? }) |
The variant a run starts with, by the set's Jev choice question and at most one lookup. Returns { variant, instructions, passes, lookup, reason } (Prompt variants) |
invalid: a set setPrompts would refuse. A judge or lookup failure never throws: the run gets the generic, and reason says why |
refreshAgentHome(home, { sha, from?, ifRevision?, definition, skills, now? }) |
The cached definition and skills replaced by the ones at sha. Returns { changed } (Following the agent's repo) |
invalid: a bad sha, from, ifRevision, definition or skill, a definition id that is not the agent's. stale: the home is not at ifRevision, or its cache is not at from. Nothing is written on either |
startRun(home, { host, runId?, sha?, prompt?, now?, leaseMs? }) |
A run's start on this machine, above; prompt ({ id, reason, confidence? }) is the variant the host chose, recorded on the run. Returns a RunStart: { runId, dir, storePath, workspace, agent, leases, sha? } |
invalid: an empty host, a bad leaseMs, sha or run id, or a prompt that is not { id, reason, confidence? } (a confidence outside 0 to 1 included) or names no variant of the agent. exists: the run id is taken. stale: sha is not the home's. format: a skill does not match its SHA-256. A start that fails part way is undone; when the undo fails too the error is an AggregateError with both |
prepareRun(home, { host, runId?, sha?, prompt?, now?, leaseMs?, access? }) |
A start's home half: the lease and the record, as a RunSeed (An agent home on a disk) |
as startRun, but exists only for a run in the index |
seedRun({ storePath, workDir }, seed) |
A start's run half: the run's store and its skills. Safe to run again on a half-written directory | invalid: a seed that gives a run the reference of a sign-in it does not lease, or a bad skill path. Nothing is written |
undoPrepare(home, seed, { access? }) |
Undoes a prepareRun whose seed never landed. Returns { undone }, which is false when that start is no longer the run's |
|
carryBack(home, run, { outcome, finalAnswer?, cost?, now?, access? }) |
A run's end, above. Returns a CarryBack: { changed, profiles, released }. A second call for the same run changes nothing |
invalid: an empty outcome, a negative or non-finite cost. missing: the run is not the agent's, or its store is not there |
runPaths(home, runId) |
A run's dir (what pi-durable-disk run --dir takes), storePath (store/run.sqlite) and workspace (work/) |
invalid: a run id that is not one URL segment |
durableProfileStore(target, { provider?, signal? }) |
pi-browser's ProfileStore over a run store; target is the host's Harness or any Session on the file. provider names who made a profile that had none (default browserbase) |
resolve rejects with invalid for an empty key, or when make returns no reference. An error from make passes through |
localAccess(home), HomeAccess |
How a call reaches a home: a directory of this machine (the default), or a disk a host claims (An agent home on a disk) | |
packHome(home, { out, maxBytes?, maxMembers?, exportRunStore?, now? }) |
The pack of a home, above. Returns { manifest, skipped }, skipped being { path, reason } |
invalid: a bad out, maxBytes or maxMembers. too_large: over maxBytes or maxMembers, or a manifest over its limit. check: a SQLite file that holds a sign-in kind or has a side file, a work directory or file that cannot be read, a file that changed while the pack was written, or a pack its own reader refuses (checks says which). format: a skill that does not match its SHA-256. No pack is written on any of them |
readPack(file, { maxBytes?, maxMembers? }) |
Judges a pack without writing a home: { ok, manifest, checks }. A pack that fails a check comes back with ok: false, including a pack over maxBytes (the size check) |
missing: no such file. invalid: not a file |
importPack(file, dir, { replace?, ifRevision?, maxBytes?, maxMembers?, now? }) |
The pack as the home in dir, in place. ifRevision is a homeRevision. Returns { home, report }, report being { ok: true, agent_id, checks, written: true, replaces } |
missing: no such file. invalid: a bad ifRevision, a dir that is not a directory, is not empty and not a home, or holds another agent. check: the pack is refused, or the target has a live run (checks says which). exists: dir is a home and replace is not set. stale: ifRevision is not the home's. format: an import journal that cannot be settled. Every refusal before the import starts leaves the home as it was; a format from an unsettled journal keeps the journal and its staging directory for a person to read (Moving a home) |
AGENT_STORE_FILE, RUN_STORE_FILE, LOCK_FILE |
The file names: agent.sqlite, run.sqlite and owner.lock |
|
DISK_DIR, agentDirName(agentId), agentDiskDir(data, agentId), runDirIn(data, agentId, runId) |
The disk of a store's data directory: disk, an agent's folder's name on it (its id in lower case, a dash and 16 hex digits of its SHA-256, so ids that differ only in case never share one), that folder (<data>/disk/agents/<name>), and a run's folder below it (runs/<runId>, with store/ and work/) |
|
PACK_FORMAT |
The pack's format name, agentrun.home/1 |
|
LOOKUP_TEXT_MAX |
The most characters of a lookup's text choosePrompt's second pass carries: 3,000 |
|
sessionVault(home, { hostKey, maxSessionCookieAgeMs?, runId?, access?, now? }) |
The session vault: capture, restore, forget, forgetAll, list and handedOut (Saved browser sessions) |
invalid: a host key that is not { id, wrap, unwrap }, a cap that is not above 0 and at most 24 hours, an empty runId; its calls: a bad session key, origin, captured state or capturedAt. key: a host key that does not open the vault. busy: with runId, another live run holds the session's save lease. format: a key file or a saved session that does not read or decrypt, or a saved session whose row's origins or capturedAt were changed. A refused call writes nothing |
localHostKey(id, bytes) |
A HostKey held in this process: 32 bytes, AES-256-GCM |
invalid: an id that is not letters, digits, ., _, : or -, or not 32 bytes |
seal(key, plain, aad), unseal(key, sealed, aad) |
The vault's own AES-256-GCM, for a host's secrets under a 32-byte key of its own: seal gives the IV, the ciphertext and the tag in one buffer, bound to the aad text; unseal opens it |
unseal throws Node's error when the bytes, the key or the aad differ from the sealing's |
VAULT_KEYS_FILE, MAX_SESSION_COOKIE_AGE_MS |
vault.keys, and 24 hours |
|
openStore(dir, { busyTimeoutMs? }) |
The agent store in dir, created if missing (busyTimeoutMs: how long a call waits synchronously for another connection to let go of the store before it throws SQLite's busy error, default 5000; opening it always waits up to 5 s): createAgent({ name, description?, id? }), commitRevision(agent, revision, { setCurrent?, ifCurrent? }) (ifCurrent, the current revision the caller read or null, makes it a compare-and-swap), setCurrent(agent, revision), setArchived(kind, id, archived), listAgents(), listRevisions(agent), readRevision(id), listRuns({ agent?, revision? }), stats(), analytics({ from?, to?, agent? }), runMetrics(runId), startRun({ agent, revision? }), carryBack(runId, { state, costUsd?, summary?, metrics?, endedAt? }), importHome(homeDir), close() (above) |
invalid, missing, exists, format: a revision whose content does not match its id. stale: the agent's current revision is not ifCurrent, checked in the commit's own transaction; nothing is written |
openStoreReader(dir, { busyTimeoutMs? }) |
The same reads on a read-only handle (busyTimeoutMs as openStore's) (store.sqlite in place, no copy), plus runStorePath(runId); it writes nothing |
missing: no store.sqlite in dir; busy: a store with an unfinished write; format: a store a newer version wrote |
AgentStore, AgentStoreReader, STORE_FILE |
The two classes openStore and openStoreReader return, and store.sqlite |
|
isToolSettingsKey(name) |
Whether name is a settings key the agent store accepts in a revision's toolSettings: a plain name, a catalog tool (monid.run), an MCP server's tool (mcp:<server>/<tool>) or its wildcard (mcp:<server>/*). A server checks it before it makes an agent, so a key the store would refuse never takes the agent's id |
|
AgentHomeError |
The one error class of this package (Errors) | |
EXPORTABLE_KINDS, SECRET_KINDS, projectForExport(kind, value) |
What an exporter may copy, what never leaves the home, and one document as an exporter writes it (Exporting a home). Also from @parcha/pi-durable-agent/export |
The types the package exports:
- Home.
AgentHome({ dir, identity }),AgentHomeState(whatreadAgentHomereturns:identity,definition,source?,skills,prompts,aliases,bindings,runs,leases,profiles),AgentDefinitionInput(a definition whoseidis optional), andHomeAccess. - Stored kinds, field for field as in Stored kinds:
AgentIdentity,AgentDefinition,AgentSource,SkillFile,PromptVariant,PromptRule,AgentAlias,ToolBindings,RunRecord,ProfileLeaseandBrowserProfile. - Prompts.
PromptVariantInput(a variant with itsid, assetPromptstakes it),RunPrompt, and forchoosePrompt:PromptJudge,PromptQuestion,PromptLookup,PromptLookupSource,PromptPassandPromptChoice. - Vault.
HostKey,SessionVault(the port plusforgetAll,listandhandedOut),SessionVaultPort,CapturedState,VaultCookieandVaultEntry. - Runs.
RunPaths,RunStart,RunSeed(plain data, so a host can carry it to the run's side),CarryBack,ProfileStoreandProfileTarget(the host's Harness, or any Session on the run store). - Store.
Agent,AgentRow,Revision,RevisionRow,RevisionKind,AgenticBody,WorkflowBody,RevisionPlace(a body's optional{mode, box?}, ids its host knows),RevisionComputer(a body's optional{kind, provider?}: the computer its runs get),NewRevision,StartedRun,RunOrigin(store.startRun's{ key, fingerprint? }),RunRow,RunState,ArchiveKind("agent" | "run"),StoreStats(whatstatsanswers),StoreAnalyticswithAnalyticsGroup,AnalyticsFilterandPendingEnd(whatanalyticsanswers and takes),StoredMetrics(a run's metrics ascarryBack,importRunandrunMetricscarry them), and for an imported runImportedRun,ImportedFromandImportSource("claude-code" | "codex" | "pi"). - Packs.
PackOptions,Skipped,HomeManifest(the manifest's fields, in Moving a home),ImportOptions,ImportReport, andHomeCheck({ name, status, message, detail? },statusbeingok,warnorfail).ImportStepandImportOptions.injectbelong to this package's tests and are not part of its contract.
@parcha/pi-durable-agent/export
EXPORTABLE_KINDS, SECRET_KINDS and projectForExport, and nothing else. It loads no other module, so an exporter or
a site build needs neither pi-durable nor pi-ai. projectForExport throws nothing of its own.
@parcha/pi-durable-agent/timeline
No dependency, no Node built-in, and no thrown error of its own: a page, a viewer and a harness can import it.
| Export | Does |
|---|---|
timelineOf(entries, options?) |
The rows of a stored conversation, in log order, with the usage row, when there is one, last. The options (TimelineOptions) are settled (nothing is running: a call with no result is interrupted), conversationId (for entries and events that carry none), agentLabel (a row's agent_label, from its conversation id), labels (labels for this call only), caps (the cut lengths of CAPS), and now, the clock for retry and poll waits, which only live rows use |
timelineReducer(options?) |
Live rows, with the same options. apply(events) takes the snapshot event, then each batch from watchEvents, and returns { rows, drop } (upsert the rows by id, delete the drop ids); rows() returns every row now, in log order; isLive(id) says whether a row is live (one the events made, which goes when it ends) rather than one a committed entry made, which a host keeps showing though a later snapshot of a compacted conversation no longer holds it |
groupTimeline(rows) |
Rows to groups, for a page: rounds of calls, folds and nested calls, each a Group |
foldCalls(calls) |
Adjacent calls of one tool, folded into Folds. A call with inner rows keeps its own line |
failedCall(call) |
Whether a call's result has tool_status failed |
renderText(rowsOrGroups, { fold? }) |
Plain text, one line per row or group; fold: false shows every call on its own line |
registerLabel(tool, label, fold?, { name? }) |
The label of a host's own tool, in the past tense, for every later call; fold says how its calls fold into one line; name is what the tool is called where calls are counted (a run's analytics), in plain words |
nameOf(tool) |
That name: the registered one, else the name the tool was called by. The registry is the one source of a tool's display name |
callFailed(tool, result) |
Whether a finished call failed: an error, or a result its tool's outcome reads as failed (a refused fetch). A result row's tool_status and runMetricsOf's tool errors both read it |
registerOutcome(tool, outcome) |
How a host's tool tells its outcome from a result it returned, not an error: outcome(result) gives { failed, text }, and the result row reads <label>: <text>, with tool_status failed when failed, in place of <label>: ok. web_fetch has its own: a page with ok: false reads refused: … or failed: … |
registerTurnEnding(tool) |
A host's tool that ends its turn (pi's terminate, as an ask-the-person tool does). A message after a round whose tools all end the turn is the person's next message, never a (steer) |
endedOnTurnEndingTool(entries) |
The id of the call a turn ended waiting on a person by, or undefined: the last meaningful row is a successful result of a tool that ends its turn. The one rule both a run's view (input_required) and its runner (the park) read, so the two agree |
labelOf(tool, args, result?, extra?) |
The label of one call. An unknown tool gets Using tool: <name> |
foldLabel(tool, calls) |
One line for several calls of one tool, from their input and output previews |
inputPreview(args, max) |
The input worth showing, or null: a description, command, query, file_path, url, path or code argument, with a URL's query values dropped, cut to max |
runMetricsOf(records) |
What a run that ended used, from the entries of all its conversations, in log order: its tokens (input, cacheRead, cacheWrite, output), its requests as the usage row counts them and unmetered, those of unknown tokens (an aborted response's partial usage, and a usage with no input at all, are never summed), its requests by provider/model (models) and the busiest model, and each tool's calls and errors (tools), their names kept to what the store takes (METRIC_NAMES). A RunMetrics, for the store's carryBack and importRun (Agents in a folder) |
runMetricsWithDecisions(records, decisions) |
runMetricsOf, with a workflow run's decision receipts (DecisionLike) beside its entries: each a request by provider/model, its usage (input_tokens, output_tokens) in the token sums, and one with none, or cancelled, in unmetered. serve passes a workflow run's receipts at its end; runMetricsOf keeps one parameter, so .map(runMetricsOf) stays safe |
costLabel(ended) |
The words before a usage row's cost: "Cost" for a run that has ended, "Cost so far" for one still going. The usage row says them as timelineOf's ended option is set |
endedUsage(row) |
A usage row made while the run ran, as an ended run's: its "Cost so far:" read as "Cost:". Any other row as it is. For a host that serves rows it read before the run ended |
CAPS |
The caps a row's summary, preview, text and thinking are cut to: 500, 500, 2000 and 100 |
RUN_RESUMED |
The entry kind a host writes on the root as a run's process opens to go on with a run its last process left unfinished (data: after, crash or server_stop; generation; at): a phase row of its own at at, "Resumed after a crash" or "Resumed after the server stopped", and no request. A reader from before it ignores it, as it does any kind it does not know |
TURN_END |
The entry kind a host writes on the root as a turn on another harness (claude-agent, codex) ends (data: key, status, ...). No row. A message after it is the person's next message, never a (steer), even when the turn's last entry was a tool's result: a stop that came after the result and before the next model request |
scrubUrls(text) |
text with each http(s) URL in it kept to its origin, its path and its query keys: query values, the fragment and credentials are dropped, as on every row. A host that adds rows of its own cleans their strings with it |
cut(text, max) |
text cut to max characters, the last three ... when it was longer: how a row's strings are cut to CAPS |
METRIC_NAMES |
How runMetricsOf keeps tool and model names: max 256 characters, the kept 100 busiest of each, and the names it sums the rest and a nameless tool under: otherTools (…other tools), otherModels (…other models), unnamedTool (…unnamed tool) |
REQUEST_STARTED, REQUEST_ENDED, REQUEST_COST, REQUEST_REFUSED, REQUEST_COST_PENDING, REQUEST_COST_LATE, REQUEST_CONTINUED |
The entry kinds a host writes to mark a model request: as it leaves, and as it ends aborted; and the cost of a request with no pi.assistant entry of its own (a compaction summary, marked request: "compaction"). An answer written before its cost came in is followed by REQUEST_COST_PENDING, and its cost comes later as REQUEST_COST_LATE (usd): it adds to the cost and is not counted as a request; the cost reads unknown while one is pending. A call an answer asked for, written after that answer's own entry (a harness reports each call as it runs it), follows REQUEST_CONTINUED in a pi.assistant of its own: it shows under that answer and counts as a call, not as a request, a cost or tokens. The usage row counts each marked request and sums each cost; one with no entry or cost (cut before its answer) other than the latest still in flight makes the cost unknown. A request the host refused before it left (REQUEST_REFUSED) is not counted, and pi's error entry for it is not shown. A conversation with no marks reads as before |
CAP_WRAPUP |
The entry kind a host writes on a run's root when its spend cap's wrap-up note went to the model ({capUsd, leftUsd, words}, with key on another harness): the model was told once, in a request it sent, to finish now with its answer. It shows as the server's own phase row, AgentRun: <words> (text the words, origin: "server", its id ending in NOTE_SUFFIX), after the message of the turn it was told in. It is no request, no cost and no message its turn took |
TOOL_STARTED, TOOL_COST |
TOOL_STARTED marks a paid tool's call before it is sent ({tool, charged_by, call, heldUsd}); the TOOL_COST of the same call settles it, and in a settled timeline one never settled (a crash) makes the cost unknown, as a cut model request does: its heldUsd is a host cap's bound, never a charge. TOOL_COST is the entry kind a host writes for what a paid tool's call charged ({tool, charged_by, usd, call?}, usd null when the call may have charged and did not say how much): it adds to the run's cost, which a null makes unknown, and shows as a phase row of its own (Tool cost: monid charged $0.0030 for monid.run, tool_name the tool, tool_use_id the call). It is not a request: the usage row names the paid tool calls apart (... over 1 model request and 1 paid tool call), each call once by its call however many charges it books (a browser call and its session's release under it are 1 paid tool call; when any of them has no price, 1 charge not priced). The charge's phase row carries it as usage ({usd, requests: 0}), so a host can sum a run's paid tools from the same rows as its cost |
BROWSER_CHARGE, chargedTool(row) |
BROWSER_CHARGE (browser) is the tool of a browser session's TOOL_COST ({tool: "browser", charged_by, usd, call, tag, conversation, seconds, at, final}). It makes no row of its own: it folds into the tool_use row of the call it names, as that row's usage ({usd, requests: 0}, every charge booked under the call summed; {usd: null, requests: 0, so_far_usd, unpriced: 1} once one has no price: the call is one charge not priced, however many it booked, as the usage row counts it). A charge with no call (a release after the run's last call) folds into the last call charged for its session (tag), else its conversation's latest browser call. Only a charge none of these is found for (its calls compacted away) keeps a phase row. A charge booked after the row was shown changes that row, under the same id, live too. The run's cost counts each charge once, as before. chargedTool(row) names the tool whose charge a row carries: a charge row's tool_name, browser for a browser step's call row (its tool_name one of the browser's tools) with usage, undefined for any other row (another tool's call row, whatever it carries, and the usage row). A browser charge folds only into a browser step's row: one that names another tool's call goes to its session's or conversation's browser call, else keeps its own row. The text view shows a call's charges on its line (Read the page "Example" · $0.0017) |
TOOL_UNMETERED |
The entry kind a host writes for a tool call that ran with no price (an MCP tool its owner set none for: {tool, charged_by, call}): it is outside the run's cost and never makes it unknown. It shows as a phase row of its own (Not metered: mcp:fake/add has no price set, so it is outside the run's cost, tool_name the tool, tool_use_id the call), with no usage, and the usage row counts these calls (usage.not_metered, and ...; 1 tool call not metered in its summary) |
HOST_PHASE |
The entry kind a host writes for a step of its own, in its words ({summary}, and at, ms, when it happened), such as a box waiting for its image: one phase row with the summary's first line, cut at the summary cap, stored and live alike, at at when given, else at the time of the entry before it. It is no request and no cost; one with no summary shows nothing |
MESSAGE_ORIGIN |
The entry kind (agentrun.message_origin, { origin, text }) a host writes just before a message the server wrote, whose user entry carries no data of its own (pi's): it names the next user entry in its conversation whose text is exactly text, once. One with withdrawn: true takes back the last open marker of the same text and origin (its input was refused). The model never reads it: it carries no model message |
originOf(value) |
value as a MessageOrigin, or undefined when it is not one: {kind: "server", reason} (said too for approval_declined), or {kind: "trigger", trigger_id, source, owner}, a trigger's input (a non-empty id, a source of TRIGGER_SOURCES: schedule, webhook, slack, github, and a boolean owner, true when the words are the trigger's owner's) |
messageOrigins(entries) |
The user entries the server wrote, each with its origin: its own data.origin, else the first open MESSAGE_ORIGIN marker with its exact text. The Timeline reads them with the same rule: such an entry is a phase row, "AgentRun: ", with origin: "server", never a "You:" user_message; an approval_declined one is the person's words (said) as their own user_message row, beside the server's phase row, which also has origin: "server" and an id ending in :server; a trigger's is a phase row, "<its source's label> : " (TRIGGER_LABELS), with origin: "trigger" and trigger: {id, source, owner} (its origin's, trigger_id as id) |
originMatcher() |
A reader for entries in order: it returns, for each user entry, its origin (its own data.origin, else the first open MESSAGE_ORIGIN marker with its exact text), and undefined for a person's own message. messageOrigins reads with it; a host that walks entries as they stream (and keeps what each user entry said) uses it directly |
isUserEntry(entry) |
Whether an entry is a user-role message the Timeline shows: a pi user entry, or a host's user entry |
TRIGGER_SOURCES |
What can fire a trigger, as a trigger origin's source names it: schedule, webhook, slack, github |
TRIGGER_LABELS |
How a trigger's row names its source, by TRIGGER_SOURCES: Schedule, Webhook, Slack, GitHub. The row's summary is " : "; a server may put the trigger's name for its id |
isMessageRow(row) |
Whether a row is a message its run took: a user_message, or a row with origin: "server" or "trigger" whose id does not end in :server (a server's row beside the person's words is not a second message) or NOTE_SUFFIX. A host that counts a run's messages (their arrival, where a stop stands) counts these |
NOTE_SUFFIX |
:note, the id suffix of the server's row for a note it told the run's model (CAP_WRAPUP): origin: "server", so a host labels it as the server's, and never a message its turn took (isMessageRow is false) |
Its types: TimelineEvent (Grep's fields, plus the optional ones above, origin among them) and TimelineKind, TimelineOptions, OutcomeFn,
EntryLike (a pi conversation entry as stored), AgentEventLike (a pi event), Call, Fold, Group, LabelFn,
FoldSpec, ToolResultView, RunMetrics and ToolMetrics (what runMetricsOf returns), and DecisionLike (a
workflow's decision receipt, as runMetricsWithDecisions reads it), TriggerSource (one of TRIGGER_SOURCES), and MessageOrigin (who wrote a message the person did
not: { kind: "server", reason: "cap_raised" | "approval" }, or "approval_declined" with said; or a trigger's firing,
{ kind: "trigger", trigger_id, source, owner }, owner true only when the trigger's owner wrote its words).
@parcha/pi-durable-agent/serve
| Export | Does | Throws |
|---|---|---|
serveHome(home, { port?, signal? }) |
Serves home.dir (Looking at a home) on 127.0.0.1 and resolves to { url, close() } once it is listening; close() stops it, and so does aborting signal. It takes no lock and opens the home's stores read-only. home is anything with a dir, an AgentHome included |
missing: the directory is not a home. invalid: a port that is not an integer from 0 to 65535 |
serveStore(store, { port?, signal? }) |
Serves the agent store at store.dir (Looking at a store) as serveHome serves a home |
missing: the directory has no store.sqlite. invalid: a port that is not an integer from 0 to 65535 |
storeApi(store) |
The read-only JSON behind the store's page, for a server of another package to answer its /api/* paths: a function of a path and its query that resolves to { status, value }, opening store.dir's reader for each read. A read that fails resolves to its status and { error } |
|
STORE_PAGE_FILES |
The store page's files by the path the page asks for each (/, /page.css, /page.js, /graph.js): { path, type }, path absolute. A page that loads its own script before page.js can set window.agentStoreHost(parts), which answers { name, route(hash, token), agent(agent, revisions), revision(agent, revision), run(run) }: the page's name in the tab's title, the views it draws itself, and what it adds to an agent's card, to a revision's head and to a stored run's head |
Its types: ServeOptions, ServeHandle, Api (what storeApi returns) and Answer ({ status, value }). The package's bin,
pi-durable-agent serve <dir> [--port <n>], calls serveStore for a directory holding a store.sqlite and serveHome
otherwise, and prints url; it exits 1 for a directory that is neither and 2 for a command or port it does not know.
@parcha/pi-durable-agent/workflow
It loads @parcha/agentrun-dsl and @parcha/agentrun-pi only when one of its functions runs.
| Export | Does |
|---|---|
checkWorkflowBody(body) |
Refuses, as invalid, a workflow revision's body a store must not commit, naming the node's path; the error's issues are workflowIssues' list |
workflowIssues(workflow, tools?) |
Every issue a save would refuse, as { issues: [{ path, code, message }], warnings }: the DSL's validation (code not run), a call node's tool not in tools (TOOL_NOT_LISTED; tools absent checks none), and what the durable runner refuses before any step. It writes nothing |
runWorkflowRevision(options) |
Runs the revision's workflow as one durable workflow task on the run's store and waits: { outcome, carry }, startWorkflowRevision then workflowCarry |
startWorkflowRevision(options) |
The first half: starts the run's workflow task and waits for its outcome, reading no file. A workflow that uses a tool the host does not register, or does not take the input, is refused with no task; the run reopened under another revision is refused (RUN_STORE_BINDING_MISMATCH); reopened as itself it resumes, or answers how it ended |
workflowCarry(run, outcome) |
The second half: { state, summary } for the store's carryBack, read with readWorkflowRun. It fails while another connection holds the store file exclusively, as a pi-durable-disk run process does until it exits |
readWorkflowRun(run) |
The run as its store shows it, read-only: no write, no lock, no Harness. With run.state (the runs row's) ended and no write-ahead log beside the store, it opens the store as an immutable file and makes no file in the run's directory. Working means live: an ended run's interrupted nodes read stopped, or failed when the run failed. A run in the driver's keyed layout is refused as format |
revisionGraph(revision, parent?) |
The revision's graph (the DSL's workflowGraph), and the DSL's graphDiff from its parent when the parent is a workflow revision; an agentic parent, or none, gives no diff |
Its types: WorkflowBody, WorkflowIssue, WorkflowRunStart (the part of a store's started run it reads), WorkflowStorage
(the reads readWorkflowRun and workflowCarry take in place of a store file: a run's journal in a shared store), WorkflowRunView,
RunState, RunNodeState (the DSL's NodeState and stopped), and the DSL's WorkflowGraph, GraphDiff, NodeState
and WorkflowOutcome.
Errors
AgentHomeError has a code and, for a check, the checks behind it (HomeCheck[], the failed ones with status
fail). What the package refuses is an AgentHomeError. A failure of the file system or of SQLite passes through as it
is, and so does what a host's make throws through durableProfileStore. A start that fails and whose undo fails too
throws an AggregateError holding both.
code |
Means |
|---|---|
invalid |
An argument or a definition is wrong, or a target is not what the call needs |
exists |
A home or a run is already there |
missing |
A home, a run, a run's store or a pack file is not there |
busy |
Another writer held the home for more than 5 seconds. The lock is an exclusive SQLite lock the kernel drops when its holder dies, so a crashed writer never leaves it |
format |
A store or a skill this version cannot read: no identity or definition, another format version, no readable head commit, a store that does not accept synchronous = FULL, a skill that does not match its SHA-256, or an import journal that cannot be settled |
stale |
The home moved since the caller read it: ifRevision, from, or a sha that is not the cache's; or an agent store's agent has another current revision than a commit's ifCurrent |
too_large |
A pack over maxBytes or maxMembers, from packHome |
check |
A pack or an import failed a check: checks lists them all |
key |
A host key that does not open the session vault: another key id, or the right id with other bytes |
The package does not import pi-browser at run time: ProfileStore is declared here by shape. npm run typecheck
(after the workspace build) checks that the declaration is exactly pi-browser's type, read from the return of
pi-browser's fileProfileStore once @parcha/pi-browser/providers/browserbase exports it, and from a copy of
pi-browser's declaration until then.
An agent home on a disk
A host can keep an agent home on a disk that one machine claims at a time (pi-durable-disk on Archil, for one). There
the home's own files live under agent/ (agent/agent.sqlite, agent/owner.lock) and each run is runs/<id>, the
pi-durable-disk run directory. The package takes no dependency on the disk and holds no key: the host passes a
HomeAccess, { claim(fn), runStore(run) }.
claim(fn)runsfn(agentDir)while the host holdsagentDiralone. The package also takesowner.lockthere, inside the claim, and commits toagentDir/agent.sqlite.runStore(run)gives a local, closed copy of the released run'srun.sqlite(runis{ runId, path }, the path asagent.runsrecords it). The package reads it from a copy of its own and never writes it.carryBack,readAgentHome,homeRevisionandprepareRuntake{ access }. Without it, the access islocalAccess(home): the home is the local directoryhome.dirwith its runs under it, as above.
A start is two halves. prepareRun(home, { host, runId?, sha?, access }) is the home's: in one commit inside the claim it
checks the sha (stale), takes the save leases and records the run, and returns a RunSeed (the profiles, a leased
one with its ref and every other held, the skill files, the root agent, the sha and the leases). It touches nothing
under runs/. seedRun({ storePath, workDir }, seed) is the run's: it writes the run's store and its skills, and can run
again on a half-written directory. startRun is the two in a row on this machine, under one hold of the home: a start
whose seeding fails is undone under the same hold, with no run directory, no record, and each lease it took back as it
was (another run's expired lease included). When the undo fails too, the error is an AggregateError with both.
undoPrepare(home, seed, { access }) is that undo for a host's own start: the seed keeps what prepareRun wrote and
replaced (seed.prior). It acts only while that start is still the run's (its record there, unfinished, as written) and
touches only leases exactly as it set them, so an undo retried after the run id was prepared again, or after the run went
on, changes nothing and returns { undone: false }.
Binding the seed to pi-durable-disk looks like this (a host's code, not part of this package). A home on a local
directory needs no access: the package reads its own files.
import { localRun, openDurableRun } from "@parcha/pi-durable-disk";
import { carryBack, prepareRun, runPaths, seedRun } from "@parcha/pi-durable-agent";
const seed = await prepareRun(home, { host: "worker-1", sha });
const run = await openDurableRun({ disk: "local", region: "local", id: seed.runId }, { ...localRun(runPaths(home, seed.runId).dir), harness, seed: (dir) => seedRun(dir, seed) });
// (a run that will never start: await undoPrepare(home, seed) gives its leases back)
// ... the run works, then:
await run.release();
await carryBack(home, { runId: seed.runId }, { outcome: "completed" });
seed runs inside the first claim of a new run, before its Harness opens the store; a throw fails the open with
nothing published, and the next open seeds again. A claim that fails leaves the home as it was, and the run can be
carried back later.
Not yet
- Agent SQL: read-only queries over a whitelist of this run's and the agent's tables, and tables of the agent's own.
- Saved credentials in the vault (passwords, one-time-code seeds), filled by name in tools: the vault keeps browser
sessions today. A session's IndexedDB, and a site's own logout calling
forget. - Tables whose rows commit inside pi's own commit, so a row and the turn that made it land together.
- The binding of a disk's claim and seed itself: this package takes their shape (
HomeAccess,seedRun), and the host or pi-durable-disk supplies them. A reader of both stores for viewers in another process.
Requirements
- Node 22.19 or later.
@earendil-works/pi-durableand@earendil-works/chord,1.5.0exactly, as peer dependencies. CI runs its suite on 1.5.0.
License
Apache-2.0