# @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](https://github.com/Parcha-ai/agentrun). Node 22.19 or later. The agent store and each run's store are
[pi-durable](https://www.npmjs.com/package/@earendil-works/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.sqlite` holds 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.** `startRun` makes the run's directory, a
  [pi-durable-disk](https://github.com/Parcha-ai/agentrun/blob/main/packages/pi-durable-disk/README.md) 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.** `carryBack` copies 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.** `packHome` writes the home as one tar file, `readPack` judges one, and `importPack` writes 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/timeline` turns 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](#looking-at-a-home)), or over an agent store's agents, revisions and runs
  ([Looking at a store](#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](https://github.com/Parcha-ai/agentrun/blob/main/packages/pi-browser/README.md) does, through the
`ProfileStore` this package gives it.

## Install

```sh
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](#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):

```sh
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
```

```js
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);
```

```text
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.** `startRun` copies the agent's sign-ins into the run's store, and
  `durableProfileStore` hands 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-run)).
- **A pack and an export leave it behind.** `SECRET_KINDS` names the kinds that never leave a home: `browser.profiles`
  among them. A pack's manifest lists the sign-ins the source had (their keys and providers), and `readPack` warns 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-browser-sessions-the-vault)). Saved credentials, such as
passwords, are not built yet ([Not 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](#an-agent-home-on-a-disk)).

## A run

1. `startRun(home, { host, runId?, prompt?, now?, leaseMs? })` creates `runs/<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's `browser.profiles` into the run store, and records the run in `agent.runs`. A profile
   another run holds the lease for is copied without its `ref` (`state: "held"`, `heldBy` naming that run). It returns
   `{ runId, dir, storePath, workspace, agent: { instructions, tools }, leases }`, where `leases` lists 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.
   `prompt` is the [prompt variant](#prompt-variants) 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 a `confidence` that is not a number from 0 to 1.
2. The host opens its own Harness on `storePath`, itself or through pi-durable-disk on `dir`, and creates the root
   conversation with `agent.instructions`, its chosen model and thinking level, and its tools for the names in
   `agent.tools`.
3. `durableProfileStore(harness)` is pi-browser's `ProfileStore` over the run store's `browser.profiles`: `resolve(key,
   make)` returns a ready profile's `ref`, or calls `make` once (one caller at a time per key, outside any commit) and
   commits the new `ref` as `ready`. A `held` profile has no `ref`, so the run starts signed out of that site and
   `make` gives it a context of its own, which is never carried back.
4. 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 its `agent.runs` entry. 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 })` and `refreshAgentHome(home, { sha, definition, skills })` write
  the files and `agent.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's
  `id`, when given, must be the agent's, and `identity.name` follows its `name`. 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 at `from` (`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 on `homeRevision`: a home whose head commit is
  not `ifRevision` is refused (`stale`) before anything is written. An API checks an `If-Match` this way, under the
  home's lock, instead of racing a separate read. The order is: the input, `ifRevision`, `from`, the same-sha no-op.
- `startRun` records the home's sha on the run (`agent.runs`, and `sha` on 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 with `AgentHomeError` (`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`, `overlay`s, 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.

```js
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).

```js
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.vault` under 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 in `vault.keys`, owner-only, beside `agent.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 clear `origins` or `capturedAt` differ 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 (`AgentHomeError` `key`). 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 own `origins`, 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 (`maxSessionCookieAgeMs` lowers it; it cannot go higher). A
  capture is dated by its state's `capturedAt` (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. With `runId`, a capture is refused (`busy`)
  while another live run holds that session's save lease.
- **Never anywhere else.** `agent.vault` is on `SECRET_KINDS`: never exported, packed or seeded into a run; a pack's
  manifest names the sessions (`vault`), never a value, and `vault.keys` never travels. No value is in an error or a
  log. `handedOut()` lists every value `restore` returned, for a redactor such as pi-browser's `redact.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](#saved-browser-sessions-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-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` (`name`s: 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.

```js
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`.

```js
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.

```sh
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](#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
```

```sql
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);
```

```js
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:

```sql
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

```sh
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`.

1. 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 that `tools` lists every tool a call node uses.
2. To run it, open a Harness on the run's `storePath` with agentrun-pi's `createWorkflowExtension` installed (it holds
   your tools), and call `runWorkflowRevision({ harness, workflows, context, run, input, tools })`. It returns the DSL's
   `outcome` and a `carry`, `{ state, summary }`, for the store's `carryBack`. Inside a `pi-durable-disk run`, whose
   process holds the store file until it exits, call `startWorkflowRevision` (the same options) in the app, and
   `workflowCarry(run, outcome)` on the host after the run releases and its process exits.
3. `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.
4. `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](#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](#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](#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](#following-the-agents-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](#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](#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](#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](#saved-browser-sessions-the-vault)) | `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](#agents-in-a-folder-the-agent-store)) | `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](#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](#exporting-a-home)). Also from `@parcha/pi-durable-agent/export` | |

The types the package exports:

- **Home.** `AgentHome` (`{ dir, identity }`), `AgentHomeState` (what `readAgentHome` returns: `identity`, `definition`,
  `source?`, `skills`, `prompts`, `aliases`, `bindings`, `runs`, `leases`, `profiles`), `AgentDefinitionInput` (a
  definition whose `id` is optional), and `HomeAccess`.
- **Stored kinds**, field for field as in [Stored kinds](#stored-kinds): `AgentIdentity`, `AgentDefinition`,
  `AgentSource`, `SkillFile`, `PromptVariant`, `PromptRule`, `AgentAlias`, `ToolBindings`, `RunRecord`, `ProfileLease`
  and `BrowserProfile`.
- **Prompts.** `PromptVariantInput` (a variant with its `id`, as `setPrompts` takes it), `RunPrompt`, and for
  `choosePrompt`: `PromptJudge`, `PromptQuestion`, `PromptLookup`, `PromptLookupSource`, `PromptPass` and `PromptChoice`.
- **Vault.** `HostKey`, `SessionVault` (the port plus `forgetAll`, `list` and `handedOut`), `SessionVaultPort`,
  `CapturedState`, `VaultCookie` and `VaultEntry`.
- **Runs.** `RunPaths`, `RunStart`, `RunSeed` (plain data, so a host can carry it to the run's side), `CarryBack`,
  `ProfileStore` and `ProfileTarget` (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` (what `stats` answers), `StoreAnalytics` with `AnalyticsGroup`, `AnalyticsFilter` and `PendingEnd` (what `analytics` answers and takes), `StoredMetrics` (a run's metrics as `carryBack`, `importRun` and `runMetrics` carry them), and for an imported run `ImportedRun`, `ImportedFrom` and `ImportSource` (`"claude-code" | "codex" | "pi"`).
- **Packs.** `PackOptions`, `Skipped`, `HomeManifest` (the manifest's fields, in [Moving a home](#moving-a-home)),
  `ImportOptions`, `ImportReport`, and `HomeCheck` (`{ name, status, message, detail? }`, `status` being `ok`, `warn`
  or `fail`). `ImportStep` and `ImportOptions.inject` belong 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 `Fold`s. 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](#agents-in-a-folder-the-agent-store)) |
| `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: <its first line>", 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_id>: <its first line>" (`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 "<label> <trigger id>: <its first line>"; 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](#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](#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)` runs `fn(agentDir)` while the host holds `agentDir` alone. The package also takes `owner.lock` there,
  inside the claim, and commits to `agentDir/agent.sqlite`.
- `runStore(run)` gives a local, closed copy of the released run's `run.sqlite` (`run` is `{ runId, path }`, the path
  as `agent.runs` records it). The package reads it from a copy of its own and never writes it.
- `carryBack`, `readAgentHome`, `homeRevision` and `prepareRun` take `{ access }`. Without it, the access is
  `localAccess(home)`: the home is the local directory `home.dir` with 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.

```js
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-durable` and `@earendil-works/chord`, `1.5.0` exactly, as peer dependencies. CI runs its suite
  on 1.5.0.

## License

Apache-2.0
