Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 29 additions & 6 deletions pi/.pi/agent/extensions/delegate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,44 @@ The tool accepts:

```ts
delegate({
label: "Plan caching improvements",
task: "Inspect the caching implementation. Return a plan, relevant files, risks, and validation steps. Do not modify files.",
model: "provider/model-id",
thinking: "high",
tools: ["read", "grep", "find", "ls"],
})
```

Replace `provider/model-id` with an exact ID from `pi --list-models`. Model IDs may themselves contain slashes. `model` and `task` are required; `thinking` inherits the parent's level when omitted (Pi clamps it to the child's model capabilities).
Replace `provider/model-id` with an exact ID from `pi --list-models`. Model IDs may themselves contain slashes. `label` is a short display summary derived by the calling agent (action + specific targets, up to 80 characters), such as `Rebase PRs #6182–6184`. Keep shared context and constraints in `task`, not the label. No extra model request is needed. Calls without a label fall back to a task preview, including older session entries.

`cwd` selects an **existing directory**, defaulting to the parent's working directory. Relative paths resolve against the parent cwd; absolute paths, `~/` paths, and a leading `@` are supported. Symlinks are resolved and non-directories rejected before starting a child. The canonical path is shown in progress and final results.
`model` and `task` are required; `thinking` inherits the parent's level when omitted (Pi clamps it to the child's model capabilities).

`cwd` selects an **existing directory**, defaulting to the parent's working directory. Relative paths resolve against the parent cwd; absolute paths, `~/` paths, and a leading `@` are supported. Symlinks are resolved and non-directories rejected before starting a child. The canonical path is shown in expanded progress and final results (Ctrl+O).

`tools` defaults to `read,bash,edit,write`. The optional allowlist accepts `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`. Use `read,grep,find,ls` for planning/review without write or shell tools. Bash can modify files even without `edit` or `write`.

For implementation, call delegate again with the implementation model and include the approved plan in the task. There is no implicit handoff or automatic planning/review policy. Keep routing preferences in `AGENTS.md` if desired; this extension does not change them.

## Optional tmux monitor panes

Configure display in Pi's `~/.pi/agent/settings.json` using the extension-owned `pi-delegate` namespace:

```json
{
"pi-delegate": {
"display": "tmux"
}
}
```

Hidden subprocesses remain the default: omit `pi-delegate.display` or set it to `"hidden"` to disable panes. Invalid values also use hidden execution. The extension reads Pi's effective settings through `pi.getSettings()`, including project overrides. Run `/reload` after editing settings; no restart is needed. `PI_DELEGATE_DISPLAY` is no longer used. These extension-owned settings do not automatically appear in Pi's interactive `/settings` menu. There is no display slash command.

The first running delegate opens a monitor **to the right of the parent's `$TMUX_PANE`**, without changing focus. Additional delegates stack vertically within that right-hand column by splitting the tallest surviving monitor; they do not split the Pi pane again. The right-hand monitors are rebalanced to equal heights (within one terminal row) after each launch and automatic close, without resizing Pi or unrelated panes. Concurrent launches and pane cleanup are serialized per parent. Queued delegates do not open panes. The viewer shows the label, model, cwd, status, elapsed time, cost, recent tool activity, and latest text. It is a **read-only monitor**, not another interactive Pi TUI: the actual child remains parent-owned, and results, usage, queueing, and cancellation work exactly as before. Cancel in the parent; closing a monitor manually does not cancel the child.

Panes close automatically when the child finishes, fails, or is cancelled, including during reload/shutdown. Final reports remain in the parent Pi transcript. Missing tmux, an invalid parent pane, or insufficient room for a split falls back to hidden execution (with a UI warning). This does not rearrange unrelated panes or change the window layout globally; with several delegates, monitors may get short. When the last monitor closes, Pi regains the column's width. Manually closed monitors are reconciled before the next split.

