From 8fdba30d44037a91623c7cf05da7f1e2a298c47e Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 21 Mar 2026 09:56:45 +0200 Subject: import pi.dev stuff --- pi/agent/extensions/ask-mode/README.md | 84 ++ pi/agent/extensions/ask-mode/index.ts | 183 ++++ pi/agent/extensions/ask-mode/utils.ts | 94 ++ pi/agent/extensions/btw/README.md | 48 + pi/agent/extensions/btw/index.ts | 230 ++++ pi/agent/extensions/fresh-subagent/README.md | 246 +++++ pi/agent/extensions/fresh-subagent/index.ts | 1144 ++++++++++++++++++++ pi/agent/extensions/handoff/README.md | 45 + pi/agent/extensions/handoff/index.ts | 130 +++ pi/agent/extensions/inline-bash/README.md | 44 + pi/agent/extensions/inline-bash/index.ts | 72 ++ pi/agent/extensions/loop-scheduler/README.md | 124 +++ pi/agent/extensions/loop-scheduler/index.ts | 380 +++++++ pi/agent/extensions/modal-editor/README.md | 98 ++ pi/agent/extensions/modal-editor/index.ts | 512 +++++++++ pi/agent/extensions/nemotron-tool-repair/README.md | 87 ++ pi/agent/extensions/nemotron-tool-repair/index.ts | 480 ++++++++ pi/agent/extensions/reload-runtime/README.md | 46 + pi/agent/extensions/reload-runtime/index.ts | 26 + pi/agent/extensions/session-name/README.md | 41 + pi/agent/extensions/session-name/index.ts | 18 + .../extensions/taskwarrior-plan-mode/README.md | 173 +++ pi/agent/extensions/taskwarrior-plan-mode/index.ts | 822 ++++++++++++++ pi/agent/extensions/taskwarrior-plan-mode/utils.ts | 252 +++++ 24 files changed, 5379 insertions(+) create mode 100644 pi/agent/extensions/ask-mode/README.md create mode 100644 pi/agent/extensions/ask-mode/index.ts create mode 100644 pi/agent/extensions/ask-mode/utils.ts create mode 100644 pi/agent/extensions/btw/README.md create mode 100644 pi/agent/extensions/btw/index.ts create mode 100644 pi/agent/extensions/fresh-subagent/README.md create mode 100644 pi/agent/extensions/fresh-subagent/index.ts create mode 100644 pi/agent/extensions/handoff/README.md create mode 100644 pi/agent/extensions/handoff/index.ts create mode 100644 pi/agent/extensions/inline-bash/README.md create mode 100644 pi/agent/extensions/inline-bash/index.ts create mode 100644 pi/agent/extensions/loop-scheduler/README.md create mode 100644 pi/agent/extensions/loop-scheduler/index.ts create mode 100644 pi/agent/extensions/modal-editor/README.md create mode 100644 pi/agent/extensions/modal-editor/index.ts create mode 100644 pi/agent/extensions/nemotron-tool-repair/README.md create mode 100644 pi/agent/extensions/nemotron-tool-repair/index.ts create mode 100644 pi/agent/extensions/reload-runtime/README.md create mode 100644 pi/agent/extensions/reload-runtime/index.ts create mode 100644 pi/agent/extensions/session-name/README.md create mode 100644 pi/agent/extensions/session-name/index.ts create mode 100644 pi/agent/extensions/taskwarrior-plan-mode/README.md create mode 100644 pi/agent/extensions/taskwarrior-plan-mode/index.ts create mode 100644 pi/agent/extensions/taskwarrior-plan-mode/utils.ts (limited to 'pi/agent/extensions') diff --git a/pi/agent/extensions/ask-mode/README.md b/pi/agent/extensions/ask-mode/README.md new file mode 100644 index 0000000..2c0d17c --- /dev/null +++ b/pi/agent/extensions/ask-mode/README.md @@ -0,0 +1,84 @@ +# Ask Mode + +Exploration-only mode for Pi. + +This extension adds a session-scoped `/ask` mode that turns Pi into a read-only +investigation assistant. It is meant for understanding a codebase, debugging, +reading logs, or answering questions without making changes. + +## What It Does + +- `/ask` enters ask mode +- `/ask ` enters ask mode and immediately sends the prompt +- `/ask-exit` leaves ask mode +- `/ask-status` shows whether ask mode is active +- limits tools to `read`, `bash`, `grep`, `find`, and `ls` +- blocks unsafe bash commands even though `bash` stays enabled +- injects per-turn instructions telling the model to inspect and explain, not implement + +## Usage Flows + +### Flow 1: Enter ask mode first, then explore + +```text +/ask +``` + +Then ask questions naturally: + +```text +Why does VM2 fail to reach readiness on the first create attempt? +``` + +### Flow 2: Enter ask mode and ask immediately + +```text +/ask Compare the fresh-subagent extension behavior with what the README claims. +``` + +### Flow 3: Leave ask mode + +```text +/ask-exit +``` + +That restores the previously active tool set. + +### Flow 4: Check whether you are still in ask mode + +```text +/ask-status +``` + +## Safety Model + +Ask mode is meant for exploration only. + +- `edit` and `write` are removed from the active tool set +- custom tools outside the ask-mode allowlist are blocked +- `bash` remains available, but only for safe read-only commands + +Examples of the kind of bash commands ask mode allows: + +- `rg foo src` +- `git diff` +- `ls -la` +- `sed -n '1,120p' file` +- `curl http://host/...` + +Examples it blocks: + +- `rm` +- `touch` +- `mkdir` +- `git commit` +- `npm install` +- `sudo ...` +- shell redirection that writes files + +## Notes And Limits + +- This is session-scoped and restores on resume if the session was left in ask mode. +- It is intended for investigation, not planning or implementation. +- If you ask for a change while ask mode is active, Pi should explain what would + need to change instead of making the change. diff --git a/pi/agent/extensions/ask-mode/index.ts b/pi/agent/extensions/ask-mode/index.ts new file mode 100644 index 0000000..4f19815 --- /dev/null +++ b/pi/agent/extensions/ask-mode/index.ts @@ -0,0 +1,183 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { TextContent } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { isSafeAskModeCommand } from "./utils.js"; + +const ASK_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"]; +const STATE_TYPE = "ask-mode"; +const CONTEXT_TYPE = "ask-mode-context"; + +interface AskModeState { + enabled: boolean; + normalTools: string[]; +} + +function hasAskModeMarker(message: AgentMessage): boolean { + const customMessage = message as AgentMessage & { customType?: string }; + if (customMessage.customType === CONTEXT_TYPE) return true; + + if (message.role !== "user") return false; + if (typeof message.content === "string") return message.content.includes("[ASK MODE ACTIVE]"); + if (!Array.isArray(message.content)) return false; + + return message.content.some( + (block) => block.type === "text" && (block as TextContent).text?.includes("[ASK MODE ACTIVE]"), + ); +} + +export default function askModeExtension(pi: ExtensionAPI): void { + let askModeEnabled = false; + let normalTools: string[] = []; + + function persistState(): void { + pi.appendEntry(STATE_TYPE, { + enabled: askModeEnabled, + normalTools, + }); + } + + function updateStatus(ctx: ExtensionContext): void { + if (!askModeEnabled) { + ctx.ui.setStatus("ask-mode", undefined); + ctx.ui.setWidget("ask-mode", undefined); + return; + } + + ctx.ui.setStatus("ask-mode", ctx.ui.theme.fg("warning", "⏸ ask")); + ctx.ui.setWidget("ask-mode", [ + ctx.ui.theme.fg("warning", "Ask mode"), + "Exploration only", + "Files are read-only", + "Bash is restricted to safe read-only commands", + ]); + } + + function enterAskMode(ctx: ExtensionContext): void { + if (askModeEnabled) { + updateStatus(ctx); + return; + } + + normalTools = pi.getActiveTools(); + askModeEnabled = true; + pi.setActiveTools(ASK_MODE_TOOLS); + ctx.ui.notify(`Ask mode enabled. Tools: ${ASK_MODE_TOOLS.join(", ")}`, "info"); + updateStatus(ctx); + persistState(); + } + + function exitAskMode(ctx: ExtensionContext): void { + if (!askModeEnabled) { + ctx.ui.notify("Ask mode is not active.", "info"); + updateStatus(ctx); + return; + } + + askModeEnabled = false; + pi.setActiveTools(normalTools.length > 0 ? normalTools : ["read", "bash", "edit", "write"]); + ctx.ui.notify("Ask mode disabled. Previous tools restored.", "info"); + updateStatus(ctx); + persistState(); + } + + pi.registerCommand("ask", { + description: "Enter ask mode for exploration-only work. Optional prompt sends a question immediately.", + handler: async (args, ctx) => { + const prompt = args.trim(); + enterAskMode(ctx); + if (prompt) { + pi.sendUserMessage(prompt); + if (!ctx.hasUI) { + await ctx.waitForIdle(); + } + } + }, + }); + + pi.registerCommand("ask-exit", { + description: "Leave ask mode and restore the previous tool set", + handler: async (_args, ctx) => exitAskMode(ctx), + }); + + pi.registerCommand("ask-status", { + description: "Show whether ask mode is active", + handler: async (_args, ctx) => { + const message = askModeEnabled + ? `Ask mode active. Tools: ${ASK_MODE_TOOLS.join(", ")}` + : "Ask mode is not active."; + if (!ctx.hasUI) { + process.stdout.write(`${message}\n`); + return; + } + ctx.ui.notify(message, "info"); + }, + }); + + pi.on("tool_call", async (event) => { + if (!askModeEnabled) return; + + if (!ASK_MODE_TOOLS.includes(event.toolName)) { + return { + block: true, + reason: `Ask mode: tool "${event.toolName}" is disabled. Use /ask-exit before modifying files or using other tools.`, + }; + } + + if (event.toolName === "bash") { + const command = String(event.input.command ?? ""); + if (!isSafeAskModeCommand(command)) { + return { + block: true, + reason: `Ask mode: bash command blocked (not recognized as safe read-only exploration).\nCommand: ${command}`, + }; + } + } + }); + + pi.on("context", async (event) => { + if (askModeEnabled) return; + return { + messages: event.messages.filter((message) => !hasAskModeMarker(message as AgentMessage)), + }; + }); + + pi.on("before_agent_start", async () => { + if (!askModeEnabled) return; + + return { + message: { + customType: CONTEXT_TYPE, + content: `[ASK MODE ACTIVE] +You are in ask mode: exploration only. + +Rules: +- Do not modify files. +- Do not use edit or write tools. +- Use read, grep, find, ls, and only safe read-only bash commands. +- Inspect, explain, compare, summarize, and answer questions. +- If a requested action would require a file change, say so explicitly instead of doing it. + +Focus on observation and analysis, not implementation.`, + display: false, + }, + }; + }); + + pi.on("session_start", async (_event, ctx) => { + const entries = ctx.sessionManager.getEntries(); + const latestState = entries + .filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === STATE_TYPE) + .pop() as { data?: AskModeState } | undefined; + + if (latestState?.data) { + askModeEnabled = latestState.data.enabled ?? askModeEnabled; + normalTools = latestState.data.normalTools ?? normalTools; + } + + if (askModeEnabled) { + pi.setActiveTools(ASK_MODE_TOOLS); + } + + updateStatus(ctx); + }); +} diff --git a/pi/agent/extensions/ask-mode/utils.ts b/pi/agent/extensions/ask-mode/utils.ts new file mode 100644 index 0000000..db8c889 --- /dev/null +++ b/pi/agent/extensions/ask-mode/utils.ts @@ -0,0 +1,94 @@ +const DESTRUCTIVE_PATTERNS = [ + /\brm\b/i, + /\brmdir\b/i, + /\bmv\b/i, + /\bcp\b/i, + /\bmkdir\b/i, + /\btouch\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\bchgrp\b/i, + /\bln\b/i, + /\btee\b/i, + /\btruncate\b/i, + /\bdd\b/i, + /\bshred\b/i, + /(^|[^<])>(?!>)/, + />>/, + /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, + /\byarn\s+(add|remove|install|publish)/i, + /\bpnpm\s+(add|remove|install|publish)/i, + /\bpip\s+(install|uninstall)/i, + /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, + /\bbrew\s+(install|uninstall|upgrade)/i, + /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, + /\bsudo\b/i, + /\bsu\b/i, + /\bkill\b/i, + /\bpkill\b/i, + /\bkillall\b/i, + /\breboot\b/i, + /\bshutdown\b/i, + /\bsystemctl\s+(start|stop|restart|enable|disable)/i, + /\bservice\s+\S+\s+(start|stop|restart)/i, + /\b(vim?|nano|emacs|code|subl)\b/i, +]; + +const SAFE_PATTERNS = [ + /^\s*cat\b/, + /^\s*head\b/, + /^\s*tail\b/, + /^\s*less\b/, + /^\s*more\b/, + /^\s*grep\b/, + /^\s*find\b/, + /^\s*ls\b/, + /^\s*pwd\b/, + /^\s*echo\b/, + /^\s*printf\b/, + /^\s*wc\b/, + /^\s*sort\b/, + /^\s*uniq\b/, + /^\s*diff\b/, + /^\s*file\b/, + /^\s*stat\b/, + /^\s*du\b/, + /^\s*df\b/, + /^\s*tree\b/, + /^\s*which\b/, + /^\s*whereis\b/, + /^\s*type\b/, + /^\s*env\b/, + /^\s*printenv\b/, + /^\s*uname\b/, + /^\s*whoami\b/, + /^\s*id\b/, + /^\s*date\b/, + /^\s*cal\b/, + /^\s*uptime\b/, + /^\s*ps\b/, + /^\s*top\b/, + /^\s*htop\b/, + /^\s*free\b/, + /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, + /^\s*git\s+ls-/i, + /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, + /^\s*yarn\s+(list|info|why|audit)/i, + /^\s*node\s+--version/i, + /^\s*python\s+--version/i, + /^\s*curl\s/i, + /^\s*wget\s+-O\s*-/i, + /^\s*jq\b/, + /^\s*sed\s+-n/i, + /^\s*awk\b/, + /^\s*rg\b/, + /^\s*fd\b/, + /^\s*bat\b/, + /^\s*exa\b/, +]; + +export function isSafeAskModeCommand(command: string): boolean { + const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command)); + const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command)); + return !isDestructive && isSafe; +} diff --git a/pi/agent/extensions/btw/README.md b/pi/agent/extensions/btw/README.md new file mode 100644 index 0000000..cf39e1c --- /dev/null +++ b/pi/agent/extensions/btw/README.md @@ -0,0 +1,48 @@ +# BTW + +Ephemeral side questions for Pi. + +This extension adds `/btw`, modeled after Claude Code's side-question flow: + +- it uses the current branch conversation as context +- it asks a separate one-shot question with the current model +- it does not add the side question or answer to session history +- it does not expose tools to that side question + +## Command + +- `/btw ` + Ask a quick side question without changing the main thread history. + +## Usage Flow + +### Flow 1: Ask a quick side question + +```text +/btw Why did the current taskwarrior loop happen? +``` + +Pi will answer in a temporary overlay. Close it with `Esc`, `Enter`, or `Space`. + +### Flow 2: Use it while you are in the middle of another task + +```text +/btw Remind me which file currently owns the SSH host key bootstrap logic. +``` + +This is meant for detours and clarifications. The main conversation stays clean. + +### Flow 3: Use it in non-interactive mode + +```bash +pi --model openai/gpt-4.1 --no-session -p '/btw Reply with exactly BTW_OK' +``` + +In non-interactive mode, the answer is printed directly to stdout. + +## Notes And Limits + +- `/btw` uses the currently selected model. +- The side question gets current branch context, not a fresh context. +- It has no tools. If the answer is not derivable from the supplied context, it should say so. +- It is best for short clarifications, not long implementation work. diff --git a/pi/agent/extensions/btw/index.ts b/pi/agent/extensions/btw/index.ts new file mode 100644 index 0000000..286c52e --- /dev/null +++ b/pi/agent/extensions/btw/index.ts @@ -0,0 +1,230 @@ +import { complete, type Message, type TextContent, type UserMessage } from "@mariozechner/pi-ai"; +import { + BorderedLoader, + convertToLlm, + type ExtensionAPI, + type ExtensionCommandContext, + type SessionEntry, + type Theme, +} from "@mariozechner/pi-coding-agent"; +import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; + +const SYSTEM_PROMPT = `You are answering a side question for the user. + +Rules: +- Use the supplied conversation context if it is relevant. +- Answer the side question directly and concisely. +- Do not use tools. +- Do not invent facts that are not supported by the supplied context. +- If the answer is not available from the supplied conversation context, say so plainly. +- Keep the answer short by default unless the user explicitly asks for depth.`; + +function extractResponseText(message: Message): string { + return message.content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n") + .trim(); +} + +function getConversationMessages(ctx: ExtensionCommandContext): Message[] { + const branch = ctx.sessionManager.getBranch(); + return branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); +} + +function wrapParagraph(text: string, width: number): string[] { + if (width <= 1) return [text]; + if (!text.trim()) return [""]; + + const words = text.split(/\s+/).filter(Boolean); + if (words.length === 0) return [""]; + + const lines: string[] = []; + let current = ""; + + for (const word of words) { + const next = current ? `${current} ${word}` : word; + if (visibleWidth(next) <= width) { + current = next; + continue; + } + + if (current) lines.push(current); + + if (visibleWidth(word) <= width) { + current = word; + continue; + } + + let remainder = word; + while (visibleWidth(remainder) > width) { + lines.push(truncateToWidth(remainder, width, "")); + remainder = remainder.slice(lines[lines.length - 1]!.length); + } + current = remainder; + } + + if (current) lines.push(current); + return lines.length > 0 ? lines : [""]; +} + +function wrapText(text: string, width: number): string[] { + return text.split(/\r?\n/).flatMap((line) => wrapParagraph(line, width)); +} + +class BtwOverlay { + constructor( + private readonly theme: Theme, + private readonly question: string, + private readonly answer: string, + private readonly done: () => void, + ) {} + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "return") || data === " " || data === "\r") { + this.done(); + } + } + + render(width: number): string[] { + const innerWidth = Math.max(20, width - 2); + const contentWidth = Math.max(10, innerWidth - 2); + const lines: string[] = []; + + const pad = (text: string) => { + const visible = visibleWidth(text); + return text + " ".repeat(Math.max(0, innerWidth - visible)); + }; + + const row = (text = "") => `${this.theme.fg("border", "│")}${pad(text)}${this.theme.fg("border", "│")}`; + const addWrappedSection = (label: string, value: string) => { + lines.push(row(` ${this.theme.fg("accent", label)}`)); + for (const wrapped of wrapText(value || "(no answer)", contentWidth)) { + lines.push(row(` ${wrapped}`)); + } + lines.push(row()); + }; + + lines.push(this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`)); + lines.push(row(` ${this.theme.fg("accent", "BTW")}${this.theme.fg("muted", " Side question")}`)); + lines.push(row()); + addWrappedSection("Question", this.question); + addWrappedSection("Answer", this.answer || "(no answer)"); + lines.push(row(this.theme.fg("dim", " Esc, Enter, or Space to close"))); + lines.push(this.theme.fg("border", `╰${"─".repeat(innerWidth)}╯`)); + + return lines; + } + + invalidate(): void {} +} + +async function runBtw(question: string, ctx: ExtensionCommandContext): Promise { + if (!ctx.model) { + throw new Error("No model selected."); + } + + const branchMessages = getConversationMessages(ctx); + const llmMessages = convertToLlm(branchMessages); + const apiKey = await ctx.modelRegistry.getApiKey(ctx.model); + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: question }], + timestamp: Date.now(), + }; + + const response = await complete( + ctx.model, + { + systemPrompt: SYSTEM_PROMPT, + messages: [...llmMessages, userMessage], + }, + { apiKey }, + ); + + if (response.stopReason === "aborted") { + throw new Error("Cancelled."); + } + + return extractResponseText(response) || "(no answer)"; +} + +export default function btwExtension(pi: ExtensionAPI): void { + pi.registerCommand("btw", { + description: "Ask a quick side question without adding it to the conversation", + handler: async (args, ctx) => { + const question = args.trim(); + if (!question) { + const usage = "Usage: /btw "; + if (!ctx.hasUI) process.stdout.write(`${usage}\n`); + else ctx.ui.notify(usage, "warning"); + return; + } + + if (!ctx.model) { + const error = "No model selected."; + if (!ctx.hasUI) process.stdout.write(`${error}\n`); + else ctx.ui.notify(error, "error"); + return; + } + + if (!ctx.hasUI) { + try { + const answer = await runBtw(question, ctx); + process.stdout.write(`${answer}\n`); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + process.stdout.write(`${text}\n`); + } + return; + } + + const answer = await ctx.ui.custom( + (tui, theme, _kb, done) => { + const loader = new BorderedLoader(tui, theme, `Asking BTW using ${ctx.model!.id}...`); + loader.onAbort = () => done(null); + + runBtw(question, ctx) + .then(done) + .catch((error) => { + const text = error instanceof Error ? error.message : String(error); + done(`BTW failed: ${text}`); + }); + + return loader; + }, + { + overlay: true, + overlayOptions: { + width: "50%", + minWidth: 50, + maxHeight: "80%", + anchor: "right-center", + offsetX: -1, + }, + }, + ); + + if (answer === null) { + ctx.ui.notify("BTW cancelled.", "info"); + return; + } + + await ctx.ui.custom( + (_tui, theme, _kb, done) => new BtwOverlay(theme, question, answer, done), + { + overlay: true, + overlayOptions: { + width: "55%", + minWidth: 56, + maxHeight: "85%", + anchor: "right-center", + offsetX: -1, + }, + }, + ); + }, + }); +} diff --git a/pi/agent/extensions/fresh-subagent/README.md b/pi/agent/extensions/fresh-subagent/README.md new file mode 100644 index 0000000..701fdda --- /dev/null +++ b/pi/agent/extensions/fresh-subagent/README.md @@ -0,0 +1,246 @@ +# Fresh Subagent + +Generic fresh-context delegation for Pi with live status, per-run log files, and +history browsing. + +This extension gives Pi a simple subagent primitive: + +- the main agent can call the `subagent` tool +- you can call `/subagent ` directly +- delegated work runs in a new `pi --mode json -p --no-session` process +- the child starts with a fresh context +- each run gets its own log file plus JSON sidecar metadata +- you can list past runs and open any run's full transcript in `$VISUAL` or `$EDITOR` + +This is still intentionally small. It does not manage agent pools, agent +catalogs, or planner chains. It is meant for focused delegation with a clean +context and auditable output. + +## What It Is For + +Subagents are generic. The main agent can hand them any focused prompt that +benefits from a clean context, for example: + +- code review +- debugging +- focused research +- second-opinion architecture checks +- summarizing noisy output +- validating whether a task is really complete +- any other self-contained side task + +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 + +## Usage Flow + +### Step 1: Run a subagent + +Direct delegation: + +```text +/subagent Compare the current plan-mode extension behavior against the requested workflow and list only the mismatches. +``` + +Focused investigation: + +```text +/subagent Find all code paths that write to the SSH known_hosts file and summarize the risk. +``` + +Independent review: + +```text +/subagent Independently review the recent changes for bugs, regressions, and missing tests. Only report concrete findings. +``` + +The watched slash command is the normal interactive path. It updates status in +the footer, keeps a widget with recent activity, and writes the full run to a +durable log file. + +### Step 2: Inspect history + +List recent runs: + +```text +/subagent-history +``` + +List more: + +```text +/subagent-history 20 +``` + +Each entry includes: + +- run ID +- status +- started timestamp +- prompt summary +- log path +- output preview when available + +You can select later runs either by: + +- `latest` +- numeric index from `/subagent-history` +- run ID prefix + +### Step 3: Inspect a specific run + +Show the paths and metadata for the latest run: + +```text +/subagent-log +``` + +Show the paths and metadata for a specific run: + +```text +/subagent-log 3 +/subagent-log 20260320T194522-review-ssh +``` + +This prints: + +- run ID +- status +- prompt +- log path +- metadata path +- `tail -f` command + +### Step 4: Open the full transcript in Helix or another editor + +Open the latest run in `$VISUAL` or `$EDITOR`: + +```text +/subagent-open +``` + +Open a specific run: + +```text +/subagent-open 2 +/subagent-open 20260320T194522-review-ssh +``` + +In TUI mode the extension temporarily releases the terminal, launches your +configured editor, then restores Pi when you exit the editor. + +In one-shot or print mode it runs the editor command directly. + +## Other Commands + +Alias with the same watched behavior: + +```text +/subagent-watch +``` + +Launch a visible fresh Pi session instead of a headless child: + +```text +/subagent-session +``` + +This is useful when you want to watch the subagent itself, not just the logged +transcript. + +## Tool Usage From The Main Agent + +Because this extension registers a `subagent` tool, the main agent can call it +itself. + +Generic handoff pattern: + +```text +Use the subagent tool for a fresh-context pass on this side task, then return only the useful result. +``` + +Review handoff pattern: + +```text +First review your own changes. Afterwards, use the subagent tool to perform an independent fresh-context review and then address any findings. +``` + +Research handoff pattern: + +```text +Use the subagent tool to inspect only the WireGuard setup path in a fresh context and summarize the concrete risks. +``` + +## One-Shot CLI Mode + +This works outside the full TUI as well: + +```bash +pi --model openai/gpt-4.1 --no-session -p '/subagent Say only SUBAGENT_COMMAND_OK' +pi --no-session -p '/subagent-history' +pi --no-session -p '/subagent-log latest' +``` + +If you want to open a run from a shell: + +```bash +pi --no-session -p '/subagent-open latest' +``` + +## What To Put In The Prompt + +Subagents start fresh, so include enough context in the prompt: + +- what to inspect or do +- the scope or files to focus on +- the expected output shape +- any constraints such as “report only concrete findings” + +Good: + +```text +/subagent Review the recent SSH bootstrap changes in hyperstack.rb. Report only concrete bugs, regressions, or missing tests. +``` + +Weak: + +```text +/subagent Review this +``` + +## Log Storage + +Fresh-subagent history lives under: + +```text +${XDG_STATE_HOME:-~/.local/state}/pi/subagents +``` + +Each run creates: + +- one `*.log` transcript file +- one `*.json` metadata file +- a rolling `latest.log` symlink pointing at the newest run + +That means you can also inspect logs outside Pi with tools like: + +```bash +tail -f ~/.local/state/pi/subagents/latest.log +ls ~/.local/state/pi/subagents +``` + +## Notes And Limits + +- The headless 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. +- `subagent-session` is visible because it uses a real Pi session instead of a + headless child. Its transcript is the session itself, not one of the + `fresh-subagent` log files. diff --git a/pi/agent/extensions/fresh-subagent/index.ts b/pi/agent/extensions/fresh-subagent/index.ts new file mode 100644 index 0000000..366f94a --- /dev/null +++ b/pi/agent/extensions/fresh-subagent/index.ts @@ -0,0 +1,1144 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { AgentToolResult, AgentToolResultContent } from "@mariozechner/pi-agent-core"; +import type { Message, TextContent } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionCommandContext, 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"; +const LOG_BASENAME = "latest.log"; +const HISTORY_SUFFIX = ".json"; +const DEFAULT_HISTORY_LIMIT = 10; +const MAX_HISTORY_LIMIT = 50; +const MAX_RECENT_ACTIVITY = 12; +const MAX_WIDGET_LINES = 10; +const MAX_RENDER_PREVIEW_LINES = 8; +const MAX_ACTIVITY_LINE_LENGTH = 160; +const MAX_UPDATE_INTERVAL_MS = 150; +const HISTORY_PERSIST_INTERVAL_MS = 1000; + +interface UsageStats { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + turns: number; +} + +interface FreshSubagentResult { + runId: string; + prompt: string; + model?: string; + cwd: string; + exitCode: number; + stopReason?: string; + errorMessage?: string; + stderr: string; + output: string; + usage: UsageStats; + logPath: string; + metadataPath: string; + latestLogPath: string; + eventCount: number; + lastStatus: string; + currentTool?: string; + recentActivity: string[]; +} + +interface SubagentHistoryEntry { + runId: string; + prompt: string; + promptSummary: string; + model?: string; + cwd: string; + startedAt: string; + finishedAt?: string; + active: boolean; + exitCode?: number; + stopReason?: string; + errorMessage?: string; + logPath: string; + metadataPath: string; + eventCount: number; + lastStatus: string; + currentTool?: string; + outputPreview?: string; +} + +interface SubagentLog { + runId: string; + logPath: string; + metadataPath: string; + latestLogPath: string; + write(line: string): void; + close(): Promise; +} + +interface RunFreshSubagentOptions { + cwd: string; + model?: string; + tools?: string[]; + signal?: AbortSignal; + onUpdate?: (partial: AgentToolResult) => void; + onState?: (details: FreshSubagentResult) => void; +} + +let latestLogPathHint: string | undefined; +let activeLogPathHint: string | undefined; + +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 ""; +} + +function getSubagentLogDir(): string { + const stateHome = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state"); + return path.join(stateHome, "pi", "subagents"); +} + +function sanitizePromptForFile(prompt: string): string { + const slug = prompt + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + return slug || "subagent"; +} + +function makeRunId(prompt: string): string { + const suffix = Math.random().toString(36).slice(2, 8); + return `${timestampForFile()}-${sanitizePromptForFile(prompt)}-${suffix}`; +} + +function timestampForFile(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + "T", + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()), + ].join(""); +} + +async function writeHistoryEntry(entry: SubagentHistoryEntry): Promise { + await writeFile(entry.metadataPath, `${JSON.stringify(entry, null, 2)}\n`, "utf8"); +} + +async function readHistoryEntries(): Promise { + const dir = getSubagentLogDir(); + await mkdir(dir, { recursive: true }); + + const files = await readdir(dir, { withFileTypes: true }); + const entries: SubagentHistoryEntry[] = []; + + for (const file of files) { + if (!file.isFile() || !file.name.endsWith(HISTORY_SUFFIX)) continue; + + const metadataPath = path.join(dir, file.name); + try { + const raw = await readFile(metadataPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (!parsed.runId || !parsed.prompt || !parsed.logPath || !parsed.startedAt) continue; + + entries.push({ + runId: parsed.runId, + prompt: parsed.prompt, + promptSummary: parsed.promptSummary || summarizePrompt(parsed.prompt, 120), + model: parsed.model, + cwd: parsed.cwd || "", + startedAt: parsed.startedAt, + finishedAt: parsed.finishedAt, + active: Boolean(parsed.active), + exitCode: parsed.exitCode, + stopReason: parsed.stopReason, + errorMessage: parsed.errorMessage, + logPath: parsed.logPath, + metadataPath, + eventCount: parsed.eventCount || 0, + lastStatus: parsed.lastStatus || "unknown", + currentTool: parsed.currentTool, + outputPreview: parsed.outputPreview, + }); + } catch { + // Ignore malformed files so one bad history entry does not break browsing. + } + } + + return entries.sort((a, b) => { + const aTime = Date.parse(a.startedAt) || 0; + const bTime = Date.parse(b.startedAt) || 0; + return bTime - aTime; + }); +} + +function getHistoryStatus(entry: SubagentHistoryEntry): string { + if (entry.active) { + return entry.currentTool ? `active:${entry.currentTool}` : `active:${entry.lastStatus}`; + } + + if (entry.stopReason === "aborted") return "aborted"; + if (entry.exitCode === 0 && entry.stopReason !== "error") return "done"; + return "error"; +} + +function normalizeHistorySelector(selector: string): string { + return selector.trim(); +} + +async function resolveHistoryEntry(selector: string): Promise<{ + entry?: SubagentHistoryEntry; + error?: string; +}> { + const entries = await readHistoryEntries(); + if (entries.length === 0) { + return { error: "No subagent history is available yet." }; + } + + const normalized = normalizeHistorySelector(selector || "latest"); + if (!normalized || normalized === "latest") { + return { entry: entries[0] }; + } + + if (/^\d+$/.test(normalized)) { + const index = Number(normalized); + if (index >= 1 && index <= entries.length) return { entry: entries[index - 1] }; + return { error: `History index ${normalized} is out of range.` }; + } + + const exact = entries.find((entry) => entry.runId === normalized); + if (exact) return { entry: exact }; + + const matches = entries.filter((entry) => entry.runId.startsWith(normalized)); + if (matches.length === 1) return { entry: matches[0] }; + if (matches.length > 1) { + return { + error: `Selector '${normalized}' is ambiguous:\n${matches + .slice(0, 8) + .map((entry) => `- ${entry.runId}`) + .join("\n")}`, + }; + } + + return { error: `No subagent history entry matched '${normalized}'.` }; +} + +async function createSubagentLog(prompt: string): Promise { + const dir = getSubagentLogDir(); + await mkdir(dir, { recursive: true }); + + const runId = makeRunId(prompt); + const logPath = path.join(dir, `${runId}.log`); + const metadataPath = path.join(dir, `${runId}${HISTORY_SUFFIX}`); + const latestLogPath = path.join(dir, LOG_BASENAME); + const stream = createWriteStream(logPath, { flags: "a" }); + + try { + await rm(latestLogPath, { force: true }); + await symlink(path.basename(logPath), latestLogPath); + } catch { + // Best-effort only. The per-run log path still works even if the symlink fails. + } + + const write = (line: string) => { + const timestamp = new Date().toISOString(); + stream.write(`${timestamp} ${line}\n`); + }; + + return { + runId, + logPath, + metadataPath, + latestLogPath, + write, + close: () => + new Promise((resolve) => { + stream.end(resolve); + }), + }; +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 3))}...`; +} + +function summarizePrompt(prompt: string, max = 80): string { + return truncate(prompt.replace(/\s+/g, " ").trim(), max); +} + +function summarizeValue(value: unknown, max = 120): string { + if (value === undefined) return ""; + if (typeof value === "string") return truncate(value.replace(/\s+/g, " ").trim(), max); + + try { + const json = JSON.stringify(value); + return truncate(json, max); + } catch { + return truncate(String(value), max); + } +} + +function splitActivityLines(text: string): string[] { + const collapsed = text.replace(/\r/g, ""); + const rawLines = collapsed.split("\n"); + const output: string[] = []; + + for (const raw of rawLines) { + const line = raw.trimEnd(); + if (!line) continue; + if (line.length <= MAX_ACTIVITY_LINE_LENGTH) { + output.push(line); + continue; + } + + let remaining = line; + while (remaining.length > MAX_ACTIVITY_LINE_LENGTH) { + output.push(`${remaining.slice(0, MAX_ACTIVITY_LINE_LENGTH - 1)}…`); + remaining = remaining.slice(MAX_ACTIVITY_LINE_LENGTH - 1); + } + if (remaining) output.push(remaining); + } + + return output; +} + +function extractContentText(content: unknown): string { + if (!Array.isArray(content)) return ""; + + return content + .map((item) => { + if (!item || typeof item !== "object") return ""; + const typedItem = item as { type?: string; text?: string }; + return typedItem.type === "text" && typeof typedItem.text === "string" ? typedItem.text : ""; + }) + .filter(Boolean) + .join("\n"); +} + +function cloneResult(result: FreshSubagentResult): FreshSubagentResult { + return { + ...result, + recentActivity: [...result.recentActivity], + usage: { ...result.usage }, + }; +} + +function renderRunningSummary(details: FreshSubagentResult): string { + const lines = [ + `subagent: ${details.lastStatus || "running"}`, + `run: ${details.runId}`, + `log: ${details.logPath}`, + ]; + + if (details.currentTool) lines.push(`tool: ${details.currentTool}`); + + if (details.recentActivity.length > 0) { + lines.push("", ...details.recentActivity.slice(-MAX_RENDER_PREVIEW_LINES)); + } + + return lines.join("\n"); +} + +function buildWidgetLines(details: FreshSubagentResult): string[] { + const lines = [ + `subagent: ${details.lastStatus || "running"}`, + `run: ${details.runId}`, + `log: ${details.latestLogPath}`, + ]; + + if (details.currentTool) lines.push(`tool: ${details.currentTool}`); + + if (details.recentActivity.length > 0) { + lines.push(...details.recentActivity.slice(-MAX_WIDGET_LINES)); + } + + return lines; +} + +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", `run: ${details.runId}`), + theme.fg("muted", `cwd: ${details.cwd}`), + theme.fg("muted", `log: ${details.logPath}`), + theme.fg("muted", `meta: ${details.metadataPath}`), + theme.fg("muted", `latest: ${details.latestLogPath}`), + theme.fg("muted", `events: ${details.eventCount}`), + ]; + + if (details.currentTool) lines.push(theme.fg("muted", `current tool: ${details.currentTool}`)); + + if (expanded) { + lines.push("", theme.fg("muted", "Prompt:"), details.prompt); + lines.push("", theme.fg("muted", "Result:"), details.output || theme.fg("muted", "(no output)")); + if (details.recentActivity.length > 0) { + lines.push("", theme.fg("muted", "Recent Activity:"), ...details.recentActivity.slice(-MAX_RENDER_PREVIEW_LINES)); + } + } 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"); +} + +function formatHistoryEntries(entries: SubagentHistoryEntry[], limit: number): string { + if (entries.length === 0) return "No subagent history is available yet."; + + return entries + .slice(0, limit) + .map((entry, index) => { + const lines = [ + `${index + 1}. ${entry.runId} [${getHistoryStatus(entry)}]${entry.model ? ` ${entry.model}` : ""}`, + ` started: ${entry.startedAt}`, + ` prompt: ${entry.promptSummary}`, + ` log: ${entry.logPath}`, + ]; + if (entry.outputPreview) lines.push(` output: ${entry.outputPreview}`); + return lines.join("\n"); + }) + .join("\n\n"); +} + +function formatHistoryDetails(entry: SubagentHistoryEntry): string { + const lines = [ + `run: ${entry.runId}`, + `status: ${getHistoryStatus(entry)}`, + `started: ${entry.startedAt}`, + `finished: ${entry.finishedAt || "(still running)"}`, + `model: ${entry.model || "(session default)"}`, + `cwd: ${entry.cwd}`, + `prompt: ${entry.prompt}`, + `log: ${entry.logPath}`, + `metadata: ${entry.metadataPath}`, + `tail: tail -f ${entry.logPath}`, + ]; + + if (entry.outputPreview) lines.push(`output preview: ${entry.outputPreview}`); + if (entry.errorMessage) lines.push(`error: ${entry.errorMessage}`); + return lines.join("\n"); +} + +function quoteForShell(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + +function getEditorCommand(): string | undefined { + return process.env.VISUAL || process.env.EDITOR; +} + +async function openInExternalEditor(filePath: string, ctx: ExtensionCommandContext): Promise<{ + ok: boolean; + message: string; +}> { + const editorCmd = getEditorCommand(); + if (!editorCmd) { + return { ok: false, message: "No editor configured. Set $VISUAL or $EDITOR." }; + } + + const command = `exec ${editorCmd} ${quoteForShell(filePath)}`; + + if (!ctx.hasUI) { + const result = spawnSync("bash", ["-lc", command], { + stdio: "inherit", + env: process.env, + }); + return result.status === 0 + ? { ok: true, message: `Opened ${filePath} in ${editorCmd}` } + : { ok: false, message: `Editor exited with code ${result.status ?? 1}` }; + } + + await ctx.waitForIdle(); + const exitCode = await ctx.ui.custom((tui, _theme, _kb, done) => { + tui.stop(); + process.stdout.write("\x1b[2J\x1b[H"); + + const result = spawnSync("bash", ["-lc", command], { + stdio: "inherit", + env: process.env, + }); + + tui.start(); + tui.requestRender(true); + done(result.status); + + return { + render: () => [], + invalidate: () => {}, + }; + }); + + return exitCode === 0 + ? { ok: true, message: `Opened ${filePath} in ${editorCmd}` } + : { ok: false, message: `Editor exited with code ${exitCode ?? 1}` }; +} + +async function runFreshSubagent(prompt: string, options: RunFreshSubagentOptions): Promise { + const log = await createSubagentLog(prompt); + latestLogPathHint = log.latestLogPath; + activeLogPathHint = log.logPath; + + 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 = { + runId: log.runId, + prompt, + model: options.model, + cwd: options.cwd, + exitCode: 0, + stderr: "", + output: "", + logPath: log.logPath, + metadataPath: log.metadataPath, + latestLogPath: log.latestLogPath, + eventCount: 0, + lastStatus: "starting", + recentActivity: [], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + turns: 0, + }, + }; + + const startedAt = new Date().toISOString(); + let finishedAt: string | undefined; + let isFinished = false; + let lastHistoryPersistAt = 0; + let historyWriteChain: Promise = Promise.resolve(); + + const buildHistoryEntry = (): SubagentHistoryEntry => ({ + runId: result.runId, + prompt: result.prompt, + promptSummary: summarizePrompt(result.prompt, 120), + model: result.model, + cwd: result.cwd, + startedAt, + finishedAt, + active: !isFinished, + exitCode: result.exitCode, + stopReason: result.stopReason, + errorMessage: result.errorMessage, + logPath: result.logPath, + metadataPath: result.metadataPath, + eventCount: result.eventCount, + lastStatus: result.lastStatus, + currentTool: result.currentTool, + outputPreview: result.output || result.errorMessage ? summarizePrompt(result.output || result.errorMessage || "", 140) : undefined, + }); + + const persistHistory = async (force = false) => { + const now = Date.now(); + if (!force && now - lastHistoryPersistAt < HISTORY_PERSIST_INTERVAL_MS) return; + lastHistoryPersistAt = now; + const entry = buildHistoryEntry(); + historyWriteChain = historyWriteChain + .then(() => writeHistoryEntry(entry)) + .catch(() => { + // Best-effort. Logging should not fail because the history sidecar write failed. + }); + await historyWriteChain; + }; + + const messages: Message[] = []; + const toolOutputById = new Map(); + let assistantBuffer = ""; + let lastEmitAt = 0; + let wasAborted = false; + + log.write(`[start] run=${result.runId}`); + log.write(`[start] prompt=${summarizePrompt(prompt, 200)}`); + log.write(`[start] cwd=${options.cwd}`); + if (options.model) log.write(`[start] model=${options.model}`); + await persistHistory(true); + + const pushActivity = (line: string) => { + const lines = splitActivityLines(line); + for (const entry of lines) { + result.recentActivity.push(entry); + if (result.recentActivity.length > MAX_RECENT_ACTIVITY) result.recentActivity.shift(); + log.write(entry); + } + }; + + const flushAssistantBuffer = (force: boolean) => { + let emitted = false; + + while (true) { + const newlineIndex = assistantBuffer.indexOf("\n"); + if (newlineIndex >= 0) { + const line = assistantBuffer.slice(0, newlineIndex); + assistantBuffer = assistantBuffer.slice(newlineIndex + 1); + if (line.trim()) pushActivity(`assistant> ${line}`); + emitted = true; + continue; + } + + if (force && assistantBuffer.trim()) { + pushActivity(`assistant> ${assistantBuffer}`); + assistantBuffer = ""; + emitted = true; + continue; + } + + if (!force && assistantBuffer.length > 240) { + pushActivity(`assistant> ${assistantBuffer.slice(0, 239)}…`); + assistantBuffer = assistantBuffer.slice(239); + emitted = true; + continue; + } + + break; + } + + return emitted; + }; + + const emitUpdate = (force = false) => { + const now = Date.now(); + if (!force && now - lastEmitAt < MAX_UPDATE_INTERVAL_MS) return; + lastEmitAt = now; + void persistHistory(force); + + const snapshot = cloneResult(result); + options.onUpdate?.({ + content: [{ type: "text", text: renderRunningSummary(snapshot) }], + details: snapshot, + }); + options.onState?.(snapshot); + }; + + result.exitCode = await new Promise((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 { + log.write(`[raw] ${line}`); + return; + } + + result.eventCount++; + + switch (event.type) { + case "agent_start": + result.lastStatus = "agent started"; + pushActivity("[agent] started"); + emitUpdate(true); + return; + case "agent_end": + flushAssistantBuffer(true); + result.lastStatus = "agent finished"; + pushActivity("[agent] finished"); + emitUpdate(true); + return; + case "turn_start": + result.lastStatus = "turn started"; + emitUpdate(); + return; + case "turn_end": + result.lastStatus = "turn finished"; + emitUpdate(); + return; + case "message_update": { + if (event.message?.role !== "assistant" || !event.assistantMessageEvent) return; + const assistantEvent = event.assistantMessageEvent; + + switch (assistantEvent.type) { + case "text_delta": + if (typeof assistantEvent.delta === "string") { + result.lastStatus = "assistant streaming"; + assistantBuffer += assistantEvent.delta; + flushAssistantBuffer(false); + emitUpdate(); + } + return; + case "text_end": + result.lastStatus = "assistant text complete"; + if (flushAssistantBuffer(true)) emitUpdate(true); + return; + case "thinking_start": + result.lastStatus = "assistant thinking"; + emitUpdate(); + return; + case "toolcall_start": + result.lastStatus = "assistant preparing tool call"; + emitUpdate(); + return; + case "toolcall_end": { + const toolCall = assistantEvent.toolCall || {}; + const toolName = toolCall.toolName || toolCall.name || "tool"; + const argsPreview = summarizeValue(toolCall.args || toolCall.input); + result.lastStatus = `assistant requested ${toolName}`; + pushActivity(argsPreview ? `[plan] ${toolName} ${argsPreview}` : `[plan] ${toolName}`); + emitUpdate(true); + return; + } + case "done": + result.lastStatus = assistantEvent.reason ? `assistant ${assistantEvent.reason}` : "assistant done"; + emitUpdate(true); + return; + case "error": + result.lastStatus = assistantEvent.reason ? `assistant ${assistantEvent.reason}` : "assistant error"; + emitUpdate(true); + return; + default: + return; + } + } + case "message_end": { + const message = event.message as Message | undefined; + if (!message) return; + + messages.push(message); + result.output = getLastAssistantText(messages); + + if (message.role === "assistant") { + flushAssistantBuffer(true); + 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(true); + return; + } + case "tool_execution_start": { + flushAssistantBuffer(true); + result.currentTool = event.toolName; + result.lastStatus = `tool ${event.toolName} running`; + const argsPreview = summarizeValue(event.args); + pushActivity(argsPreview ? `[tool:start] ${event.toolName} ${argsPreview}` : `[tool:start] ${event.toolName}`); + emitUpdate(true); + return; + } + case "tool_execution_update": { + flushAssistantBuffer(true); + result.currentTool = event.toolName; + result.lastStatus = `tool ${event.toolName} running`; + + const partialText = extractContentText(event.partialResult?.content); + if (partialText) { + const previous = toolOutputById.get(event.toolCallId) || ""; + const delta = partialText.startsWith(previous) ? partialText.slice(previous.length) : partialText; + toolOutputById.set(event.toolCallId, partialText); + + if (delta.trim()) { + for (const line of splitActivityLines(delta)) { + pushActivity(`[tool:${event.toolName}] ${line}`); + } + } + } + + emitUpdate(); + return; + } + case "tool_execution_end": { + flushAssistantBuffer(true); + const toolName = event.toolName || result.currentTool || "tool"; + const finalText = extractContentText(event.result?.content); + const previous = toolOutputById.get(event.toolCallId) || ""; + const delta = finalText.startsWith(previous) ? finalText.slice(previous.length) : finalText; + if (delta.trim()) { + for (const line of splitActivityLines(delta)) { + pushActivity(`[tool:${toolName}] ${line}`); + } + } + + result.lastStatus = event.isError ? `tool ${toolName} failed` : `tool ${toolName} done`; + pushActivity(event.isError ? `[tool:end] ${toolName} error` : `[tool:end] ${toolName} done`); + result.currentTool = undefined; + emitUpdate(true); + return; + } + case "auto_retry_start": + result.lastStatus = `retry ${event.attempt}/${event.maxAttempts}`; + pushActivity(`[retry] ${event.attempt}/${event.maxAttempts} ${event.errorMessage || ""}`.trim()); + emitUpdate(true); + return; + case "auto_retry_end": + result.lastStatus = event.success ? "retry recovered" : "retry failed"; + emitUpdate(true); + return; + default: + return; + } + }; + + 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) => { + const chunk = data.toString(); + result.stderr += chunk; + for (const line of splitActivityLines(chunk)) { + log.write(`[stderr] ${line}`); + } + }); + + proc.on("close", (code) => { + if (buffer.trim()) processLine(buffer); + resolve(code ?? 0); + }); + + proc.on("error", (err) => { + result.errorMessage = err.message; + 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.lastStatus = "aborted"; + pushActivity("[agent] aborted"); + } + + result.output ||= getLastAssistantText(messages); + log.write(`[finish] exit=${result.exitCode} stop=${result.stopReason || "unknown"}`); + if (result.errorMessage) log.write(`[finish] error=${result.errorMessage}`); + + finishedAt = new Date().toISOString(); + isFinished = true; + await persistHistory(true); + await log.close(); + activeLogPathHint = undefined; + emitUpdate(true); + await historyWriteChain; + return result; +} + +function getLogInfoText(entry?: SubagentHistoryEntry): string { + if (entry) return formatHistoryDetails(entry); + + const latestPath = latestLogPathHint || path.join(getSubagentLogDir(), LOG_BASENAME); + const lines = [`latest log: ${latestPath}`, `tail -f ${latestPath}`]; + if (activeLogPathHint) lines.push(`active run: ${activeLogPathHint}`); + return lines.join("\n"); +} + +function createSlashCommandHandler(watch: boolean) { + return async (args: string, ctx: ExtensionContext, pi: ExtensionAPI) => { + const prompt = args.trim(); + if (!prompt) { + ctx.ui.notify("Usage: /subagent ", "warning"); + return; + } + + const statusId = "fresh-subagent"; + const widgetId = "fresh-subagent-watch"; + const applyUiState = (details: FreshSubagentResult) => { + if (!ctx.hasUI || !watch) return; + const statusText = details.currentTool + ? `subagent: ${details.currentTool}` + : `subagent: ${details.lastStatus}`; + ctx.ui.setStatus(statusId, ctx.ui.theme.fg("warning", statusText)); + ctx.ui.setWidget(widgetId, buildWidgetLines(details), { placement: "belowEditor" }); + }; + + if (ctx.hasUI) { + ctx.ui.setStatus(statusId, ctx.ui.theme.fg("warning", "subagent: starting")); + if (watch) ctx.ui.setWidget(widgetId, ["subagent: starting"], { placement: "belowEditor" }); + } + + try { + const details = await runFreshSubagent(prompt, { + cwd: ctx.cwd, + model: getProviderScopedModel(ctx), + onState: applyUiState, + }); + + if (!ctx.hasUI) { + const text = details.output || details.errorMessage || details.stderr || "(no output)"; + if (text) process.stdout.write(`${text}\n`); + process.stdout.write(`${getLogInfoText()}\n`); + return; + } + + pi.sendMessage( + { + customType: "fresh-subagent-result", + content: details.output || "(no output)", + display: true, + details, + }, + { triggerTurn: false }, + ); + } finally { + if (ctx.hasUI) { + ctx.ui.setStatus(statusId, undefined); + ctx.ui.setWidget(widgetId, undefined); + } + } + }; +} + +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. Each run is logged and added to subagent history.", + 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.", + "Each subagent run is logged to its own file. Tell the user about /subagent-history or /subagent-open if they want the full transcript later.", + ], + 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 = summarizePrompt(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); + }, + }); + + const watchedSubagentHandler = createSlashCommandHandler(true); + + pi.registerCommand("subagent", { + description: "Run a fresh-context subagent with live status, widget updates, and durable run history", + handler: async (args, ctx) => watchedSubagentHandler(args, ctx, pi), + }); + + pi.registerCommand("subagent-watch", { + description: "Alias for /subagent with live watched output", + handler: async (args, ctx) => watchedSubagentHandler(args, ctx, pi), + }); + + pi.registerCommand("subagent-session", { + description: "Launch a visible fresh-context Pi session for a subagent task", + handler: async (args, ctx) => { + const prompt = args.trim(); + if (!prompt) { + ctx.ui.notify("Usage: /subagent-session ", "warning"); + return; + } + + if (!ctx.hasUI) { + process.stdout.write("/subagent-session requires interactive mode.\n"); + return; + } + + await ctx.waitForIdle(); + const currentSession = ctx.sessionManager.getSessionFile(); + const result = await ctx.newSession({ + parentSession: currentSession, + }); + + if (result.cancelled) { + ctx.ui.notify("Subagent session launch cancelled", "info"); + return; + } + + pi.setSessionName(`subagent: ${summarizePrompt(prompt, 40)}`); + pi.sendUserMessage(prompt); + }, + }); + + pi.registerCommand("subagent-log", { + description: "Show the log path and metadata for the latest or selected subagent run", + handler: async (args, ctx) => { + const selector = args.trim(); + const resolved = selector ? await resolveHistoryEntry(selector) : {}; + const text = resolved.entry ? getLogInfoText(resolved.entry) : resolved.error || getLogInfoText(); + + if (!ctx.hasUI) { + process.stdout.write(`${text}\n`); + return; + } + + pi.sendMessage( + { + customType: "fresh-subagent-log-info", + content: text, + display: true, + details: { text }, + }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("subagent-history", { + description: "List recent fresh-subagent runs so you can browse their full logs later", + handler: async (args, ctx) => { + const requested = Number(args.trim() || DEFAULT_HISTORY_LIMIT); + const limit = Number.isFinite(requested) + ? Math.max(1, Math.min(MAX_HISTORY_LIMIT, requested)) + : DEFAULT_HISTORY_LIMIT; + const text = formatHistoryEntries(await readHistoryEntries(), limit); + + if (!ctx.hasUI) { + process.stdout.write(`${text}\n`); + return; + } + + pi.sendMessage( + { + customType: "fresh-subagent-history", + content: text, + display: true, + details: { text }, + }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("subagent-open", { + description: "Open a subagent log in $VISUAL/$EDITOR. Usage: /subagent-open [latest|index|run-id-prefix]", + handler: async (args, ctx) => { + const selector = args.trim() || "latest"; + const resolved = await resolveHistoryEntry(selector); + if (!resolved.entry) { + const text = resolved.error || `No subagent history entry matched '${selector}'.`; + if (!ctx.hasUI) process.stdout.write(`${text}\n`); + else ctx.ui.notify(text, "warning"); + return; + } + + const opened = await openInExternalEditor(resolved.entry.logPath, ctx); + if (!ctx.hasUI) { + process.stdout.write(`${opened.message}\n`); + return; + } + + ctx.ui.notify(opened.message, opened.ok ? "info" : "error"); + }, + }); + + pi.registerMessageRenderer("fresh-subagent-result", (message, { expanded }, theme) => { + return new Text(renderSubagentSummary(message.details as FreshSubagentResult, expanded, theme), 0, 0); + }); + + pi.registerMessageRenderer("fresh-subagent-log-info", (message) => { + return new Text(String(message.content || message.details?.text || getLogInfoText()), 0, 0); + }); + + pi.registerMessageRenderer("fresh-subagent-history", (message) => { + return new Text(String(message.content || message.details?.text || "No subagent history is available yet."), 0, 0); + }); +} diff --git a/pi/agent/extensions/handoff/README.md b/pi/agent/extensions/handoff/README.md new file mode 100644 index 0000000..1f70211 --- /dev/null +++ b/pi/agent/extensions/handoff/README.md @@ -0,0 +1,45 @@ +# Handoff + +Focused session handoff for Pi. + +This is the upstream `handoff.ts` example installed as a local extension in +your dotfiles-backed Pi tree. It generates a compact, self-contained prompt for +starting a new session without manually rewriting the whole context. + +## What It Does + +- adds `/handoff ` +- reads the current session branch +- asks the active model to summarize the relevant context for a new thread +- opens the generated handoff prompt for editing +- creates a new session and drops the edited prompt into the new editor + +## Usage Flows + +### Flow 1: Split off the next implementation phase + +```text +/handoff implement the next phase of the WireGuard cleanup work +``` + +Pi generates a fresh prompt with the relevant context, ope