Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
05c77e8
feat(pi): add the Pi agent type on Pi durable
salman1993 Oct 20, 2026
20964bd
feat(pi): follow the current agent-type API
salman1993 Oct 20, 2026
5bb2898
feat(pi): run on Pi durable 1.3.0
salman1993 Oct 20, 2026
753e750
feat(pi): load Buzz tools on search with Pi's tool_search
salman1993 Oct 20, 2026
62fec43
fix(pi): continue a run after its storage fails, and list sessions sa…
salman1993 Oct 20, 2026
0f82a1c
Merge remote-tracking branch 'origin/main' into sm/pi-harness
salman1993 Oct 20, 2026
63be0e3
feat(pi): run Pi agents on Windows
salman1993 Oct 20, 2026
3ba2fb5
fix(pi): keep a removed agent's queued mention from running, and surv…
salman1993 Oct 20, 2026
3e77104
feat(pi): show Pi agents' usage on the Usage page (#1442)
salman1993 Oct 20, 2026
e0a814e
fix(pi): address review of the Pi agent type
salman1993 Oct 21, 2026
47223e2
fix(pi): route adopted mentions to their thread, show pi sign-ins, ru…
salman1993 Oct 21, 2026
30e5267
Merge remote-tracking branch 'origin/main' into sm/pi-harness
salman1993 Oct 21, 2026
1a708fb
fix(pi): use no $NAME keys, and find the transcript button by its new…
salman1993 Oct 21, 2026
ab4f8be
fix(pi): read auth.json under its lock, count left runs' sends before…
salman1993 Oct 21, 2026
5aeeede
fix(pi): stop an agent's storage once another host takes its lock
salman1993 Oct 21, 2026
1847369
fix(pi): end watch and timer runs an earlier app left, rather than co…
salman1993 Oct 21, 2026
6e271c3
Merge remote-tracking branch 'origin/main' into sm/pi-harness
salman1993 Oct 21, 2026
26abc3a
fix(pi): end left one-off runs without continuing Pi first
salman1993 Oct 21, 2026
7bb1878
Merge remote-tracking branch 'origin/main' into sm/pi-harness
salman1993 Oct 21, 2026
f5b817d
fix(pi): continue no Pi work until left standalone runs are ended
salman1993 Oct 21, 2026
16becb4
refactor(pi): parse stored prompts without Claude's transcript module
salman1993 Oct 21, 2026
21a3d36
docs(pi): trim the README to what contributors need
salman1993 Oct 21, 2026
a53cbd3
feat(pi): add session controls, so a recovered run can be interrupted
salman1993 Oct 21, 2026
90b8434
docs(pi): link the Pi durable README
salman1993 Oct 21, 2026
3731f24
Merge remote-tracking branch 'origin/main' into sm/pi-cancel-recovery
salman1993 Oct 21, 2026
5ea3cfd
refactor(claude-code): keep the prompt helper private again
salman1993 Oct 21, 2026
a6ca0df
docs(pi): keep the compaction-usage date limit in the README
salman1993 Oct 21, 2026
2278038
fix(pi): show a Stop during a tool call as stopped
salman1993 Oct 21, 2026
c51d93b
fix(pi): run session actions before the agent's first mention
salman1993 Oct 21, 2026
11746cd
fix(pi): recheck a session action after preparing it
salman1993 Oct 21, 2026
6d57be2
docs(pi): keep the limit on a send posted twice after a quit
salman1993 Oct 21, 2026
abc3e0e
fix(pi): take on left runs after a turn gives up, and cancel pending …
salman1993 Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ records. Codex uses an inherited thinking choice only when the effective model
supports it, otherwise that model's default applies. Transient process/session
state may appear in the Activity tab, but is not durable history.

A type with `sessionControls` (Claude, Codex and goose) offers three immediate
A type with `sessionControls` (Claude, Codex, goose and Pi) offers three immediate
session controls in the composer's gauge popover: **Compact session**,
**Start a new session**, and **Interrupt**. A side rail keeps all three visible.
Compact and New are enabled while that harness session is idle; Interrupt is
Expand Down
2 changes: 1 addition & 1 deletion src/bundled/claude-code/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const KINDS: Record<string, string> = {
};