Monitor snapshots are bounded, stored in a private temporary directory, and removed after the run. The standalone viewer exits if its parent dies or the snapshot disappears. A forcibly killed parent can leave a temporary directory (and a dead pane if tmux's `remain-on-exit` is enabled); this is not durable background execution. The viewer requires Node.js (or Bun); standalone Pi builds use `node` from PATH.

## Choosing isolation with Worktrunk

The **parent agent decides before launching** whether isolation is needed. The extension does not classify tasks, create worktrees, switch branches, merge, or clean up worktrees.
Expand Down Expand Up @@ -60,7 +83,7 @@ New worktrees contain the selected committed base, **not the parent's uncommitte

- Each call starts a fresh Pi process in its selected working directory. Repository/global context files and normal skill discovery remain available; the parent's conversation is **not** copied.
- The tool stays pending until the child exits. Up to **three children run concurrently per parent session**; further calls wait in a cancellable FIFO queue. This limit does not cover other Pi sessions. Pi must also be in parallel tool mode: a sequential tool anywhere in the same assistant batch makes that entire batch sequential.
- Progress is streamed by code, not model-driven polling. Each row shows queued/running/final status, canonical cwd, time spent queued, and child execution time. One-second heartbeats update timing even when the child is quiet. Queueing does not launch a child or consume model tokens.
- Progress is streamed by code, not model-driven polling. Collapsed rows show just the short model name, queued/running/final status, elapsed time (queue time while queued), rounded cost, and a one-line label. Ctrl+O reveals the full model ID and task, canonical cwd, queue/execution timing, detailed usage, activity, and output. One-second heartbeats update timing even when the child is quiet. Queueing does not launch a child or consume model tokens.
- Parent and sibling tools can now execute alongside delegates. The parent must avoid writes, staging, branch changes, and conflicting external actions in a workspace used by a child. The extension does not enforce workspace ownership or coordinate other processes.
- The child uses the running Pi executable/version, normal configured providers, and inherited environment. Extensions, MCP, and prompt templates are disabled. Providers/authentication available **only** through parent extensions or transient parent API-key overrides are not transferred.
- The parent's current project-trust decision is passed explicitly **only when the canonical selected cwd equals the parent's canonical cwd**. Any different directory, including another worktree or subdirectory, starts with `--no-approve`; a trusted parent does not silently approve another project's dynamic configuration. Global context remains available, but project-local trust-gated resources in a new worktree are not enabled by delegation.
Expand All @@ -69,14 +92,14 @@ New worktrees contain the selected committed base, **not the parent's uncommitte
- Children share the selected checkout and inherited credentials. Isolation exists only when the parent supplies independent worktrees; there is no automatic isolation or parent permission-hook enforcement inside the child. Do not use this where disabling parent security extensions would violate your expectations.
- Only a normally stopped final text response counts as success. Provider errors, token limits, nonzero exits, invalid JSONL, and missing final responses are errors; available partial text, usage, and bounded stderr are preserved.
- Output is limited to 50 KiB/2,000 lines. Longer results are saved in a private temporary directory, with the path included in the result. These files are not automatically deleted; remove them when no longer needed. Child sessions are otherwise ephemeral (`--no-session`).
- Recent tool activity and text stream into the tool row; expand it for more output. Child model usage is returned to Pi's session accounting.
- Recent tool activity and text stream into the expanded tool row; they are hidden when collapsed. Child model usage is returned to Pi's session accounting.

## Tests

From the `pi/` Stow package directory, with Node.js 24+ and the managed Pi installation present:

```sh
node --test .pi/agent/extensions/delegate/runtime.test.ts .pi/agent/extensions/delegate/index.test.mjs
node --test .pi/agent/extensions/delegate/runtime.test.ts .pi/agent/extensions/delegate/index.test.mjs .pi/agent/extensions/delegate/monitor.test.ts
```

Tests cover overlapping child processes and the three-child cap, queue cancellation/shutdown, cwd resolution and trust boundaries, queued/timing rendering, awaited completion, streaming, model/tool arguments, UTF-8/JSONL framing, usage, errors, truncation, recursion guards, and cancellation/shutdown. They use local fake child processes, not paid model requests. `index.test.mjs` uses the installed Pi extension loader; `runtime.test.ts` tests the process lifecycle independently.
Tests cover overlapping child processes and the three-child cap, queue cancellation/shutdown, cwd resolution and trust boundaries, queued/timing rendering, awaited completion, streaming, model/tool arguments, UTF-8/JSONL framing, usage, errors, truncation, recursion guards, and cancellation/shutdown. Monitor tests also cover private/bounded snapshots, display fallback, terminal-control sanitization, and a real isolated tmux server (skipped if tmux is unavailable). They use local fake child processes, not paid model requests. `index.test.mjs` uses the installed Pi extension loader; `runtime.test.ts` tests the process lifecycle independently.
101 changes: 91 additions & 10 deletions pi/.pi/agent/extensions/delegate/index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { stripVTControlCharacters } from "node:util";

const agentDir = fileURLToPath(new URL("../../", import.meta.url));
const version = (await readFile(join(agentDir, "install/current-version"), "utf8")).trim();
const loaderPath = join(agentDir, "install/releases", version, "node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js");
const { loadExtensions } = await import(pathToFileURL(loaderPath).href);
const extensionPath = fileURLToPath(new URL("index.ts", import.meta.url));

async function fixture(fn) {
async function fixture(fn, config) {
const directory = await mkdtemp(join(tmpdir(), "delegate-extension-test-"));
const oldScript = process.argv[1];
try {
const loaded = await loadExtensions([extensionPath], directory);
assert.deepEqual(loaded.errors, []);
loaded.runtime.getSettings = () => config === undefined ? {} : { "pi-delegate": config };
const extension = loaded.extensions[0];
const tool = extension.tools.get("delegate").definition;
const script = join(directory, "fake-pi.mjs");
Expand Down Expand Up @@ -56,7 +58,8 @@ async function fixture(fn) {
test("tool loads with a small schema and forwards model/thinking/tools to an awaited child", async () => {
await fixture(async ({ tool, ctx }) => {
assert.equal(tool.executionMode, "parallel");
assert.deepEqual(Object.keys(tool.parameters.properties).sort(), ["cwd", "model", "task", "thinking", "tools"]);
assert.ok(tool.promptGuidelines.includes("Avoid delegating trivial work unless the user explicitly requests delegation, including for testing."));
assert.deepEqual(Object.keys(tool.parameters.properties).sort(), ["cwd", "label", "model", "task", "thinking", "tools"]);
const result = await tool.execute("test", {
task: "Plan this", model: "test/vendor/model", thinking: "high", tools: ["read", "grep"],
}, undefined, undefined, ctx);
Expand All @@ -71,6 +74,37 @@ test("tool loads with a small schema and forwards model/thinking/tools to an awa
});
});

test("only pi-delegate.display enables monitors; legacy environment is ignored and unavailable tmux falls back", async () => {
const oldDisplay = process.env.PI_DELEGATE_DISPLAY;
try {
for (const legacy of ["tmux", "hidden"]) {
process.env.PI_DELEGATE_DISPLAY = legacy;
for (const config of [undefined, null, "tmux", [], {}, { display: "hidden" }, { display: "invalid" }, { display: true }, { display: "tmux" }]) {
await fixture(async ({ tool, ctx, extension }) => {
assert.equal(extension.commands.has("delegate-display"), false);
const notices = [];
ctx.hasUI = true;
ctx.ui = { notify: (text) => notices.push(text) };
const oldPane = process.env.TMUX_PANE;
process.env.TMUX_PANE = "";
try {
const result = await tool.execute("test", { task: "Inspect", model: "test/vendor/model" }, undefined, undefined, ctx);
assert.equal(result.isError, false);
assert.equal(result.details.status, "completed");
assert.equal(notices.some((text) => text.includes("continuing hidden")), config?.display === "tmux");
} finally {
if (oldPane === undefined) delete process.env.TMUX_PANE;
else process.env.TMUX_PANE = oldPane;
}
}, config);
}
}
} finally {
if (oldDisplay === undefined) delete process.env.PI_DELEGATE_DISPLAY;
else process.env.PI_DELEGATE_DISPLAY = oldDisplay;
}
});

test("omitted thinking inherits from the parent; tools use the documented default", async () => {
await fixture(async ({ tool, ctx }) => {
const result = await tool.execute("test", { task: "Implement", model: "test/vendor/model" }, undefined, undefined, ctx);
Expand Down Expand Up @@ -272,16 +306,63 @@ test("pre-aborted tool calls never start children", async () => {
});
});

test("renderers preserve queued state and show canonical cwd and timing", async () => {
test("collapsed delegate rows stay two lines and expansion reveals details", async () => {
await fixture(async ({ tool, ctx }) => {
const theme = { fg: (_color, text) => text, bold: (text) => text };
const args = { model: "test/vendor/model", label: "Check CI\nfor PRs #6182–6184",
task: "User explicitly requests checking CI. Handle ONLY PRs #6182–6184. Do not modify files.",
};
const result = await tool.execute("test", args, undefined, undefined, ctx);
assert.equal(JSON.parse(result.details.output).task, `Task: ${args.task}`);
assert.ok(tool.promptGuidelines.some((guideline) => guideline.includes("short label derived from its task")));
const state = {};
const context = { args, state, cwd: ctx.cwd, expanded: false };
assert.deepEqual(tool.renderCall(args, theme, context).render(200), [
"delegate model", "Check CI for PRs #6182–6184",
]);
for (const status of ["queued", "running", "completed", "failed", "aborted"]) {
const details = { ...result.details, status, queuedMs: 2100, elapsedMs: 41000,
usage: { ...result.details.usage, cost: { total: 0.1234 } },
activity: ["→ bash a very long command\nwith multiple lines", "✓ bash"],
};
// Match Pi's composition order: call first, result second, then render.
const header = tool.renderCall(args, theme, context);
const body = tool.renderResult({ ...result, details }, { expanded: false }, theme, context);
assert.deepEqual(body.render(200), []);
assert.deepEqual(header.render(200), [
`delegate model · ${status} ${status === "queued" ? "2s" : "41s"} · $0.12`,
"Check CI for PRs #6182–6184",
]);
const narrow = header.render(20);
assert.equal(narrow.length, 2);
assert.ok(narrow.every((line) => stripVTControlCharacters(line).length <= 20));

const expandedContext = { ...context, expanded: true };
const expandedHeader = tool.renderCall(args, theme, expandedContext);
const expanded = tool.renderResult({ ...result, details }, { expanded: true }, theme, expandedContext).render(10000).join("\n");
const expandedTitle = expandedHeader.render(200).map((line) => line.trimEnd()).join("\n");
assert.match(expandedTitle, /delegate test\/vendor\/model/);
assert.ok(expandedTitle.includes(args.task));
assert.ok(expandedTitle.includes("Check CI for PRs #6182–6184"));
assert.match(expanded, /41s elapsed · 2s queued/);
assert.ok(expanded.includes(result.details.cwd));
assert.ok(expanded.includes("turns · ↑10 ↓2 · $0.1234"));
assert.ok(details.activity.join("\n").split("\n").every((line) => expanded.includes(line)));
assert.ok(expanded.includes(result.content[0].text));
}
});
});

test("errors without delegate details stay compact but can be expanded", async () => {
await fixture(async ({ tool, ctx }) => {
const theme = { fg: (_color, text) => text, bold: (text) => text };
const result = await tool.execute("test", { task: "Inspect", model: "test/vendor/model" }, undefined, undefined, ctx);
const details = { ...result.details, status: "queued", queuedMs: 2100, elapsedMs: 0 };
const rendered = tool.renderResult({ ...result, details }, { expanded: false, isPartial: true }, theme).render(200).join("\n");
assert.match(rendered, /queued · 0s elapsed · 2s queued/);
assert.ok(rendered.includes(result.details.cwd));
const header = tool.renderCall({ model: "test/vendor/model", task: "Inspect" }, theme, { cwd: ctx.cwd }).render(200).join("\n");
assert.ok(header.includes(ctx.cwd));
const args = { model: "test/vendor/model", task: "Inspect" };
const context = { args, state: {}, cwd: ctx.cwd, isError: true, expanded: false };
const result = { content: [{ type: "text", text: "Unknown model" }] };
const header = tool.renderCall(args, theme, context);
assert.deepEqual(tool.renderResult(result, { expanded: false }, theme, context).render(200), []);
assert.deepEqual(header.render(200), ["delegate model · failed", "Inspect"]);
assert.deepEqual(tool.renderResult(result, { expanded: true }, theme, context).render(200).map((line) => line.trimEnd()), ["Unknown model"]);
});
});

Expand Down
Loading
Loading