Abstract: pi separates loop termination, agent idleness, and product settlement.
Snapshot: earendil-works/pi at commit fc85bdd, analyzed on 2026-07-23.
All four packages and the latest stable release are 0.81.1. The generic
AgentHarness is under active development, so this article distinguishes the
current coding-agent path from its planned migration target.
An Agent does not become settled merely when the model stops generating. That condition is insufficient as soon as the system has asynchronous event handlers, concurrent tools, persistence, automatic retries, compaction, or queued follow-up messages. A terminal can stop its spinner while an extension is still writing. An SDK promise can resolve while a retry is about to begin. A transcript can contain every raw message while the context projected into the next request has already lost something.
pi does not hide these different clocks behind one
isRunning boolean. Its minimalism is a hierarchy of settlement contracts.
Scope and current runtime path
The project once commonly referenced as badlogic/pi-mono now lives at
earendil-works/pi. The current monorepo publishes four lockstep packages:
| Package | Contract |
|---|---|
| @earendil-works/pi-ai | Provider models, auth, streaming, and normalized assistant-message events |
| @earendil-works/pi-agent-core | The low-level loop, stateful Agent, and new generic AgentHarness |
| @earendil-works/pi-coding-agent | Coding tools, sessions, compaction, resources, extensions, CLI, and RPC |
| @earendil-works/pi-tui | Retained-mode terminal components and differential rendering |
Chat and Slack automation live in the separate pi-chat repository. A
built-in operating-system permission layer does not exist. The root README
explicitly says that pi inherits the filesystem, process, network, and
credential authority of the process that launched it, and points users to
Gondolin, Docker, or OpenShell for containment.
That makes pi closer to an agent kernel plus one opinionated coding distribution than to a turnkey enterprise workflow platform. It exposes mechanisms; it does not claim to ship every policy.
The coding agent does not yet use the new AgentHarness.
The repository currently contains two harness-shaped paths:
CURRENT CODING PRODUCT
AgentSession → Agent → runAgentLoop → pi-ai
NEW GENERIC CORE
AgentHarness → runAgentLoop → pi-ai
packages/coding-agent/src/core/sdk.ts still constructs new Agent(...).
AgentSession wraps that object with product-specific persistence, resources,
extensions, retry, compaction, and UI events.
The generic AgentHarness calls runAgentLoop() directly and owns its own
phase, queues, abort controller, session writes, and save-point snapshots. Its
design document, however, still lists the coding-agent migration as planned
with no completed items. It also lists lifecycle, hook, retry,
auto-compaction, recovery, and settled timing work as unfinished.
This distinction matters. A source file can be real and tested without being the runtime path the product currently ships.
Completion semantics and request lifecycle
agent_end: the loop has no more events.
At the lowest level, runAgentLoop() emits messages and tool lifecycle events
through an awaited sink. agent_end is the final loop event.
That statement is intentionally narrow: no more loop events will be produced. It does not, by itself, define when every consumer of that event has finished.
The convenience agentLoop() API demonstrates the difference. It starts
runAgentLoop() and pushes events into an EventStream. The consumer sees
ordered observations, but arbitrary work performed after reading an event is
not automatically a scheduling barrier for the producer. That is a good API
for rendering and telemetry. It would be the wrong place to assume that an
asynchronous “approval listener” blocks a tool.
Agent idle: awaited subscribers have settled.
The stateful Agent takes the stronger path:
await runAgentLoop(
messages,
this.createContextSnapshot(),
this.createLoopConfig(options),
event => this.processEvents(event),
signal,
this.streamFunction,
);
processEvents() first reduces the event into agent state. On
message_end, it clears the streaming message and appends the final message
to state.messages. It then awaits subscribers in registration order.
The source comment is unusually precise:
agent_endis the final emitted event for a run, but the agent does not become idle until all awaited listeners for that event have settled.
Consequently, Agent.prompt() and Agent.waitForIdle() establish a stronger
barrier than merely observing agent_end.
agent_settled: the coding session has finished post-run work.
The coding product has one more layer:
await this.agent.prompt(messages);
while (await this._handlePostAgentRun()) {
await this.agent.continue();
}
this._flushPendingBashMessages();
await this._emitAgentSettled();
_handlePostAgentRun() may:
- schedule another attempt after a retryable provider error;
- trigger automatic compaction and continue;
- process steering or follow-up messages queued by an
agent_endextension handler.
Only after that loop closes and pending bash messages are flushed does
AgentSession emit agent_settled and resolve its session-level
waitForIdle().
The open issue #5886 tracks remaining “fully settled” edge cases. The
agent_settled event is a substantial improvement, not evidence that every
long-turn, compaction, and extension interaction is now proven closed.
One request through the runtime.
An input can arrive through the interactive CLI, print mode, RPC, or the SDK.
AgentSession.prompt() first handles extension commands and file-backed
prompt templates. The input extension hook can transform or consume the
request.
ResourceLoader assembles global and hierarchical AGENTS.md /
CLAUDE.md files, skills, prompt templates, themes, extensions, and
SYSTEM.md / APPEND_SYSTEM.md. Project trust gates whether project-scoped
settings, resources, and code are loaded. This protects the process from an
unknown repository silently injecting an extension. It does not sandbox a
tool after that tool has been enabled.
The coding SDK constructs Agent with:
- model and thinking level;
- a
transformContextpath; convertToLlm, including application-message and image handling;- a
streamFnbacked bymodelRuntime.streamSimple; - provider timeout, retry, WebSocket timeout, and header hooks;
- session ID and tool-execution configuration.
pi-ai reduces provider-specific deltas into a common
AssistantMessageEventStream. Text, thinking, tool calls, usage, completion,
and errors therefore have a shared upper-level shape.
Normalization does not make providers semantically identical. Reasoning blocks, signed content, cache accounting, token usage after abort, and cost quality still vary. Cross-provider continuation is best effort, not a proof that every provider state can be translated losslessly.
The assistant message becomes durable before tool preflight.
When the model has completed an assistant message containing tool calls,
Agent.processEvents(message_end) appends it to in-memory state before
awaiting AgentSession.
The current AgentSession handler then:
- awaits extension handling;
- notifies product listeners;
- appends the finalized message to
SessionManager.
An extension may replace a message_end payload. The implementation mutates
the already-stored message object in place so that Agent.state.messages,
subsequent lifecycle events, listeners, and JSONL persistence all observe the
same replacement.
Because the listener is awaited by Agent, tool preflight cannot begin until
that assistant message has crossed the extension and persistence boundary.
This is the settlement fix behind a class of earlier message/tool races,
including the regression tracked in #2113.
Concurrent completion and deterministic transcript order.
Tool preparation happens in model source order. Each call gets a
tool_execution_start, validation, and the beforeToolCall hook. Prepared
calls execute concurrently by default.
The central pattern is:
// Simplified: validation, hooks, abort, and error normalization omitted.
const runs = calls.map(call => async () => {
const result = await execute(call);
await emit(tool_execution_end(result)); // completion order
return result;
});
const ordered = await Promise.all(runs.map(run => run()));
for (const result of ordered) {
await emit(message_end(toToolResult(result))); // source order
}
Promise.all starts the work concurrently but preserves input positions in
its result array. A fast second tool can therefore update the UI first, while
the transcript sent to the next model turn remains in the order of the
assistant's tool calls.
That separation gives pi both low-latency observability and reproducible history. The closed issue #3468 is a useful record of why the two orderings cannot be conflated.
There are additional guards:
- if any called tool declares
executionMode: "sequential", the entire batch becomes sequential; - a single
terminate: trueresult does not discard its siblings; the batch terminates only when every finalized result terminates; - tool calls in an assistant message stopped by
lengthare converted to failures rather than executing potentially truncated arguments; - validation, hook, and execution exceptions become explicit error tool results that the model can observe.
These are small rules, but they define replay, auditing, and whether one fast failure can erase the evidence of other already-started effects.
Append-only history and lossy model context.
The coding agent stores a session as an append-only JSONL tree. Entries have IDs and parent IDs. Messages, model changes, thinking-level changes, compactions, branch summaries, custom data, labels, and session metadata are separate entry types. Selecting a leaf chooses an active branch without deleting its siblings.
There are at least four distinct state representations:
full JSONL tree
→ selected branch
→ compacted model-context projection
→ one provider request snapshot
Compaction occurs as estimated context approaches the model window, using a reserved margin and a recent tail. The full tree survives; older content in the model view is replaced by a summary.
That is why append-only persistence does not imply context fidelity. The closed repeated-compaction issue #2608 could lose relevant projected context without erasing the original log. A durable record and a correct context compiler require different tests.
The same distinction limits crash recovery. Host JavaScript tool implementations, model clients, auth, hooks, and resource loaders cannot be reconstructed from JSONL alone. Most provider streams cannot resume from an arbitrary delta. An unfinished non-idempotent tool must not be blindly replayed after restart.
The generic harness documents a realistic semi-durable direction: treat the session log as the source of truth, reinject host capabilities, and resume at safe save points. Applications still need operation IDs, external ledgers, or human confirmation for email, payment, deployment, and other irreversible effects.
Trust boundaries and deployment fit
The new harness moves lifecycle ownership above runAgentLoop() without
inheriting coding-product internals. Its save point occurs after an assistant
turn and its tool-result messages:
- agent-emitted messages are persisted;
- pending extension/session writes are flushed after them;
- if the loop continues, the harness obtains a fresh snapshot of model, thinking, tools, resources, stream options, session ID, and system prompt;
- the fresh snapshot applies only to the next provider request.
This is a strong answer to a subtle configuration problem. Freezing everything for the entire run makes live changes ineffective. Mutating an in-flight request produces incoherent behavior. A save point permits change between requests while preserving each request as an immutable snapshot.
The tradeoff is a temporary dual architecture. The generic path still needs final phase semantics, a settled-event audit, deterministic writes from settled callbacks, auto-compaction, retry, a general hook facade, broader reentrancy tests, and coding-agent migration.
This explicit rebuild is preferable to making the large, product-specific
AgentSession increasingly generic. But readers and extension authors must
not mistake the target architecture for the current one.
Trust is not containment.
pi's permission statement is unusually direct: there is no built-in system for restricting filesystem, process, network, or credential access. Extensions are arbitrary code and run with process authority.
The documented containment options move different boundaries:
- Gondolin keeps pi and provider authentication on the host while routing
built-in tools and
!commands into a microVM. Other host extensions still require review. - Docker contains the whole pi process, but bind mounts write through and credentials commonly enter the container.
- OpenShell runs the process under policy and can use inference routing to keep raw provider keys outside the sandbox.
A read-only tool list is capability selection, not operating-system containment. Project trust prevents unreviewed local configuration from loading; it does not reduce the authority of a trusted extension. Clear documentation makes the responsibility visible, but deployers still have to implement it.
Validation scope.
I installed dependencies without lifecycle scripts, then ran the repository's official model-data hydration command. The first test collection attempts revealed that generated provider catalogs were absent; hydration fixed the environment without modifying tracked source.
I then ran 18 package-local, focused Vitest cases covering:
- completion-order tool events versus source-order tool-result messages;
- awaited subscribers and
waitForIdle; - save-point refresh and pending-write order;
- the
message_end/ tool race regression; - retry plus
agent_settled; - extension message replacement persistence;
- bash/session message ordering.
All 18 passed. I did not run the full suite or tests requiring real provider credentials. These results support the commit-specific timing claims; they are not a blanket reliability certification.
Deployment fit.
pi is a strong fit for framework authors, CLI and infrastructure engineers, and teams that want to choose their own provider, extension, session, and sandbox layers. Stable event and state boundaries are more valuable to that audience than a long feature checklist.
It is not turnkey for organizations expecting built-in RBAC, per-tool human approval, credential isolation, exactly-once cross-process effects, or a visual business workflow. Those can be built around pi, but their integration and governance cost is part of the architecture.
The comparison boundary is useful:
- smolagents
CodeAgentcan compress many inner calls into one Python interpreter action; pi keeps a structured tool lifecycle; - OpenClaw adds a long-running gateway control plane for channels, identities, delivery, and recoverable delegation;
- LangGraph makes graph transitions and checkpoints the primary workflow abstraction;
- pi prioritizes a replaceable kernel and lets a distribution define the workflow.
None is universally “more agentic.” They choose different units of control.
Operational conclusion.
Agent deployments can be separated into three layers:
- kernels that define provider, event, tool, and session contracts;
- distributions that package coding, research, or personal-agent behavior;
- policy layers that add identity, sandboxing, approvals, budgets, and audit.
pi already makes that split visible. It will resonate most with Unix-minded developers, infrastructure engineers, and framework builders who dislike hidden magic. They accept an assembly tax in exchange for replaceable parts. Users who need safe defaults and one coherent workflow may find the same freedom exhausting.
The operational point is that “done” is not merely runtime trivia. Billing, SLA measurement, notifications, human takeover, and dispatching the next job all need a settlement level. Stopping the clock when token generation ends ignores extension work, persistence, retries, and unconfirmed side effects.
Agent observability therefore needs to become settlement observability. A trace should say not only which events occurred, but which listeners have settled, which writes have flushed, which projection produced the next request, and which external operations remain uncertain.
Minimalism does not eliminate complexity; it assigns the integration tax. The next competitive advantage for ecosystems like pi will not be extension count alone. It will be machine-checkable lifecycle and authority contracts that keep independently developed extensions composable without making the whole system unauditable.
Source index and open questions
- S1 — Snapshot and package boundaries: pinned source,
v0.81.1release, root README and permission statement - S2 — Loop and stateful Agent:
agent-loop.ts,Agentsubscriber contract - S3 — Current coding path:
sdk.ts,AgentSession, session format - S4 — Generic harness and durability:
AgentHarness, migration TODO, durable-harness design - S5 — Maintainer context and issue history: coding-agent design essay, #5886 settlement, #2113 message/tool race, #3468 parallel result order, #2608 repeated compaction
- Full research record: 10-pi-evidence.md
Open questions.
- When the coding agent migrates to the generic
AgentHarness, how should maintainers prove thatagent_settled, retry, compaction, and extension behavior did not drift? - If provider streams cannot resume and tools are non-idempotent, should the harness, tool author, or application define the smallest safe recovery checkpoint?
- If the kernel intentionally omits a permission system, what capability manifest, signing, and audit format would make a large extension ecosystem governable?