summaryrefslogtreecommitdiff
path: root/pi/agent/extensions/fresh-subagent
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-20 20:33:43 +0200
committerPaul Buetow <paul@buetow.org>2026-03-20 20:33:43 +0200
commite66e46fcc27aee1246f40b76fedd87d2138e6d15 (patch)
treebce8f25d8c643971ad65825af586965483b9bc9f /pi/agent/extensions/fresh-subagent
parent8f2e5923b7952f9f1ecb34e049f37f6ec6169647 (diff)
Add Pi plan mode and fresh subagent extensions
Diffstat (limited to 'pi/agent/extensions/fresh-subagent')
-rw-r--r--pi/agent/extensions/fresh-subagent/README.md83
-rw-r--r--pi/agent/extensions/fresh-subagent/index.ts313
2 files changed, 396 insertions, 0 deletions
diff --git a/pi/agent/extensions/fresh-subagent/README.md b/pi/agent/extensions/fresh-subagent/README.md
new file mode 100644
index 0000000..c74dd78
--- /dev/null
+++ b/pi/agent/extensions/fresh-subagent/README.md
@@ -0,0 +1,83 @@
+# Fresh Subagent
+
+Minimal fresh-context subagent support for Pi.
+
+## What it does
+
+- registers a `subagent` tool the main agent can call
+- registers a `/subagent <prompt>` command for direct use
+- runs the delegated work in a new `pi --mode json -p --no-session` process
+- defaults to the current session model when one is active
+- returns only the final answer or review result
+
+This is intentionally small. It does not manage agent catalogs, chains, or
+parallel workers. It is meant for one-off delegation with a clean context.
+
+## What it is for
+
+Subagents are generic. The main agent can hand them any focused prompt that
+benefits from a clean context, for example:
+
+- independent code review
+- fresh-context debugging
+- focused codebase research
+- second-opinion architecture checks
+- summarizing a noisy command output or diff
+- validating whether a completed task is actually done
+
+One common use is the `taskwarrior-task-management` review loop:
+
+1. The main agent implements the change
+2. The main agent self-reviews the change
+3. The main agent uses `subagent` for an independent fresh-context review
+4. The main agent fixes findings
+5. Only then does the task move toward completion
+
+## Direct usage
+
+Run a manual fresh-context review:
+
+```text
+/subagent Independently review the recent changes for bugs, regressions, and missing tests. Only report concrete findings.
+```
+
+Run a focused side investigation:
+
+```text
+/subagent Find all code paths that write to the SSH known_hosts file and summarize the risk.
+```
+
+Run a generic delegation:
+
+```text
+/subagent Compare the current plan-mode extension behavior against the requested workflow and list only the mismatches.
+```
+
+One-shot CLI usage also works now:
+
+```bash
+pi --model openai/gpt-4.1 --no-session -p '/subagent Say only SUBAGENT_COMMAND_OK'
+```
+
+## Agent usage
+
+Because this is registered as a tool, the main agent can call it itself. A good
+generic pattern is:
+
+```text
+Use the subagent tool for a fresh-context pass on this side task, then return only the useful result.
+```
+
+For review-specific flows:
+
+```text
+First review your own changes. Afterwards, use the subagent tool to perform an independent fresh-context review and then address any findings.
+```
+
+## Notes
+
+- The subagent uses a fresh session via `--no-session`.
+- The subprocess still runs in the same working directory unless you override
+ `cwd`.
+- The extension disables itself inside child subagent processes to avoid
+ accidental recursive registration.
diff --git a/pi/agent/extensions/fresh-subagent/index.ts b/pi/agent/extensions/fresh-subagent/index.ts
new file mode 100644
index 0000000..52fed1b
--- /dev/null
+++ b/pi/agent/extensions/fresh-subagent/index.ts
@@ -0,0 +1,313 @@
+import { spawn } from "node:child_process";
+import type { AgentToolResult, AgentToolResultContent } from "@mariozechner/pi-agent-core";
+import type { Message, TextContent } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { Text } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+
+const CHILD_ENV_FLAG = "PI_FRESH_SUBAGENT_CHILD";
+
+interface UsageStats {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ cost: number;
+ turns: number;
+}
+
+interface FreshSubagentResult {
+ prompt: string;
+ model?: string;
+ cwd: string;
+ exitCode: number;
+ stopReason?: string;
+ errorMessage?: string;
+ stderr: string;
+ output: string;
+ usage: UsageStats;
+}
+
+function getProviderScopedModel(ctx: ExtensionContext): string | undefined {
+ if (!ctx.model) return undefined;
+ return `${ctx.model.provider}/${ctx.model.id}`;
+}
+
+function getLastAssistantText(messages: Message[]): string {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role !== "assistant") continue;
+ const text = message.content
+ .filter((part): part is TextContent => part.type === "text")
+ .map((part) => part.text)
+ .join("\n")
+ .trim();
+ if (text) return text;
+ }
+ return "";
+}
+
+async function runFreshSubagent(
+ prompt: string,
+ options: {
+ cwd: string;
+ model?: string;
+ tools?: string[];
+ signal?: AbortSignal;
+ onUpdate?: (partial: AgentToolResult<FreshSubagentResult>) => void;
+ },
+): Promise<FreshSubagentResult> {
+ const args = ["--mode", "json", "-p", "--no-session"];
+ if (options.model) args.push("--model", options.model);
+ if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
+ args.push(prompt);
+
+ const result: FreshSubagentResult = {
+ prompt,
+ model: options.model,
+ cwd: options.cwd,
+ exitCode: 0,
+ stderr: "",
+ output: "",
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ cost: 0,
+ turns: 0,
+ },
+ };
+
+ const messages: Message[] = [];
+
+ const emitUpdate = () => {
+ options.onUpdate?.({
+ content: [{ type: "text", text: result.output || "(running...)" }],
+ details: { ...result },
+ });
+ };
+
+ let wasAborted = false;
+
+ result.exitCode = await new Promise<number>((resolve) => {
+ const proc = spawn("pi", args, {
+ cwd: options.cwd,
+ shell: false,
+ stdio: ["ignore", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ [CHILD_ENV_FLAG]: "1",
+ },
+ });
+
+ let buffer = "";
+
+ const processLine = (line: string) => {
+ if (!line.trim()) return;
+
+ let event: any;
+ try {
+ event = JSON.parse(line);
+ } catch {
+ return;
+ }
+
+ if (event.type !== "message_end" || !event.message) return;
+
+ const message = event.message as Message;
+ messages.push(message);
+ result.output = getLastAssistantText(messages);
+
+ if (message.role === "assistant") {
+ result.usage.turns++;
+ const usage = message.usage;
+ if (usage) {
+ result.usage.input += usage.input || 0;
+ result.usage.output += usage.output || 0;
+ result.usage.cacheRead += usage.cacheRead || 0;
+ result.usage.cacheWrite += usage.cacheWrite || 0;
+ result.usage.cost += usage.cost?.total || 0;
+ }
+ if (!result.model && message.model) result.model = message.model;
+ if (message.stopReason) result.stopReason = message.stopReason;
+ if (message.errorMessage) result.errorMessage = message.errorMessage;
+ }
+
+ emitUpdate();
+ };
+
+ proc.stdout.on("data", (data) => {
+ buffer += data.toString();
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+ for (const line of lines) processLine(line);
+ });
+
+ proc.stderr.on("data", (data) => {
+ result.stderr += data.toString();
+ });
+
+ proc.on("close", (code) => {
+ if (buffer.trim()) processLine(buffer);
+ resolve(code ?? 0);
+ });
+
+ proc.on("error", () => {
+ resolve(1);
+ });
+
+ if (options.signal) {
+ const killProc = () => {
+ wasAborted = true;
+ proc.kill("SIGTERM");
+ setTimeout(() => {
+ if (!proc.killed) proc.kill("SIGKILL");
+ }, 5000);
+ };
+
+ if (options.signal.aborted) killProc();
+ else options.signal.addEventListener("abort", killProc, { once: true });
+ }
+ });
+
+ if (wasAborted) {
+ result.stopReason = "aborted";
+ result.errorMessage = "Fresh subagent was aborted";
+ }
+
+ result.output ||= getLastAssistantText(messages);
+ return result;
+}
+
+function renderSubagentSummary(details: FreshSubagentResult, expanded: boolean, theme: any): string {
+ const status =
+ details.exitCode === 0 && details.stopReason !== "error" && details.stopReason !== "aborted"
+ ? theme.fg("success", "✓")
+ : theme.fg("error", "✗");
+ const header = `${status} ${theme.fg("toolTitle", theme.bold("subagent"))}${
+ details.model ? theme.fg("muted", ` ${details.model}`) : ""
+ }`;
+
+ const lines = [header, theme.fg("muted", `cwd: ${details.cwd}`)];
+ if (expanded) {
+ lines.push("", theme.fg("muted", "Prompt:"), details.prompt);
+ lines.push("", theme.fg("muted", "Result:"), details.output || theme.fg("muted", "(no output)"));
+ } else {
+ const preview = details.output ? details.output.split("\n").slice(0, 5).join("\n") : "(no output)";
+ lines.push("", preview);
+ }
+
+ if (details.errorMessage) lines.push("", theme.fg("error", `Error: ${details.errorMessage}`));
+ if (details.stderr.trim()) lines.push("", theme.fg("dim", details.stderr.trim()));
+ return lines.join("\n");
+}
+
+export default function freshSubagentExtension(pi: ExtensionAPI): void {
+ if (process.env[CHILD_ENV_FLAG] === "1") return;
+
+ const params = Type.Object({
+ prompt: Type.String({ description: "Prompt to run in a fresh-context subagent" }),
+ model: Type.Optional(Type.String({ description: "Optional model override. Defaults to the current session model." })),
+ cwd: Type.Optional(Type.String({ description: "Working directory for the subagent process" })),
+ tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist for the subagent process" })),
+ });
+
+ pi.registerTool({
+ name: "subagent",
+ label: "Subagent",
+ description: "Spawn a fresh-context subagent with a prompt and return its final answer.",
+ promptSnippet: "Delegate a self-contained task to a fresh-context subagent and get its result back",
+ promptGuidelines: [
+ "Use this tool for any self-contained side task that benefits from a clean context, such as review, research, summarization, or focused implementation checks.",
+ "Pass a complete prompt with enough context for the subagent to succeed independently, because it starts with a fresh session.",
+ ],
+ parameters: params,
+
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
+ const details = await runFreshSubagent(params.prompt, {
+ cwd: params.cwd ?? ctx.cwd,
+ model: params.model ?? getProviderScopedModel(ctx),
+ tools: params.tools,
+ signal,
+ onUpdate,
+ });
+
+ const content: AgentToolResultContent[] = [{ type: "text", text: details.output || "(no output)" }];
+ const isError = details.exitCode !== 0 || details.stopReason === "error" || details.stopReason === "aborted";
+
+ if (isError) {
+ const text = details.errorMessage || details.stderr || details.output || "Fresh subagent failed.";
+ return {
+ content: [{ type: "text", text }],
+ details,
+ isError: true,
+ };
+ }
+
+ return { content, details };
+ },
+
+ renderCall(args, theme) {
+ const preview = args.prompt.length > 80 ? `${args.prompt.slice(0, 80)}...` : args.prompt;
+ return new Text(
+ `${theme.fg("toolTitle", theme.bold("subagent"))}\n ${theme.fg("dim", preview)}`,
+ 0,
+ 0,
+ );
+ },
+
+ renderResult(result, { expanded }, theme) {
+ const details = result.details as FreshSubagentResult | undefined;
+ if (!details) {
+ const text = result.content[0];
+ return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
+ }
+ return new Text(renderSubagentSummary(details, expanded, theme), 0, 0);
+ },
+ });
+
+ pi.registerCommand("subagent", {
+ description: "Run a fresh-context subagent with a prompt",
+ handler: async (args, ctx) => {
+ const prompt = args.trim();
+ if (!prompt) {
+ ctx.ui.notify("Usage: /subagent <prompt>", "warning");
+ return;
+ }
+
+ ctx.ui.setStatus("fresh-subagent", ctx.ui.theme.fg("warning", "subagent: running"));
+
+ try {
+ const details = await runFreshSubagent(prompt, {
+ cwd: ctx.cwd,
+ model: getProviderScopedModel(ctx),
+ });
+
+ if (!ctx.hasUI) {
+ const text = details.output || details.errorMessage || details.stderr || "(no output)";
+ if (text) {
+ process.stdout.write(`${text}\n`);
+ }
+ return;
+ }
+
+ pi.sendMessage(
+ {
+ customType: "fresh-subagent-result",
+ content: details.output || "(no output)",
+ display: true,
+ details,
+ },
+ { triggerTurn: false },
+ );
+ } finally {
+ ctx.ui.setStatus("fresh-subagent", undefined);
+ }
+ },
+ });
+
+ pi.registerMessageRenderer("fresh-subagent-result", (message, { expanded }, theme) => {
+ return new Text(renderSubagentSummary(message.details as FreshSubagentResult, expanded, theme), 0, 0);
+ });
+}