/** A Buzz prompt, or a steer wrapping one, as the turn's opening item. */
export function prompt(id: string, at: number, raw: string): TranscriptItem {
function prompt(id: string, at: number, raw: string): TranscriptItem {
let sections = promptSections(raw);
const steer = sections.find((section) => section.tag === STEER);
if (steer) sections = promptSections(steer.body);
Expand Down
169 changes: 29 additions & 140 deletions src/bundled/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,156 +12,45 @@ Create agents that run Pi on your computer, with any provider Pi supports, and a

# Pi for Agents

A desktop plugin that ships with Buzz on macOS, Linux and Windows, off until
turned on in **Settings → Plugins**. Its agents run on
[Pi durable](https://earendil.com/posts/pi-1-0/) (`@earendil-works/pi-durable`),
not the Pi coding agent, with any provider Pi supports.
Agents owns the identity, admission, configuration, and signed Buzz actions. This
plugin owns Pi work after delivery handover. One Pi host process ([`host.mjs`](host.mjs))
runs every Pi agent here. Each agent has its own SQLite storage, and each Buzz
conversation the agent is in is one durable Pi conversation.

## Try it

From the feature worktree:

```sh
source bin/activate-hermit
BUZZODZ_PROFILE=pi-agents BUZZ_DEV_VIEWER= bin/just desktop
```

Pi needs Node.js 22.19 or later on this computer (`node:sqlite`). On macOS and
Linux the app's PATH is searched first, then an interactive login shell's, so
Homebrew, nvm, fnm and similar installs work. On Windows, `node` must be on the PATH Buzz
starts with.

1. Turn on **Pi** in **Settings → Plugins**. Then open **Agents → New agent**
and choose **Pi** as the **Harness**. Its **Model** starts on **Pi default**:
the `defaultProvider` and `defaultModel` in `~/.pi/agent/settings.json`, as
the `pi` command sets them. The first time Buzz asks Pi anything, it
installs Pi's packages (a few seconds).
2. Click the model (marked with Pi, and with the provider when Buzz has its
mark) and choose a provider. A provider Pi has no key for shows an
**API key** field; **Save to Pi** stores it in
`~/.pi/agent/auth.json`, shared with the `pi` command. A provider that signs
in some other way (OAuth, cloud credentials) says to run `pi`, then `/login`.
Choose a model, click **Test connection**, then **Use**. Name the agent and
click **Create agent**. Its library card shows the same control.
3. Open the agent. Its **Identity** tab has the **Provider**, **Model**,
**Thinking** level (Pi's default, or one the model has) and
**Instructions**. Its **Settings** tab has the provider's key with **Test
connection**, the **Workspace** (default `~/.buzz`), and thread or channel
**Conversations**.
4. In a channel, send: `@Pi create hello.txt containing hello in the workspace,
read it back, and report what you found` (use the agent's actual name).
Expect steps under your message, one reply in the thread, and the file in the
workspace. Then reply in the same thread: `@Pi what did you write?`. It
remembers, because the thread is the same Pi conversation.
5. While it works, send another mention in the thread. It steers the running
turn: Pi reads it at its next step. The activity moves to the newer message.
6. Quit Buzz during a long turn, start it again, and mention the agent in that
thread. Pi continues the interrupted run from its storage, and the new
mention steers it.
7. Choose **View activity** on one of its messages in that thread, or open the
agent's **Activity** tab from there. **This thread** is its Pi conversation,
with the agent's other recent ones below. **View transcript** shows each
turn's prompt, thinking, tool calls with their output, and what it sent,
read from Pi's storage.

The composer's model picker offers the agent's provider's models, and each
model's thinking levels, for one turn. Without a pick, a turn runs the
agent's saved model and Thinking level.

## Checks
Agents run on [Pi durable](https://github.com/earendil-works/pi/blob/main/packages/durable/README.md) in one Node
host ([`host.mjs`](host.mjs)). Each agent has its own SQLite storage, and each
Buzz thread or channel is one durable Pi conversation. Agents owns identity,
admission and signing; this plugin owns the turn after handover.

```sh
bin/pnpm exec vitest run src/bundled/pi
PI_DEPS="$HOME/Library/Application Support/Buzz/pi/deps/<sum>" bin/pnpm exec vitest run src/bundled/pi/host.live.test.ts
PI_DEPS=<installed deps folder> bin/pnpm exec vitest run src/bundled/pi/host.live.test.ts
```

The unit tests cover the stdio protocol (`pi.ts`), the runtime against a fake
host, the install script in a throwaway home, and the New agent and Settings
fields. The opt-in live test runs the real `host.mjs` on Pi durable, using Pi's
faux provider as the model. It checks that a final `send` ends the run, that the
model reads what a Buzz tool answered, that a plain answer comes back as text,
that a request steers a working run, that a run cut off by a stop continues in
the next host, that aborting a run leaves it unanswered, and that a deferred
Buzz tool is offered once `tool_search` finds it. `PI_DEPS` is any folder
the launcher installed Pi's packages in.
The live test runs the real host on Pi's faux provider.

## Buzz's copy of Pi's packages
## Packages

[`launch.mjs`](launch.mjs) runs the host on this computer's Node. On macOS and
Linux, [`pi.sh`](pi.sh) finds that Node and starts the launcher on it; Windows,
which has no bash, starts `node` itself. The launcher's first line on stdin
carries the host source and the packages to install, since together they pass
Windows' 32 KB command line. On first use it runs `npm ci` with
[`deps/package-lock.json`](deps/package-lock.json), which pins every package and
its integrity hash. Install scripts are off, and npm uses the registry it is
configured for. Each lock gets its own folder under
`~/Library/Application Support/Buzz/pi/deps/`
(`${XDG_DATA_HOME:-~/.local/share}/buzz/pi/deps/` on Linux,
`%LOCALAPPDATA%\Buzz\pi\deps\` on Windows). The folder appears
whole or not at all, and is shared by every Buzz build. An install removes the
folders no build has started for 30 days. Agents' storage is in `agents/<pubkey>.sqlite`
beside it. A lock on each agent's storage keeps a second Buzz build from opening
it; that build's turns fail and say so. It also keeps Pi's `auth.json` writes
from colliding with the `pi` command's.

To update Pi, change the versions in [`deps/package.json`](deps/package.json),
then regenerate the lock without registry URLs:
[`launch.mjs`](launch.mjs) installs [`deps/package-lock.json`](deps/package-lock.json)
with `npm ci` (no install scripts) into a per-lock folder under Buzz's app data
`pi/deps/`, shared by every Buzz build. To update Pi, change
[`deps/package.json`](deps/package.json), then:

```sh
cd src/bundled/pi/deps
npm install --package-lock-only --ignore-scripts --omit-lockfile-registry-resolved
```

## Limits and boundaries

- Only the owner's events, or ones the owner delegated, start work. Watches,
timers and catch-ups run as standalone turns, each a one-off Pi
conversation, in the lanes and with the 5-minute deadline Buzz's agent
runner gives every harness.
- Pi has the coding tools `read`, `write`, `edit` and `bash`, in the workspace,
with no sandbox or approvals, like Claude's `bypassPermissions`. Buzz's tools
are offered as `buzz_<name>` (`buzz_send`, `buzz_read`, …), since `read` and
`edit` would collide. They run inside Buzz through the agent's native-held
handle, so the host holds no Buzz key. A final `buzz_send` ends the run.
The Buzz tools Claude and Codex load on search (the attention tools, and
those plugins add) are deferred: Pi's `tool_search` loads them when the
model looks for them, and they stay loaded in that conversation.
- **Stop** on a turn's activity aborts the Pi run it is in, and posts
nothing.
- A turn that answered in text and never called `buzz_send` has its answer
posted in the thread, as goose does. A turn that fails posts `⚠️ I couldn't
finish that: …` and tags the owner.
- A Buzz tool call that a stop cuts off runs again when the run continues, so a
`send` may be posted twice.
- Pi's storage keeps every conversation, including each one-off watch or timer
turn; nothing prunes it. The Activity tab lists thread and channel
conversations only, and rereads an open transcript when its turn ends, not
as it streams.
- A host that stops while a turn waits is started again once and waits for it
there. A run resumed that way shows no steps.
- Runs left unfinished when the app quit continue at the first turn after it
starts again, in whatever thread. A mention's run shows as working and its
answer or failure is posted where it would have been, but it shows no steps
and Stop does not reach it. A watch's or timer's run is ended instead, as
quitting ends other harnesses' turns: continued, it would have no deadline.
- Pi durable records some requests that have no answer, such as compaction's,
only in its totals. Usage counts each model's share of those as one more
request, at the newest compaction.
- A provider that needs more than a key, such as Bedrock or Vertex, is set up
with the `pi` command. Signing in from Buzz is not supported.
- A `!command` or `$NAME` key in `auth.json`, which the `pi` command runs or
reads from its environment to get the key, is not used: one host serves
every agent, and a command would hold them all, and the desktop app often
lacks the shell's environment. Its turns fail and say so; enter the key in
Buzz instead.
- Only Pi's built-in providers run. One that a Pi package adds (`packages` in
Pi's settings), such as databricks, is not loaded: the provider field, the
New agent Model field and a turn's error say so, and Use and Save wait for another.
- On Windows, Pi's `bash` tool needs a bash, such as Git for Windows'; the
other tools work without one.
- Windows, Linux, packaged-app behavior, and human acceptance need separate
validation.
## Limits

- Pi's `read`, `write`, `edit` and `bash` run in the workspace with no sandbox
or approvals. Buzz tools are `buzz_<name>`.
- Keys in Pi's `auth.json` must be literal: Buzz runs no `!command` key and
reads no `$NAME` key. Set up a provider that needs more than a key with
`pi`. Providers from Pi packages don't run.
- Runs left unfinished when the app quit continue at the next turn. A
mention's run posts its answer or failure but shows no steps; its thread's
session controls interrupt it. A watch's or timer's run is ended.
Comment thread
salman1993 marked this conversation as resolved.
- When the app quits or the host stops during a Buzz tool call, Pi tells the
model the call may have run, so a `send` it makes again may be posted
twice. A Stop or Interrupt ends the run instead.
- Pi durable records compaction's usage only in each conversation's totals,
not per compaction. Usage dates all of it at the newest compaction, so each
compaction moves the conversation's earlier compaction spend to its day.
- On Windows, Pi's `bash` tool needs Git Bash. Linux and Windows are not yet
validated.
41 changes: 41 additions & 0 deletions src/bundled/pi/host.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,29 @@ const g1 = await pi.rpc("deliver", turn("G", "ninth"));
await asking.gate;
await pi.rpc("abort", { agent, conversation: g1.conversation });
out.aborted = await pi.rpc("wait", { agent, submission: g1.submission });
// Session controls act on a conversation key's Pi conversation.
const s = held(), running = held();
faux.setResponses([async (_context, options) => { running.release(); await abortable(s.gate, options?.signal); return fauxAssistantMessage("never"); }]);
const s1 = await pi.rpc("deliver", turn("S", "session"));
await running.gate;
await pi.rpc("session", { agent, key: "chan/root", action: "interrupt" });
out.interrupted = await pi.rpc("wait", { agent, submission: s1.submission });
// Interrupted in a tool call, the run leaves Pi's aborted result for it.
faux.setResponses([() => fauxAssistantMessage(fauxToolCall("bash", { command: "sleep 30" }), { stopReason: "toolUse" })]);
const t1 = await pi.rpc("deliver", turn("T", "tool"));
await new Promise((resolve) => { const check = () => pi.events.some((event) => event.type === "step" && event.tool === "bash") ? resolve() : setTimeout(check, 5); check(); });
await pi.rpc("session", { agent, key: "chan/root", action: "interrupt" });
out.toolStop = { wait: await pi.rpc("wait", { agent, submission: t1.submission }), last: (await pi.rpc("entries", { agent, conversation: t1.conversation })).entries[0] };
// Past what Pi keeps verbatim, so compacting summarizes the older turn.
faux.setResponses([() => fauxAssistantMessage("Read it"), () => fauxAssistantMessage("Read it too"), () => fauxAssistantMessage("Summary of the chat")]);
for (const request of ["S2", "S3"]) {
const long = await pi.rpc("deliver", turn(request, "word ".repeat(25_000)));
await pi.rpc("wait", { agent, submission: long.submission });
}
await pi.rpc("session", { agent, key: "chan/root", action: "compact" });
await pi.rpc("session", { agent, key: "chan/root", action: "new" });
out.renewed = { place: await pi.rpc("place", { agent, key: "chan/root" }), kinds: (await pi.rpc("entries", { agent, conversation: s1.conversation })).entries.map((entry) => entry.kind).slice(0, 2) };
out.unplaced = await pi.rpc("session", { agent, key: "chan/none", action: "compact" });
// A tool loaded on search is offered once tool_search finds it.
const offered = [];
/** The tools a request offers: its own, and those its system messages add. */
Expand Down Expand Up @@ -352,6 +375,24 @@ describe.skipIf(!DEPS)("the Pi host on Pi durable", () => {
status: "unanswered",
reason: "aborted",
});
expect(out.interrupted).toMatchObject({
status: "unanswered",
reason: "aborted",
});
expect(out.toolStop.wait).toMatchObject({
status: "unanswered",
reason: "aborted",
});
expect(out.toolStop.last).toMatchObject({
kind: "pi.tool-result",
aborted: true,
});
// A new session is shown the whole conversation at its next turn.
expect(out.renewed).toEqual({
place: { seen: 0 },
kinds: ["pi.reset", "pi.compaction"],
});
expect(out.unplaced).toEqual({});
const [before, after] = out.search.offered;
expect(before).toEqual(
expect.arrayContaining(["buzz_send", "tool_search"]),
Expand Down
37 changes: 36 additions & 1 deletion src/bundled/pi/host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -642,9 +642,13 @@ export function serve({
if (!found) return { entries: [] };
const page = await found.entries({}, PAGE, cursor, context);
return {
entries: page.items.map(({ id, kind, model }) => ({
entries: page.items.map(({ id, kind, model, data }) => ({
id,
kind,
// Pi writes this result itself for a call an abort cut off.
...(data?.diagnostics?.some?.((d) => d.code === "aborted") && {
aborted: true,
}),
// System entries hold every tool's schema; a transcript shows none.
...(model && kind !== "pi.system" ? { model: model.map(slim) } : {}),
})),
Expand Down Expand Up @@ -790,6 +794,37 @@ export function serve({
const { harness } = await agentOf(agent);
await (await harness.conversation(conversation, context))?.abort(context);
},
/** A session control on a conversation key's Pi conversation, if it has
* one: `interrupt` aborts its run and any compaction, `compact` summarizes
* it and settles once the summary is placed, and `new` starts a new
* context, shown the whole Buzz conversation at its next turn. */
async session({ agent, key, action }) {
const { harness } = await agentOf(agent);
const place = (await harness.snapshot(Places, context))?.[key];
const conversation =
place && (await harness.conversation(place.conversation, context));
if (!conversation) return;
if (action === "interrupt") return conversation.abort(context);
Comment thread
salman1993 marked this conversation as resolved.
if (action === "new") {
await conversation.reset(undefined, context);
await harness.commit(async (tx) => {
const places = await tx.doc(Places);
if (places[key]) places[key] = { ...places[key], seen: 0 };
}, context);
return;
}
const { outcome } = (
await harness.waitForTask(
await conversation.compact(undefined, context),
Comment thread
salman1993 marked this conversation as resolved.
context,
)
).state;
if (outcome.status === "failed") throw new Error(outcome.error.message);
if (outcome.status !== "completed") return;
const { submissionId } = outcome.result;
if (submissionId !== undefined)
await (await harness.submission(submissionId, context))?.wait(context);
},
/** Closes the agent's storage. Unfinished runs stay, and continue when it
* opens again. */
async close({ agent }) {
Expand Down
7 changes: 7 additions & 0 deletions src/bundled/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
(pubkey, signal) => agents.core(pubkey, signal),
() => agents.tools(),
repositoriesContext,
(pubkey) => agents.handle(pubkey),
);
const { ActivityTab, Create, CreateModel, SettingsTab } = createTabs({
setup: runtime.setup,
Expand Down Expand Up @@ -94,6 +95,12 @@ export const apply: PluginModule["apply"] = (ctx) => {
component: ActivityTab,
},
],
sessionControls: {
scope: (agent) => config(agent.config).scope,
state: (agent, place) => runtime.sessionState(agent, place),
run: (agent, place, action) => runtime.session(agent, place, action),
subscribe: (listener) => runtime.subscribe(listener),
},
models: (agent) => runtime.models(agent),
run: (delivery) => runtime.run(delivery),
runOnce: (turn) => runtime.runOnce(turn),
Expand Down
Loading
Loading