diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-21 09:56:45 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-21 09:56:45 +0200 |
| commit | 8fdba30d44037a91623c7cf05da7f1e2a298c47e (patch) | |
| tree | f1f863325c6abede8da8a6413cc180618ea06037 /pi/agent/extensions | |
| parent | ebe3566cefcccd288faa000cfe9bda298542cc5d (diff) | |
import pi.dev stuff
Diffstat (limited to 'pi/agent/extensions')
24 files changed, 5379 insertions, 0 deletions
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 <prompt>` 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<AskModeState>(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 <question>` + 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<string> { + 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 <side question>"; + 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<string | null>( + (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<void>( + (_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 <prompt>` 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 <prompt> +``` + +Launch a visible fresh Pi session instead of a headless child: + +```text +/subagent-session <prompt> +``` + +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: s |
