Files
dev/third_party/pi/packages/durable/docs/spec.md
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

280 KiB
Raw Blame History

Durable specification

@earendil-works/pi-durable is a durable, extensible agent harness. This document is normative. It uses existing package types as follows:

import type {
  AttachedReplicatedState,
  Context,
  Draft,
  JsonRepresentation,
  JsonValue,
  ReplicatedState,
} from "@earendil-works/chord";
import type { Op } from "@earendil-works/chord/delta";
import type {
  AssistantMessage,
  CacheRetention,
  DeferredHandle,
  Message,
  Models,
  ModelThinkingLevel,
  TextContent,
  Tool,
  ToolCall,
  ToolReference,
  ToolResultMessage,
  Transport,
  UserMessage,
} from "@earendil-works/pi-ai";
import type { ExecutionEnv } from "@earendil-works/pi-durable/env";

type JsonObject = { [key: string]: JsonValue };
type TaskOutcomeError = { message: string; detail?: JsonValue };

Durable targets the transcript SystemMessage contract from pi-ai PR #9548. Message includes that type once the PR lands.

The core rule is:

A Session atomically commits immutable entries, full task records, and Chord-tracked documents. Only committed state is observable.

1. Terms and invariants

  • A Session owns one mutation line, conversations, entries, tasks, submissions, and documents.
  • A conversation is a transcript scope. It may fork another conversation.
  • An entry is an immutable transcript record.
  • A task is a durable state machine attached to one conversation.
  • A document is mutable JSON state represented by Chord operations and occasional complete bases.
  • A definition is a typed token describing one document or document family.
  • A source exposes committed document changes to Chord without exposing a mutable object.
  • A turn is one assistant response and the tool calls it makes. A run is the sequence of turns from an admitted input to the final answer; turn boundaries (section 6) sit between its turns. A conversation is busy while a run is active.

Required invariants:

  1. One Session commit is atomic across all record and document writes.
  2. A document update is published only after its storage commit succeeds.
  3. All visible progress is durable. There is no volatile publication path.
  4. External effects do not run inside the Session mutation transaction.
  5. Entries and IDs are immutable and never reused after a committed write.
  6. Document drafts are fully revoked when their transaction callback settles: the Session synchronously prepares or aborts every open change at that point. Values assigned into a draft are copied by value and must be strict JSON.
  7. The mutation line remains held through storage settlement and committed-state adoption. Commit/close observers run synchronously only to capture immutable state; document-state and watch user callbacks run later, off the line.
  8. A storage error is fatal to the open Session (section 10.1). It publishes nothing and must be reopened. Preparation and checkpoint failures occur before storage admission and roll back normally.

2. Core records

The concrete JSON representations may add bookkeeping fields, but must preserve these contracts.

declare const idBrand: unique symbol;
type Id<Kind extends string, Type = unknown> = number & {
  readonly [idBrand]: {
    readonly kind: Kind;
    readonly type: Type;
  };
};
type ConversationId = Id<"conversation">;
type EntryId = Id<"entry">;
type TaskId<Result = unknown> = Id<"task", Result>;
type SubmissionId = Id<"submission">;
type DocumentId = Id<"document">;

declare const seqBrand: unique symbol;
/** Strictly increases between commits; gaps are permitted. */
type Seq = number & { readonly [seqBrand]: "sequence" };
const ROOT_CONVERSATION_ID = 1 as ConversationId;

type ConversationOwnership =
  | { readonly kind: "ownerless" }
  | { readonly kind: "task"; readonly taskId: TaskId };

type ConversationRecord = {
  readonly id: ConversationId;
  readonly parent?: {
    readonly conversationId: ConversationId;
    readonly at: EntryId;
  };
  readonly owner?: {
    readonly conversationId: ConversationId;
    readonly taskId: TaskId;
  };
};

The referenced pi-ai member is:

interface SystemMessage {
  role: "system";
  content: string | TextContent[];
  sections?: Record<string, string | null>;
  toolsAdded?: Tool[];
  toolsRemoved?: ToolReference[];
  timestamp: number;
}

In pi-ai, content is the base prompt on the leading message and additional instruction text on later messages. sections is an ordered named patch: a string adds or replaces a section, while null removes it. toolsRemoved is applied before toolsAdded within one message. Replaying every system message in transcript order yields the effective prompt and tool set.

Durable always writes content: "". Every prompt part, including the preamble, is a named section resolved from the conversation's agent (section 7.4).

type ContextEdit = {
  readonly target: EntryId;
} & (
  | { readonly action: "omit"; readonly messages?: never }
  | { readonly action: "replace"; readonly messages: readonly Message[] }
);

type EntryRecord = {
  readonly id: EntryId;
  readonly conversationId: ConversationId;
  readonly kind: string;
  readonly model?: readonly Message[];
  readonly data?: JsonValue;
  readonly head?: EntryId;
  readonly edits?: readonly ContextEdit[];
  readonly byTaskId?: TaskId;
};

type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {
  readonly head?: EntryId | "self";
};

type SubmissionRecordBase = {
  readonly id: SubmissionId;
  readonly conversationId: ConversationId;
  readonly requestId?: string;
};

type SubmissionRecord =
  | (SubmissionRecordBase & {
      readonly type: "input";
    } & (
      | {
          readonly status: "queued";
          readonly entry?: never;
          readonly answer?: never;
          readonly reason?: never;
          readonly detail?: never;
        }
      | {
          readonly status: "placed";
          readonly entry: EntryId;
          readonly answer?: never;
          readonly reason?: never;
          readonly detail?: never;
        }
      | {
          readonly status: "done";
          readonly entry: EntryId;
          readonly answer: EntryId;
          readonly reason?: never;
          readonly detail?: never;
        }
      | {
          readonly status: "unanswered";
          readonly entry?: EntryId;
          readonly answer?: never;
          readonly reason: string;
          readonly detail?: JsonValue;
        }
    ))
  | (SubmissionRecordBase & {
      readonly type: "write";
    } & (
      | {
          readonly status: "queued";
          readonly entry?: never;
          readonly answer?: never;
          readonly reason?: never;
          readonly detail?: never;
        }
      | {
          readonly status: "done";
          readonly entry: EntryId;
          readonly answer?: never;
          readonly reason?: never;
          readonly detail?: never;
        }
      | {
          readonly status: "unanswered";
          readonly entry?: never;
          readonly answer?: never;
          readonly reason: string;
          readonly detail?: JsonValue;
        }
    ));

type SubmissionSettlement =
  | { readonly status: "done"; readonly answer: EntryId }
  | { readonly status: "unanswered"; readonly reason: string; readonly detail?: JsonValue };

type SubmissionCreate = SubmissionRecord extends infer Record
  ? Record extends SubmissionRecord
    ? Omit<Record, "id">
    : never
  : never;

ID brands are compile-time only. IDs and sequences remain ordinary numbers in memory, JSON, JSONL, and SQLite. Code applies a brand only at a trusted creation or decoding boundary. The distinct sequence brand prevents an entity ID from being used as a document commit point.

Conversation history parenting and task ownership are separate:

  • parent controls inherited entries and historical documents.
  • owner records task attribution and connects scopes for subtree abort and idle traversal. It is not an access-control capability.
  • Creation always supplies ConversationOwnership explicitly. The Session derives the persisted owner's conversation from the named task; callers never construct the persisted owner pair.

A conversation's owner remains recorded after the owning task becomes terminal.

2.1 Entries and context

Entry IDs are Session-global and ordered. parent.at is an entry in the parent history visible to the child.

The active transcript is the raw entry range from the newest applicable head through the tail. A head on an entry changes subsequent context; it does not remove older entries from storage. Fork traversal is child entries followed by parent entries through each parent.at cap.

Context derivation:

  1. Find the newest visible entry H at or before the cutoff that has head.
  2. Let from = H.head, or transcript start when H is absent.
  3. Scan visible entries from from through the cutoff.
  4. For each target, the newest edit in that range wins. omit contributes no model messages; replace contributes its messages instead of the target's.
  5. If H exists, context entries are H followed by non-head entries in the range. Otherwise they are the range.
  6. Keep every positional system message and its tool/section changes.
  7. Move each assistant's tool results, found before the next assistant message, directly after it in tool-call order, ahead of any intervening user or system message. The provider protocol requires results to follow their call.
  8. Synthesize an error result for every call without one, for example after a fork cut or at an interrupted tail. Drop tool results with no preceding call.
  9. Exclude model-less entries and assistant messages with aborted, error, or deferred stop reasons from future provider requests.
  10. Move the first system message to the front when only user messages precede it, or when it is the baseline's: the first pi.system entry after H, unless an edit targets it. Generation writes it with every section and tool in effect before the first request after a compaction or reset, and omits the retained pi.system entries. Without H, the first pi.system entry is the baseline unless an earlier entry carries a system message, in its messages or an edit's replacement, even one an edit omits later: only then did generation write it with every section and tool in effect, before the first request or once an agent without any gets one. A run's input is committed before generation renders the system prompt, so the context otherwise starts with the input, or with the kept history, assistant messages included. Providers treat only a leading system message as the initial prompt and tool set; without one, a later tool change rewrites the request's tool list and invalidates the whole prompt cache. Only the first system message moves, so replay gives the same sections and tools. Any other system message with an assistant message before it stays in place, since moving it would change what it means; a retained system message of another kind ahead of the baseline therefore keeps the baseline from leading. Stored entries and contributions keep the committed order.

Views carry raw active entries. UI reduction and model-context reduction are separate consumers. Older stored history is available through the owning Conversation object's cursor-based entries() scan.

2.2 Public Harness surface

This is the v1 host-facing API.

type ModelRef = {
  readonly provider: string;
  readonly modelId: string;
};

type UserInput = UserMessage["content"];

type SubmissionDraft = {
  readonly requestId?: string;
} & (
  | {
      readonly type: "input";
      readonly content: UserInput;
      readonly whenBusy?: "steer" | "followUp" | "reject";
      readonly entry?: never;
    }
  | {
      readonly type: "write";
      readonly entry: EntryDraft;
      readonly content?: never;
      readonly whenBusy?: never;
    }
);

type InputSubmissionDraft = Extract<SubmissionDraft, { readonly type: "input" }>;

/** Runs inside the creating commit, after the creation hook and the `agent` change. */
type ConversationInit = (tx: Tx, conversationId: ConversationId) => void | Promise<void>;

type ConversationCreateOptions = {
  readonly ownership: ConversationOwnership;
  /** Applied in the creating commit after the creation hook's copy, before `init`. */
  readonly agent?: AgentChange;
  readonly init?: ConversationInit;
};

type AnyTask = {
  readonly definition: {
    readonly name: string;
    readonly version: number;
    readonly initial: unknown;
    readonly phases: Readonly<Record<string, unknown>>;
    readonly abort: unknown;
    readonly migrate?: unknown;
    readonly hooks?: object;
  };
};

type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
  readonly models: Models; // the pi-ai Models interface
  readonly registry: RegistryReader<Tool>; // section 7.1
  readonly settings?: HarnessSettings;
  /** Builds a conversation's environment. Never called on the Session line; may be async. */
  readonly env?: (
    target: { readonly conversationId: ConversationId; readonly cwd?: string; readonly read: DocumentReader },
    context: Context,
  ) => ExecutionEnv | undefined | Promise<ExecutionEnv | undefined>;
  /** Runs in every commit that creates or forks a conversation, after the built-in creation hook (below). */
  readonly conversationCreated?: (tx: Tx, conversation: ConversationRecord) => void | Promise<void>;
  /** Wall clock for sleeps, retries, message timestamps, and task lifecycle times. Default `Date.now`. */
  readonly now?: () => number;
  /** Failures that do not fail the calling operation, and the Session's failure, once (section 10.1). */
  readonly onReport?: (error: unknown) => void;
};

/** Curated pi-ai request options; absent fields use pi-ai defaults. */
type ConversationStreamOptions = {
  transport?: Transport;
  timeoutMs?: number;
  /** Provider/SDK retries inside one request attempt. */
  maxRetries?: number;
  maxRetryDelayMs?: number;
  headers?: Record<string, string>;
  metadata?: JsonObject;
  cacheRetention?: CacheRetention;
  deferred?: boolean | { window?: "15m" | "1h" | "24h" };
};

/** Durable generation attempt retries; the JSON shape of pi-ai `RetryPolicy`. */
type ConversationRetryPolicy = {
  enabled: boolean;
  maxRetries: number;
  baseDelayMs: number;
  maxAgentDelayMs?: number;
};

/** Automatic compaction thresholds (section 8.7); manual compaction ignores `enabled`. */
type CompactionPolicy = {
  /** Threshold and overflow compaction. */
  enabled: boolean;
  /** Room kept free for the answer: generation blocks to compact above `contextWindow - reserveTokens`. */
  reserveTokens: number;
  /** Approximate size of the recent context a summary keeps verbatim. */
  keepRecentTokens: number;
  /** Background compaction starts `backgroundTokens` below the blocking threshold; `0` disables it. */
  backgroundTokens: number;
};

type ToolExecutionMode = "parallel" | "sequential";

/** How many queued items of one mode a boundary selects (section 6). */
type QueueMode = "all" | "one-at-a-time";

/** Read at every resolution and never copied; getters are fine. Synchronous: some readers run on the Session line. */
type HarnessSettings = {
  /** Default extension selection; absent: every installed extension, in install order. */
  readonly extensions?: readonly Extension[];
  readonly stream?: ConversationStreamOptions;
  readonly retry?: Partial<ConversationRetryPolicy>;
  readonly compaction?: Partial<CompactionPolicy>;
  readonly toolExecution?: ToolExecutionMode;
  readonly steeringMode?: QueueMode;
  readonly followUpMode?: QueueMode;
  /** How long an idle conversation keeps its last context read in memory; busy ones always keep it. */
  readonly contextRetentionMs?: number;
  /** How long a loaded document no watch or document state observes stays in memory after its last use (section 4). */
  readonly documentRetentionMs?: number;
};

/** Resolved: every field over its built-in default, object fields merged. */
type Settings = {
  readonly extensions?: readonly Extension[]; // default: every installed extension, in install order
  readonly stream: ConversationStreamOptions; // default {}
  readonly retry: ConversationRetryPolicy; // { enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60000 }
  readonly compaction: CompactionPolicy; // { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000, backgroundTokens: 32768 }
  readonly toolExecution: ToolExecutionMode; // "parallel"
  readonly steeringMode: QueueMode; // "one-at-a-time"
  readonly followUpMode: QueueMode; // "one-at-a-time"
  readonly contextRetentionMs: number; // 600000
  readonly documentRetentionMs: number; // 600000
};

/** Stored choices of one conversation; names, not objects. Unset fields follow the host. */
type AgentState = {
  model?: ModelRef;
  thinkingLevel?: ModelThinkingLevel;
  /** An array selects exactly these extensions, in order. An object edits the host default selection. */
  extensions?: string[] | { add?: string[]; remove?: string[] };
  /** Filters the selected extensions' tools: the enabled tools. An array enables exactly these, in order. */
  tools?: string[] | { remove: string[] };
  /**
   * Filters the enabled tools the model may call: the tools it is offered. Unset: all but deferred ones. An array:
   * exactly these, in order, deferred ones too. An object: all but deferred ones and those in `remove`, in registry
   * order, followed by the deferred tools in `add` that are not in `remove`, in `add` order.
   */
  modelTools?: string[] | { remove?: string[]; add?: string[] };
  /** Rendered after every extension section, as the section `instructions`. */
  instructions?: string;
  /** Directory within the environment's file system, passed to `HarnessOptions.env`. */
  cwd?: string;
};

/** Built-in rewindable agent document; see below. */
declare const AgentDoc: RewindableConversationDocToken<AgentState>;

/** Objects are handles for their names. */
type AgentChange = {
  readonly model?: ModelRef | null;
  readonly thinkingLevel?: ModelThinkingLevel | null;
  readonly extensions?:
    | readonly Extension[]
    | { readonly add?: readonly Extension[]; readonly remove?: readonly Extension[] }
    | null;
  readonly tools?: readonly ToolRegistration[] | { readonly remove: readonly ToolRegistration[] } | null;
  readonly modelTools?:
    | readonly ToolRegistration[]
    | { readonly remove?: readonly ToolRegistration[]; readonly add?: readonly ToolRegistration[] }
    | null;
  readonly instructions?: string | null;
  readonly cwd?: string | null;
};

/** One change in any commit. A given field replaces the stored one; `null` clears it; `undefined` changes nothing. */
function configure(tx: Tx, conversationId: ConversationId, change: AgentChange): Promise<void>;

/** A conversation's agent resolved against a registry snapshot and the settings (section 7.1). */
type Agent<Tool extends ToolRegistration = ToolRegistration> = {
  readonly model?: ModelRef;
  readonly thinkingLevel: ModelThinkingLevel;
  readonly extensions: readonly Extension<Tool>[];
  /** Offered to the model: enabled, `callers` includes `model`, and selected by `modelTools`. */
  readonly tools: readonly Tool[];
  /** Nested calls resolve among these: enabled, and `callers` includes `tools`. */
  readonly callable: readonly Tool[];
  /** Enabled deferred tools the model may call and is not offered yet, in registry order. */
  readonly deferred: readonly Tool[];
  /** Extension sections, then `instructions` when set. */
  readonly sections: readonly PromptSection<Tool>[];
  readonly instructions?: string;
  readonly cwd?: string;
};

/** Entry whose `data` has type `D`; `never` means the kind carries no data. */
type TypedEntry<D extends JsonValue> = Omit<EntryRecord, "data"> &
  ([D] extends [never] ? { readonly data?: never } : { readonly data: D });

type TypedEntryDraft<D extends JsonValue> = Omit<EntryDraft, "kind" | "data"> &
  ([D] extends [never] ? { readonly data?: never } : { readonly data: D });

interface Entry<D extends JsonValue = never> {
  readonly kind: string;
  is(entry: EntryRecord | undefined): entry is TypedEntry<D>;
}

function defineEntry<D extends JsonValue = never>(kind: string): Entry<D>;

type ContextView = {
  readonly head: EntryRecord | undefined;
  readonly entries: readonly EntryRecord[];
  /** Per entry of `entries`, its model messages after edits and rule 9, before rules 7 and 8. */
  readonly contributions: readonly (readonly Message[])[];
  readonly messages: readonly Message[];
};

type SettledSubmissionRecord = SubmissionRecord & {
  readonly status: "done" | "unanswered";
};

interface Submission {
  readonly id: SubmissionId;
  status(context: Context): Promise<SubmissionRecord>;
  wait(context: Context): Promise<SettledSubmissionRecord>;
  abort(context: Context): Promise<"aborted" | "already_placed" | "settled">;
}

type SettledTask<R> = TaskRecord<JsonValue, JsonValue, R> & {
  readonly state: Extract<TaskState<JsonValue, R>, { status: "terminal" }>;
};

type TaskInspection = {
  readonly record: TaskRecord<JsonValue, JsonValue, JsonValue>;
  readonly state:
    | { readonly kind: "running" }
    | { readonly kind: "ready"; readonly migrates: boolean }
    | { readonly kind: "waiting"; readonly on: readonly TaskId[] }
    /** Outcome held until its ordinary owned work drains (section 5.5). */
    | { readonly kind: "completing" }
    | {
        readonly kind: "blocked";
        readonly reason: "missing_task" | "task_too_old" | "migration_failed";
        readonly error?: unknown;
      };
};

type HarnessInspection = {
  readonly scheduling: "paused" | "running" | "closing";
  readonly tasks: readonly TaskInspection[];
  /** Queued and placed submissions in ID order. */
  readonly submissions: readonly SubmissionRecord[];
};

type ConversationWatch = WatchHandle<ConversationView>;

type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H>
  ? H
  : never;

interface Conversation {
  readonly id: ConversationId;
  submit(submission: SubmissionDraft, context: Context): Promise<Submission>;
  /** Resolved with the current registry snapshot and settings. */
  agent(context: Context): Promise<Agent>;
  /** `configure()` in its own commit. */
  configure(change: AgentChange, context: Context): Promise<void>;

  commit<T>(
    change: (tx: Tx) => T | Promise<T>,
    context: Context,
  ): Promise<T>;
  /** With `at`, the context as of that visible entry, as `fork(at)` would start with. */
  context(context: Context, options?: { readonly at?: EntryId }): Promise<ContextView>;
  entries(
    query: Omit<EntryQuery, "conversationId">,
    limit: number,
    cursor: Cursor | undefined,
    context: Context,
  ): Promise<Page<EntryRecord, Cursor>>;
  fork(
    at: EntryId,
    options: ConversationCreateOptions,
    context: Context,
  ): Promise<Conversation>;
  compact(instructions: string | undefined, context: Context): Promise<TaskId<CompactionResult>>;
  reset(handoff: string | undefined, context: Context): Promise<void>;
  /** `{ background: true }` also aborts background work under the conversation (section 5.4). */
  abort(context: Context, options?: { readonly background?: boolean }): Promise<void>;
  waitForIdle(context: Context): Promise<void>;
  viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>>;
  watch(context: Context): Promise<ConversationWatch>;
}

interface Harness extends Session {
  resume(): void;

  root(
    context: Context,
    options?: { readonly agent?: AgentChange; readonly init?: ConversationInit },
  ): Promise<Conversation>;
  conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
  createConversation(options: ConversationCreateOptions, context: Context): Promise<Conversation>;

  getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
  inspect(context: Context): Promise<HarnessInspection>;
  submission(id: SubmissionId, context: Context): Promise<Submission | undefined>;
  abortSubmission(
    id: SubmissionId,
    context: Context,
    conversationId?: ConversationId,
  ): Promise<"aborted" | "already_placed" | "settled" | "not_found">;
  abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal">;
  waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
  waitForIdle(context: Context): Promise<void>;
  /** Session total of every conversation's `pi.usage` (section 8.6). */
  usage(context: Context): Promise<UsageState>;
  /** Live tasks as a structural view (section 9.5). */
  taskGraph(context: Context): Promise<AttachedReplicatedState<TaskGraph>>;
  watchTaskGraph(context: Context): Promise<TaskGraphWatch>;
}

declare const Harness: {
  open<Tool extends ToolRegistration>(
    storage: Storage,
    options: HarnessOptions<Tool>,
    context: Context,
  ): Promise<Harness>;
};

Typed documents and the structural conversation watch are the state surfaces; there are no fixed rewindable or sticky accessors, namespace router, semantic view events, or manual Chord view bridge. submit() durably admits either a user input or passive entry write and returns one Submission that tracks its settlement.

Harness.open() binds the Session to one application-owned registry (section 7.1). createRegistry() always holds the built-in task definitions (section 8), and open rejects a registry whose snapshot lacks any of them. Open changes surviving running tasks to pending, keeps waiting tasks waiting, and re-evaluates finalization and failFast for completing and waiting tasks (section 5.5). It never migrates or terminalizes a task because its definition is missing or unmigratable. Such a task stays pending or waiting and is blocked: the scheduler skips it and reconsiders it whenever the registry changes (section 5.4). Any built-in document touched by recovery migrates through its ordinary typed access path. Open does not scan or migrate other documents. No handler dispatches during open. Applications should install their extensions before open so recovered work can resume immediately; an install after open also unblocks it.

The root conversation always has reserved ID ROOT_CONVERSATION_ID (1). root(context, { agent, init }) creates it lazily: when the root is absent, one commit on the Session line creates the ownerless root, runs the creation hook (below), applies agent with configure(), and runs init(tx, rootId). When the root already exists, agent and init are ignored and no write occurs. Reopen finds the same root by its reserved ID. A conversation whose agent has no model produces a durable no_model generation failure.

resume() is idempotent while running and only enables scheduling. It does not repeat open-time reconciliation, and it throws after close. Work that must happen before any task runs, such as installing extensions or seeding, happens before resume(). Calls that ask for progress also enable scheduling, so they never wait on a paused Harness: Conversation.submit(), Conversation.compact(), Conversation.abort(), Submission.wait(), Harness.waitForTask(), Harness.waitForIdle(), and Conversation.waitForIdle(). Recovered work starts with them. A viewer that only reads never enables scheduling: inspect(), getTask(), submission(), Submission.status(), usage(), document reads, every conversation read, state, and watch, and the task graph.

createConversation({ ownership, agent, init }) and fork(at, { ownership, agent, init }) commit atomically: the conversation, its explicitly selected ownership, the creation hook's documents, the agent change, and every write made by init. A first input is an ordinary submit() afterward. A request ID makes a retried submit() to the same conversation exactly-once; a host that must survive a crash between the two calls first finds its conversation again, for example through a key written by init. Host callers must choose ownerless or task ownership; neither the Harness nor a conversation handle infers ownership from call context.

Every Harness commit that creates or forks a conversation runs the built-in creation hook in the same commit, whether through the conveniences or through raw tx.createConversation() and tx.forkConversation(), for example inside a tool commit. It runs inside tx.createConversation() and tx.forkConversation(), before they return, so a configure() later in the same callback overrides its copy. It creates empty pi.live, pi.inbox, and pi.usage, creates pi.provider with a fresh provider-facing UUIDv7, and handles pi.agent:

  • A fork keeps the asOf copy of its parent's pi.agent (section 3.7), whatever its ownership.
  • A new task-owned conversation gets a copy of the stored pi.agent of the owner task's conversation, every field, instructions included. A later change to the owner does not reach the child. Fields the owner leaves unset stay unset and follow the host.
  • A new ownerless conversation gets an empty pi.agent, {}.

HarnessOptions.conversationCreated, if given, then runs in the same commit with the new record, for every creation path, so the host can create the documents its application needs in every conversation; the record's parent and owner tell forks and task-owned conversations apart. Table reads throw ReadAfterWrite there, as in init, and a throw fails the creating commit. The conveniences then apply their agent change and run init. Extensions have no creation hook, so their hooks and sections treat an absent document of their own as its default. A conversation created by a plain Session has no built-in documents.

// In a tool's commit. The child starts as a copy of this conversation's agent: model, extensions, tools, cwd.
const child = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
// A cheaper model, only the read tool, and its own worktree; everything else stays as copied.
await configure(tx, child.id, { model: haiku, tools: [readTool], cwd: worktree });

init runs after the conversation creation, which is a table write, so table reads inside it throw ReadAfterWrite (section 4). Document access remains available.

The built-in agent document is final at version 1:

field value
kind pi.agent
version 1 (no migration)
scope/history/fork conversation, rewindable, asOf
schema AgentState
initial() {}
checkpoint complete base on every change
view mount docs["pi.agent"]

It stores only what someone chose for the conversation: extension and tool names, never code, and no prompt text other than instructions. Code comes from the registry by name when the agent is resolved (section 7.1). configure(tx, id, change) edits it in any commit, including the one that creates or forks the conversation; Conversation.configure() does so in its own commit. Conversation.agent() resolves it with the current registry snapshot and settings, reading an absent document as {}; it never writes.

configure() replaces whole fields: a given field replaces the stored one, null clears it, and undefined leaves it. Extension and tool objects stand for their names, so an old object of an extension that was installed again still selects it.

await root.configure({ tools: { remove: [editTool] } }, context);
await root.configure({ tools: { remove: [bashTool] } }, context); // edit is offered again
await root.configure({ tools: null }, context); // every tool of the selected extensions again

A UI toggle therefore reads the current value and writes the new one. An extensions object { add, remove } always edits the host default selection (section 7.1), never a copied selection, whether that is an array or an { add, remove } object; to edit a copied selection, write an array computed from the resolved agent's extensions.

Nothing checks that a stored extension or tool name is installed. The document records choices; the registry records what this process can execute now. A stored name may therefore be uninstalled, for example during extension reload, after restart before installation, or in a fork opened by a process that lacks an extension. Resolution skips it, the document is never rewritten because of registry movement, and the name takes effect again when it is installed again.

A change neither starts generation nor appends a system entry. Request preparation later compares the desired prompt and tools with transcript history and appends the required positional system baseline or delta (section 7.4).

The built-in provider document is final at version 1:

field value
kind pi.provider
version 1 (no migration)
scope/history/fork conversation, latest, initial
schema { sessionId: string }
initial() { sessionId: uuidv7() }
checkpoint complete base on every change
view mount docs["pi.provider"]

The UUID is a provider-facing conversation identity, not the numeric Durable ConversationId and not an enclosing application's Session ID. Every new, task-owned, raw-created, and forked conversation receives its own UUID in its creating commit; a fork never copies its parent's UUID. Generation requests and compaction summarization pass it to pi-ai as options.sessionId. Reset, compaction, model changes, and reopen do not change it. A legacy conversation without the document creates and persists it on the Session line before its first generation or compaction provider request. Concurrent callers therefore observe one winner. Provider behavior still applies: for example, Codex suppresses cache/session identity when cacheRetention is "none".

A missing tool implementation never fails a request. Request preparation offers only the agent's resolved tools. If the replayed tool state still offers a tool that no longer resolves, the appended system delta lists it in toolsRemoved; when it resolves again, a later delta adds its current declaration. When the model calls a tool that its request did not offer, or whose implementation does not resolve when its tool task runs (section 7.3), an error tool result with an error diagnostic with code tool_unavailable (section 7.3) states that the tool is not available, and the run continues so the model can react (section 8.3).

HarnessOptions.settings holds Harness-wide run policy: the default extension selection, request options, retry, compaction thresholds, tool execution, and queue modes. Nothing copies or stores it. Every reader resolves it anew into Settings, each absent field over its built-in default and object fields merged key by key, and uses that value for one decision (section 7.1 lists the readers). A settings object with getters therefore follows the user's preferences without a Session write. Settings are synchronous because queue modes are read on the Session line. stream is forwarded to generation requests; stream.maxRetries are provider retries inside one request, while retry governs durable generation attempts (section 8.3). Settings changes make no commit and emit no event.

// The user's settings, read live through getters.
class UserSettings implements HarnessSettings {
  readonly manager: SettingsManager;
  constructor(manager: SettingsManager) {
    this.manager = manager;
  }
  get stream() {
    return { timeoutMs: this.manager.get("timeoutMs") };
  }
  get compaction() {
    return { enabled: this.manager.get("autoCompact") };
  }
}
manager.set("autoCompact", false); // no Session write; every conversation follows at its next threshold check

HarnessOptions.env builds a conversation's execution environment from its ID, its agent's cwd, and a DocumentReader for committed documents. The Harness never calls it on the Session line and calls it at each use:

  • The tool task calls it for each execution and passes the result as api.env (section 7.3). A rerun after recovery gets the conversation's environment at that time, including its current cwd. A throw becomes an ordinary error result of the call.
  • Generation prepare calls it once for the section renderers (input.env, section 7.4). A throw is reported, and the sections render with env: undefined.
  • runtime.env(context) calls it for custom tasks and rejects with its error.

Without an env option, or when it returns undefined, there is no environment. The host caches what is expensive, such as one environment per directory. Extensions read the built environment, such as env.cwd, never a descriptor. An application that needs more than a directory per conversation keeps that in its own document and reads it through target.read:

// Absent: the conversation runs locally. Only conversations with this document run in a container.
// Subagents do not copy it: their creator writes it too when they should run in the container.
const ContainerDoc = defineDoc<{ image: string }>({
  kind: "app.container", version: 1, scope: "conversation", history: "latest", fork: "current",
  initial: () => ({ image: "node:22" }),
});
const harness = await Harness.open(storage, {
  models,
  registry,
  env: async ({ conversationId, cwd, read }, context) => {
    const container = await read.snapshot(ContainerDoc, conversationId, context);
    return container !== undefined
      ? containers.env(container.image, cwd ?? "/work", context)
      : localEnv(cwd ?? process.cwd()); // cached NodeExecutionEnv per directory
  },
}, context);

Agent and settings changes take effect when their readers resolve them (section 7.1). Generation preparation resolves the agent once per request and fixes that turn's prompt, offered tools, model, and request options. A change made while the turn runs applies at the next preparation. A tool the model already called is resolved when its tool task runs; when its name is no longer among the agent's tools, because the filter, the selection, or the registry changed, the call produces tool_unavailable (section 7.3). The retry policy is read when generation classifies an attempt's result, because it governs the next attempt (section 8.3).

A Conversation.commit() is a Session commit bound to that conversation. tx.createTask() defaults TaskOptions.conversationId to the bound conversation. Conversation.entries() binds the query to that conversation and paginates its fork-aware stored history; callers cannot substitute another conversation ID. Generic Session-wide document operations remain available directly on Harness because Harness extends Session.

fork() requires a concrete visible parent entry and explicit ownership, then applies section 3.7. compact() admits a manual compaction task in one commit and returns its ID, not its future summary entry. The task is conversation-owned and not background, so Conversation.abort() cancels it and idle waits include it. It does not take run control, so it does not make the conversation busy: the conversation keeps working while it summarizes, and its summary is placed through a write submission, at once when idle, otherwise at the next boundary (section 8.7). reset() durably admits a write submission of a pi.reset entry (section 8.1) with head: "self", carrying the handoff text as a user message when given, and then resolves; while busy, placement follows section 6 and may occur later. Observe its placement through the conversation watch. An idle wait does not guarantee placement of queued passive writes.

abortTask() commits abortRequested, then signals and joins the active run; the scheduler then starts the abort invocation and marks the foreground-owned subtree in its next reconcile commit (section 5.4). Conversation.abort() withdraws queued input submissions, marks non-background tasks selected by ordinary ownership traversal, signals them, and resolves only after that scope is ordinarily idle. Passive writes and background subtrees survive. Conversation idle means no live non-background task selected from that conversation. Harness idle applies the same traversal from every ownerless conversation root. Waiting, deadline-blocked, and completing work is still live and therefore not idle. Cancelling an idle wait aborts only that waiter.

Conversation handles are stateless; compare them by id. Hosts discover conversations through lookups and scans. There is no creation listener; the task graph view (section 9.5), inspect(), and the idle waits report live work.

submit() returns after durable admission, not settlement. An input submission creates a user message with the Harness clock's timestamp at placement, which is admission when it is placed at once; whenBusy defaults to followUp. A write submission uses the ordered passive path in section 6 and never starts generation. Submission.wait() settles an input only after its answer or terminal failure; it settles a write when the entry is placed or the write becomes terminally unplaceable. Cancelling wait() only cancels that wait. It does not withdraw the submission; Submission.abort() is the explicit queued withdrawal operation. Harness.submission() reacquires a submission after reopen; records remain queryable after settlement.

abortTask() durably requests cancellation and returns marked after the mark is committed and any active run invocation has joined, or after a blocked task without live ordinary owned work has been settled as orphaned (section 5.4). The abort invocation starts once the task's ordinary owned work is no longer live; a completing task is only marked, and its final record keeps its held outcome with abortRequested: true (section 5.5). It does not await terminal settlement. waitForTask() observes the terminal receipt. Aborting an already terminal task returns terminal; an unknown ID rejects. Explicit task abort includes a background task. Cancelling a task or idle wait does not abort work.

inspect() returns live work at one point on the Session line, for recovery decisions after open, viewers, and diagnostics. It writes nothing, does not enable scheduling, and runs no task code. Each live task carries its state under the current registry: running with an active invocation; completing for a held outcome; waiting with the live tasks it still waits for, the live part of its on, or, when abort-marked, its live ordinary owned work (section 5.5); ready when the next scheduling pass would reserve it, with migrates when its definition is newer and has migrate; or blocked (section 5.4). A migration shows as failed only after the scheduler tried it, or when the newer definition has no migrate; inspection never runs one to find out. Blocked reasons are derived, never stored. Queued and placed submissions complete the view. Finished tasks, settled submissions, transcripts, and documents use their own APIs.

Conversation.viewState() returns its current structural mount as a disposable read-only Chord state. Conversation.watch() atomically captures that immutable revision and registers for later exact complete-commit frames. Its WatchHandle uses the same serialized asynchronous, bounded-buffer contract as watchDoc() in section 9.2. Neither carries semantic events or owns a second persistence authority.

close() seals mutation admission and task reservation, signals invocations, and ends every document state, view state, and watch at that seal: a commit that settles during close publishes nothing to them, and a state keeps its last value. Outside the Session line it lets already-admitted storage commits settle, joins task, tool, and hook invocations, including code that ignores its signal, then closes Storage. Once close() resolves, no invocation code of this Harness runs and no handler holds Storage, so a new Harness may open the same Storage. Already-running watch callbacks remain caller-owned and may finish independently. Close writes no task outcome. Cancelling a close() call cancels only that wait; shutdown continues, and every later close() awaits the same shutdown.

Handles belong to that open Harness and must be reacquired after reopen. Once close begins, every new Harness, conversation, and submission operation rejects, and resume() throws. Of the operations already queued on the Session line at the seal, commits settle (section 4) and reads complete. Waits, states, and watches reject, except two that need no later commit: Submission.wait() of an already settled submission resolves, and an acquisition of an absent document resolves undefined. An inspect() among them reports scheduling: "closing".

3. Documents

3.1 Definitions

Scope directly determines document ownership and lifetime. Only conversation documents declare history and fork behavior.

type LatestConversationSemantics = {
  readonly scope: "conversation";
  readonly history: "latest";
  readonly fork: "current" | "initial";
};

type RewindableConversationSemantics = {
  readonly scope: "conversation";
  readonly history: "rewindable";
  readonly fork: "asOf" | "current" | "initial";
};

type DocumentSemantics =
  | { readonly scope: "session" }
  | LatestConversationSemantics
  | RewindableConversationSemantics
  | { readonly scope: "task" };

type CheckpointInfo = {
  /** Deltas already stored after the newest base, excluding this change. */
  readonly deltasSinceBase: number;
};

type CommonDocDefinition<T extends JsonObject> = {
  readonly kind: string;
  readonly version: number;
  initial(): T;
  migrate?(value: JsonObject, fromVersion: number): T;
  checkpointWhen?(value: Readonly<T>, ops: readonly Op[], info: CheckpointInfo): boolean;
};

type DocDefinition<T extends JsonObject> =
  CommonDocDefinition<T> & DocumentSemantics;

type DocFamilyDefinition<T extends JsonObject, I extends JsonValue> =
  Omit<CommonDocDefinition<T>, "initial"> & DocumentSemantics & {
    readonly family: true;
    initial(seed: I): T;
  };

declare const docType: unique symbol;
interface DocToken<T extends JsonObject, D extends DocDefinition<T>> {
  readonly definition: D;
  readonly [docType]?: T;
}
interface DocFamilyToken<
  T extends JsonObject,
  I extends JsonValue,
  D extends DocFamilyDefinition<T, I>,
> {
  readonly definition: D;
  readonly [docType]?: T;
}

type SessionDocToken<T extends JsonObject> = DocToken<
  T,
  CommonDocDefinition<T> & { readonly scope: "session" }
>;
type ConversationDocToken<T extends JsonObject> = DocToken<
  T,
  CommonDocDefinition<T> & (LatestConversationSemantics | RewindableConversationSemantics)
>;
type RewindableConversationDocToken<T extends JsonObject> = DocToken<
  T,
  CommonDocDefinition<T> & RewindableConversationSemantics
>;
type TaskDocToken<T extends JsonObject> = DocToken<
  T,
  CommonDocDefinition<T> & { readonly scope: "task" }
>;

type SessionDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocFamilyToken<
  T,
  I,
  DocFamilyDefinition<T, I> & { readonly scope: "session" }
>;
type ConversationDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocFamilyToken<
  T,
  I,
  DocFamilyDefinition<T, I> & (LatestConversationSemantics | RewindableConversationSemantics)
>;
type RewindableConversationDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocFamilyToken<
  T,
  I,
  DocFamilyDefinition<T, I> & RewindableConversationSemantics
>;
type TaskDocFamilyToken<T extends JsonObject, I extends JsonValue> = DocFamilyToken<
  T,
  I,
  DocFamilyDefinition<T, I> & { readonly scope: "task" }
>;

function defineDoc<T extends JsonObject>(
  definition: CommonDocDefinition<T> & { readonly scope: "session" },
): SessionDocToken<T>;
function defineDoc<T extends JsonObject>(
  definition: CommonDocDefinition<T> & LatestConversationSemantics,
): ConversationDocToken<T>;
function defineDoc<T extends JsonObject>(
  definition: CommonDocDefinition<T> & RewindableConversationSemantics,
): RewindableConversationDocToken<T>;
function defineDoc<T extends JsonObject>(
  definition: CommonDocDefinition<T> & { readonly scope: "task" },
): TaskDocToken<T>;

function defineDocFamily<T extends JsonObject, I extends JsonValue>(
  definition: Omit<CommonDocDefinition<T>, "initial"> & {
    readonly family: true;
    readonly scope: "session";
    initial(seed: I): T;
  },
): SessionDocFamilyToken<T, I>;
function defineDocFamily<T extends JsonObject, I extends JsonValue>(
  definition: Omit<CommonDocDefinition<T>, "initial"> & LatestConversationSemantics & {
    readonly family: true;
    initial(seed: I): T;
  },
): ConversationDocFamilyToken<T, I>;
function defineDocFamily<T extends JsonObject, I extends JsonValue>(
  definition: Omit<CommonDocDefinition<T>, "initial"> & RewindableConversationSemantics & {
    readonly family: true;
    initial(seed: I): T;
  },
): RewindableConversationDocFamilyToken<T, I>;
function defineDocFamily<T extends JsonObject, I extends JsonValue>(
  definition: Omit<CommonDocDefinition<T>, "initial"> & {
    readonly family: true;
    readonly scope: "task";
    initial(seed: I): T;
  },
): TaskDocFamilyToken<T, I>;

Validation rules:

  • Versions are positive integers.
  • Typed access rejects when the token's scope or conversation history/fork policy disagrees with the persisted incarnation. A migration cannot reinterpret those lifetime semantics.
  • Session documents are current-only and belong to the Session. Closing and reopening the Session does not retire them.
  • Conversation documents declare history and fork; fork: "asOf" requires history: "rewindable".
  • Task documents are current-only, are never copied by a conversation fork, and retire atomically when their task becomes terminal.
  • initial() and migrate() return JSON objects.

checkpointWhen() only selects complete storage bases to bound replay. It does not change scope, lifetime, history, or fork semantics.

Concrete built-in document grouping and semantics are declared when the built-in definitions are implemented. The generic document mechanism does not special case model, tool, inbox, or presentation state.

3.2 Records and lifetimes

A persisted document instance has one DocumentRecord:

type DocumentRecord = {
  readonly id: DocumentId;      // unique incarnation
  readonly kind: string;        // stable definition kind
  readonly key?: string;        // families only
  readonly createdAt: Seq;      // stamped by the committing storage
  readonly retiredAt?: Seq;
} & (
  | { readonly scope: { readonly kind: "session" } }
  | ({ readonly scope: { readonly kind: "conversation"; readonly conversationId: ConversationId } } & (
      | { readonly history: "latest"; readonly fork: "current" | "initial" }
      | {
          readonly history: "rewindable";
          readonly fork: "asOf" | "current" | "initial";
        }
    ))
  | { readonly scope: { readonly kind: "task"; readonly taskId: TaskId } }
);

type DocumentCreate = DocumentRecord extends infer Record
  ? Record extends DocumentRecord
    ? Omit<Record, "createdAt" | "retiredAt">
    : never
  : never;

id is never reused. Retiring and recreating the same logical kind, scope, and family key creates a new incarnation. Membership is the half-open interval createdAt <= at < retiredAt; an unretired incarnation has no upper bound. A creation retired in the same commit has an empty lifetime.

A singleton is identified logically by kind and scope. A family is identified logically by kind, scope, and key. The record preserves scope and conversation history/fork semantics so unavailable extension code does not make existing data disappear. Definition versions belong to stored bases and deltas because one incarnation may contain records written by multiple definition versions. DocumentCreate is not another persisted record; it is the same scoped union without storage-assigned lifetime fields.

3.3 Access and creation

There is no mutable session.document() API.

type SessionEnd = { readonly reason: "closed" } | { readonly reason: "failed"; readonly error: unknown };

interface Session extends DocumentObserver {
  commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
  close(context: Context): Promise<void>;
  /** Settles once closed, Storage included: `{ reason: "closed" }`, or `{ reason: "failed", error }` (section 10.1). */
  readonly closed: Promise<SessionEnd>;
  subscribeCommits(listener: (publication: CommitPublication, context: Context) => void): () => void;
  subscribeClose(listener: () => void): () => void;

  snapshot<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<Readonly<T> | undefined>;
  snapshot<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<Readonly<T> | undefined>;
  snapshot<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<Readonly<T> | undefined>;
  snapshot<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<Readonly<T> | undefined>;
  snapshot<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<Readonly<T> | undefined>;
  snapshot<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<Readonly<T> | undefined>;

  snapshotAsOf<T extends JsonObject>(token: RewindableConversationDocToken<T>, conversationId: ConversationId, at: EntryId, context: Context): Promise<Readonly<T> | undefined>;
  snapshotAsOf<T extends JsonObject, I extends JsonValue>(token: RewindableConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, at: EntryId, context: Context): Promise<Readonly<T> | undefined>;

  documentState<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<DocumentState<T> | undefined>;
  documentState<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<DocumentState<T> | undefined>;
  documentState<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<DocumentState<T> | undefined>;
  documentState<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<DocumentState<T> | undefined>;
  documentState<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<DocumentState<T> | undefined>;
  documentState<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<DocumentState<T> | undefined>;
}

interface Tx {
  conversation(id: ConversationId): Promise<ConversationRecord | undefined>;
  entry(id: EntryId): Promise<EntryRecord | undefined>;
  /** Undefined when the entry is absent or has another kind. */
  entry<D extends JsonValue>(token: Entry<D>, id: EntryId): Promise<TypedEntry<D> | undefined>;
  task(id: TaskId): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
  scanConversations(query: ConversationQuery, limit: number, cursor?: Cursor): Promise<Page<ConversationRecord, Cursor>>;
  scanEntries(query: EntryQuery, limit: number, cursor?: Cursor): Promise<Page<EntryRecord, Cursor>>;
  scanTasks(query: TaskQuery, limit: number, cursor?: Cursor): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
  submissionByRequest(conversationId: ConversationId, requestId: string): Promise<SubmissionRecord | undefined>;

  createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord>;
  forkConversation(
    parentConversationId: ConversationId,
    at: EntryId,
    options: { readonly ownership: ConversationOwnership },
  ): Promise<ConversationRecord>;
  appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
  /** The token supplies `kind` and types `data`. */
  appendEntry<D extends JsonValue>(
    token: Entry<D>, conversationId: ConversationId, value: TypedEntryDraft<D>,
  ): Promise<TypedEntry<D>>;
  createTask<I, S extends { phase: string }, R, H extends object>(
    task: Task<I, S, R, H>, input: I, options: TaskOptions,
  ): Promise<TaskId<R>>;
  /** Raw submission record; no admission rules (busy check, inbox, placement). Hosts use `Conversation.submit()`. */
  createSubmission(create: SubmissionCreate): Promise<SubmissionRecord>;
  /** Settle a queued or placed submission; only a placed input can be answered. A settled one stays unchanged. */
  settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void;
  /** Newest visible entry of the conversation that carries a `head`. */
  latestHeadMarker(conversationId: ConversationId): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>;
  /** Record a queued submission's placement at `entry`: an input becomes `placed`, a write `done` (section 6). The caller appends the entry and edits `pi.inbox` and `pi.live`. */
  placeSubmission(id: SubmissionId, entry: EntryId): void;

  doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
  doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
  doc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<Draft<T>>;
  doc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, seed: I): Promise<Draft<T>>;
  doc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, seed: I): Promise<Draft<T>>;
  doc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, seed: I): Promise<Draft<T>>;

  retireDoc<T extends JsonObject>(token: SessionDocToken<T>): Promise<void>;
  retireDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<void>;
  retireDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId): Promise<void>;
  retireDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string): Promise<void>;
  retireDoc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string): Promise<void>;
  retireDoc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string): Promise<void>;
}

ID-creating transaction methods are asynchronous because remote storage may allocate globally unique numeric IDs durably. Conversation creation always requires explicit ownership; no transaction wrapper injects the executing task. For task ownership, the caller supplies only a typed task ID. The Session derives the persisted owner conversation from the task's final candidate record.

An owner task may be committed or staged earlier in the same transaction. Before Storage admission, the Session rejects a missing, completing, terminal, or abort-marked owner of a new task or conversation, judged on the owner's final candidate record in that transaction. A task therefore cannot create owned work in the commit that finishes it. A child task whose explicit conversationId differs from its owner's conversation, and a child task with background: true, reject the same way. Existing owner edges remain valid when their owners finish afterward. Conversation creation returns an inert record, never an operational handle. forkConversation() additionally validates one visible entry and applies section 3.7. Only public typed tx.doc() is get-or-create. Internal fork copying may create new incarnations directly from stored values without a definition. tx.doc() receives the definition token that supplies its static type, initializer, migration, and checkpoint policy. Scope-preserving token overloads require callers to supply only the concrete conversation/task ID and, for a family, its key and creation seed. Ordinary definitions are not registered and ordinary documents are not scanned at open.

  • Existing instances are reconstructed and migrated lazily according to section 3.6. A migration reached through tx.doc() is staged in its enclosing transaction.
  • A missing singleton calls the token's initial(). A missing family member calls initial(seed). Creation stores an initial base.
  • The first acquisition of one logical address is memoized before awaiting. Later acquisitions in that transaction return the same draft; for a missing family, the first call's detached seed wins and later seeds are ignored.
  • snapshot(), snapshotAsOf(), documentState(), and watchDoc() never create or persist migration. They return undefined when the requested incarnation is absent and migrate a reconstructed value only in memory.
  • Task-scoped tx.doc() validates against the transaction's latest candidate task record, falling back to committed state. This internal validation is not a caller table read and does not trigger ReadAfterWrite. Its conversation is derived from the task record.
  • retireDoc() resolves the logical address without creating it. Retirement of an acquired draft persists its final content before retirement. A later tx.doc() at that address in the same transaction creates a new incarnation with a new draft and ID.
  • A terminal candidate rejects later task-document access. Terminal settlement retires both existing task documents and task documents created earlier in the same transaction.
  • snapshot() returns the current shareable immutable revision. Mutation of it or any retained descendant is unsupported; callers that need mutable ownership must copy it first.
  • documentState() returns a disposable read-only Chord state bound to one committed incarnation.
  • tx.doc() returns one revocable Astra overlay Draft<T> for the transaction.
  • Historical reads never create documents in the past.

snapshotAsOf() is available only for rewindable conversation documents. It validates that at is visible through the requested conversation's ancestry, selects the ancestor conversation that owns that entry, then finds the logical singleton/family incarnation whose creation/retirement interval contains the entry's commit. It never starts from today's incarnation and never creates an instance. It returns undefined when no such instance existed.

After B forks A at entry E, asking for B's state at inherited E reads A's historical instance; B's copied incarnation was created later. If an instance was retired and recreated, historical lookup selects the incarnation alive at the target commit.

3.4 Mutation ownership

Durable uses Chord Delta's immutable transaction shape directly:

interface Prepared<T extends object> {
  readonly base: T;
  readonly value: T;
  readonly ops: readonly Op[];
  readonly baseRevision: number;
  abort(): void;
}
interface Change<T extends object> {
  readonly state: Draft<T>;
  prepare(): Prepared<T>;
  abort(): void;
}
interface Tracker<T extends object> {
  readonly value: T;
  readonly revision: number;
  beginChange(): Change<T>;
  prepareReplace(value: T): Prepared<T>;
  adopt(prepared: Prepared<T>): void;
}

Each loaded document owns one tracker whose value is its current immutable revision. Immutability is a trusted ownership contract, not runtime freezing: a revision and its descendants must never be mutated after transfer to the tracker. prepare() revokes the draft, emits detached self-contained operations, and computes value with the optimized immutable applier before Storage admission. Unchanged subtrees are structurally shared with base. Operation placement payloads may also be shared with value; mutating either a prepared operation or an immutable revision is unsupported. Astra preserves an empty operation batch for changes it proves are no-ops, but Durable performs no additional whole-value equality pass for nonempty structural batches.

abort() is idempotent. adopt() accepts only a prepared result from that tracker at its current revision, then performs only a synchronous pointer swap to the already-computed immutable value. It performs no diffing, application, allocation, or callback. The Session line permits at most one open change per tracker and ensures no result becomes stale between preparation, Storage settlement, and adoption.

The first tx.doc() acquisition calls tracker.beginChange() and memoizes that change's draft for the rest of the possibly async Session callback.

Transaction behavior:

begin transaction
  acquire and memoize document changes by logical address
  mutate revocable Astra overlay drafts
callback settles
  seal Tx; synchronously prepare or abort every open change, revoking every draft
  if any acquisition is pending: abort open changes, reject, then drain and abort it
callback fails with no pending acquisition
  abort every open change; persist and publish nothing
callback succeeds with no pending acquisition
  prepare every open change -> immutable next revision + self-contained Chord Op[]
  Session evaluates each required/ordinary document write exactly once
  Storage.commit persists the atomic batch while the Session line remains held
storage succeeds
  adopt every prepared change by pointer swap and enqueue immutable revision/ops publication
  commit listeners, including conversation view mounts (section 9.3), capture it synchronously
  release the line; invoke listeners later
storage fails
  abort every prepared change, fail the Session (section 10.1), and publish nothing

A pending acquisition that resolves after sealing never exposes a draft; its change is aborted and its promise rejects. The Session observes every such settlement before releasing the line. Initializer, migration, and replacement roots come from caller code: the Session copies each one into exclusive kernel ownership, rejecting any value that is not strict JSON, before it becomes an immutable tracker revision. Loaded and fork-copy roots are already detached strict JSON from Storage and enter the tracker without another copy. Chord's track() and prepareReplace() take ownership in O(1) without traversal, so every root they receive must come from one of these sources. Migration callbacks never receive a live tracker revision.

Preparation, validation, or checkpoint failure occurs before Storage admission and rolls back normally. The Session performs no strict-JSON walk of prepared operations or selected bases: roots are checked on entry and Chord checks every draft placement, so every revision, operation payload, and base is strict JSON by construction. Tracker branding and baseRevision enforce ownership and staleness; the Session never substitutes caller-created prepared values. The prepared immutable candidate itself becomes the adopted and published value. Storage receives that complete value only when the Session selects a base; otherwise it receives only the prepared operation batch. An existing current-version document with an empty batch writes and publishes nothing. A nonempty structural batch whose final value is deeply equal to its base remains a valid durable change and publication. Creation and required version transitions still write a base when their prepared batch is empty; an equal-value version base does not emit a watch update.

Values assigned into a draft are copied immediately by value. Repeated placements are independent. Chord checks each placement while copying it and throws at the offending assignment, before the draft changes, when the value is not strict JSON: undefined array elements or nested object values, non-finite numbers, functions, symbols, bigints, accessors, symbol keys, sparse arrays, or objects whose prototype is neither Object.prototype nor null. Assigning undefined directly to an object property deletes that property. Draft reads, draft writes, and all Tx operations reject after the callback settles. The prepared immutable value remains readable by Session-owned checkpoint and Storage preparation.

let escaped: Draft<LiveState> | undefined;
await session.commit(async tx => {
  escaped = await tx.doc(LiveDoc, conversationId);
}, context);
escaped!.generation = undefined; // throws: the draft was revoked

Fire-and-forget work that mutates a draft before the owner callback settles may silently enter that transaction and is unsupported.

3.5 Bases and checkpoints

Creation always stores a complete base.

For an ordinary later mutation, the definition alone decides whether the storage record is a base:

const useBase = definition.checkpointWhen?.(candidateValue, ops, { deltasSinceBase }) ?? false;

The Session evaluates this predicate exactly once after tracker preparation. deltasSinceBase counts the deltas already stored after the incarnation's newest base, excluding the change being evaluated. Storage reports it when it materializes the current value; the Session advances it after each adopted write and resets it after every base, so evaluation performs no Storage read. Creation and version transitions require bases and do not call it. The Session then gives Storage only the selected representation:

required or predicate true -> base with complete value
otherwise                  -> delta with the prepared Chord operation batch

A Chord root-replacement operation remains a delta unless the definition selected a checkpoint; it does not authorize reclamation. Predicate failure aborts the prepared transaction before Storage admission. Storage executes no definition code and never receives an unused complete candidate with a selected delta.

  • For Session, task, and latest conversation documents, a committed base permits physical reclamation of older records.
  • For rewindable conversation documents, bases bound replay but never permit removal of addressable history.
  • A definition that never checkpoints may create an unbounded replay tail. That is a definition bug, not a backend heuristic.
  • Storage does not count encoded bytes, compare against initial(), or invent checkpoints.

A definition can bound replay directly:

checkpointWhen: (_value, _ops, info) => info.deltasSinceBase >= 31

A high-churn live document can checkpoint when it becomes empty:

checkpointWhen: (value, _ops) =>
  value.generation === undefined &&
  value.tools === undefined

3.6 Versions and migrations

One migration callback handles every supported older version.

stored == token -> use value
stored < token  -> call migrate(value, storedVersion)
stored > token  -> reject typed access
no migrate      -> reject older stored version

migrate() is pure and returns a complete current-version value. Migration is access-driven: tx.doc(), snapshot(), snapshotAsOf(), documentState(), and watchDoc() reconstruct and migrate through the token supplied to that call. Harness open does not sweep ordinary documents.

  • Read-only access migrates only in memory and never writes. It may cache a tracker over that migrated immutable revision together with the older stored version marker; the next successful tx.doc() access still writes the required current-version base. A cached tracker serves only tokens of the version its value was materialized for. Access with any other version, such as a token from reloaded extension code, drops it and reloads the stored value, so each token migrates from the stored version or rejects a newer one. Migration always starts from a detached stored value. tx.doc() stages migration in its enclosing transaction; callback failure persists nothing, and later draft edits coalesce into one final required base.
  • Rewindable history is not rewritten. Current and historical reconstructed values are migrated after replay.
  • The first tx.doc() transaction after any stored-version migration writes a required current-version base, even when the migrated JSON is deeply equal.
  • A fork obtains the selected stored value/version from Storage rather than a typed migrated tracker cache. The child copies that stored pair and migrates on later typed access.
  • Unaccessed documents and documents with unavailable definitions preserve their stored instances, versions, and bytes.

3.7 Forks

A conversation fork points to one concrete visible entry E.

The child transcript includes entries through E, even if the same commit also appended later entries. Document state at E is the final state of the commit containing E. Different document states require separate commits.

Each conversation document follows the history/fork policy persisted in its DocumentRecord:

conversation setting child value
fork: "asOf" parent value at E's commit
fork: "current" committed parent value selected when the fork commit runs
fork: "initial" no copied instance; initializer on first child access

current and asOf copy logically present conversation singleton and family instances, preserving unknown definitions and their stored versions. Copied values become independent child instances with new IDs and initial bases. initial copies no instance; first access in the child creates it from the supplied definition. Task documents and tasks are never copied. Session documents remain shared and are not rewindable.

Fork copying reads committed stored values rather than typed tracker caches. A transaction that creates a fork therefore rejects if it also writes one of the parent's fork: "current" documents; commit the parent change first so the fork has one unambiguous stored source revision.

Forks stage backend-side document.copy commands carrying the child create record and an exact source incarnation/point. Storage materializes each source and persists its stored value/version as the child's independent initial base; remote Storage performs this server-side. Every copy reads committed pre-batch source state independent of write-array order. A selected source may not be created, changed, or retired in the same batch. Copying remains atomic with the conversation, overrides, registry writes, and other mutations.

An unaccessed copy retains only its descriptor in Session memory. Typed access inside the creating transaction lazily reads the detached source, migrates when required, and replaces the copy with one ordinary child create containing the final prepared value. Definition-free copies publish explicit document.copy metadata rather than a value. That metadata announces Storage-backed initial state and is never interpreted as a document value. Document states, watches, and mounted views hydrate by capturing their baseline and subscription atomically on the Session line: a later commit already present becomes the baseline, while one committed after registration is delivered. Publications and watches are convergence mechanisms, not audit streams. A mounted aggregate must acquire all of its document baselines and commit subscription in one Session-line operation so it never exposes a mixture from one commit.

4. Transactions and storage ownership

The Session keeps the documents it loads or creates in memory, as Chord trackers, so reads and changes need no Storage access. The cache is bounded by use, not by history: a document no watch or document state observes leaves it once it was not read or written for documentRetentionMs (default ten minutes; 0 drops it right after use), and the next access loads it from Storage. An observed document never leaves. Eviction runs on the mutation line, after each commit and when an unreferenced timer for the next expiry fires (the oldest idle document's, or sooner once the retention is shortened), so a transaction never loses a tracker it holds. A retention that is not a number of milliseconds, 0 or more, means the default; Infinity keeps documents. An observer pins the incarnation it observes, and releases it when that incarnation retires. Where timers cannot be unreferenced, as in Cloudflare Workers, only commits evict. A document that is used again and again moves to the back of the eviction order at most once per granule, a tenth of the retention up to one second. Task documents also retire with their task (section 5.5). createSession(storage, { documentRetentionMs }) sets it for a plain Session; the Harness reads its settings at each use.

A Session commit callback may be asynchronous. It owns the Session mutation line through callback execution, preparation, storage settlement, committed baseline adoption, and publication enqueue. Commit observers run on the line; document-state and watch user callbacks run later. External model, process, tool, network, and human effects run outside it. subscribeCommits() observes complete immutable publications synchronously on the line after adoption. subscribeClose() observes close synchronously when it begins, after admission is sealed, also when a failure closes the Session; the Harness ends waits, watches, and streams there, and signals task invocations after every close listener has run, so what a task holds ends with the Session's reason rather than as cancelled. Both return idempotent disposers; their listeners must not block or call Session APIs, and one that throws is reported without affecting the others (section 10.1). Document-state subscribers and watch listeners still run later, off the line.

await session.commit(async tx => {
  const conversation = await tx.conversation(conversationId); // table read
  const live = await tx.doc(LiveDoc, conversationId);

  await tx.appendEntry(conversationId, message);     // first table write
  delete live.generation;                            // document mutation remains valid
  await tx.createTask(Follow, {}, { ownership: { kind: "conversation" }, conversationId }); // further table writes are fine
}, context);

Mutation admission occurs on the Session line before a commit callback starts. Closing seals mutation admission and task reservation. Already-admitted commits settle before storage closes. Once a commit is admitted, caller cancellation does not interrupt storage settlement or undo the commit. Cancelling a close wait does not reopen admission.

Table rules:

  • Tables are conversations, entries, tasks, and submissions.
  • Table reads are allowed before the first table write.
  • Any table read after the first table write throws ReadAfterWrite.
  • Document access and read-your-writes remain available after table writes.
  • Creation methods return their created ID/record; callers do not read it back.

Storage ownership:

  • Commit arguments are borrowed until Storage.commit() settles.
  • Anything retained after settlement is detached first.
  • Memory storage recursively copies retained JSON containers.
  • JSONL and SQLite detach through serialization and decoded indexes.
  • Every storage read returns a detached JSON value.
  • Immutable strings may be shared; mutable arrays and objects may not.

5. Tasks

5.1 Definition

type TaskOutcome<R> =
  | { readonly status: "completed"; readonly result: R }
  | { readonly status: "failed"; readonly error: TaskOutcomeError; readonly result?: R }
  | { readonly status: "aborted"; readonly reason?: string; readonly result?: R }
  | { readonly status: "orphaned"; readonly reason: string }
  | { readonly status: "faulted"; readonly error: TaskOutcomeError };

type JoinPolicy = "failFast" | "allSettled";

type TaskState<S, R> =
  | { readonly status: "pending"; readonly checkpoint: S }
  | { readonly status: "running"; readonly checkpoint: S }
  /** Parked without an invocation until every task in `on` is terminal; then resumes at `checkpoint` (section 5.5). */
  | {
      readonly status: "waiting";
      readonly checkpoint: S;
      readonly on: readonly TaskId[];
      readonly policy: JoinPolicy;
    }
  /** Outcome decided; terminal once no ordinary owned work below is live (section 5.5). Runs no more code. */
  | { readonly status: "completing"; readonly outcome: TaskOutcome<R> }
  | { readonly status: "terminal"; readonly outcome: TaskOutcome<R> };

/** Who owns a task: its conversation (a top-level task) or another task of the same conversation. */
type TaskOwnership = { readonly kind: "conversation" } | { readonly kind: "task"; readonly taskId: TaskId };

type TaskRecord<I, S, R> = {
  readonly id: TaskId<R>;
  readonly conversationId: ConversationId;
  readonly kind: string;
  readonly version: number;
  readonly input: I;
  /** Owning task; absent for a task its conversation owns. Immutable. */
  readonly owner?: TaskId;
  readonly background: boolean;
  readonly abortRequested: boolean;
  /** `restart` when the mark abandons the task after a restart, not an abort request (section 5.4). */
  readonly abortReason?: "restart";
  /** From `TaskOptions.abandonOnRestart`. Immutable. */
  readonly abandonOnRestart?: true;
  /** Wall clock at the first change to `running`; kept through waits and recovery. */
  readonly startedAt?: number;
  /** Wall clock at the change to `terminal`. */
  readonly endedAt?: number;
} & (
  | {
      readonly state: Extract<TaskState<S, R>, { status: "pending" | "running" | "waiting" }>;
      readonly memos?: Readonly<Record<string, JsonValue>>;
    }
  | {
      readonly state: Extract<TaskState<S, R>, { status: "completing" | "terminal" }>;
      readonly memos?: never;
    }
);

type RunningTask<I, S, R> = TaskRecord<I, S, R> & {
  readonly state: Extract<TaskState<S, R>, { status: "running" }>;
};

/**
 * State a task commits for itself: a replacement checkpoint, a wait, or its outcome. A returned `terminal` state
 * becomes `completing` while ordinary owned work below is live (section 5.5).
 */
type NextTaskState<S, R> = Extract<TaskState<S, R>, { status: "running" | "waiting" | "terminal" }>;

interface HookRunner<H extends object> {
  each<K extends keyof H>(name: K, invoke: (handler: NonNullable<H[K]>) => void | Promise<void>): Promise<void>;
}

type PhaseHandler<I, P, S, R, H extends object> = (
  task: RunningTask<I, P, R>,
  runtime: TaskRuntime<I, S, R, H>,
  context: Context,
) => Promise<void>;

interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, DocumentReader {
  readonly taskId: TaskId<R>;
  readonly conversationId: ConversationId;
  readonly signal: AbortSignal;
  /** Registry snapshot of the current phase; refreshed at every phase boundary. */
  readonly registry: RegistrySnapshot;
  /** The task's conversation's agent, resolved once for this phase (section 7.1). */
  agent(context: Context): Promise<Agent>;
  /** `HarnessOptions.settings`, resolved at each access. */
  readonly settings: Settings;
  readonly models: Models;
  readonly hooks: HookRunner<H>;
  /** Calls `HarnessOptions.env` for the task's conversation; rejects with its error. */
  env(context: Context): Promise<ExecutionEnv | undefined>;

  commit(
    change: (
      tx: Tx,
      current: RunningTask<I, S, R>,
    ) => NextTaskState<S, R> | undefined | Promise<NextTaskState<S, R> | undefined>,
    context: Context,
  ): Promise<void>;

  memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
  memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
  conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
  /** Committed task record. */
  getTask<T>(id: TaskId<T>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, T> | undefined>;
  /** Terminal receipt; rejects when the invocation ends. */
  waitForTask<T>(id: TaskId<T>, context: Context): Promise<SettledTask<T>>;
  /** `abortTask()` of a task this task owns, resolving once it is terminal; rejects for a task another owns. */
  abortOwned(id: TaskId, context: Context): Promise<void>;
  /** Live tasks this task owns directly, in ID order, whichever invocation created them; not those in its conversations. */
  ownedTasks(context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue>[]>;
  /** Outcomes of terminal tasks, in order; rejects when one is not terminal. Used after a wait (section 5.5). */
  outcomes<T>(ids: readonly TaskId<T>[], context: Context): Promise<TaskOutcome<T>[]>;
  /** Committed entry visible from the task's conversation. */
  entry(id: EntryId, context: Context): Promise<EntryRecord | undefined>;
  entry<D extends JsonValue>(token: Entry<D>, id: EntryId, context: Context): Promise<TypedEntry<D> | undefined>;
  /** Committed raw active transcript and model context, optionally cut off at `at`. */
  context(conversationId: ConversationId, context: Context, options?: { readonly at?: EntryId }): Promise<ContextView>;
  /** The Harness clock. */
  now(): number;
  /** Forward a non-fatal failure to `HarnessOptions.onReport`. */
  report(error: unknown): void;
  sleep(until: number, context: Context): Promise<void>;
}

type TaskDefinition<I, S extends { phase: string }, R, H extends object> = {
  readonly name: string;
  readonly version: number;
  initial(input: I): S;
  readonly phases: {
    [P in S["phase"]]: PhaseHandler<I, Extract<S, { phase: P }>, S, R, H>;
  };
  abort(task: RunningTask<I, S, R>, runtime: TaskRuntime<I, S, R, H>, context: Context): Promise<void>;
  migrate?(input: JsonValue, checkpoint: JsonValue, fromVersion: number): {
    input: I;
    checkpoint: S;
  };
  readonly hooks?: H;
};

interface Task<I, S extends { phase: string }, R, H extends object> {
  readonly definition: TaskDefinition<I, S, R, H>;
}

type TaskOptions = {
  /** Required: a task always names its owner (section 5.5). */
  readonly ownership: TaskOwnership;
  /** Default: the owner task's conversation, or the transaction's bound conversation. */
  readonly conversationId?: ConversationId;
  /** Conversation-owned tasks only: excluded from conversation abort and idle, and from cascades. */
  readonly background?: boolean;
  /** Its creator awaits it only in memory and does not resume after a restart (section 5.4). */
  readonly abandonOnRestart?: boolean;
};

function defineTask<I, S extends { phase: string }, R, H extends object = {}>(
  definition: TaskDefinition<I, S, R, H>,
): Task<I, S, R, H>;

TaskId<R> is the numeric task ID itself with an erased result-type brand. It replaces a separate task-reference wrapper: createTask() returns TaskId<R>, typed waits infer R from that ID, and result-agnostic operations accept TaskId<unknown>. A task's conversationId is immutable after creation; a replacement that moves an existing task to another conversation rejects before Storage admission so persisted conversation-owner edges cannot become stale.

The phase map is exhaustive and phase-narrowed. A handler may perform several commits around one effect, but each durable checkpoint is a full replacement. TaskRuntime.commit() rereads and gates the current durable task on the Session line before invoking its callback. It rejects when the invocation has ended, the Harness is closing, the task is terminal, or a run invocation's task carries an abort mark. Its tx.createTask() defaults to the task's conversation, and every entry it appends records the task as byTaskId. When the callback returns a state, the runtime replaces the task's state in the same commit, so the checkpoint or outcome is atomic with the callback's entries, documents, and child tasks and is type-checked against the task's checkpoint and result types. Returning nothing leaves the state unchanged. A terminal or completing state drops the memos. A waiting state ends the invocation even when its checkpoint is unchanged; it rejects when on names a missing task or closes a wait cycle (section 5.5), which no task on it could finish first, and an empty on resumes at the next scheduling pass. pending is never returned; only reconciliation and handover write it.

Tx has no task replacement operation. A task changes only its own state, through its runtime. The scheduler owns reservation, reconciliation, handover, faults, and orphaning; abortTask() owns abort marks. Other code stops a task with abortTask() and reads its result with waitForTask().

// Intent, effect, outcome.
prepare: async (task, runtime, context) => {
  await runtime.commit(() => ({ status: "running", checkpoint: { phase: "charge", key: newKey() } }), context);
},
charge: async (task, runtime, context) => {
  const receipt = await payments.charge(task.state.checkpoint.key); // idempotent by key
  await runtime.commit(async (tx, current) => {
    const entry = await tx.appendEntry(current.conversationId, receiptEntry(receipt));
    return { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
  }, context);
},
// The abort handler decides the outcome; returning without one faults the task.
abort: async (task, runtime, context) => {
  await payments.cancel(task.state.checkpoint);
  await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted", reason: "user" } }), context);
},

memo(name, candidate) is one gated commit; memo(name) reads the committed record. sleep(until) compares against the Harness now clock and rejects when the invocation is signalled or its context is cancelled. Watches acquired through the runtime stop when the invocation ends. snapshot()/snapshotAsOf() read committed documents, for example to supply PromptInput.read (section 7.4). getTask() and entry() read committed records with one lookup each; waitForTask() waits for a terminal receipt, for example a child task created by a tool. context() captures its bounds on the Session line and derives the view from immutable entries off the line, like Conversation.context(). The scheduler keeps each conversation's last range and view in memory: a later read by any of its tasks with the same head marker scans and derives only the entries after that range's tail. A conversation keeps them while busy and for contextRetentionMs once idle. Expiry is checked on task changes and by one unreferenced timer, which never keeps the process alive; where timers cannot be unreferenced, as in Cloudflare Workers, only task changes check it. Kept entries are frozen and each read returns its own view arrays. Like every runtime operation, these reject after the invocation ends.

Reservation durably changes pending, or waiting once it may resume, to running. One invocation runs phase handlers in sequence; checkpoint commits retain running. Before every phase, including the first, the scheduler runs one step callback on the Session line. It reads the committed task and synchronously applies the first matching rule below to the phase that just returned. It writes any fault or handover in the same commit, together with the Harness cleanup for a scheduler-written outcome (section 5.4), which may await document access inside that callback. It ends the invocation there when a rule stops it. A runtime commit the invocation queued earlier therefore either lands before the step and counts, or reaches the line after it and rejects. Before the first phase only rules 1–3 apply:

  1. Terminal, completing, or waiting: stop.
  2. Session closing: stop; preserve the checkpoint and any abort mark for reopen.
  3. Run mode with a durable abort mark: end and join the run invocation; a fresh abort invocation starts once the task's ordinary owned work is no longer live (section 5.5).
  4. Uncaught error: write terminal faulted.
  5. Checkpoint changed, including progress within the same phase: refresh the registry snapshot and either hand over (section 5.4) or invoke the phase handler in the same task invocation.
  6. Checkpoint unchanged: write terminal faulted because no durable progress was made.

An abort invocation runs its abort handler once. After it settles, a step applies rules 1, 2, and 4; a handler that returns without a terminal outcome faults the task. When Storage rejects a step's fault or handover write, the invocation still ends; the task stays running and the next reservation runs it again.

On open, running-task reconciliation changes surviving running tasks back to pending, preserving their checkpoint and abort mark; waiting tasks stay waiting, and completing and waiting tasks are re-evaluated (section 5.5). Task migration runs at reservation, atomically with pending or waiting -> running. One callback handles every supported older version. A task whose definition is missing, older than the stored version, or fails migration stays pending or waiting and blocked until a fitting definition is installed or the task is aborted (section 5.4). close() marks the runtime closing, seals admission and reservation, signals invocations, and ends states and watches. Outside the Session line it settles admitted commits and joins invocations before closing storage. Already-running watch callbacks remain caller-owned. Later runtime commits reject, and close writes no task outcome. Closing starts no fresh phase or abort invocation. It does not set abort marks, terminalize tasks, retire task documents, or publish document retirement. The hosting layer stops exposing Harness state to clients before it closes the Harness, for example by closing the Chord host whose objects serve it; reconnecting to a reopened Session hydrates the last committed state and resumes recovery from its durable checkpoints.

5.2 Effect sandwich

commit intent phase
perform external effect
commit outcome or next phase

Reopening in an intent phase means the effect may have happened. The phase handler retries safely, polls an external handle, or records interruption. Deferred providers are represented by a durable phase containing their handle and next poll time.

Runtime-owned memos are small first-writer-wins values stored in the live task envelope. Candidate insertion and reading the winner are one Session commit, so concurrent candidates return the same durable winner. Memos survive checkpoints and disappear in the terminal replacement. Bulk progress belongs in a document.

5.3 Terminal tasks

The terminal task record is the durable result receipt. Its result may directly contain a small value or reference an entry:

{ status: "completed", result: { entryId: toolResultId } }

A terminal transition atomically:

  1. Writes the terminal task record.
  2. Appends any result entries.
  3. Retires all documents scoped to that task.
  4. Resolves any submissions settled by the task.

A task whose ordinary owned work is live splits this at a completing hold (section 5.5, rule 4): a task-written outcome's own writes land at hold, and items 1 and 3 and its waiters follow in the final commit.

The execution checkpoint and memos disappear from the terminal representation. Terminal records remain queryable for waits, waiters, inspection, and reopen.

The Session stamps lifecycle times on task records with the clock it was opened with (HarnessOptions.now for a Harness, Date.now by default): startedAt at the first change to running, endedAt at the change to terminal. Once set, each carries over from the replaced record, so startedAt survives waits, a completing hold, and reopen, and the span between them includes those. A task that never ran, such as one orphaned before its first reservation, has only endedAt; records written by earlier versions have neither, and a task live across the upgrade gets startedAt at its next run. inspect() shows them on each live task's record. They are lifecycle times, not execution time: a tool's execution time is its result's durationMs (section 7.3). There are no free-standing task dependencies: ordering comes from a task waiting on other tasks (section 5.5). An abort mark lets pending work reach its abort handler, or its orphaned settlement when its definition is unavailable.

5.4 Scheduler, abort, and ownership

The scheduler serially reserves eligible tasks, then runs handlers off the Session line. One in-memory TaskInvocation contains mode, abort controller, and completion promise.

Abort protocol:

commit abortRequested
signal and join active run invocation
wait until ordinary owned work is no longer live (section 5.5)
start a fresh abort invocation
abort handler commits terminal outcome

A run invocation may not commit after its durable abort mark appears. Every runtime operation rejects after its owning invocation ends, even while the Session remains open, and the invocation's signal and handler context abort when it ends for any reason. An invocation ends only after its last handler returned, so this cancels only detached leftovers, such as an unawaited waitForTask() or a fetch started with the handler's context; they could no longer write anything. Returning from one phase handler does not end an invocation that continues into another phase. Invocation mode is volatile and derived from the durable mark on reopen. Cancelling one caller's Context only cancels that call or wait; it does not durably abort shared work unless the invoked API commits an abort mark.

A task may create owned conversations and owned tasks (section 5.5). Conversations are durable scopes; tasks are the units of live work counted by idle and marked by abort. History parents are irrelevant to ownership traversal.

Ordinary traversal starts at an explicitly addressed conversation, visits its conversation-owned tasks, follows the tasks each non-background task owns, and follows the conversations each non-background task owns. It follows owner edges after the owner becomes terminal, but a background task is a boundary: ordinary traversal skips that task and its complete owned subtree. Direct conversation operations start inside that conversation regardless of its owner. Directly aborting a live background task includes that task and follows its ordinary owned subtree; nested background owners remain boundaries. close() stops every invocation, foreground and background, without writing an outcome, so the work resumes on reopen. A host that must durably cancel everything aborts each live task that inspect() lists.

Conversation.abort() withdraws queued inputs and marks live non-background tasks selected by ordinary traversal. With { background: true } it crosses background boundaries: it marks every live task that traversal ignoring the background flag reaches when the operation is admitted and withdraws the queued inputs of every conversation it reaches. It then waits until every task it marked is terminal and the conversation is ordinarily idle; background work created afterwards is neither marked nor awaited. Conversation.waitForIdle() waits until that traversal contains no live non-background task. Harness idle performs the same traversal from every ownerless conversation root rather than globally counting tasks, so ordinary work below a background owner does not block it. Explicit waitForTask() waits for its referenced task regardless of the task's background flag.

An abort mark cascades idempotently to foreground-owned work. The durable cancellation intent is a live owner's own record: its abort mark, or a held completing outcome other than completed (section 5.5). A terminal owner never cascades: it became terminal only after its ordinary owned work drained, so its intent is already applied, and work later started in a conversation it owned, for example a user interrogating a finished subagent, runs normally and is reached by ordinary traversal from above. Queued inputs in its owned conversations stay queued, as after a failed run (section 6). The scheduler derives the marks in a later reconcile commit, and again at open, so a crash in between loses nothing; work already queued on the Session line may make one more commit first. Every live non-background task whose owner chain, walking up through owning tasks and owning conversations, reaches a cancelled live owner, without first crossing a background task that has no cancellation intent of its own, gets an abort mark, including work created there after the cascade. Deriving the marks in their own commit keeps them out of the owner's commit, which may already have written the tasks involved. Every conversation the cascade reaches is treated like Conversation.abort(): its queued input submissions become unanswered with reason aborted and leave its inbox, while queued writes stay. The cascade starts below the cancelled task, so that task's own conversation keeps its queue. Held outcomes failed, faulted, orphaned, and aborted record the same durable cancellation intent as an abort mark; completed does not. Conversation records and owner edges are never retired with the task. Active invocations are signalled after commit. A terminal receipt guarantees that the task's ordinary owned work drained; background work below it may continue.

Every run invocation start resolves the task's definition by TaskRecord.kind from the scheduler's current registry snapshot:

definition missing                 -> stay pending, blocked: missing_task
stored version newer than it       -> stay pending, blocked: task_too_old
stored version older than it       -> migrate(input, checkpoint, storedVersion)
  failure                           -> stay pending, blocked: migration_failed
  success                           -> commit migrated record + running atomically
equal version                       -> commit running

An abort invocation resolves the definition the same way. When the definition can take the task, its abort handler runs; otherwise the task is orphaned as described below.

A failed migration is reported once through onReport and retried only after the registry resolves a different definition object for the task's kind.

The Harness never terminalizes a task merely because registry code is missing or incompatible, neither at open nor later. A blocked task without an abort mark keeps its durable record unchanged, remains live, still blocks ordinary idle waits, and is reconsidered whenever the registry changes. The blocked reason is derived runtime state, not a persisted task status. Document migration remains access-driven.

Aborting a blocked task cannot run its abort handler, because that code is missing or cannot take the task. The Harness therefore settles it as terminal orphaned instead of aborted: aborted means the task's own abort handler ran and decided the outcome, while orphaned means no task code ran, so external effects the task started may remain uncleaned. The orphaned reason is the blocked reason (missing_task, task_too_old, or migration_failed). When abortTask() finds no active invocation, no live ordinary owned work, and a current snapshot that cannot take the task, the marking commit settles it directly; otherwise the scheduler settles it when it would reserve the abort invocation, which is after the owned work drained (section 5.5), so an orphaned outcome never holds. Only an abort (direct, by conversation, or by cascade) orphans a task; a missing definition alone never does, and neither does a restart mark (below), whose task waits for its definition, so its abort handler can still clean up.

A task created with abandonOnRestart is awaited by its creator only in memory, and the creator does not resume after a restart, as a replay-unsafe tool does not. When such a task is still live once another Harness starts scheduling (resume(), or the first submission or wait), it is pointless: the first reservation pass sets its abort mark with abortReason: "restart" and reserves nothing, so the next pass sees the marks. Opening alone marks nothing, so an inspection-only open marks no task. The cascade passes the restart reason on to the ordinary owned work below; any other cancellation intent marks without a reason, and replaces a restart mark below it. A request (abortTask(), a conversation abort) replaces a restart mark too, so a restart-marked task waiting for its definition is then orphaned; an owner's own cleanup through runtime.abortOwned() keeps the mark. Abort handlers see the reason on the task record and can tell an abandonment from a request.

No task below an owner with cancellation intent starts a run phase, even before the cascade has marked it: reservation skips it, a running invocation ends before its next phase, and both schedule the cascade again. Such work runs only its abort handler. A reconciliation or reservation commit that fails fails the Session (section 10.1).

The orphaning commit performs the cleanup the task's code cannot: affected input submissions become unanswered with the reason, any matching active run control is cleared, and task-scoped documents retire. The terminal task record and unanswered submissions carry the reason; the only transcript entry written is the conversion of a committed generation partial (below). Faulting a run task performs the same control/submission cleanup with a faulted outcome; a fault while ordinary owned work is live holds, and its cleanup runs in the final commit (section 5.5, rule 4).

The scheduler knows nothing about runs or task kinds. The Harness, which owns submissions, run control, and the built-in tasks, gives it one hook that the scheduler calls in the commit that makes an outcome it wrote itself (faulted and orphaned) terminal, which is the final commit after a hold. The hook ignores tasks whose kind is not a built-in run, tool, or compaction kind, so it never creates pi.live elsewhere. It settles the run when pi.live.run names the task (section 8): a committed generation partial becomes an aborted pi.assistant entry, exactly as the generation abort handler converts it, so the transcript keeps what the model produced and pi.usage counts its spend (the scheduler's commit has no task scope, so the entry has no byTaskId); its inputs become unanswered with reason faulted (detail: the error message) or the blocked reason; and run, generation, and tools are removed. Faults come from task bugs or malformed provider data, such as a non-JSON value in a response. A storage failure fails the Session and writes no outcome (section 10.1). For a pi.tool task it marks the task's tool slot done without an entry; the run continues, and context derivation synthesizes the missing result (section 2.1). For a pi.compaction task it removes the task's compaction status (section 8.7). Outcomes a task commits for itself do their own settlement. The Harness also supplies the per-phase agent resolution behind runtime.hooks and runtime.agent() (section 7.1); the scheduler only passes each phase's registry snapshot to it.

At every normal phase boundary (section 5.1, rule 5), the step refreshes the invocation's registry snapshot. If the task definition resolved by name is a different object than the one the invocation started with and the new definition can reserve the task (same version, or a higher version with migrate), the invocation hands over: the step commits the task back to pending with its checkpoint, memos, and abort mark and ends the invocation, and the next reservation starts a fresh invocation under the new definition, applying the reservation rules above. When the definition is missing or cannot reserve the task, the invocation keeps running under its old definition, reports once through onReport per resolved definition, and reconsiders at its next boundary. A handler that never settles never hands over.

5.5 Structured concurrency

Tasks and conversations form one ownership tree. Every task names its owner at creation, tx.createTask(task, input, { ownership }) with a required TaskOwnership: its conversation ({ kind: "conversation" }, a top-level task) or a task ({ kind: "task", taskId }, a child task). Every conversation is ownerless or owned by a task (section 2.2). A child task always lives in its owner's conversation; only owned conversations cross conversation boundaries. Owner edges are immutable.

The ordinary owned work of a task T is every task that ordinary traversal reaches below T: the tasks T owns, the tasks in the conversations T owns, and, transitively, the same for each of those that is not background. A background task and everything below it are excluded. A conversation-owned task may be background; a child task may not.

Two rules follow from the tree.

Abort flows down (section 5.4). A live owner's cancellation intent (its abort mark, or a held non-completed outcome) marks its ordinary owned work. Background tasks are boundaries unless aborted directly or by Conversation.abort(context, { background: true }).

Owned work does not outlive its owner's finish. A task finishes only after its ordinary owned work drained:

  1. A terminal state a task commits, or a terminal outcome the scheduler writes (faulted, orphaned), while the task's ordinary owned work is live is stored as { status: "completing", outcome } instead. The scheduler writes the final terminal record in a later commit once no ordinary owned work is live, evaluated after every commit, including work created after the hold. Whether work is live is judged on the commit's candidate records, so work the finishing commit itself creates, for example a task in a conversation the task owns, holds it. At open it re-evaluates every completing task.
  2. A held outcome is final: no phase, runtime commit, or abort handler runs again, no definition is needed, the task is never reserved or migrated, and abortTask() on it only marks it and returns marked, which cancels the work below. The final record keeps the held outcome and the mark.
  3. A held non-completed outcome is cancellation intent, so the work below is aborted, drains, and then the task becomes terminal. A held completed outcome waits for the work to finish normally.
  4. Writes split at the hold. A task-written terminal commit lands all of its other writes, such as a tool's result entry and slot, at hold. Only the record's terminal state, task-document retirement, and task waiters are deferred to the final commit. A scheduler-written outcome writes only the record at hold; its Harness cleanup (section 5.4) runs in the final commit, so a faulted run task keeps pi.live.run until its tools drained.
  5. Waiters, idle waits, inspection, and ordinary traversal see a completing task as live until its final commit.

New owned work needs a live owner: createTask with task ownership and createConversation with task ownership reject an owner that is completing, terminal, or abort-marked in the commit's final candidate (section 3.3). Conversations stay usable after their owner finished: new runs started there, for example to interrogate a finished subagent, are ordinary work of that conversation, reached by traversal from above, and not part of any finish.

Waiting. A phase may commit { status: "waiting", checkpoint, on, policy }. The task stops without an invocation and resumes at checkpoint once every task in on is terminal. on may name any tasks, including already terminal ones; tasks the waiting task does not own, whose owner is not the waiting task, require policy: "allSettled". on may not name a missing task or close a cycle in the wait-for graph (section 5.1), whose edges run from each task to the tasks it waits on and to its live ordinary owned work, which holds it as completing. A task waiting on itself, or on an owner that holds it, closes the shortest such cycles; a background task may wait on the owner of its conversation, which it does not hold. The check follows both kinds of edge from the tasks in on, visiting each task once, and the rejection names the cycle. Only durable waits are edges: a wait held only in memory, such as runtime.waitForTask() or a nested call's, is not checked. An abort-marked task waits only for its owned work, so its on is no edge. With failFast, the first task in on that holds or ends with a non-completed outcome gives every other live task in on an abort mark, in the scheduler's next reconcile commit. The waiting task itself is not marked: it resumes once all of on is terminal and reads their outcomes with runtime.outcomes(). A task may create children and keep running phases before it waits, and may wait on any subset of its children in sequence.

Abort order is bottom-up. An abort invocation of a task starts only once its ordinary owned work is no longer live, so an abort handler sees final outcomes below it. An abort mark on a waiting task lets it leave the wait early for its abort handler under this same rule; tasks in its on that it does not own are not awaited. Ordering is judged on committed records: code a child runs after its terminal commit is not ordered. An abort handler may not return waiting and cannot create owned children, because its task is abort-marked (section 3.3): it compensates inline, or creates background conversation-owned tasks, which no cascade reaches, and waits for them with runtime.waitForTask(), which holds its invocation.

The cascade of the example Checkout task, which waits failFast on four Payment children: an expired card fails its payment; the three live payments get abort marks and refund in their own abort handlers; once all four are terminal, Checkout resumes, reads [aborted, failed, aborted, aborted] through outcomes(), and decides its own outcome. abortTask(Checkout) instead marks Checkout, the cascade marks the live payments, their abort handlers run first, then Checkout.abort runs and sees their final outcomes.

6. Submissions and inbox

Submission records back awaitable host objects. Admission is Harness-internal; boundaries place queued submissions with tx.placeSubmission(), and run tasks settle the inputs they answer with tx.settleSubmission(). Queued submissions wait in the built-in inbox document, an ordered list of tagged items:

type InboxItem =
  /** `entry` is the input's `InputEntry` (`kind` and optional `data`), present when given. */
  | { readonly id: SubmissionId; readonly mode: "steer" | "followUp"; readonly content: UserInput; readonly entry?: InputEntry }
  /** `entry` is the write's `EntryDraft`, stored as plain JSON. */
  | { readonly id: SubmissionId; readonly mode: "write"; readonly entry: JsonObject };

type InboxState = { items: InboxItem[] };
field value
kind pi.inbox
version 1
scope/history/fork conversation, latest, initial
initial() { items: [] }
checkpoint complete base whenever items is empty
view mount docs["pi.inbox"]
created with every Harness conversation (section 2.2)

Items are in ID order. A queued input stores its content and its entry's kind and data; its entry gets the Harness clock's timestamp at placement.

An input is placed as one entry whose model is the user message of its content: a pi.user entry, or, with entry: { kind, data }, an entry of that kind carrying that data. Only kind and data are taken from entry; admission rejects a kind that is not a non-empty string or starts with pi.. Nothing in the Harness reads input entries by kind; context, compaction, events, and run accounting go by model and the submission records.

A write of only kind and data, with no model, head, or edits (present counts, even if empty), is display-only: it changes no model context, so admission appends it and settles it done in the same commit, busy or idle, ahead of earlier submissions still queued. Between a tool call and its result it changes nothing, because results are ordered by message. The one exception is a queued reset: appended before it, the entry would fall out of the context the reset starts, so a display-only write queues behind a queued reset and is placed after it at the same boundary. Queued submissions belong to their conversation, so a fork starts with an empty inbox.

Run control lives in the built-in live document pi.live (section 8). Its optional run value names the task currently responsible for the run and its placed input-submission IDs. run !== undefined defines busy; get-or-create of the idle document does not. The value remains while generation, tools, and tools hand work to one another: taskId names the generation that settles the inputs, including while its tool round runs, then the next generation. Tool tasks never own the run; the current round's tool tasks are listed in pi.live.tools. The ID list is mutable state because a boundary adds placed steering inputs to an active run; every terminal path settles exactly the listed inputs.

Admission and terminal transitions:

action submission state other writes
input, idle with empty inbox placed, with user entry create run and generation
display-only write, no queued reset done, with entry append entry; no run
write, idle with empty inbox done, with entry append entry; no run
input or other write, busy or non-empty inbox queued append inbox item
boundary places user item placed, with entry add ID to current or successor run
boundary places write done, with entry append entry
run answers input done, with required answer entry remove run, or hand it to a successor
run fails or its task aborts input unanswered, with reason remove run; inbox unchanged
withdraw queued item unanswered, reason aborted remove inbox item
stale head write unanswered, reason stale remove inbox item

requestId deduplicates within one conversation before any write; reusing one for the other submission type rejects. A busy input with whenBusy: "reject" writes no record and reports ConversationBusy. An idle input queues with mode steer when whenBusy is steer and followUp otherwise.

An idle conversation with a non-empty inbox, for example after a failed run, queues every new input and every write that is not display-only behind the waiting items and runs a final boundary in the same commit. A display-only write is appended without one, so a chat message never restarts failed work. Order is preserved: a follow-up queued before the failure is selected before the new input.

A Submission waits until done or unanswered; abort withdraws only a still- queued submission, reports already_placed for a placed input, and reports settled for any terminal submission. Conversation abort withdraws queued steer/follow-up submissions but keeps writes for later placement.

Boundary selection is deterministic by item ID, with the settings' steeringMode and followUpMode (section 2.2), read at each boundary on the Session line: one-at-a-time selects the first item of that mode, all every item of that mode.

boundary write steer follow-up
postTools all first/all by mode none
final all first/all by mode first/all by mode

A boundary places its selected writes first, in ID order, and then its selected user items, in ID order. A user item queued before a reset or compaction summary therefore lands after it and runs in the new context. Queued user items never become stale because of a head.

A head write, queued or placed at once, whose target is older than the start of the active range, the newest head marker's head, is stale: placing it would bring back history that head cut. Heads placed earlier in the same boundary count: after a queued reset, a queued summary targeting an older entry is stale. A head: "self" write is never stale.

Compaction summaries are head writes (section 8.7), so this rule alone orders compactions by cut position: a summary cutting before the active range start is stale, and one cutting at or after it is placed, whenever it was selected. Example: background compaction B selects at tail 100 and cuts at 70. While it summarizes, a blocking compaction A cuts at 150 and appends its summary. B's summary then settles stale, because 70 is older than 150. Had A cut at 60 instead, B would be placed after it: B summarized the context at its selection, the then-newest summary plus the entries before 70, which covers everything A's summary covers. That holds while the only heads are compaction summaries and resets, which make older cuts stale; an application edit placed while a compaction summarizes is lost, and an application head can be undone (section 12).

A postTools boundary that selects a head: "self" write (a reset) behaves as final: it also selects follow-ups, and the current run ends with its inputs unanswered with reason reset, because its context was cut before an answer. A reset placed at a final boundary follows the answer, so the inputs are already done.

At ordinary postTools, generation continues even with no queued trigger; selected steer IDs join that continuation. A terminating or handoff tool round (section 8.5) uses final behavior instead. At final, the current run's placed input submissions settle first, unless an onYield continuation keeps them open (below); selected user IDs start one successor generation with those IDs as the new run's inputs. Writes never trigger generation by themselves. A final boundary without user triggers leaves the conversation idle, except for an onYield continuation (section 8.3), which applies only when the boundary selected no user item and no reset.

Only successful run ends apply the final boundary: an answer, terminate, or handoff. Failure, a run task's abort handler, fault, and orphan settle the run's inputs unanswered and leave the inbox alone (Conversation.abort() separately withdraws queued user items); the queued items stay visible in pi.inbox until the next input or queued write runs a boundary, or their withdrawal.

Selected and stale items are removed positionally while retained item order is preserved. Chord's Astra operation generator must express scattered removals without carrying retained values; IDs are not substituted for positional inbox semantics.

7. Extensions, hooks, tools, and system prompt

7.1 Extensions, registry, and agent resolution

Extension code reaches the Harness through one application-owned registry of extensions. An extension is a named bundle of tools, prompt sections, hooks, tool and section wrappers, and task definitions. The registry is process-local, may outlive a Harness, and is not persisted; durable state stays in conversations, entries, tasks, and documents. A conversation selects extensions by name in its pi.agent document (section 2.2).

type DocumentReader = Pick<Session, "snapshot" | "snapshotAsOf">;

type PromptInput<Tool extends ToolRegistration = ToolRegistration> = {
  readonly conversationId: ConversationId;
  /** The request's resolution; `agent.tools` are the tools offered in this request. */
  readonly agent: Agent<Tool>;
  /** Built by `HarnessOptions.env` for this preparation (section 2.2). */
  readonly env: ExecutionEnv | undefined;
  /** Sections already in effect after replaying the active transcript. */
  readonly shown: Readonly<Record<string, string>>;
  /** Committed document reads. */
  readonly read: DocumentReader;
};

type PromptSection<Tool extends ToolRegistration = ToolRegistration> = {
  readonly key: string;
  render(input: PromptInput<Tool>, context: Context): string | undefined | Promise<string | undefined>;
  /** Default true: wrap the text as `<key>\n...\n</key>`. */
  readonly tag?: boolean;
};

/** Built by `hook()`; matches tasks by name. */
type HookRegistration = { readonly task: string; readonly handlers: object };

/** Built by `wrapTool()` and `wrapSection()`; targets a tool name or a section key. */
type Wrap<Tool extends ToolRegistration = ToolRegistration> =
  | { readonly tool: string; wrap(tool: Tool): Tool }
  | { readonly section: string; wrap(section: PromptSection<Tool>): PromptSection<Tool> };

interface Extension<Tool extends ToolRegistration = ToolRegistration> {
  readonly name: string;
  readonly tools?: readonly Tool[];
  readonly sections?: readonly PromptSection<Tool>[];
  readonly hooks?: readonly HookRegistration[];
  /** Apply where this extension is selected, in order. */
  readonly wraps?: readonly Wrap<Tool>[];
  /** Resolved by name for every task, whichever conversations select this extension. */
  readonly tasks?: readonly AnyTask[];
}

function defineExtension<Tool extends ToolRegistration = ToolRegistration>(extension: Extension<Tool>): Extension<Tool>;
function section<Tool extends ToolRegistration = ToolRegistration>(
  key: string,
  render: PromptSection<Tool>["render"],
  options?: { readonly tag?: boolean },
): PromptSection<Tool>;
function hook<K extends AnyTask>(task: K, handlers: Partial<HooksOf<K>>): HookRegistration;
function wrapTool<Tool extends ToolRegistration>(tool: Tool, wrapper: (tool: Tool) => Tool): Wrap<Tool>;
function wrapSection<Tool extends ToolRegistration = ToolRegistration>(
  key: string,
  wrapper: (section: PromptSection<Tool>) => PromptSection<Tool>,
): Wrap<Tool>;

interface RegistryReader<Tool extends ToolRegistration = ToolRegistration> {
  /** Immutable view of the whole current registry. */
  snapshot(): RegistrySnapshot<Tool>;
  /** Called synchronously after every publication; wakes the scheduler to reconsider blocked tasks. */
  subscribe(listener: () => void): () => void;
}

interface RegistrySnapshot<Tool extends ToolRegistration = ToolRegistration> {
  installed(): readonly Extension<Tool>[];
  extension(name: string): Extension<Tool> | undefined;
  /** Every installed tool with its extension, in install order. Names may repeat across extensions. */
  tools(): readonly { readonly extension: Extension<Tool>; readonly tool: Tool }[];
  sections(): readonly { readonly extension: Extension<Tool>; readonly section: PromptSection<Tool> }[];
  /** Built-in and installed task definitions. */
  tasks(): readonly AnyTask[];
  task(name: string): AnyTask | undefined;
}

interface Registry<Tool extends ToolRegistration = ToolRegistration> extends RegistryReader<Tool> {
  /** Installs `extension`, or replaces the installed extension with its name in place. Publishes at once. */
  install(extension: Extension<Tool>): void;
  /** Removes the installed extension with `extension.name`, whichever object it is. A later install appends. */
  uninstall(extension: Extension): void;
}

function createRegistry<Tool extends ToolRegistration = ToolRegistration>(): Registry<Tool>;

The Tool parameter lets an application attach metadata, such as prompt snippets, to its tools and read it typed in its section renderers. The Harness only relies on ToolRegistration; its own surfaces, such as runtime.agent(), use the default. Metadata fields must be optional: the registry does not check them, and a default-typed extension may be installed. Pi-ai declarations derived from a tool keep only pi-ai Tool fields (toToolDeclaration), so application metadata never enters the transcript.

type CodingAgentTool = ToolRegistration & { readonly promptSnippet?: string };
// The renderer sees CodingAgentTool: no cast needed to read promptSnippet.
export const ToolList = defineExtension<CodingAgentTool>({
  name: "tool-list",
  sections: [section("tools", ({ agent }) => agent.tools.map((tool) => `- ${tool.name}: ${tool.promptSnippet ?? tool.description}`).join("\n") || undefined)],
});
const registry = createRegistry<CodingAgentTool>();

Rules:

  • The built-in tasks of section 8 are always present and are not an extension: tasks() includes them, and nothing uninstalls or replaces them. Any extension registers hooks against them.
  • Tool names and section keys are unique within one extension; across extensions they may repeat. Section keys match ^[a-z][a-z0-9_-]*$, and the key instructions is reserved for the agent's instructions (below).
  • Task names are unique across the built-in tasks and every installed extension. install() validates the registry as it would be after the replacement, including the rules above, and throws without publishing anything when it is invalid. defineExtension() and section() check nothing.
  • Publication is synchronous, invokes no extension callback, and notifies subscribers. There is no batch: an extension is the unit of reload, and installing it again replaces its tools, sections, hooks, wrappers, and tasks in one publication at its install position.
  • Extensions, tools, and task definitions are immutable, and wrappers are pure: a wrapper returns a new object and never mutates its input.
  • An extension or tool object passed as a selection or wrapper target stands for its name: settings.extensions holding an old Skills object resolves to the installed skills extension.

A snapshot is a plain immutable value; nothing is released. The scheduler takes one per phase-handler invocation and passes it through the runtime; handlers and hooks read that snapshot and never take their own. At every normal phase boundary the scheduler takes a fresh one, and task definitions hand over as in section 5.4. Different phases may observe different registry states; nothing requires one run to see a single registry state across its generation and tool tasks.

export const ContextFiles = defineExtension({ name: "context-files", sections: [section("agents-md", renderAgentsMd)] });
export const Skills = defineExtension({ name: "skills", sections: [section("skills", renderSkills)] });
export const Coding = defineExtension({
  name: "coding",
  sections: [
    section("preamble", () => "You are an expert coding assistant.", { tag: false }),
    // The environment the host built for this conversation, in the conversation's directory.
    section("cwd", (input) => input.env && `Working directory: ${input.env.cwd}`),
  ],
});
export const Permissions = defineExtension({
  name: "permissions",
  hooks: [hook(ToolTask, { beforeTool: async (call) => (isDangerous(call) ? { block: "Needs approval" } : undefined) })],
});
// A role and a review loop, for conversations that select it.
export const Reviewer = defineExtension({
  name: "reviewer",
  sections: [section("role", () => "You review diffs. Report problems as a list. Never edit files.")],
  hooks: [hook(GenerationTask, { onYield: requestSecondPass })],
});

const registry = createRegistry();
for (const extension of [CodingTools, Coding, ContextFiles, Skills, Permissions, Reviewer]) registry.install(extension);
const harness = await Harness.open(storage, {
  models,
  registry,
  // Reviewer is installed but not selected by default: only conversations that select it get its role and hooks.
  settings: { extensions: [CodingTools, Coding, ContextFiles, Skills, Permissions] },
  env: ({ cwd }) => localEnv(cwd ?? process.cwd()), // cached NodeExecutionEnv per directory
}, context);
// The conversation remembers its model and directory; a restart elsewhere keeps both.
const root = await harness.root(context, { agent: { model: sonnet, cwd: process.cwd() } });

The registry does not track which running work still uses a replaced or uninstalled extension. Work that already started keeps using the code it took, so an extension that frees resources right after uninstall() can make a still-running call fail with an ordinary error result. Extensions that need graceful disposal manage their resources' lifetime themselves, for example by reference counting.

Resolution. A conversation's Agent is resolved from its stored pi.agent (absent: every field unset), a registry snapshot, and the resolved settings:

extensions  base = settings.extensions ?? every installed extension; in that order
            array: exactly these, in array order; { add, remove }: base, then add appended, minus remove
            duplicates keep their first position; names not installed are skipped
tools       the selected extensions' tools in extension order; a later same-name tool replaces an earlier one in place
            then every selected extension's tool wrappers, in extension order, then in each extension's order
            then the `tools` filter, which yields the enabled tools: an array keeps exactly these names in its order,
            a repeated name at its first position; { remove } drops these names
            `tools`: the enabled tools whose `callers` include "model" (default both), through the `modelTools`
            filter, the same way; `callable`: the enabled tools whose `callers` include "tools"
sections    the selected extensions' sections, same-key replacement in place, then their section wrappers,
            then `instructions` when set
hooks       the selected extensions' hooks for the task's name, in extension order
model       stored, else none: the request fails no_model
thinking    stored, else "off"
cwd         stored, else undefined

A wrapper that throws or returns a tool or section with a different name drops its target for that resolution: the tool is not offered, and calls to it produce tool_unavailable; the failure is reported through onReport. A wrapper that finds no target does nothing, so Timing below may be selected where no bash is. When instructions is set, Agent.sections ends with the tagged section instructions rendering that text; wrappers do not apply to it.

A later extension's same-name tool overrides an earlier one for the conversations that select both, and wrappers apply to whichever tool won:

export const Timing = defineExtension({
  name: "timing",
  wraps: [wrapTool(bashTool, (tool) => ({
    ...tool,
    execute: async (args, api, context) => {
      const start = Date.now();
      try {
        return await tool.execute(args, api, context);
      } finally {
        metrics.record("bash", Date.now() - start);
      }
    },
  }))],
});
// A bash inside a Python virtualenv for one conversation: it replaces CodingTools' bash in place,
// and Timing, if selected, wraps it.
export const Venv = defineExtension({
  name: "venv",
  tools: [createBashTool({ commandPrefix: "source .venv/bin/activate" })],
});
registry.install(Venv); // installed, but not in the default selection
await conversation.configure({ extensions: { add: [Venv] } }, context);

Who resolves what, and when. Each reader resolves once per decision:

reader resolves when
generation prepare agent (tools, sections, model, thinking level), stream options, compaction thresholds once per request; model, stream options, and offered tools are fixed in the request checkpoint (section 8.3)
every task phase extension selection for hooks; runtime.agent() at most once per phase handler, at its first hook dispatch or agent() call, from that phase's snapshot and the committed pi.agent, off the Session line, with the invocation's context; fixed for the rest of the phase
tool task the implementation of an accepted call at call and execute: the name among the phase agent's tools, the current selection after the tools filter; no such tool, for example one removed after preparation: tool_unavailable. Replay policy on reopen uses the same lookup (section 7.3)
generation, when a tool round starts tool execution mode once per round (section 8.3)
generation, compaction retry policy at each attempt's classification
compaction select model, thinking level, stream options, compaction thresholds once; the summary request stays pinned across its retries (section 8.7)
boundaries and idle admission, on the Session line queue modes per boundary (section 6)
tool task, prepare, runtime.env() environment at each call (section 2.2)

Registry installs and settings changes make no commit, so they emit nothing (section 9.4). A conversation on the default selection changes when the settings or the installed extensions change; its next preparation appends the system delta (section 7.4). A UI showing resolved tools or a resolved model resolves again through Conversation.agent().

7.2 Hooks

A hook is a typed question asked by a task before it commits a decision. Hooks are declared by task definition and come with an extension, built by hook(task, handlers) (section 7.1). A task asks the hooks of the extensions its conversation selects. hook() is typed by the task token, but dispatch matches the task's name, so hooks survive a reload of their task; keeping hook signatures compatible across task versions is the task author's responsibility. Hooks run in extension order off the line; a crash before the consuming commit may rerun them. Abort errors always propagate.

hook composition ordinary throw
beforeRequest replacement chain report, continue
afterResponse all observers report, continue
onYield first continuation wins report, continue
beforeTool argument replacement chain; first block wins block tool with error text
afterTool result replacement chain report, continue
afterTools all observers report, continue
beforeCompact first decision wins report, continue

Hooks use task memos for durable first-writer-wins decisions. There is no public semantic event channel; current UI status is document state.

type HookResult<T> = T | undefined | Promise<T | undefined>;

/** What a hook may use: committed reads and the asking task's memos. */
interface HookApi extends DocumentReader {
  readonly taskId: TaskId;
  readonly conversationId: ConversationId;
  /** `HarnessOptions.models`. */
  readonly models: Models;
  memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
  memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
}

interface GenerationHooks {
  /** Before every request attempt, including recovery; the result is used for that request only. */
  beforeRequest(
    request: { readonly messages: readonly Message[] },
    api: HookApi,
    context: Context,
  ): HookResult<{ readonly messages: readonly Message[] }>;
  /** Every terminal provider message, before classification. */
  afterResponse(message: AssistantMessage, api: HookApi, context: Context): void | Promise<void>;
  /** A final answer; `continue` appends a user message and continues the run. */
  onYield(answer: AssistantMessage, api: HookApi, context: Context): HookResult<{ readonly continue: UserInput }>;
  /** After every tool of the round is terminal; `results` are the round's result entries in call order. */
  afterTools(assistant: EntryId, results: readonly EntryId[], api: HookApi, context: Context): void | Promise<void>;
}

/** `parent` is set for a nested call (section 7.3). */
type ToolHookCall = ToolCall & { readonly parent?: { readonly taskId: TaskId; readonly callId: string } };

interface ToolHooks {
  /** Before intent; replaces the arguments or blocks the call with error text. */
  beforeTool(
    call: ToolHookCall,
    api: HookApi,
    context: Context,
  ): HookResult<{ readonly arguments?: JsonObject; readonly block?: string }>;
  /** After execution, before the result is committed; replaces the result. */
  afterTool(
    call: ToolHookCall,
    result: ToolExecutionResult,
    api: HookApi,
    context: Context,
  ): HookResult<ToolExecutionResult>;
}

interface CompactionHooks {
  /**
   * After range selection, before summarizing; the first decision wins. `entries` are the active entries the summary
   * replaces, the head marker first, and `messages` their model context, the summarizer's source; `firstKept` is the
   * first entry kept verbatim.
   */
  beforeCompact(
    compaction: {
      readonly reason: CompactionReason;
      readonly entries: readonly EntryRecord[];
      readonly messages: readonly Message[];
      readonly firstKept: EntryId;
      readonly instructions?: string;
    },
    api: HookApi,
    context: Context,
  ): HookResult<{ readonly decline: true } | { readonly summary: string }>;
}

GenerationTask, ToolTask, and CompactionTask are the exported built-in task tokens whose H parameters are these interfaces. runtime.hooks.each(name, invoke) calls invoke with every handler under name among the phase's resolved hooks: those of the extensions the task's conversation selects, for the task's name, in extension order (section 7.1). An ordinary throw from invoke is reported through onReport and each continues with the next handler; once the invocation is signalled, the error propagates. A task that composes differently, such as beforeTool turning a throw into a block, or stops after a first decision, does so inside its own invoke. Hooks receive the task's runtime as their HookApi; hook memos and the task's own memos share one namespace, so hook authors prefix their memo names.

Selection decides which behavior a conversation has; the extension's own document holds that behavior's state:

const PlanModeDoc = defineDoc<{ enabled: boolean }>({
  kind: "app.plan-mode", version: 1, scope: "conversation", history: "latest", fork: "current",
  initial: () => ({ enabled: false }),
});
export const PlanMode = defineExtension({
  name: "plan-mode",
  hooks: [hook(ToolTask, {
    // An absent document means plan mode is off.
    beforeTool: async (call, api, context) =>
      (await api.snapshot(PlanModeDoc, api.conversationId, context))?.enabled && writes(call)
        ? { block: "Plan mode: read-only" }
        : undefined,
  })],
});
// PlanMode is in the default selection; /plan toggles this conversation's state.
await root.commit(async (tx) => {
  (await tx.doc(PlanModeDoc, root.id)).enabled = true;
}, context);

7.3 Tools

type ToolControl = {
  readonly addTools?: readonly string[];
  readonly terminate?: true;
  readonly handoff?: string;
};

/** Remark about a call for the model and the UI; never part of the tool's data. */
type ToolDiagnostic = {
  readonly severity: "info" | "warn" | "error";
  readonly message: string;
  readonly code?: string;
};

/** Three channels: `output` for the model, `structuredOutput` for programs that call the tool, `details` for UIs. */
type ToolExecutionResult<TDetails extends JsonValue = JsonValue> = {
  readonly output?: ToolResultMessage["content"];
  readonly structuredOutput?: JsonValue;
  readonly isError?: boolean;
  readonly details?: TDetails;
  readonly diagnostics?: readonly ToolDiagnostic[];
  /** Spend of the execution itself, such as a model call; stored on the result and in `pi.usage.tools`. */
  readonly usage?: Usage;
  readonly control?: ToolControl;
};

interface ConversationHandle {
  readonly id: ConversationId;
  submit(submission: InputSubmissionDraft, context: Context): Promise<Submission>;
  abort(context: Context): Promise<void>;
  waitForIdle(context: Context): Promise<void>;
}

interface ToolExecutionApi<TDetails extends JsonValue = JsonValue> extends DocumentObserver, DocumentReader {
  readonly taskId: TaskId;
  readonly conversationId: ConversationId;
  readonly callId: string;
  /** The tool task's phase snapshot. */
  readonly registry: RegistrySnapshot;
  /** The calling conversation's agent, as the tool task's phase resolved it. */
  agent(context: Context): Promise<Agent>;
  /** `HarnessOptions.models`: the catalog, credentials, and request transforms generation uses. */
  readonly models: Models;
  /** Built by `HarnessOptions.env` for this call. */
  readonly env: ExecutionEnv | undefined;
  output(chunk: string | Uint8Array): void;
  /** The output retained so far, as the model will see it, and whether earlier output was dropped. */
  retainedOutput(): { readonly text: string; readonly truncated: boolean };
  diagnostic(diagnostic: ToolDiagnostic): void;
  details(value: TDetails, context: Context): Promise<void>;
  commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
  memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
  memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
  createTask<I, S extends { phase: string }, R, H extends object>(
    task: Task<I, S, R, H>,
    input: I,
    options: Omit<TaskOptions, "conversationId">,
    context: Context,
  ): Promise<TaskId<R>>;
  getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
  waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
  conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
  /** Run another tool as a nested call of this one (below) and wait for its result. */
  executeTool(
    name: string,
    args: JsonObject,
    context: Context,
    options?: { readonly key?: string; readonly progress?: boolean },
  ): Promise<NestedToolExecutionResult>;
}

/** A nested call's result, for programs: no output for the model, and `control` does not apply. */
type NestedToolExecutionResult = {
  /** The nested call's tool task. */
  readonly taskId: TaskId;
  /** The tool's own value: a schema tool's validated `structuredOutput` (absent on an error result without one, whose text is then a `tool_error` diagnostic); a schema-less tool's bounded output, collapsed (below). */
  readonly structuredOutput?: JsonValue;
  readonly isError: boolean;
  readonly details?: JsonValue;
  readonly diagnostics: readonly ToolDiagnostic[];
  readonly usage?: Usage;
  readonly durationMs?: number;
};

type ToolRegistration<TParameters extends TSchema = TSchema, TDetails extends JsonValue = JsonValue> =
  Tool<TParameters> & {
  readonly replay?: "safe" | "unsafe";
  readonly executionMode?: ToolExecutionMode;
  /** Pure repair of commonly malformed arguments; runs before validation, which still checks its result. */
  prepareArguments?(args: unknown): Static<TParameters>;
  /** Schema of `structuredOutput`; omitted, programs get the bounded output, collapsed (section 7.3). */
  readonly structuredOutputSchema?: TSchema;
  /** Who may call the tool; default both. */
  readonly callers?: readonly ("model" | "tools")[];
  readonly outputLimits?: {
    readonly maxBytes?: number;
    readonly maxLines?: number;
    readonly retain?: "head" | "tail";
  };
  execute(
    args: Static<TParameters>,
    api: ToolExecutionApi<TDetails>,
    context: Context,
  ): Promise<ToolExecutionResult<TDetails>>;
};

/** Identity function that infers `TParameters` from `parameters` and `TDetails` from the reported details. */
function defineTool<TParameters extends TSchema, TDetails extends JsonValue = JsonValue>(
  tool: ToolRegistration<TParameters, TDetails>,
): ToolRegistration<TParameters, TDetails>;

execute() receives args typed by parameters: the Harness validates the call's arguments against that schema before it runs. defineTool() makes an inline tool literal infer its types, as in tools: [defineTool({ ... })]. execute is a method, so differently typed tools share one tools array.

Omitted replay is unsafe. Omitted outputLimits are 50 KiB, 2,000 lines, and retain: "head". Omitted executionMode follows the settings' toolExecution; one sequential call makes its whole round sequential (section 8.3).

A running tool calls another tool with api.executeTool(), as a codemode script or an MCP bridge does. The nested call is its own pi.tool task, owned by the calling tool's task, so it has its own task ID for idempotence and ownership, runs the whole tool pipeline (section 8.4) including the ToolTask hooks, which see call.parent, and reports progress in pi.live.nestedTools (section 8.2). Its result returns to the caller as a NestedToolExecutionResult and never enters the transcript; the model sees only what the caller puts in its own result. Its tool resolves among the agent's callable tools: the enabled tools whose callers include tools (section 7.1). A call that is unavailable, invalid, blocked, throws, is interrupted, or is aborted returns an isError result; a nested task the scheduler faulted or orphaned returns a synthesized one.

The nested call's key names it within its caller; the nested call ID is <callId>/<key>. By default the key is the call's position among the invocation's nested calls, 1, 2, ... An explicit key must be non-empty, without /, not __proto__, and not a positive integer, so call IDs never collide. One commit creates the nested task, its slot, and its member of NestedCallDoc (pi.tool.nested-call), a task-scoped document family of the caller keyed by the call's key, holding { taskId }; it retires with the caller. One document per call, so admitting a call rewrites no other call's. A caller that is not replay-safe creates its nested calls, and the child tasks it creates through api.createTask() unless the options say otherwise, with abandonOnRestart (section 5.4): it never resumes after a restart, so neither do they. With progress: false, a nested call commits only its status to its slot, no running output, details, or diagnostics; its result still carries its details and diagnostics, and a schema-less tool's output as structuredOutput, but an interrupted or aborted call, whose result is built from its slot, then has none of them. Programs never get the partial output of an interrupted or aborted call. Nothing bounds how deeply calls nest; callers keeps a tool from being called by tools at all.

The nested call's result does not go into its receipt, which stays { kind: "nested" }. Its terminal commit writes the result as result into its NestedCallDoc member and sets its slot's summary (isError, durationMs, usage, bounded error text). The stored result is exactly what executeTool() returns: it keeps no output, which only the model reads, so each result, an image or a bash tail, is stored once. The caller is never terminal before its owned nested calls, so the write always finds its documents open; a member that names another task throws. The caller reads the result after the wait, and every nested result is gone from storage once its caller is terminal. A nested call the scheduler faulted or orphaned has no stored result; the caller gets a synthesized isError one.

A later executeTool() with a used key, from the same invocation or from a replay-safe rerun after recovery that calls in the same order or passes the same key, waits for that task again, finished or still running, instead of creating another; a different tool name or arguments that are not structurally equal throws. So a replay-safe caller admits each nested call once and reuses its committed result. This is not exactly-once execution: a nested call interrupted by a crash before its result commits follows its own replay policy, rerunning when safe and returning interrupted otherwise, so a replay-safe tool with external effects still needs its own idempotency. Nested calls start when made, whatever their executionMode; the caller decides what runs at once by what it awaits. A nested result's usage is counted under its tool when it settles (section 8.6); a caller that passes it on in its own result counts it again.

Before a caller settles, by any path, it lets nested call admissions its invocation started commit, then aborts every live nested call it owns, found with runtime.ownedTasks(), so also one an earlier attempt made and a rerun never made again, with runtime.abortOwned(), all at once, and waits for them, so they settle into their slots before the caller's settlement removes them. executeTool() throws once execute() has returned, and rejects when the caller is aborted or the Harness closes; cancelling its context stops only the wait. An abort that arrives during this cleanup still aborts the caller, whose finished result is then replaced by the aborted one. The caller's result message lists none of its nested calls (no pi-ai nestedCalls): their task records keep every call and its arguments, and a tool that wants a list in its result, as code mode does, writes one into its own details. A crash between the caller's return and its result commit leaves it in execute: a replay-safe caller runs again and finds its nested calls by key; any other caller settles interrupted, and its nested calls were abandoned before they could run.

Programs that call a tool get its structuredOutput. A tool that declares structuredOutputSchema must return a structuredOutput matching it, except on an error result, where it may omit it; the Harness validates it after the afterTool chain, like arguments. A tool without a schema must not return one; programs get its bounded output, the content the model sees without the rendered diagnostics, for a success and for an error alike: one text item as its string, one image as its ImageContent, no items as "", and anything else as the content list. structuredOutput is always the tool's own value, never text the Harness makes: a schema tool's error result keeps the value it returned, and one without gives programs no structuredOutput; the text of its output becomes a tool_error diagnostic of the nested result instead, unless an error diagnostic already says what went wrong, so programs get it, for example "permission denied: /etc". A thrown error's message is in its tool_error diagnostic too. So a hook that redacts a schema-less tool's output redacts what programs get. An error result the Harness writes itself (unavailable, invalid arguments, blocked, interrupted, aborted, abandoned, or a scheduler fault) has no structuredOutput; its diagnostics say what went wrong. A thrown error becomes a tool_error diagnostic with its message. A nested call whose result breaks this gets an error result with an invalid_structured_output diagnostic, without the broken value: a schema tool's then has no structuredOutput, a schema-less tool's its output; a model-issued call, whose structuredOutput the model never sees, only loses it, and the break is reported through HarnessOptions.onReport. structuredOutput has no size bound; keeping it small is the tool's job, as bash keeps its retained tail and the spill path rather than the whole output. A model-issued call's result entry does not store it, and the Harness does not build a schema-less one for it; a nested call's result does, until its caller settles. A tool that returns the text it streamed in its structuredOutput reads it with api.retainedOutput(), the bounded text the model sees when the result omits output.

callers says who may call a tool: the model, other tools through executeTool(), or both (the default). A code mode tool is ["model"], so scripts cannot start scripts; tools behind code mode, such as MCP tools, can be ["tools"], so the model is never offered them. A conversation's modelTools narrows which of its model-callable tools the model is offered, for example only the code mode tool, while the others stay callable by tools. Neither can widen what tools enables.

A deferred tool is not offered to the model until it is loaded: modelTools must name it, in its array or its add. Other tools can call it any time. A tool loads deferred tools with control.addTools, which writes their names to modelTools (section 8.5); since pi.agent is rewindable and forks as of the fork's entry, loaded tools are per branch, and a baseline after a head marker declares them, built from the resolved tools. Loaded tools follow the other tools in load order, so a load appends declarations to the transcript and the cached prefix holds. @earendil-works/pi-durable/tool-search provides the coding agent's tool_search (createToolSearchExtension()): it ranks Agent.deferred with BM25 over the tools' names, descriptions, and parameter names and descriptions, and loads the matches. The codemode section does not list deferred tools; scripts find them with searchTools().

Tools reach files and processes only through api.env, never through an environment captured when the tool was built. The tool task builds it for each call with HarnessOptions.env from the conversation's ID and cwd (section 2.2), so a conversation's directory or sandbox needs no wrapper. A throw from env becomes an ordinary error result of the call. A rerun after recovery gets the conversation's environment at that time. api is a plain object so wrappers can spread it. A tool that needs an environment and receives none throws, which produces an ordinary error result.

A running tool reports output and details to the UI, mirroring the two halves of its final result, plus diagnostics (below):

  • output(chunk) appends running text output, like stdout. If execute() omits output, the final retained output becomes one text content item; no output becomes an empty content list. Retained output is an exact slice of whole lines of the stream (the first lines for head, the last for tail), trailing newline included; a single line longer than maxBytes is cut at the byte limit on a character boundary. Control characters other than tab and newline are removed from the retained text; accepting a chunk does no per-chunk sanitizing.
  • details(value) replaces the running details with a complete JSON value; it does not merge keys. If execute() omits details, the last value becomes the final details, so a renderer handles one details shape from the first update through the final result.

Neither is sent to the model while the tool runs. output() synchronously accepts UTF-8 output into that invocation-owned bounded buffer and throws after invocation end. Throttled commits publish the retained output, dropped byte/line counts, and the current details and diagnostics in its pi.live.tools slot. The throttle is adaptive, like the environment's shell output capture: the first change after an idle period commits at once; each commit then delays the next by at least settings.progress.outputIntervalMs (default 100 ms) and by its written size at 100 KiB/s, so a large rewrite buys a proportionally longer pause. Changes made during the delay coalesce into the next commit. The throttle is Harness policy, not part of outputLimits, which only bounds what is retained. Explicit text in explicit result content is bounded by the same limits before transcript persistence; non-text content is retained as declared by its pi-ai type. When bounding drops text, the Harness adds a warn diagnostic with code truncated stating the dropped lines and bytes.

Diagnostics are a channel, not text. Remarks about a call, such as truncated output, a spill path, a corrected path, a file changed on disk, or a capped search, go through api.diagnostic() or ToolExecutionResult.diagnostics, never into the content the model reads as the tool's data. The Harness adds the ones it owns, and a tool adds only what it alone knows. Every diagnostic is model-visible; information only for UIs belongs in details. diagnostic() synchronously records the diagnostic in the tool's slot with the next throttled commit and throws after invocation end. At settlement the result's diagnostics are those recorded through api, then those in the result, then the Harness's. When there are any, the result content ends with one text item:

<harness>
[warn] output truncated: 51,204 lines, 2,301,112 bytes dropped
</harness>

with one [severity] message line per diagnostic, and the pi.tool-result entry stores the structured list, possibly empty, as data: { diagnostics } (section 8.1). The stored message is exactly what the model saw, while UIs and code read the list. A warn diagnostic does not set isError.

output() never spills complete output to a file because spilling requires a filesystem, which may be remote or unavailable. A tool that must preserve complete output spills through the ExecutionEnv or FileSystem it was given, and reports the resulting path in a diagnostic, and in its details when a renderer needs it. The environment's shell streams raw output chunks and spills the complete output to a file once it crosses byte or line thresholds; it keeps no bounded view of its own, so output() is the one place output is bounded, sanitized, and throttled. The bash tool pipes those chunks into output(), reports the spill path as a diagnostic, returns structuredOutput: { output, truncated, fullOutputPath?, exitCode } with the retained output, answers a nonzero exit with an error result carrying an exit_code diagnostic, and throws on a timeout; either error result still carries the retained output and diagnostics.

An environment that moves output over a slow link, such as one on another host, need not move all of it. api.outputWindow names the tail a tail-retaining call keeps and the pace of its progress commits; the bash tool passes it as ShellExecOptions.window. The environment may then omit output and report the omission as info.skipped on the chunk that follows: the decoded byte count, the newline count, and whether the omitted text ended with a newline. It may omit only output followed by more than the window, by at least one byte or one line, and delivers all of that following output in the same chunk, so the omitted text can never be part of the kept tail and no progress commit sees a gap. output(chunk, skipped) adds the omission to the dropped counts, so the retained tail, the dropped counts, and the truncated diagnostic are the same as if every byte had arrived; only the moments at which progress is sampled differ. The environment should deliver no faster than the pace, since progress commits sample no faster. A head-retaining call has no window. A wrapper that replaces output to transform text sets outputWindow to undefined, so no omitted text bypasses its transform.

exec takes a string or an argv array. A string runs through the environment's shell. An array runs its first element directly with the rest as arguments, without a shell, so a host that builds a command from data, such as a file name, never quotes it for a particular shell. Each onOutput chunk names the stream it came from; the bash tool ignores it, while a host that needs stdout and stderr apart collects them separately, bounds them itself, and aborts the call when it has enough. openBinaryReader opens one regular file for positional reads, so a host reads a bounded range instead of the whole file, and every read sees the file it opened even if the path is renamed; noFollow refuses a symbolic link as the final path component. Its scanLines makes one pass over the file inside the environment and reports the newline count, the byte range of a span of lines, and the decoded sizes of that span and its first line, so a caller can count and locate lines without moving the file. openDirReader pages a directory in file-system order, reading metadata only for the entries it returns and skipping entries removed meanwhile. The optional listFiles lists a whole tree for indexes such as file completion: files, directories and symbolic links (not followed), relative with / separators, depth first in byte order of names, without .git and, inside git work trees, without what git ignores; maxEntries, maxBytes and timeoutMs end it early with truncated. An environment that cannot list right now fails with not_supported, and callers fall back to their own walk. All operations stop when their context is aborted, and only that call's work stops: a timeout or abort kills only that command's processes. cleanup() kills every command the environment still runs and belongs to its owner's shutdown, never to a single request. watch reports changes to files and directories for hosts that load resources, such as instructions or skills, from the environment; Durable itself never calls it. A target may be missing, and creating it is a change; a recursive target covers its subtree except excluded entries, without following symbolic links below it. When watch resolves, coverage is established, so a host that watches before it loads cannot lose a change made during the load. A change arrives as reported paths (each covering its subtree; calls may be spurious), as overflow when coverage was uncertain for a while and everything must be rescanned, or as a final error. A native watcher reports changes within about two seconds; a polling one compares snapshots because its file system, for example a network or FUSE file system, does not report changes made elsewhere, and can miss a change undone between two snapshots. NodeExecutionEnv uses events only to trigger rescans and reports differences between snapshots, so replaced files, renamed parents, and directories created with their contents are reported whatever events the platform sends. An environment is trusted, not a confinement boundary: checking a canonical path before opening it does not prevent a concurrent rename or symlink swap, so callers that restrict paths do so for hygiene, not security.

The details() promise resolves after the corresponding or coalesced document commit. During normal settlement the tool task stops its throttle and awaits the commit in flight; the terminal commit, which appends the result entry, is the final flush and settles any details() promise still pending. Abort and close obey invocation and Session admission gates: uncommitted buffered updates may be discarded, while admitted commits settle. Cancellation, callback, tracker preparation, and checkpoint failures occur before Storage admission and do not fail the Session. A Storage failure follows the fatal Session rule (section 10.1).

Tools come with extensions (section 7.1) and have a name, description, JSON schema, replay policy, and execute function. wrapTool(tool, wrapper) decorates a tool without replacing it. Each resolution composes the winning tool of that name with the selected extensions' wrappers (section 7.1); a wrapper never captures a base, so reloading the base keeps its wrappers. The composite supplies the declaration, argument validation, replay policy, and execution.

A tool call is accepted only if its request offered the tool. Generation checks this against the tool set replayed from the committed model context through the request's cutoff; beforeRequest replacements do not change it. It answers a call to a tool it did not offer with the tool_unavailable result described in section 2.2, without a tool task. An agent change after preparation does not affect this check (section 2.2). The tool task resolves the called name among the tools of the phase's agent resolution, the tools filter applied. It uses that implementation until execution settles: one phase handler resolves, validates, runs beforeTool, records intent, executes, and commits the result, so no phase boundary separates resolution from settlement. A name that does not resolve, for example because its extension was uninstalled or deselected, or the tools filter dropped it after the request was prepared, produces tool_unavailable. The implementation's prepareArguments, if any, first repairs the call's arguments, for example edits sent as a JSON string; the stored call keeps what the model sent. Arguments are validated against the resolved implementation's schema before and after beforeTool; a failure, or a throwing repair, produces an invalid_arguments error result.

After hooks and validation, the tool task durably records the final arguments and resolved replay policy before execution. Recovery does not rerun beforeTool and passes the same stored arguments to execute(). A replay-safe tool may reconstruct a submission from those arguments when that transformation is pure. Random values, timestamps, mutable document/configuration reads, or other derived inputs that must remain stable are first captured in a durable memo, checkpoint, or task input. A background reporter task receives the final message in its own durable input so it can finish independently. Recovery does not let a changed registry declaration alter the stored replay policy.

A tool-acquired conversation handle accepts only input submissions; tools use ordinary transaction writes for passive entries.

A tool executes in a durable task. It may:

  • publish bounded running output and details to its pi.live.tools slot;
  • commit memos;
  • create and wait for tasks;
  • atomically create or fork explicitly owned conversations through commit();
  • observe documents for which it has a token/reference;
  • mutate authorized documents through commit();
  • return bounded model content and separate diagnostic details.

Tool operations use the invoking task's admission and invocation-lifetime gates. Task creation defaults to that task's conversation. Trusted document and conversation access follows sections 3.3 and 9.2; there is no additional subtree authorization layer. Possession of a Session-global typed ID is sufficient in trusted code.

Tools have no conversation-creation convenience method. They create or fork a conversation inside commit(), state its ownership explicitly, atomically stage any related document/task writes, and receive only the inert record. After that commit settles, conversation(id) acquires an invocation-bound operational handle for submission, explicit abort, and idle waits without changing ownership. A commit callback must not call conversation() or use a previously acquired handle: nested Session operations and external effects are forbidden while the mutation line is held. A handle's operations reject after the invocation ends. A Submission returned by its submit() is invocation-bound in the same way; the admitted submission remains durable after those methods reject. Invocation-owned document watches stop when the invocation ends.

A foreground subagent conversation is explicitly owned by its tool task. One transaction creates or forks the child; the owner edge is its durable record, so a replay-safe rerun finds the child with scanConversations({ ownerTaskId }) before creating one. After settlement the tool reacquires the child, submits with a request ID derived from its task ID, which a rerun keeps, and waits for that submission's result. Provider call IDs are not unique across a Session and must not serve as request IDs. Aborting or abnormally terminalizing the tool task cascades through the owned scope. The tool reports the child in its running details, for example api.details({ conversationId }), so a UI that sees the call can attach to the child's view or events.

export const Subagent = defineExtension({
  name: "subagent",
  tools: [defineTool({
    name: "subagent",
    description: "Delegate a self-contained task to a subagent and get its answer back.",
    parameters: Type.Object({ task: Type.String() }),
    replay: "safe",
    execute: async (args, api, context) => {
      const { task } = args;
      const child = await api.commit(async (tx) => {
        const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
        if (existing !== undefined) return existing.id;
        // Starts as a copy of this conversation's agent: model, thinking level, cwd, extensions, tools.
        const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
        // Without this extension, the child is not offered this tool.
        await configure(tx, created.id, { extensions: { remove: [Subagent] } });
        return created.id;
      }, context);
      await api.details({ conversationId: child }, context);
      const handle = (await api.conversation(child, context))!;
      const request = { type: "input", content: task, requestId: `subagent:${api.taskId}` } as const;
      const settled = await (await handle.submit(request, context)).wait(context);
      if (settled.status !== "done" || settled.type !== "input") throw new Error(`Subagent failed: ${settled.status}`);
      return { output: [{ type: "text", text: await answerText(api, settled.answer, context) }] };
    },
  })],
});

{ remove: [Subagent] } edits the host default selection (section 2.2). The main conversation here leaves its selection unset, so that is its selection too. A parent with any stored selection, an array or an { add, remove } object, computes the child's array from (await api.agent(context)).extensions instead, before the commit.

A persistent background subagent outlives the parent's turns and can be messaged, steered, stopped, and listed later. One transaction, after deduplicating by the subagent's name in an application registry document, creates a background conversation-owned anchor task A in the parent, a child conversation C owned by A, and the registry mapping from the name to C. A completes at once; it holds completing while C has ordinary work (section 5.5) and then stays terminal. Its record keeps background, so the parent's ordinary abort and idle traversal stop at it, while Conversation.abort(context, { background: true }) still reaches C's work through it, and Harness.waitForIdle() does not wait for it. Later runs in C are ordinary work of C; a terminal owner never cascades.

Each message to a subagent is delivered by a background conversation-owned reporter task in the parent, created in the tool's transaction and keyed there by the tool task ID so a rerun of the call does not create another. The reporter submits to C with a request ID derived from its own task ID, waits for that submission's settlement, and posts the answer to the parent as an input with whenBusy: "followUp", under another request ID derived from its task ID. A crash before either admission retries it; a crash after returns the existing Submission, so the child gets the message once and the parent gets the answer once. The reporter therefore needs no transaction-level admission; tx.createSubmission() writes a raw record without admission rules. The application's name document uses fork: "initial" so forks of the parent do not inherit it. A UI lists subagents from that document; a child is working while its pi.live.run is set. Owner edges alone drive abort and idle traversal. The anchor and reporter task definitions come with the subagent extension, so pending reporters resume after a restart once the host installs it again:

export const SubagentTools = defineExtension({ name: "subagent-tools", tasks: [Anchor, Reporter], tools: [subagentTool] });

// In subagentTool's spawn commit, after the name checks:
const anchor = await tx.createTask(Anchor, null, { ownership: { kind: "conversation" }, background: true });
// Owned by a task of the parent: starts as a copy of the parent's agent.
const child = await tx.createConversation({ ownership: { kind: "task", taskId: anchor } });
await configure(tx, child.id, {
  extensions: { remove: [SubagentTools] },
  instructions: `You are the subagent "${name}". Answer the main agent's requests.`,
});

A tool result may request addTools, terminate, or handoff. The generation's tools phase (section 8.5) adds the named tools to the conversation's stored tools filter; they take effect at the next preparation. The round terminates only when every result of the round requests terminate, as in the pi agent loop; the tools phase then uses a final boundary. Any handoff in the round, the last one in call order when several ask, ends the run the same way after appending a pi.reset entry with head: "self" and the handoff text as a user message (section 8.1), exactly what reset(handoff) writes.

A result's usage is stored on the tool-result message and added to the conversation's pi.usage.tools[toolName] in the result commit (section 8.6). A tool that runs an owned conversation must not report that conversation's spend again: the child's own pi.usage already counts it (section 12).

On reopen, a tool reruns only when both its stored intent policy and the current declaration, resolved as at call, say safe. A current unsafe declaration may veto a stored-safe replay; a current-safe declaration never upgrades stored unsafe. A tool that no longer resolves is treated as unsafe. Every other orphaned effect produces an interrupted result containing the durable partial output. Completed, failed, and aborted tool terminal outcomes retain their tool-result entry ID for the generation's tools phase.

Error results the Harness writes itself set isError and carry an error diagnostic with one of the codes tool_unavailable, invalid_arguments, blocked, interrupted, aborted, or tool_error (a throw from execute()) and the error text as its message. Their content is the durable partial output, if any, and details is the tool's last reported value, if any.

@earendil-works/pi-durable/tools provides read, write, edit, and bash factories, ported from the agent harness tools, the CodingTools extension with all four, and createCodingTools({ images }), the same with an image processor for read. They use only api.env; nothing installs them automatically.

read returns an image file, detected by content, as one image block with an info diagnostic (code image) saying its type and what was done to it, so programs get a bare ImageContent. An image must be read whole: unlike text, one that grows while it is read is read again. The limits are the conversation model's inputLimits.images.resize, each absent one DEFAULT_IMAGE_LIMITS (2000x2000 pixels, 4.5 MiB of base64). Without an ImageProcessor, read passes PNG, JPEG, GIF, and WebP through, undecoded, when the file size puts their base64 within the byte limit (checked before reading the file) and their header declares dimensions within the limits; otherwise, and for BMP, it returns an error result (code unsupported_image). With one, await processor.prepare(bytes, mimeType, limits) returns the image fitted to the limits, or undefined for an error result; the diagnostic says when its format changed or it was resized, with the factor that maps coordinates back. The Photon processor decodes every image, returns it as it is when it is upright, inline, and within the limits, and otherwise re-encodes it as PNG or JPEG (JPEG first for JPEG sources). @earendil-works/pi-durable/images provides createPhotonImages(wasm), on Photon's WebAssembly, given its module or bytes; /images/node reads the wasm from the installed package and prepares images in a worker thread, which exits after 30 s without images ({ worker: false } prepares them inline); /images/cloudflare imports it as a Workers module and prepares inline. Neither is loaded unless imported. A conversation whose model does not take images still gets the image; pi-ai replaces it with a placeholder in requests, and the diagnostic says so.

For text, read reads a file through openBinaryReader: image detection reads the header (and a PNG's chunk headers), scanLines counts and locates the selected lines, and only the shown head is read and decoded, so its cost and transfer are bounded by the output limits plus one pass over the file inside the environment. Its result is exactly that of decoding the whole file, splitting it into lines, and truncating the selection. A file that changes while it is read is read again once, then fails. edit and write serialize their read-modify-write of one file within the process, keyed by the environment's FileSystem.id (equal ids see the same files at the same paths) and the canonical path, so two calls with fresh environment objects for one file system still queue, and other files and file systems never wait. It is not a lock against bash or other processes.

7.4 System prompt and dynamic tools

Durable has no durable prompt sections. The conversation's resolved agent produces the desired sections for each request; the transcript's pi.system entries are the only durable record of what the model saw. Durable stores prompt and tool changes directly as PR #9548 SystemMessage values at their transcript positions, always with empty content:

type SystemEntry = EntryRecord & {
  readonly kind: "pi.system";
  readonly model: readonly [SystemMessage];
};

const baseline: SystemMessage = {
  role: "system",
  content: "",
  sections: { preamble: renderedPreamble, cwd: renderedCwd },
  toolsAdded: allEffectiveTools,
  timestamp: now,
};

const delta: SystemMessage = {
  role: "system",
  content: "",
  sections: { cwd: nextRenderedCwd, legacy: null },
  toolsRemoved: [{ name: "read" }],
  toolsAdded: [nextRead],
  timestamp: now,
};

Generation preparation takes these steps against its phase snapshot:

  1. Resolve the conversation's agent (section 7.1) and replay the active transcript's system messages into the shown sections and offered tools.
  2. The desired tools are the agent's tools, in order.
  3. Build the environment with HarnessOptions.env (section 2.2) and render the agent's sections, in order, with PromptInput: the conversation, the agent, the environment, the shown sections, and a DocumentReader for committed documents (the task runtime). The results are the desired sections.
  4. Compare desired sections and tool declarations with the replayed state and append one positional pi.system entry when they differ.

Preparation does not recheck the transcript before appending: only the Harness writes model context to a busy conversation, through submissions, run tasks, boundaries, and a blocking compaction, which appends only while its generation waits for it (section 8.7), so the context it read is still current (section 12). Display-only writes (section 6) may land meanwhile; they contribute no messages, and the system entry planning, the compaction threshold, and the request cutoff depend only on contributions and the newest entry ID.

Model and thinking level are request options, not prompt state.

The selected extensions' sections and the agent's instructions are the only source of prompt text; there is no separate builder. section(key, render, { tag }) builds a section, and wrapSection(key, wrapper) decorates one, composed per resolution like tool wrappers. Sections render in the agent's order: extension order, a later extension's same-key section replacing the earlier one in place, and instructions last (section 7.1). A section whose render returns undefined is omitted. With tag omitted or true, text is wrapped as <key>\n...\n</key>. A section that throws keeps its shown text, if any, and is reported; the request is still sent. Errors thrown after the generation invocation is cancelled propagate; an abort error of the section's own, such as its fetch timing out, is an ordinary failure. With no sections, the desired section set is empty.

A minimal prompt is one untagged section:

const Chat = defineExtension({
  name: "chat",
  sections: [section("preamble", () => "You are a helpful assistant.", { tag: false })],
});
registry.install(Chat);

A conversation's prompt varies through its extension selection, its instructions, input.env, and input.read. A subagent that should not see skills does not select the skills extension, and a cwd section renders input.env.cwd; no section checks whether it runs in a subagent. Renderers must be deterministic for equal inputs: any change in rendered text, such as an embedded timestamp, appends a system delta and invalidates provider prompt caches.

Replay applies messages in transcript order. Non-empty content appends instructions. A section string adds or replaces that name without moving an existing section; null removes it, and a later re-addition appends it to the ordered section map. Within one message, tool removals happen before additions, so a same-name replacement gets the new declaration and position.

Preparation compares both values and order. If values can be patched without changing order, it emits the minimal patch. If shown and desired section order differ, one commit appends two pi.system entries: the first removes every shown section with null, and the second re-adds every desired section in desired order. This makes order-only changes and deletion/re-addition between requests replay exactly; merely restating equal values is insufficient.

A PR #9548 SystemMessage is always a patch, not a reset: it cannot remove previous content or restore section order merely by restating current values. Therefore, when a head removes the previous request-visible baseline, which is the case when the active context has a head marker and no pi.system entry was appended after that marker (has a higher ID), the new pi.system entry adds ContextEdit omissions for every earlier pi.system entry still retained after the cut. Its own message is then a complete baseline containing every desired section in order and every effective tool declaration. Model-context replay sees the new baseline instead of the omitted retained deltas. Preparation writes this baseline even when it restates the replayed values, so every later preparation finds a pi.system entry after the marker. Head rebaselining takes precedence over ordinary order/value patching. Without a head cut, an order mismatch uses the two-entry remove/re-add sequence above; only when order already matches does preparation emit the minimal changed values and null removals.

Tool changes are planned separately and ride on the last planned entry, or on one entry of their own when sections are unchanged. Declarations are compared with pi-ai declarationsEqual() and written with toToolDeclaration(), so application metadata never enters the transcript. A changed declaration is removed and re-added in the same message. Replay keeps retained tools in place and appends additions; when that would not yield the desired order, the message removes every offered tool and re-adds the desired tools in order. A head rebaseline adds every desired tool.

The rendered strings stored in historical SystemMessage.sections remain authoritative even if the current renderer changes. Pi-ai decides whether to send the messages positionally to a capable provider or fold them into one leading system message; Durable does not rewrite its stored transcript for provider compatibility.

7.5 Extension reload

Extension code is reloaded in process through the registry. The host installs the new extension object under the installed name, which replaces the old one in place in one publication. The Harness keeps running; no close or reopen is required.

registry.install(SkillsV2); // same name: conversations selecting skills render v2 at their next request
registry.uninstall(Skills); // selecting conversations get a system delta removing its section; nothing is rewritten
// Restart: the host installs its extensions again; stored names resolve against them.
  • New phase invocations and tool tasks use the new extension immediately; running invocations see it at their next phase boundary.
  • Work already running keeps its snapshot until its phase handler settles, and a pinned tool keeps its implementation until its execution settles. Replacement never signals or interrupts it. Resources the old code uses are the extension's responsibility (section 7.1).
  • A task definition replaced by name hands over at its next normal phase boundary (section 5.4). A task definition must increase its version when the meaning of persisted input or checkpoint state changes and migrate supported older state; a failed migration blocks the task rather than terminalizing it.
  • A replaced extension keeps its install position, so a reload does not move its hooks, tools, and sections relative to other extensions; within the extension, the new object's order applies. Moving a task definition from one extension to another leaves a gap between the two installs in which its pending tasks are blocked.
  • Document definitions are passed explicitly to typed access and need no registration; changed tokens take effect on their next access.

If old extension code ignores cancellation and never settles, new work already uses the replacement. Harness close still joins every invocation. Safe forced termination of arbitrary non-cooperative JavaScript requires worker/process isolation; that host terminates the process and reopens the Session from durable state. Old and new Harness instances must never own the same Session concurrently: open the new one after the old one's close() resolved, when none of its invocation code runs any more (section 2.2).

7.6 Code mode

@earendil-works/pi-durable/codemode provides the codemode tool (createCodemodeExtension({ sandbox })): callers: ["model"], replay: "safe". Its argument is a script, run with pi-codemode's deterministic sandbox.start(). The script calls the agent's callable tools as tools.<name>(args); each call is a nested call with key c<id>, the VM's own number for the call. As in the coding agent, a tool that declares structuredOutputSchema resolves with its structuredOutput, also on an error result that carries one (bash with a nonzero exit resolves with { output, exitCode, ... }, which the script checks), and a schema-less tool resolves with its text output, images left out, as its declaration (Promise<string>) says. Every other error rejects, with the schema-less tool's text as message when it is not empty, else the error diagnostics' messages, and the value, if any, as error.structuredOutput. Which tools declare a schema is recorded in the start data, so a resumed script maps results as it began.

The tool's description is fixed: the script environment and its globals. The extension's codemode prompt section lists the agent's callable tools as TypeScript declarations, grouped by namespaces (a tool belongs to the one with the longest matching name prefix), within an inline budget of 3000 estimated tokens: every namespace places its cheapest tool before any places a second, and what does not fit is said so and found with searchTools(). Scripts find tools with ALL_TOOLS, searchTools(query, { limit?, namespace? }) (BM25), describeTool(name), and describeNamespace(name), answered from the start data and journaled like other globals. The result's content is a Script completed/Script failed header with the wall time since startedAt, the text items (==> text N/M <== when there are several), the console lines in one <console_output> block, the returned value, and on failure the error with the calls already made. Text past max_output_tokens (default 10000) keeps its start and end, the complete text written to a file (details.fullOutputPath); images are saved to files, their paths next to them. The Harness cuts a result past the tool's outputLimits (4 MiB, 100,000 lines) without such a file, so the text is also cut when it exceeds 1 Mi characters or the line limit, whatever max_output_tokens says. The files go to a temp directory of the environment that the run creates once and records in its run document (outputDir) before writing to it, under fixed names (output.txt, image-<n>.<ext>): a rerun after a crash overwrites them instead of adding new ones.

With models (default on), scripts get the model catalog (models.getModelsOfType, getAvailableOfType, getModelOfType; journaled globals, configured headers removed) and models.classify() and models.generateImages(). Those two are paid effects, so each runs as an internal tool of the extension, pi.codemode.models.classify and pi.codemode.models.generateImages (callers: ["tools"]): the code mode tool turns the script's call of the global into a nested call of the tool, which then has its own task, live row, abort, usage accounting, and replay policy. Tools named pi.codemode.* are recorded in the start data as hidden: never in the section, tools, ALL_TOOLS, or tool search. They declare a schema, so like any such tool they resolve with the model's result whatever its stopReason, which the script checks; their rows show a failed one as an error. At most four such calls of one script run at once, and a call waiting for a slot when the script has ended is not started. A script that received generated images and showed none gets a note in its result. The tool's declaration carries the Lark grammar of the script source as constrainedSampling, which pi-ai applies for models that support it.

The result's `details` list the script's tool calls in call order (`CodemodeToolDetails`: id, name, cut arguments, status `ok`/`error`/ `cancelled`, duration, cut error), written once with the result; a call still running or not yet launched when the script ended is `cancelled`. While the script runs, UIs show its nested calls from `pi.live.nestedTools`.

Three documents, two of them scoped to the code mode call's task:

  • pi.codemode.run: start, written once before the VM starts (module version, startedAt, seed, the VM's fingerprint once reported, the deadline from timeout_ms, the tools and globals the script sees, the store snapshot), and result, the outcome, written once.
  • pi.codemode.journal: entries, one per result delivered to the VM, in delivery order: the call's ID, the clock value, issuedAfter (the calls the VM had issued after processing it, from its acknowledgment), and for a global its name, arguments, and outcome; and firstIssued, the calls the VM had issued after the script's first run.
  • pi.codemode.store: conversation-scoped, history: "rewindable", fork: "asOf"; a successful script's store() writes are applied per key in the transaction that writes its result.

Live, a finished call's result is appended to the journal and then delivered as result number entry index + 1, with the clock at the entry's time; one journal commit is in flight at a time and later results join the next. Acknowledged counts are recorded with the next journal commit, or at once when the VM has processed everything delivered. A call is launched only once the count of the drain that made it is committed, so every key an attempt admitted is at most its last recorded count.

A run the sandbox ends as aborted without the call having asked for it (the host closed the sandbox, for example before closing the Harness) decides nothing: the call stores no outcome and ends with its invocation, so a later attempt replays the script.

A rerun (execute() after recovery) returns a stored result without a VM. Otherwise it ends the call with a stored error result, admitting no new nested call, when the module version differs or the deadline passed; it starts the VM from start and does the same when the fingerprint the VM reports differs, before delivering or reattaching anything. It then delivers the journal in order, each entry once the VM has issued its call; a tool's result comes from its nested call by key, a global's from the journal after comparing name and arguments. Calls the journal does not have are held until the last journaled result is acknowledged. Divergence ends the call with a stored error result, then stops the VM with an error the script cannot catch: a count that differs from a recorded issuedAfter, a VM that waits without having issued the next entry's call, a key or global called with another name or arguments, or a script that ends while processing an entry whose issuedAfter was recorded (or during its first run with a non-empty journal), or before issuing a call that a later entry answers. A live call whose key an earlier attempt admitted with another tool or other arguments diverges the same way. Every recorded count, firstIssued included, must equal the replay's. On a rerun, live calls are held until the acknowledgment of the last journaled result, or of the first run when the journal is empty; by then the counts have matched, so the script has made every call an earlier attempt could have admitted. Every held call's key is checked against its stored nested call, whatever the call's target, and only when none differs are they launched; later calls are new. Nothing is admitted or delivered once the run has its outcome. A script that ends before using every entry is otherwise fine: the live run ended there too. A count the replay acknowledges but the journal lacks is recorded with the first live commit. Nested calls admitted before the restart may resume on their own whatever the rerun decides; the call's cleanup aborts them.

8. Built-in tasks

The initial implementation provides:

kind responsibility
pi.generation prepare system prompt and tools, request or poll model, retry, classify response
pi.tool validate, hook, execute, persist output and details, append result
pi.compaction select a transcript range, summarize, place a headed summary

Generation uses HarnessOptions.models without a Durable-specific model adapter. It resolves models.getModel(ref.provider, ref.modelId), builds a pi-ai Context from the prepared prompt/messages/tools, and calls models.streamSimple() with the task invocation's abort signal, the agent's thinking level, and the settings' stream options. Deferred continuation calls models.fetchDeferred() and models.cancelDeferred() with that same model and signal. Missing models and synchronous/streamed pi-ai errors are classified into the durable generation outcomes below.

Generation and tool progress are throttled durable document commits. A crash may lose only the uncommitted throttle window. Recovery converts committed partials to normal interrupted/aborted transcript entries, clears presentation state, and then retries or terminates according to the task phase. Retry deadlines, attempts, compaction, and tool progress are current document state for late joiners; completed-attempt usage/accounting is an entry or terminal detail. Truncation and spill paths are tool diagnostics (section 7.3).

Compaction changes model context by appending a summary entry with a head. It does not delete transcript history.

8.1 Built-in entries

Built-in entry kinds carry no data, except pi.tool-result, whose token is Entry<{ diagnostics: ToolDiagnostic[] }>; every tool result carries data, with an empty list when it has no diagnostics (section 7.3), and pi.compaction, whose token is Entry<{ reason: CompactionReason }>. Each kind is exported as an Entry token.

kind model written by
pi.user [UserMessage], timestamp from the Harness clock at admission or placement submissions, onYield continuations
pi.assistant [AssistantMessage] with any stop reason generation
pi.system [SystemMessage] with content: "" (section 7.4) generation preparation
pi.tool-result [ToolResultMessage] tool tasks; generation for calls to tools its request did not offer
pi.reset absent, or [UserMessage] with the handoff text; always head: "self" reset(), generation tools phase for handoff
pi.compaction [UserMessage] with the wrapped summary; head is the first kept entry compaction tasks (section 8.7)

Every generation response becomes a pi.assistant entry: answers, failed attempts with their error text and usage, and converted partials with stop reason aborted. Context derivation (section 2.1, rule 9) keeps failed and aborted messages out of later requests, so no separate usage or notice kind exists.

8.2 Live document

type LiveState = {
  /** Run control (section 6); present exactly while the conversation is busy. */
  run?: { taskId: TaskId; inputs: SubmissionId[] };
  /** Presentation of the current generation attempt. */
  generation?: {
    attempt: number;
    /** Committed throttled partial of the in-flight response. */
    message?: JsonRepresentation<AssistantMessage>;
    /** Durable backoff before the next attempt. */
    retry?: { at: number; error: string };
    /** Provider-side deferred response being polled. */
    deferred?: { pollAt: number };
  };
  /**
   * Nested calls of running tool calls (section 7.3), in creation order, so a parent precedes its children and task
   * IDs ascend.
   */
  nestedTools?: {
    /** `<parentCallId>/<key>`: the call's ID in tool events, unique because keys contain no `/`. */
    callId: string;
    parentCallId: string;
    /** The calling tool task, model-issued or nested; the Harness relates slots by task ID. */
    parentTaskId: TaskId;
    taskId: TaskId;
    name: string;
    /** The call's arguments, unbounded; once it executes, those it runs with, after repair, hooks, and coercion. */
    arguments: JsonObject;
    status: "pending" | "running" | "done";
    output?: string;
    droppedBytes?: number;
    droppedLines?: number;
    details?: JsonValue;
    diagnostics?: ToolDiagnostic[];
    /** Once done: how the call ended, with up to 500 characters of error text; the result is not here (section 7.3). */
    summary?: { isError: boolean; durationMs?: number; usage?: Usage; error?: string };
  }[];
  /** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */
  tools?: {
    callId: string;
    name: string;
    /**
     * Absent for a call not started yet (sequential round, `pending`) and for a call its request did not offer,
     * which starts `done` with the `entry` generation wrote.
     */
    taskId?: TaskId;
    status: "pending" | "running" | "done";
    /** Retained running output and what the bounds dropped. */
    output?: string;
    droppedBytes?: number;
    droppedLines?: number;
    /** Last `details()` value. */
    details?: JsonValue;
    /** Diagnostics recorded through `api.diagnostic()`. */
    diagnostics?: ToolDiagnostic[];
    /** Result entry once done; absent when the tool task faulted or was orphaned. */
    entry?: EntryId;
  }[];
  /** Live compaction tasks in task ID order; absent when none (section 8.7). */
  compactions?: CompactionStatus[];
  /**
   * The newest compaction failure since a compaction last produced a summary (section 8.7), so a client can show it
   * after the task ended.
   */
  compactionFailure?: CompactionFailure;
};

type CompactionFailure = {
  taskId: TaskId<CompactionResult>;
  reason: CompactionReason;
  /** The failed or faulted outcome's error message, or why the scheduler orphaned the task. */
  error: string;
};

type CompactionStatus = {
  taskId: TaskId<CompactionResult>;
  reason: CompactionReason;
  /** Whether a generation waits for it: a compaction the generation owns. */
  blocking: boolean;
  attempt: number;
  /** Durable backoff before the next summarization attempt. */
  retry?: { at: number; error: string };
};
field value
kind pi.live
version 1
scope/history/fork conversation, latest, initial
initial() {}
checkpoint complete base whenever nothing runs: generation absent and no running tool or nested slot
view mount docs["pi.live"]
created with every Harness conversation (section 2.2)

Nothing runs while idle, at a final boundary, in the commit where generation hands over to its tool round (every slot is still pending or done), between the tools of a sequential round, and whenever no tool of a parallel round is running. A slot holds output only while running, so every base is small, and the stored delta chain spans at most one generation, including its retries and deferred polls, or the overlapping execution of one round's tools. Retained head output grows by Chord string appends. A sliding tail usually becomes a front trim plus an append; when Chord's bounded overlap search finds no shared part, as with highly repetitive output or a window larger than its 64 KiB scan, the commit writes the retained window as one set. Either way each commit writes at most one window, and the throttle bounds the rate.

Generation creates tools with one pending slot per call in the commit that appends the tool-calling answer; a call its request did not offer starts done with its result entry. A tool task sets its slot running in its intent commit, publishes throttled output and details into it, and in its terminal commit sets done and entry and removes output, droppedBytes, droppedLines, details, and diagnostics, which the result entry now carries (section 7.3). The generation's tools phase removes tools. Slot updates apply only while a slot with the task's taskId exists; without one, the durable partial output, details, and diagnostics are empty.

A nested slot is pushed to nestedTools in the commit that creates its task, with the arguments the caller passed, runs like a tool slot, gets the arguments it executes with in its intent commit when they differ (a model-issued call's are in its assistant entry), and in its terminal commit becomes done, with its progress removed like a tool slot's and its summary set, enough for a status line; its result is in the caller's NestedCallDoc, never in pi.live (section 7.3). When any call settles, its terminal commit removes the nested slots below it, transitively by parentTaskId; its left-running nested calls were aborted first. endRun and the generation's tools phase remove nestedTools with tools.

A compaction's status is added in the commit that creates the task and removed in the commit that decides its outcome: the task's own outcome commit, even when that outcome holds as completing (section 5.5), or the scheduler's cleanup (section 5.4). It stays small: the summary text lives in the task's placement, never here. Partials are normalized to strict JSON before assignment. Every terminal path of a run task removes run, generation, and tools in the commit that settles the run's inputs. tx.settleSubmission() stages each input's new status and resolves the transaction's latest candidate submission record, falling back to committed state, during assembly, like task-document validation (section 3.3), so it is not a caller table read and works after the commit's first table write.

8.3 Generation

type GenerationInput = {};
type GenerationCheckpoint =
  | {
      phase: "prepare";
      attempt: number;
      /** The blocking compaction this generation waited for; it starts no other compaction. */
      compacted?: TaskId<CompactionResult>;
      /** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */
      overflow?: string;
      /** Set when the blocking threshold started `compacted`; its outcome is checked once when `prepare` resumes. */
      threshold?: true;
    }
  | {
      phase: "request";
      attempt: number;
      compacted?: TaskId<CompactionResult>;
      model: ModelRef;
      thinkingLevel: ModelThinkingLevel;
      streamOptions: ConversationStreamOptions;
      /** Newest entry included in the request. */
      cutoff: EntryId;
    }
  | { phase: "retry"; attempt: number; compacted?: TaskId<CompactionResult>; until: number }
  | {
      phase: "poll";
      attempt: number;
      compacted?: TaskId<CompactionResult>;
      model: ModelRef;
      cutoff: EntryId;
      handle: DeferredHandle;
      pollAt: number;
    }
  | {
      /** Waiting on the round's tool tasks; `waiting` holds this checkpoint (section 8.5). */
      phase: "tools";
      assistant: EntryId;
      tools: TaskId<ToolTaskResult>[];
      /** Calls of a sequential round not started yet, in call order. */
      pending: string[];
    };
type GenerationResult = { entryId: EntryId };

pi.generation is version 1 and starts at { phase: "prepare", attempt: 1 }. The run's inputs live in pi.live.run, not in the task input.

  • prepare runs section 7.4 with the phase's agent resolution and resolves the settings once. When the agent has no model or models.getModel() does not know it, the task fails with no_model. When it resumes with overflow and its compaction did not complete with an entryId, the run fails with model_error and the overflow text as detail. Otherwise one commit appends the planned pi.system entries and moves to request with the new tail as cutoff, the agent's model and thinking level, and the settings' stream options. These stay fixed for this request attempt; a retry prepares again. compacted carries over to request, retry, poll, and the next prepare.
  • Before that commit, prepare checks the compaction thresholds (section 8.7) when the settings' compaction policy is enabled, the model's contextWindow is positive, and compacted is absent. The estimate starts at the newest assistant message in the committed model context whose entry was appended after the head marker (all qualify without one) and whose usage is nonzero: pi-ai calculateContextTokens() of its usage, plus pi-ai estimateMessageTokens() of every context message after it and of the planned system messages. Its request included the marker, because a head is placed only while no request is in flight. Without such a message, every message is estimated. Both thresholds apply only when range selection finds a cut.
    • Above contextWindow - reserveTokens, the blocking threshold: instead of appending, one commit creates a compaction owned by the generation with reason threshold, adds its status, and commits waiting on it with allSettled and checkpoint { phase: "prepare", attempt, compacted, threshold: true }. When prepare resumes, it reads the compaction's outcome once. If the compaction completed, with or without a summary (nothing to compact, or declined), prepare continues and sends the request. If it failed, faulted, was orphaned, or was aborted directly, the generation fails with model_error without a request, and its inputs settle unanswered with detail Compaction failed: <error> (Compaction was aborted for an abort). A failed compaction leaves a context above the blocking threshold, where a request would at best leave no room for the answer.
    • Above contextWindow - reserveTokens - backgroundTokens with backgroundTokens > 0 and no compaction status in pi.live: the commit that moves to request also creates a background compaction, owned by the conversation, with reason threshold, and adds its status. The generation does not wait for it. The settings' retry policy is read when the attempt's result is classified, because it governs the next attempt (section 7.1).
  • request and poll resolve the checkpoint's model through models.getModel(); an unknown model fails the task with no_model, like prepare. request converts a leftover partial (below) before it resolves the model.
  • request first converts a committed partial left in pi.live by an interrupted attempt into an aborted pi.assistant entry. It then streams the model context through cutoff with the invocation signal, the thinking level as reasoning (omitted for off), the conversation's persisted provider sessionId, and the pinned streamOptions, committing throttled partials at most every settings.progress.partialIntervalMs (default 100 ms). Before streaming, the beforeRequest chain may replace the messages for this request only. Recovery resends the same committed messages with the same pinned model, thinking level, and stream options, and reruns beforeRequest.
  • afterResponse observes every terminal message, from request or poll, before classification; a still deferred result is not terminal.
  • Before classifying, the handler stops the partial throttle and awaits any partial commit in flight, so no stale partial lands after the outcome. The terminal message is classified in one commit that also clears the partial:
    • stop/length: before the commit, the onYield chain runs; the first { continue } wins. The commit appends the answer and applies the final boundary (section 6). With a continuation and no selected user item or reset, it appends a pi.user entry with the continuation content, creates a successor generation, and hands it pi.live.run, keeping the inputs open. Otherwise it settles the run's inputs done, removes run and generation, and starts a successor run for the selected user items, if any; a dropped continuation is not retried. Both complete with { entryId }.
    • toolUse with at least one tool call: the commit appends the assistant entry and starts the tool round described below, moving to waiting in the tools phase. A toolUse message without calls is classified like stop.
    • error that pi-ai isContextOverflow() recognizes, while the settings' compaction policy is enabled, compacted is absent, and range selection finds a cut: the commit appends the error entry, removes generation, creates a compaction owned by the generation with reason overflow, adds its status, and commits waiting on it with allSettled and checkpoint { phase: "prepare", attempt, compacted, overflow }; the recovery request does not count against the retry policy. Any other overflow, including a second one, is never retried and fails like the non-retryable errors below. A stop or length response is never classified as overflow; the next threshold check handles silent overflow.
    • error that isRetryableAssistantError() accepts while the settings' retry policy allows another attempt (enabled and attempt <= maxRetries, so maxRetries counts retries after the first attempt): append the error entry and move to retry with until = now + retryDelayMs(policy, attempt).
    • any other error, or aborted without an abort mark: append the error entry, settle the inputs unanswered with model_error, remove run and generation, and fail.
    • deferred: move to poll with pollAt = now + (handle.pollAfterMs ?? 5000).
  • retry sleeps until until, then returns to prepare with the next attempt, so agent and settings changes made during the backoff apply.
  • poll sleeps until pollAt and calls models.fetchDeferred(). A still deferred result moves pollAt strictly later; any other result is classified as above.
  • The abort handler calls models.cancelDeferred() in poll when the model is known (a failure is reported), converts a committed partial, settles the inputs unanswered with aborted, removes run and generation, and ends aborted.

A tool round starts in the commit that appends the tool-calling answer:

  1. The offered tools are replayed with pi-ai getCurrentTools() from the committed model context through cutoff: request already holds it, and poll derives it again. A call to a tool not offered gets its tool_unavailable result entry here, without a task.
  2. Every other call gets a pi.tool task owned by the generation, with input { assistant, callId }. The round is sequential when the settings' toolExecution, read as the round starts, is sequential or any called tool, resolved from the phase's agent as the tool task resolves it (section 7.3), has executionMode: "sequential": then only the first call gets its task now and the rest wait in pending. Otherwise every call gets its task at once and they run in parallel. The checkpoint's tools lists the tasks created so far and grows by one per started sequential call, while pending shrinks.
  3. The generation commits waiting on its tool tasks with allSettled and the tools checkpoint (section 8.5); pi.live.run stays with it.
  4. pi.live.tools receives the round's slots (section 8.2), and generation is removed.

Input submissions settle unanswered with one of these reasons: no_model, model_error (detail: provider error text), aborted, faulted (detail: error message), or an orphaning blocked reason (section 5.4). Fault and orphan settlement convert a committed partial into an aborted pi.assistant entry, like the abort handler.

8.4 Tool

type ToolTaskInput =
  | { kind: "model"; assistant: EntryId; callId: string }
  | { kind: "nested"; parent: TaskId; parentCallId: string; key: string; call: ToolCall; progress?: false };
type ToolTaskCheckpoint =
  | { phase: "call" }
  | { phase: "execute"; arguments: JsonObject; replay: "safe" | "unsafe" };
type ToolTaskResult = { kind: "model"; entryId: EntryId; control?: ToolControl } | { kind: "nested" };

pi.tool is version 2 and starts at { phase: "call" }; migrate gives a version 1 input kind: "model", and readers treat a terminal result without kind as a model-issued call's. A model-issued call's input stays small because the terminal record keeps it; the call is read from the assistant entry. A nested call (section 7.3) carries its call, since no entry holds it, and settles differently: its result commit records the result's usage (section 8.6), writes its result for programs to the caller's NestedCallDoc (section 7.3), marks its nested slot done with its summary, appends no entry, and ends with { kind: "nested" }; control does not apply. Below, { entryId } is a model-issued call's result.

  • call reads the call with runtime.entry(), resolves the tool among the phase's agent tools (section 7.3), validates the arguments with pi-ai validateToolArguments(), runs the beforeTool chain, and validates again (section 7.3). One commit then records intent: it moves to execute with the final arguments and the tool's replay policy and sets the slot running. The same handler executes the tool, runs the afterTool chain, and commits the result; it builds api.env with HarnessOptions.env right before executing (section 2.2). A tool that does not resolve, invalid arguments, or a block commits the corresponding error result instead, without intent.
  • execute is reached only by recovery. When both the stored and the current policy, resolved as in call, are safe, it executes again with the stored arguments, without beforeTool, and settles like call. Otherwise it commits an interrupted error result from the slot's durable partial output, details, and diagnostics, and ends failed with { entryId }.
  • The result commit bounds the content, appends the diagnostics block (section 7.3), appends one pi.tool-result entry with model: [{ role: "toolResult", toolCallId, toolName, content, details, isError, durationMs, timestamp }] and data: { diagnostics }, marks the slot done, and completes with { entryId, control }. An isError result still completes. durationMs is how long execute() took in this attempt, measured with a monotonic clock, including when it threw; results of calls that did not execute, and interrupted or aborted results, have none.
  • A throw from execute(), or from HarnessOptions.env building its environment, becomes a tool_error result and the task ends failed with { entryId }; once the invocation is signalled it propagates instead. Ending failed, like aborted, records cancellation intent (section 5.4), so conversations the call owns, which nothing supervises any more, are aborted. A returned isError result still completes, and its call's owned work survives. Either way the run continues with the result entry. A tool task whose owned conversations still have ordinary work when it commits its result holds completing with the result entry already written (section 5.5); the generation resumes only when the tool task is terminal.
  • The abort handler commits an aborted error result from the slot's durable partial output, details, and diagnostics and ends aborted with { entryId }.

8.5 Tool rounds

The generation resumes in its tools phase once every tool task it waits on is terminal. In a sequential round with calls left in pending, one commit creates the next call's owned tool task and waits on it again with the shorter pending. Otherwise it reads the tool records with runtime.getTask() and the round's result entries from the pi.live.tools slots, runs the afterTools observers, and then commits once:

  • It edits the conversation's stored pi.agent tools and modelTools for every addTools name: an array gets the name appended unless it holds it already, and { remove } loses the name. An unset tools already enables every tool, and nothing is written for it. An unset or object modelTools also gets the name appended to its add, which loads a deferred tool and has no effect for others. Names are written without consulting the registry, so a tool that is not registered now is offered once it is again. The next preparation offers the tool when it resolves and the model may call it.
  • When every result of the round requests terminate, or any requests handoff, it appends the handoff's pi.reset entry, if any, settles the run's inputs done with the tool-calling answer, removes run and tools, and applies the final boundary.
  • Otherwise it removes tools and applies the postTools boundary. When that boundary placed a reset, the run's inputs settle unanswered with reset and selected user items start a successor run (section 6). Otherwise selected steer IDs join pi.live.run, and it creates the next generation, owned by the conversation, and hands it the run.

It completes with { entryId } of the tool-calling answer. Its abort handler runs only after its tool tasks are terminal (section 5.5). For every call still in pending, which never started, it appends an aborted error result; a started call whose task faulted or was orphaned keeps its done slot without an entry (section 8.2). It then settles the inputs unanswered with aborted, removes run, generation, and tools, and ends aborted. abortTask() on the generation therefore aborts its whole round.

8.6 Usage

type UsageState = {
  /** Assistant entries and compaction summarization attempts, keyed `provider/modelId`. */
  models: Record<string, Usage>;
  /** Tool results, keyed by tool name; their usage has no model identity. */
  tools: Record<string, Usage>;
};
field value
kind pi.usage
version 1
scope/history/fork conversation, latest, initial
initial() { models: {}, tools: {} }
checkpoint complete base on every change
view mount docs["pi.usage"]
created with every Harness conversation (section 2.2)

pi.usage is the ledger of the conversation's own spend. Every built-in writer of a pi.assistant entry adds its message's usage to models under the message's own provider/model, and every writer of a pi.tool-result entry with usage adds it to tools, in the same commit, as does the result commit of a nested call (section 7.3) with usage. Every summarization attempt of a compaction task adds its response's usage to models in the commit that classifies it (section 8.7); that spend has no entry, whether the summary is placed, fails, or ends stale. Failed and aborted attempts count. A fork starts at zero, so no spend is counted twice. Harness.usage() sums every conversation's document into the Session total; other totals, such as an ownership subtree, are application sums over scanConversations(). Nothing stores a total across conversations.

8.7 Compaction

type CompactionReason = "manual" | "threshold" | "overflow";
type CompactionInput = { reason: CompactionReason; instructions?: string };
/** The pinned summarization request. */
type SummaryRequest = {
  attempt: number;
  model: ModelRef;
  thinkingLevel: ModelThinkingLevel;
  streamOptions: ConversationStreamOptions;
  maxTokens: number;
  /** Newest entry of the context the range was selected from. */
  tail: EntryId;
  /** First entry kept verbatim; the summary's `head`. */
  firstKept: EntryId;
};
type CompactionCheckpoint =
  | { phase: "select" }
  | ({ phase: "summarize" } & SummaryRequest)
  | ({ phase: "retry"; until: number } & SummaryRequest);
/**
 * `entryId` of a blocking compaction's summary, or the `submissionId` of a conversation-owned compaction's summary
 * write; both absent when nothing was compacted.
 */
type CompactionResult = { entryId?: EntryId; submissionId?: SubmissionId };

pi.compaction is version 1 and starts at { phase: "select" }. Compaction replaces an old prefix of the model context with a summary entry whose head is the first kept entry (section 2.1). Raw history stays in storage. There are three ways to start one; the task is the same, only ownership and placement differ:

started by owner background generation waits placement
Conversation.compact() conversation no no write submission
generation above the background threshold conversation yes no write submission
generation above the blocking threshold, or on overflow the generation no yes direct append

Only a blocking compaction runs while the conversation's run waits for it. The others never take run control: the conversation keeps working while they summarize, and their summary is placed like any passive write. Compactions do not coordinate with each other; several may run at once, and section 6 decides between their summaries.

Range selection is a pure function of a ContextView, whose contributions give each active entry's model messages after every edit in the range (section 2.1, rules 4 and 9), including edits carried by older in-range markers, and the settings' keepRecentTokens.

  1. Cut candidates are the non-marker entries whose contribution begins with a user or assistant message. Tool results and system entries are never candidates, so a kept assistant message keeps its tool results. A user entry is not a candidate either while a result for a call of the assistant before it follows it, before the next assistant (section 2.1, rule 7).
  2. Walk the non-marker entries from newest to oldest, adding pi-ai estimateMessageTokens() of each contribution. At the first entry where the sum reaches keepRecentTokens, the cut is the first candidate at or after that entry in transcript order, or the newest candidate when none follows.
  3. There is nothing to compact when the sum never reaches keepRecentTokens, or when no non-marker entry before the cut contributes a model message.

The summarized entries are the head marker, if any, followed by the non-marker entries before the cut. Their messages are their contributions, ordered as in section 2.1, rules 7 and 8, so an earlier summary is summarized again together with the history after it. Nothing is compacted while the context is smaller than keepRecentTokens, so with a window where contextWindow - reserveTokens is below it, overflow may come before any threshold compaction.

1 user   2 assistant (read)   3 tool result, 30k tokens   4 assistant   5 user   6 assistant
keepRecentTokens 20k: the walk from 6 reaches 20k at 3; the first candidate at or after 3 is 4.
The summary covers 1-3; the model context becomes [summary, 4, 5, 6].

Phases:

  • select takes the phase's agent resolution, resolves the settings once, and captures the committed context. When the agent has no model or models.getModel() does not know it, the task fails with no_model. With nothing to compact it completes with {}. Otherwise the beforeCompact hooks (section 7.2) run with the summarized entries and messages; the first decision wins. { decline: true } completes with {}, and { summary } is placed as below in the same commit. Without a decision, one commit moves to summarize with attempt 1, the agent's model and thinking level, the settings' stream options, maxTokens = min(floor(0.8 * reserveTokens), model.maxTokens) (the model's value only when positive), the captured tail, and the cut as firstKept.
  • summarize derives the summarized messages again from the context at tail, which is immutable, and serializes them to text: [User]: ..., [Assistant]: ..., [Assistant tool calls]: name(key=json, ...), and [Tool result]: ... truncated to 2000 characters; system messages and thinking are omitted (replayed thinking is often not part of the live context, so it could make the request larger than the context it summarizes). The request has no tools and two messages: a system message with the built-in summarization system prompt, and a user message with the serialized text in <conversation> tags followed by the built-in summarization prompt, and Additional focus: <instructions> when the input has instructions. It uses the pinned thinking level as reasoning, the conversation's persisted provider sessionId, and the pinned stream options without deferred, with cacheRetention: "none" and the pinned maxTokens. Providers such as Codex may suppress that identity when caching is disabled. Recovery resends the same request. The response is classified in one commit that adds its usage to pi.usage (section 8.6):
    • stop with non-empty text and no tool call: the text is the summary, placed as below.
    • error that isRetryableAssistantError() accepts while the settings' retry policy, read at classification, allows another attempt: move to retry with until = now + retryDelayMs(policy, attempt), and set the status's attempt and retry.
    • anything else, including a length stop, which leaves the summary incomplete: fail with model_error.
  • retry sleeps until until, then returns to summarize with the next attempt and the same pinned request.

The built-in prompts are the coding agent's structured checkpoint format. The prompt also asks the model to carry forward an earlier summary at the start of the conversation.

Placement. The summary entry is

{
  kind: "pi.compaction",
  head: firstKept,
  model: [{ role: "user", content: [{ type: "text", text: wrapped }], timestamp: now }],
  data: { reason },
}

where wrapped is "The conversation history before this point was compacted into the following summary:\n\n<summary>\n", the summary, and "\n</summary>". The commit that places it also removes the task's status, removes pi.live.compactionFailure, and commits the task's completed outcome:

  • A blocking compaction appends the entry directly and completes with { entryId }. Its generation holds the run and waits, so nothing else writes the conversation.
  • A conversation-owned compaction admits the entry as a write submission with request ID compaction:<taskId>, following section 6 exactly: in an idle conversation with an empty inbox it is appended at once or settles stale; in an idle conversation with queued items it queues and a final boundary runs; in a busy one it queues for the next boundary. It completes with { submissionId } and never waits for a queued placement; Harness.submission() observes the placement.

The next generation's preparation finds a head marker without a later pi.system entry and writes a complete system baseline (section 7.4).

Cancellation. The abort handler removes the task's status and ends aborted; it writes no entry. An attempt cut short by abort or a crash has no committed response, so its usage is not counted. A manual compaction is ordinary work of its conversation: Conversation.abort() aborts it. A background compaction survives it and stops only through abortTask() or Conversation.abort(context, { background: true }). A blocking compaction is aborted with its generation, before the generation's abort handler runs (section 5.5). A summary that is already queued is a write submission: conversation abort keeps it, and Submission.abort() withdraws it.

Failure. The commit that fails a compaction (model_error, no_model) removes its status and sets pi.live.compactionFailure to its task, reason, and error message, replacing an older failure; the Harness cleanup does the same for a compaction the scheduler faults or orphans, with the fault's message or the orphan reason. Only a compaction that produces a summary removes it; one that is aborted or finds nothing to compact leaves it.

When a blocking compaction ends without an entryId, an overflow generation fails with model_error. A threshold generation sends its request anyway when the compaction completed (it found nothing to compact or was declined), and fails with model_error when it failed, faulted, was orphaned, or was aborted directly (section 8.3).

9. Document observation and Chord

9.1 Document state

Chord's existing replicated-state layer exposes this source-adoption contract:

interface ReplicatedStateSourceFrame<T> {
  readonly cursor: number;
  readonly value: T;
  readonly ops: readonly Op[];
  readonly context: Context;
}

interface ReplicatedStateSourceAttachment<T> {
  /** Fixed immutable snapshot captured at the atomic attachment boundary. */
  readonly snapshot: { readonly value: T; readonly cursor: number };
  /** Install the sole listener and synchronously drain every buffered frame. */
  activate(listener: (frame: ReplicatedStateSourceFrame<T>) => void): void;
  dispose(): void;
}

interface ReplicatedStateSource<T> {
  /** Atomically capture a snapshot and begin buffering every later commit. */
  attach(): ReplicatedStateSourceAttachment<T>;
}

interface ReplicatedStateSourceOptions {
  readonly onError?: (error: Error) => void;
}

interface AttachedReplicatedState<T> extends ReplicatedState<T> {
  readonly value: T;
  dispose(): void;
}

function replicatedState<T>(
  source: ReplicatedStateSource<T>,
  options?: ReplicatedStateSourceOptions,
): AttachedReplicatedState<T>;

type DocumentState<T extends JsonObject> =
  AttachedReplicatedState<Readonly<T> | null>;

type WatchEnd =
  | { readonly reason: "stopped" | "cancelled" | "session_closed" | "retired" }
  | { readonly reason: "listener_error"; readonly error: Error }
  /** The Session failed; `error` is what failed it, usually a storage error (section 10.1). */
  | { readonly reason: "session_failed"; readonly error: unknown };

interface WatchHandle<T> {
  /** Acquisition revision before start; latest delivered immutable revision afterward. */
  readonly value: T;
  /** Installs the sole serialized asynchronous listener. */
  start(listener: (value: T, ops: readonly Op[], context: Context) => Promise<void>): void;
  /** Idempotently stops future callbacks and returns this watch's terminal result. */
  stop(): Promise<WatchEnd>;
  /** Settles when the watch terminates; an already-running callback remains caller-owned. */
  readonly closed: Promise<WatchEnd>;
}

type DocumentWatch<T extends JsonObject> = WatchHandle<Readonly<T> | null>;

interface DocumentObserver {
  watchDoc<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<DocumentWatch<T> | undefined>;
  watchDoc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId, context: Context): Promise<DocumentWatch<T> | undefined>;
  watchDoc<T extends JsonObject>(token: TaskDocToken<T>, taskId: TaskId, context: Context): Promise<DocumentWatch<T> | undefined>;
  watchDoc<T extends JsonObject, I extends JsonValue>(token: SessionDocFamilyToken<T, I>, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
  watchDoc<T extends JsonObject, I extends JsonValue>(token: ConversationDocFamilyToken<T, I>, conversationId: ConversationId, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
  watchDoc<T extends JsonObject, I extends JsonValue>(token: TaskDocFamilyToken<T, I>, taskId: TaskId, key: string, context: Context): Promise<DocumentWatch<T> | undefined>;
}

documentState() attaches Durable's committed document stream to Chord before returning. The returned state is already hydrated with the exact shareable immutable revision, has no mutation methods, and publishes every later exact committed revision and operation batch without another tracker, value copy, or re-diff. Disposing it unregisters only that observation; Durable remains the sole document mutator.

State and watch acquisition never create; absent lookup returns undefined. Successful acquisition binds one concrete incarnation. Retirement publishes a JSON null root replacement and ends that incarnation's stream. If the state remains exposed, consumers see null, never stale state. A later recreation requires acquiring a new state or watch.

Session close ends every state at its seal: the state keeps its last value and receives no frame from a commit that settles during close.

Each document state assigns its own in-memory contiguous Chord delivery sequence. Durable does not persist or expose that sequence through WatchHandle. States and watches retain immutable revisions independently of the loaded tracker cache. Reopen creates a new state lifetime and hydration.

Internally, state attachment uses Chord's synchronous atomic snapshot-and-register boundary: the snapshot includes every commit before attachment, and every later exact frame is buffered until activation. This prevents a snapshot from being paired with operations based on a newer unseen revision.

9.2 watchDoc

Tasks, hooks, and tools may observe any existing document for which their code has a token and owner/key. There is no additional subtree permission system inside trusted Session code.

On the Session line, watchDoc() resolves one existing concrete incarnation, captures its current immutable tracker revision in O(1), and registers for every later exact committed revision and operation batch. It returns undefined when absent. Before start(), watch.value remains the acquisition revision while later frames buffer.

const watch = await api.watchDoc(JobOutputDoc, producerTaskId, context);
if (watch === undefined) return;
try {
  await initializeConsumer(watch.value, context);
  watch.start(async (value, ops, deliveryContext) => {
    await consume(value, ops, deliveryContext);
  });
} catch (error) {
  await watch.stop();
  throw error;
}

start() installs the sole listener and schedules delivery; it never invokes user code inline. Each callback receives the exact immutable committed value and the exact operation batch that produced it. Immediately before invocation, watch.value advances to that value. The watch awaits the listener before starting its next callback, but Session commits never wait for callback settlement.

A watch retains at most 100 pending committed frames, excluding the frame already being delivered. Adding frame 101 replaces the complete undelivered suffix with one self-contained root replacement [["r", newestValue]], using the newest exact immutable revision and that commit's Context. Later exact frames follow that replacement normally. Another overflow replaces the pending suffix again. No serialized-byte measurement, value copy, operation replay, or re-diff occurs. This is convergent observation, not an audit stream; consumers requiring every transition must persist those facts separately.

watch.value and every previously returned revision remain stable forever under the trusted immutability contract. Consumers must not mutate them or retained descendants. The selected commit Context's values are preserved without inheriting the producer's cancellation. A callback owns its own asynchronous work.

A watch remains bound to its original incarnation. Retirement queues the exact terminal [["r", null]] frame, or folds it into an overflow replacement with value null. After that callback settles, the watch closes as retired and never follows a replacement incarnation.

stop() is idempotent. It synchronously unregisters, discards pending frames, prevents another callback from starting, and returns the common terminal promise. An already-running callback is not aborted or joined and remains caller-owned. The acquisition Context governs watch lifetime. Cancellation during acquisition cleans up before rejecting; later cancellation and Session close stop future delivery similarly. Listener failure discards pending work and closes only that watch as listener_error. The first termination reason wins, and every late rejection is observed.

9.3 Conversation view

The public view is a fixed structural mount of the conversation's active transcript and its built-in documents:

type ConversationView = {
  readonly conversation: ConversationRecord;
  /** Raw active entries, as `ContextView.entries` (section 2.1): the head marker, then the non-head entries from its head. */
  readonly entries: readonly EntryRecord[];
  /** `pi.agent`, `pi.live`, `pi.inbox`, `pi.provider`, and `pi.usage`, keyed by kind; absent documents are absent. */
  readonly docs: Readonly<Record<string, JsonObject>>;
};

These kinds and their fields are public protocol. Third-party documents are exposed through their own Chord objects, not mounted.

The Harness keeps at most one mount per conversation. The first viewState(), watch(), or event attachment builds its revision on the Session line from the committed active transcript and documents; the mount is dropped when its last observer detaches. A mount derives each next revision from the Session's subscribeCommits() publications, like watchDoc(): every publication is already durable, so the view shows only committed state. One publication that touches the conversation yields one Chord batch:

document op ["s", ["generation", "message"], value]
-> view op ["s", ["docs", "pi.live", "generation", "message"], value]

An appended entry without a head is a splice at the end of entries. An appended head marker H makes the entries H followed by the current non-head entries at or after H.head: the kept entries stay, and splices remove the others and insert H at the front. Every kept entry is already mounted because Harness writers never target a head before the current range: resets and handoffs head themselves, compaction summaries target a kept entry of the current range, and head writes that reach further back are stale (section 6). A raw head write that targets further back shows only the mounted entries (section 12). Document creation and retirement set and delete the docs key. Parent entries through parent.at are immutable, so a child's mount follows only its own conversation. A publication that touches nothing mounted creates no revision; a redundant nonempty batch remains a real publication. Mounted document revisions are structurally shared under the trusted immutability contract. The mount performs no semantic projection and owns no persistence.

Conversation.viewState() exposes the mount as a disposable read-only Chord state. Conversation.watch() exposes the same mount through the serialized exact-frame watch of section 9.2, with bounded pending frames and full-value overflow replacements.

9.4 Agent events (experimental)

watchEvents(harness, conversationId, context): Promise<AgentEventStream>, exported from the package root, is an experimental adapter that translates one conversation's committed publications into agent events shaped like the coding agent's AgentSessionEvents. It owns no tracker or persistence, covers exactly one conversation (not its owned subtree), and emits an event only after the commit that makes it true. Its protocol may change without notice.

/**
 * Which call a tool event is about. `toolCallId` is the provider's ID for a model-issued call and
 * `<parent call ID>/<key>` for a nested one; consumers match it and never parse it. `taskId` is the call's tool task,
 * absent for a call that never got one. A nested call also names its caller by call ID and by tool task.
 */
type ToolEventCall = {
  toolCallId: string;
  toolName: string;
  taskId?: TaskId;
  parentToolCallId?: string;
  parentTaskId?: TaskId;
};

type AgentEvent =
  | {
      type: "snapshot";
      entries: readonly EntryRecord[];
      run?: { inputs: readonly SubmissionId[] };
      /** Current generation attempt: its in-flight partial, retry backoff, or deferred poll. */
      generation?: { attempt: number; message?: AssistantMessage; retry?: { at: number; error: string }; deferred?: { pollAt: number } };
      tools: readonly ToolSlot[];
      /** `pi.live.nestedTools` (section 8.2). */
      nestedTools: readonly NestedToolSlot[];
      /** `pi.live.compactions` (section 8.2). */
      compactions: readonly CompactionStatus[];
      /** `pi.live.compactionFailure` (section 8.2). */
      compactionFailure?: CompactionFailure;
      inbox: readonly { id: SubmissionId; mode: InboxItem["mode"] }[];
      /** `pi.agent`; `{}` when absent. */
      agent: AgentState;
      usage: UsageState;
    }
  | { type: "run_start"; inputs: readonly SubmissionId[] }
  | { type: "run_end"; inputs: readonly SubmissionId[] }
  | { type: "turn_start" }
  | { type: "turn_end" }
  | { type: "message_start"; message: Message }
  | { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }
  | { type: "message_end"; entry: EntryRecord }
  | ({ type: "tool_execution_start"; args: JsonObject } & ToolEventCall)
  | ({
      type: "tool_execution_update";
      /** A front trim and then an append of the retained window, or its replacement. */
      output?: { trimStart?: number; append?: string } | { set: string };
      details?: JsonValue;
      diagnostics?: readonly ToolDiagnostic[];
    } & ToolEventCall)
  /** A model-issued call ends with its `entry`, a nested call with its `result`; see below for when neither. */
  | ({ type: "tool_execution_end"; entry?: EntryRecord; result?: NestedToolExecutionResult } & ToolEventCall)
  | { type: "inbox_update"; items: readonly { id: SubmissionId; mode: InboxItem["mode"] }[] }
  | { type: "submission"; record: SubmissionRecord }
  | { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }
  | { type: "auto_retry_end"; attempt: number }
  | { type: "deferred_poll"; pollAt: number }
  | { type: "entry_appended"; entry: EntryRecord }
  | { type: "agent_changed"; agent: AgentState }
  | { type: "usage_changed"; usage: UsageState }
  | { type: "task_failed"; taskId: TaskId; kind: string; message: string }
  | { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }
  /** The task's receipt tells whether it produced a summary; the summary entry has its own events. */
  | { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };

/** One change to the in-flight assistant message, relative to that message. */
type MessageChange =
  | { type: "text_start" | "thinking_start" | "toolcall_start"; contentIndex: number; block: AssistantMessage["content"][number] }
  | { type: "text_delta" | "thinking_delta"; contentIndex: number; delta: string }
  | { type: "toolcall_delta"; contentIndex: number; path: readonly (string | number)[]; delta: string }
  | { type: "block"; contentIndex: number; block: AssistantMessage["content"][number] }
  | { type: "message"; message: AssistantMessage };

interface AgentEventStream {
  /** The `snapshot` event at attachment. */
  readonly snapshot: Extract<AgentEvent, { type: "snapshot" }>;
  start(listener: (events: readonly AgentEvent[], context: Context) => Promise<void>): void;
  stop(): Promise<WatchEnd>;
  readonly closed: Promise<WatchEnd>;
}

Every event is relative to the snapshot and the events before it, never to Durable's document layout. snapshot replaces everything a consumer holds; every other event applies on top of it. Attachment captures the snapshot and registers for later publications atomically on the Session line.

Events derive from committed changes:

  • run_start/run_end: pi.live.run appears, is removed, or is replaced by a successor run whose first input differs. Steers joining the current run and handovers between its tasks are not run events. The inputs' outcomes are submission events.
  • turn_start: a pi.generation task is created. turn_end: a generation's outcome is committed, when it holds completing or becomes terminal, whichever comes first, so a successor created at hold starts after it.
  • message_start: the first committed partial of an attempt, or, for a message entry without a committed partial, the entry itself; message_end follows for every entry with model messages. Generation commits a partial only once it has content, and every built-in path that clears one appends its entry, so each started message ends with its entry. Entries without messages are entry_appended.
  • message_update: the Chord operations on the committed partial, translated: an insertion into content starts blocks, an append to a block's text or thinking is a delta, an append to a string inside a tool call's arguments is a toolcall_delta with the path relative to arguments, any other change inside a block sends that block, which already holds the batch's later changes to it, and any other change to content sends the whole message. Each update carries the partial's current usage, as the coding agent's JSON mode does; a change of only usage sends no changes. Only deltas travel, so throttled partials cause no write amplification on the wire.
  • tool_execution_start: a slot becomes running; args come from the tool task's intent checkpoint in the same commit. A slot that becomes done without running (a call not offered, invalid arguments, a block, a fault before intent) gets only tool_execution_end. tool_execution_update: output appends, front trims, and replacements of the slot's retained window, and the slot's current details and diagnostics when they change; removed ones, as when a safe replay restarts the tool, send null and []. tool_execution_end: the slot becomes done, with its pi.tool-result entry, which carries the diagnostics, or without one after a fault or orphan. An unfinished slot that disappears because its run ended ends with the result entry appended in the same commit, as for the unstarted calls of an aborted round, directly before its message_start, or without an entry. Nested slots produce the same events with parentToolCallId; a nested call ends with its result, taken from the NestedCallDoc value its terminal commit published, or without one after a fault or orphan or when its slot disappears unfinished. Snapshots show done nested slots with their summary, not their result; a late subscriber reads a live caller's results from its NestedCallDoc, keyed by call key (the slot's callId after <parentCallId>/).
  • inbox_update, agent_changed, usage_changed: the document changed; a retired one reads as its initial value, as in a snapshot. Registry installs and settings changes make no commit and emit nothing; a UI showing resolved tools or a resolved model resolves again through Conversation.agent().
  • auto_retry_start/auto_retry_end, deferred_poll: pi.live.generation gains or drops retry, or gains deferred or moves its pollAt.
  • task_failed: a task of the conversation settles faulted or orphaned.
  • compaction_start/compaction_end: a status appears in or disappears from pi.live.compactions. compaction_end carries error when the same commit recorded the task in pi.live.compactionFailure. A retry backoff shows only in the snapshot's compactions. A queued summary is placed later with its own message_start/message_end and submission events.

One commit produces one batch, in this order: tool_execution_start, message_start of a first partial, message_update, tool_execution_update, and retry/deferred events; then entries in append order with their message_start/message_end or entry_appended, where a tool result's tool_execution_end directly precedes its message_start, as in the coding agent, and the tool_execution_end of nested calls, children first, precede the entries; then the tool_execution_end of tools ending without an entry; then compaction_end, task_failed, turn_end, run_end; then submission events in ID order; then inbox_update, agent_changed, and usage_changed; and last compaction_start, run_start, and turn_start. A stream buffers at most 100 undelivered batches; adding another replaces every undelivered batch with one snapshot of the newest committed state. The stream therefore converges but does not promise every transition; Submission.wait() reports exact outcomes. A reconnecting consumer attaches again and starts from its snapshot; nothing is replayed. Transport backpressure and disconnect policy belong to the consumer.

Print mode awaits its own input Submission and prints its answer. A TUI renders ConversationView; events may drive transient animation.

9.5 Task graph view

The task graph view shows every live task of the Session as one structural value, for UIs and debugging: what runs, what waits for what, and which conversations each task owns. It is to the Session's tasks what the conversation view (section 9.3) is to one conversation.

type TaskGraphState =
  | { readonly status: "pending" | "running"; readonly phase: string }
  | {
      readonly status: "waiting";
      readonly phase: string;
      readonly on: readonly TaskId[];
      readonly policy: JoinPolicy;
    }
  /** Outcome held until its ordinary owned work drains (section 5.5). */
  | { readonly status: "completing"; readonly outcome: TaskOutcome<JsonValue>["status"] };

type TaskGraphNode = {
  readonly id: TaskId;
  readonly kind: string;
  readonly conversationId: ConversationId;
  /** Owner task; absent for a conversation-owned task. */
  readonly owner?: TaskId;
  readonly background: boolean;
  readonly abortRequested: boolean;
  readonly state: TaskGraphState;
  /** Conversations this task owns, in ID order. */
  readonly conversations: readonly ConversationId[];
};

type TaskGraph = {
  /** Every live task, keyed by its decimal ID. */
  readonly tasks: Readonly<Record<string, TaskGraphNode>>;
};

type TaskGraphWatch = WatchHandle<TaskGraph>;

interface Harness {
  taskGraph(context: Context): Promise<AttachedReplicatedState<TaskGraph>>;
  watchTaskGraph(context: Context): Promise<TaskGraphWatch>;
}

A node holds the committed task record without its input, checkpoint payload, outcome payload, and memos: phase is the checkpoint's phase, on and policy are the stored wait, and outcome is the held outcome's status. running is the durable status: open changes surviving running tasks to pending (section 5.4), so they show as pending until the scheduler reserves them again, while waiting and completing tasks keep their status. Whether a pending task is blocked, and which of the tasks in on are still live, depend on the registry and the rest of the graph; inspect() derives them. A node is present from the commit that creates its task until the commit that makes the task terminal. Owner edges are immutable, so a node's conversations only grow while it lives.

The graph holds live tasks only. A conversation whose owner task is terminal, such as a background subagent's conversation after its anchor finished, has no node that lists it; a later task there is a top-level node of that conversation. A UI that places it under its parent reads the conversation's owner edge from ConversationRecord.owner, which ConversationView.conversation also carries.

The Harness keeps at most one graph mount. The first taskGraph() or watchTaskGraph() builds its revision on the Session line from the committed live tasks and the conversations they own, one owner scan per live task, and holds the line while it does; the mount is dropped when its last observer detaches. It advances from subscribeCommits() publications like the conversation view: one publication that changes a node yields one Chord batch that sets or deletes tasks[id], or sets a node's conversations. A publication that changes no node creates no revision. Task and conversation records are immutable values; a node shares nothing with them.

taskGraph() returns the mount as a disposable read-only Chord state. watchTaskGraph() returns it through the serialized exact-frame watch of section 9.2. Both read only: neither enables scheduling. Close ends them at its seal like every other state and watch (section 2.2).

const graph = await harness.taskGraph(context);
graph.subscribe((value) => {
  for (const node of Object.values(value.tasks)) {
    const where = node.owner === undefined ? `conversation ${node.conversationId}` : `task ${node.owner}`;
    render(`${node.id} ${node.kind} ${node.state.status} under ${where}`);
  }
});

10. Storage contract

Ordered scans are cursor-based. Exact identity lookups are keyed.

type Page<T, C> = {
  readonly items: readonly T[];
  readonly next?: C;
};

type Cursor = Readonly<Record<string, JsonValue>>;

/** ID order: `ascending` is oldest first, `descending` newest first. */
type ScanOrder = "ascending" | "descending";

type ConversationQuery = {
  readonly ownerConversationId?: ConversationId;
  readonly ownerTaskId?: TaskId;
  readonly order?: ScanOrder; // default ascending
};

type EntryQuery = {
  readonly conversationId: ConversationId;
  readonly minEntryId?: EntryId; // inclusive
  readonly maxEntryId?: EntryId; // inclusive
  readonly order?: ScanOrder; // default descending
};

type TaskQuery = {
  readonly conversationId?: ConversationId;
  readonly kind?: string;
  readonly status?: "pending" | "running" | "waiting" | "completing" | "terminal";
  readonly abortRequested?: boolean;
  readonly background?: boolean;
  readonly order?: ScanOrder; // default ascending
};

type SubmissionQuery = {
  readonly conversationId?: ConversationId;
  readonly status?: SubmissionRecord["status"];
  readonly order?: ScanOrder; // default ascending
};

type DocumentPoint = Seq | "current";

type DocumentAddress = {
  readonly kind: string;
  readonly scope: DocumentRecord["scope"];
  readonly key?: string;
};

type DocumentQuery = {
  readonly scope: DocumentRecord["scope"];
  readonly at: DocumentPoint;
  readonly kind?: string;
};

type DocumentContent =
  | { readonly version: number; readonly kind: "base"; readonly value: JsonObject }
  | { readonly version: number; readonly kind: "delta"; readonly ops: readonly Op[] };

type StoredDocument = {
  readonly record: DocumentRecord;
  readonly version: number;
  readonly value: JsonObject;
  /** Deltas replayed after the selected base to materialize `value`. */
  readonly deltasSinceBase: number;
};

type StorageWrite =
  | { readonly type: "conversation"; readonly value: ConversationRecord }
  | { readonly type: "entry"; readonly value: EntryRecord }
  | { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> }
  | { readonly type: "submission"; readonly value: SubmissionRecord }
  | {
      readonly type: "document.create";
      readonly record: DocumentCreate;
      readonly content: Extract<DocumentContent, { kind: "base" }>;
    }
  | {
      readonly type: "document.copy";
      readonly record: DocumentCreate;
      readonly source: { readonly id: DocumentId; readonly at: DocumentPoint };
    }
  | {
      readonly type: "document.change";
      readonly id: DocumentId;
      readonly content: DocumentContent;
    }
  | { readonly type: "document.retire"; readonly id: DocumentId };

/**
 * Trusts the owning Session to supply semantically valid records, references,
 * ancestry, and transitions. Enforces atomicity, global ID ownership, immutable
 * conversation/entry creation, document record consistency, and detached
 * values; Session serializes commits. Sequences strictly increase but may have
 * gaps. Once commit() resolves, later reads through that Storage observe it.
 */
interface Storage {
  commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;
  /**
   * Allocate from the one global numeric namespace, above every ID minted or stored before, also across reopen: scans
   * order by ID as creation order, and the Harness relies on it. The generic brand is compile-time only.
   */
  mintId<I extends Id<string>>(): Promise<I>;

  conversation(id: ConversationId, context: Context): Promise<ConversationRecord | undefined>;
  scanConversations(query: ConversationQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<ConversationRecord, Cursor>>;

  entry(id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>;
  entry(conversationId: ConversationId, id: EntryId, context: Context): Promise<{ readonly entry: EntryRecord; readonly commitSeq: Seq } | undefined>;
  findLatestHeadMarker(conversationId: ConversationId, atOrBeforeEntryId: EntryId | undefined, context: Context): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>;
  scanEntries(query: EntryQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<EntryRecord, Cursor>>;

  task(id: TaskId, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
  scanTasks(query: TaskQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;

  submission(id: SubmissionId, context: Context): Promise<SubmissionRecord | undefined>;
  scanSubmissions(query: SubmissionQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<SubmissionRecord, Cursor>>;
  submissionByRequest(conversationId: ConversationId, requestId: string, context: Context): Promise<SubmissionRecord | undefined>;

  findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>;
  document(id: DocumentId, at: DocumentPoint, context: Context): Promise<StoredDocument | undefined>;
  scanDocuments(query: DocumentQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<DocumentRecord, Cursor>>;

  close(context: Context): Promise<void>;
}

Every error a method throws is final for the Session (section 10.1), so a backend retries its own transient failures. A read throws StorageRequestError for an invalid request, with no durable effect; from commit() it is fatal like any other error.

A document.copy reads committed pre-batch source state independent of command order. The source must be an alive conversation document at the selected point, and kind/key/history/fork must match the child create record. Storage persists one independent complete child base at the source's stored version. A batch may not create, change, or retire a selected source. Later source changes, reclamation, retirement, or backend reopen cannot affect the child.

Cursors are backend-owned JSON objects. Callers only round-trip them to the same scan on the same storage; cross-storage or cross-query use is unsupported. Conversation, entry, task, and submission scans run in their query's order, by ID. A cursor carries the order of the scan that returned it: a scan given a cursor continues in that order whether its query repeats order or omits it, and rejects a query that asks for the other order. Storage implementations must honor order; one that ignored it would return pages in the wrong direction without an error. The Session owns the mutation line, so storage implementations do not add a second caller-facing commit mutex. Each backend still makes one admitted batch atomic.

findLatestHeadMarker() returns the newest visible entry carrying head at or below its optional inclusive cutoff. The returned entry is the marker; its head value is the actual lower bound for context. scanEntries() pages the inclusive ID range, newest first by default, while applying every conversation ancestry cap; oldest first, it reads the root's segment first and then each fork's. With no bounds it pages complete visible history. To read context through entry E, find the marker at or before E, then scan from marker?.head through E. For current context the upper bound is omitted. Conversation owner filters are indexed and conjunctive. They support ownership traversal without an all-conversation scan; application-maintained registries are not a substitute for these kernel indexes.

entry(id) combines exact global lookup with the commit sequence required by historical document reads. entry(conversationId, id) returns that pair only when the entry is visible through the requested conversation's ancestry. limit is always the maximum page size. findDocument() resolves one exact logical kind/scope/key address at current or historical membership. A missing key means the singleton, not every family member. scanDocuments() enumerates only the incarnations alive in one exact scope at its selected point and may restrict one family/singleton kind. It uses ascending incarnation IDs. There is no ordinary open-time all-document scan. Task queries support conversation, kind, live/terminal status, abort mark, and background status.

document(id, at) materializes one specific incarnation and never follows a replacement at the same logical address. Callers resolve an address with findDocument() when they do not already hold an incarnation ID. It selects the newest applicable base, applies its ordered Chord delta tail, and returns the detached materialized value, stored definition version, and number of replayed deltas after that base. Base/delta records are backend-private. The lookup never scans unrelated documents. An unknown ID returns undefined. At "current", a retired incarnation returns undefined. A numeric lookup of a rewindable conversation incarnation returns undefined outside its half-open lifetime and reconstructs the selected value inside it. A numeric lookup of a known current- only incarnation rejects rather than depending on reclaimed content. Metadata membership remains queryable historically. A missing required base, a version change inside a delta tail, or an operation that cannot be applied inside an addressable lifetime is storage corruption, not absence.

One normalized batch contains at most one create/change content command per incarnation and may also retire that incarnation. Storage applies content before retirement independent of write-array order. Create plus retire stamps both lifetime bounds with the batch sequence. Retire plus create at one logical address makes the new incarnation current at that sequence. Deltas cannot cross a stored version boundary; a version transition must be a base.

The semantic conformance suite covers memory, SQLite, and JSONL.

10.1 Failures

One rule per kind of error, so a host knows what happens without reading code.

A storage error ends the Session. Any error a Storage method throws, a commit, a read, or mintId(), fails the Session, with two exceptions below. Neither the Session nor the Harness retries anything: a Storage retries its own transient failures inside the method. Reopening recovers from what was committed, which is always consistent, because commits are atomic; a call that threw may or may not have committed, so a host that resubmits after reopening reuses its requestId, which finds an admitted submission. On the first failure:

  • The call that hit it rejects with that error. Every later call, every pending wait (submission.wait(), waitForTask(), waitForIdle(), abortTask()), and every wait that starts later rejects with SessionFailed, whose cause is the first error; so does every Storage call still underway when it returns, whatever it returned.
  • Watches and event streams end with { reason: "session_failed", error }; attached replicated states stop updating and keep their last value.
  • Running task invocations get their abort signal. HarnessOptions.onReport gets the error once, after the Session is sealed, so a report handler that calls back finds it failed.
  • The Session closes itself. closed settles { reason: "failed", error } once that close has run, also when the backend could not close cleanly; after a plain close() it settles { reason: "closed" }. Closing the backend waits for every Storage call underway, refusing new ones, and for running task code to return: code that ignores its abort signal keeps the backend open and closed pending.
  • A failing Storage.close() fails the Session too, unless it already failed; the close() call rejects with it, and closed settles failed. Waits and watches had already ended as for a close (session_closed).

The exceptions fail only their call, and apply to reads alone: a StorageRequestError, which a Storage throws for an invalid read, with no durable effect (an unknown conversation, a malformed cursor or one from another scan order, history a document does not keep), and a read rejected because its caller's context was aborted. An error from commit() or mintId() is never exempt: once a batch is admitted, whether it committed is unknown.

A commit callback that catches a failed read cannot commit afterwards: the Session checks its health after the callback. Adopting a committed batch in memory that fails also fails the Session, since memory would be behind storage.

The Session's own bookkeeping fails it too. A throw in a commit the scheduler makes for itself (reconciliation, reservation, a fault or terminal write) fails the Session. The only extension code there is a task's migrate, whose throw is caught and blocks that task; anything else that throws is a storage failure, a host callback such as a RegistryReader whose snapshot() throws, or a bug, and running the commit again fixes none of them. A throw in a commit listener of the Session's own components (scheduler, submissions, views, task graph, document observers) fails it as well, since their memory would fall behind storage; the commit itself stands.

User code fails only its unit. A task phase that throws ends that task faulted; a throwing migrate blocks it as migration_failed; a tool that throws, or whose environment cannot be built, gets an error result; hooks that throw are reported and skipped, except beforeTool, whose throw blocks the call; a commit callback, conversationCreated, init, or a document's initial, migrate, or checkpointWhen that throws rolls its commit back and rejects it. Callbacks that must not fail their caller are contained: a host subscribeCommits() or subscribeClose() listener, a watch listener, a replicated-state subscriber, and a view observer that throws, or returns a promise that rejects, are reported, and the others still run; a watch whose listener throws ends with listener_error. A throwing or rejecting onReport is dropped, since nothing is left to report it to. A throwing now() is reported once, and Date.now serves instead.

11. Backends

11.1 Memory

Memory storage is the reference semantics. It copies retained write values and all read results. This deliberately simulates the ownership boundary naturally created by SQLite encoding/decoding and JSONL serialization; it is not defensive validation. It preserves rewindable records and reclaims latest records only after a committed base or retirement.

11.2 SQLite

One SQL transaction is one Session commit. SQLite stores:

  • conversation, entry, task, and submission records;
  • document records;
  • indexed document bases/deltas by document and commit sequence.

Live task transitions replace one row. Terminal tasks remain as small records. Document reads use indexed base-plus-tail ranges. The first implementation stores Chord records directly; it does not translate generic operations to SQLite JSON functions.

Schema shape, WAL checkpoint cadence, and synchronous defaults are backend implementation choices validated by conformance, reopen, query-plan, and storage benchmarks.

11.3 JSONL

JSONL depends on the portable FileSystem capability, not the broader ExecutionEnv. It uses reclaimable sidecars without exposing them to the harness. Persistence alone does not provide the ownership boundary: any decoded indexes, materialized values, or caches retained in memory must be detached from commit arguments and must not be exposed directly by reads. A JSONL backend cannot simply add file appends around aliasing memory tables.

main.jsonl       table writes, document records, and one marker per commit
doc-<id>.jsonl   one document incarnation
task-<id>.jsonl  live task replacements

Publication protocol:

  1. Append complete prepared records to every affected sidecar.
  2. Append one complete main marker listing those records.
  3. Publish in memory only after the marker write succeeds.

Every commit uses this protocol; there is no standalone-sidecar fast path. JSONL creation accepts an fsync option that defaults to false. Without fsync, it guarantees ordinary process-crash consistency, not survival of power, host, kernel, or filesystem failure. With fsync: true, the backend appends all affected sidecar records, flushes each affected sidecar, and only then appends the main marker. Ordinary publication does not explicitly flush main.jsonl; an acknowledged tail commit may therefore still disappear, but a marker that survives should not overtake its sidecar data. A main-only commit has no sidecars to flush. Before destructive reclamation with fsync: true, the backend flushes main.jsonl once so the authorizing marker cannot disappear while its replacement or removal survives. If that flush fails, the committed state remains published and reclamation is deferred. A non-empty temporary replacement is also flushed before rename.

Recovery:

  • Remove torn final lines.
  • Ignore and remove unconfirmed sidecar tails.
  • Apply confirmed records only.
  • Missing required confirmed data is corruption and opening fails.
  • A later committed latest base or retirement may prove an earlier physical record unnecessary.
  • Any uncertain append failure poisons the open backend.

Reclamation starts only after the authorizing base/retirement commits. When no sidecar records remain, it removes the sidecar directly. Otherwise, it writes a temporary replacement, renames it, and invalidates cached file descriptors so future appends cannot target an unlinked inode. Flushing main.jsonl to authorize reclamation does not compact it. main.jsonl is not compacted in the initial implementation.

12. API footguns

These are contracts, not invitations to add defensive machinery:

  • Detached draft work: draft reads, draft writes, and all Tx operations reject after the Session callback settles. Fire-and-forget work that runs before callback settlement can still mutate the active transaction and is unsupported.
  • Read after write: read every required table row before the first table write. Document drafts remain usable afterward; table reads do not.
  • Writing to a busy conversation: only the Harness appends to a conversation with an active run. Raw entries appended by Harness.commit() or a custom task while a generation prepares its request can misplace its system prompt entries; use a write submission.
  • Queued items after a failed run: failure and task abort leave the inbox alone. Queued follow-ups wait in pi.inbox, and their wait() does not settle, until the next input or queued write's boundary places them or the host withdraws them; a display-only write runs no boundary. A compaction summary is such a submission: when a manual or background compaction finishes in that idle conversation, its final boundary places the waiting follow-ups and starts a run.
  • Compaction spend: every manual and background compaction pays for one summarization request per attempt, also when its summary ends stale because a later compaction cut further.
  • Heads and edits during compaction: a summary reflects the context when its compaction selected the range. An application edit placed while it summarizes, such as a write replacing entry 20, is lost when the summary cuts past its target: the summary still describes the old entry 20, and the edit's target leaves the range. An application head placed meanwhile, for example one cutting at 85 to forget entries 10-84, is undone by a summary cutting at 90: that summary still describes 10-84. Place such writes before compacting.
  • Summary timestamps: a queued summary's user message carries the time its compaction finished, not its placement. Code that judges usage staleness by message timestamps, such as pi-ai estimateContextTokens() over view messages, can misjudge; the Harness estimate uses entry order (section 8.3).
  • Raw head writes into the past: a head written directly with tx.appendEntry() that targets an entry before the conversation's active range changes model context, but a mounted view keeps only the entries it already holds until the mount is rebuilt. Use a write submission, whose stale check rejects it.
  • Double-counted subagent spend: a tool that runs an owned conversation must not report that conversation's usage in its result; the child's pi.usage already counts it, and subtree sums would count it twice.
  • Owned work holds its owner: a task that owns live ordinary work stays completing until that work ends (section 5.5). Foreground work an extension starts in a subagent's conversation holds the calling tool, and with it the run; interrogating a subagent while its tool is completing extends the hold. Esc or a host timeout ends it; work that should not hold its owner is created conversation-owned and background.
  • Work created by hooks: hooks run inside the asking task's invocation, so work they create owned by that task holds it. Work that must delay a task's finish has to be created before the task commits its outcome; a listener reacting to published events afterwards cannot extend it. A run task's owned work must not write the transcript after the run was released.
  • Compensation in abort handlers: an abort handler cannot create owned children, because its task is abort-marked. Compensate at the level that did the effect, in that task's own abort handler, or inline, or through background conversation-owned tasks awaited with waitForTask(); a non-background one would be aborted by any cascade that reaches its conversation.
  • Hook memo names: hooks share the asking task's memo namespace with the task and other hooks. Prefix memo names.
  • Long transactions: an async commit callback holds the Session mutation line. Never await models, tools, processes, network calls, humans, a nested Session commit, or a Session waiter inside it. Use methods on the current Tx.
  • Reads inside a commit: snapshot(), documentState(), watchDoc(), getTask(), and the other Session, Harness, and runtime reads may queue on the mutation line, for example to load a document not in memory. Inside a commit callback, which holds that line, such a read never resolves, and the line stays held. Only a cache hit returns, so the hang is intermittent. Read through tx.
  • Explicit creation: only typed tx.doc() creates an absent document. Snapshot, state, and watch lookup return undefined instead.
  • Family initialization: the first acquisition of an absent family address selects its seed. Existing instances and later calls ignore seeds; a seed is neither identity nor an update.
  • Checkpoint starvation: if checkpointWhen() never returns true, replay and current-only document storage can grow without bound while the document is live.
  • Wrong fork setting: current, initial, and asOf are product semantics, not optimizations. Changing one changes child conversation behavior.
  • Schema stability: document kinds and visible mount paths are persisted/public protocol. Value migration cannot rename a kind; a kind change requires explicit copy and retirement. Passing incompatible definition tokens that claim the same kind is unsupported caller misuse; Session does not maintain a document-definition registry to detect it.
  • Trusted immutable revisions: snapshot(), state values, watch values, and their descendants may share tracker-owned containers. Never mutate them; copy first when mutable ownership is required. Runtime freezing is not provided.
  • Watch activation: before start(), watch.value remains the immutable acquisition revision. Initialize the consumer from it first. After start, the property advances to each delivered immutable revision before its callback; every earlier reference remains stable.
  • Buffered watches: slow or unstarted watches retain up to 100 exact committed frames. Overflow replaces the undelivered suffix with one full-value root replacement, so intermediate committed states may be omitted. A consumer that must audit every transition must persist each fact in an immutable entry or journal and scan that history explicitly.
  • Watch stop: a listener may call and await stop(); it prevents future callbacks but neither aborts nor joins the callback already running.
  • Durable progress cadence: clients see only committed progress. A crash may lose the current uncommitted throttle window.
  • Large terminal results: terminal task records remain queryable. Put large results in entries or longer-lived documents and retain only their IDs in the outcome. Never reference a task-scoped document retired by that same outcome.
  • Raw transcript: view entries are not model context. Rendering edits, display-only entries, and model filtering require the appropriate reducer.
  • Reserved pi. names: task names, document kinds, and entry kinds starting with pi. belong to built-ins by convention. Nothing enforces it; reusing one collides with Harness behavior, such as pi.system entries being replayed as system messages.
  • Early resource disposal: uninstalling or replacing an extension only stops new use. Freeing resources immediately can fail calls that are still running.
  • Guards left out of a selection: a conversation with an array selection that omits a guard extension, such as a permissions hook, runs without it. Extensions are selected whole; there is no registry-wide hook or wrapper.
  • Selection edits and copies: { add, remove } always edits the host default selection. Written to a child that copied any selection from its owner, an array or an { add, remove } object, it replaces that selection, so the child gets the host default with the edit, not the owner's selection. Compute the child's array from the resolved agent instead (section 7.3).
  • Copies are one-time: a new task-owned conversation copies its owner's pi.agent at creation; later owner changes do not reach it. A fork keeps its asOf copy instead (section 2.2).
  • Environment on recovery: a tool rerun after recovery builds its environment again, with the conversation's current cwd, which may differ from the first attempt's.
  • Moving default selections: a conversation on the default selection changes whenever the settings or the installed extensions change; each change appends pi.system entries and misses the provider prompt cache.
  • Unstable prompt text: a section renderer whose output changes without a real content change, for example by embedding the time, appends system deltas and defeats provider prompt caching.
  • Non-cooperative code at close: close joins every invocation. A task handler, tool, or hook that ignores its signal keeps close() pending and Storage open until it returns; cancelling the close wait does not end it.
  • Objects outliving the Harness: close the Chord objects and links that expose Harness state before closing the Harness. A state ended by close keeps its last value and never updates again.
  • Fatal storage errors: after a storage error the Session has failed and closed itself; reopen it (section 10.1). Retrying the failed call cannot succeed: retry transient failures inside the Storage.
  • JSONL durability: default JSONL ordering handles ordinary process crashes; without durable mode it does not promise acknowledged commits survive power or host failure.

13. Non-goals

Durable has no:

  • whole-Session DOM;
  • visible-undurable publication;
  • Session-kernel semantic event journal or independently maintained event state;
  • session-scoped rewindable documents;
  • automatic checkpoint heuristic;
  • automatic third-party view mounting;
  • CRDT/offline multi-writer merge;
  • SQL translation of arbitrary Chord operations;
  • JSONL global compaction or automatic corruption repair;
  • compatibility layer for earlier harness prototypes;
  • forced termination of non-cooperative extension code inside one process.