summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-21 09:56:45 +0200
committerPaul Buetow <paul@buetow.org>2026-03-21 09:56:45 +0200
commit8fdba30d44037a91623c7cf05da7f1e2a298c47e (patch)
treef1f863325c6abede8da8a6413cc180618ea06037
parentebe3566cefcccd288faa000cfe9bda298542cc5d (diff)
import pi.dev stuff
-rw-r--r--pi/agent/.gitignore2
-rw-r--r--pi/agent/extensions/ask-mode/README.md84
-rw-r--r--pi/agent/extensions/ask-mode/index.ts183
-rw-r--r--pi/agent/extensions/ask-mode/utils.ts94
-rw-r--r--pi/agent/extensions/btw/README.md48
-rw-r--r--pi/agent/extensions/btw/index.ts230
-rw-r--r--pi/agent/extensions/fresh-subagent/README.md246
-rw-r--r--pi/agent/extensions/fresh-subagent/index.ts1144
-rw-r--r--pi/agent/extensions/handoff/README.md45
-rw-r--r--pi/agent/extensions/handoff/index.ts130
-rw-r--r--pi/agent/extensions/inline-bash/README.md44
-rw-r--r--pi/agent/extensions/inline-bash/index.ts72
-rw-r--r--pi/agent/extensions/loop-scheduler/README.md124
-rw-r--r--pi/agent/extensions/loop-scheduler/index.ts380
-rw-r--r--pi/agent/extensions/modal-editor/README.md98
-rw-r--r--pi/agent/extensions/modal-editor/index.ts512
-rw-r--r--pi/agent/extensions/nemotron-tool-repair/README.md87
-rw-r--r--pi/agent/extensions/nemotron-tool-repair/index.ts480
-rw-r--r--pi/agent/extensions/reload-runtime/README.md46
-rw-r--r--pi/agent/extensions/reload-runtime/index.ts26
-rw-r--r--pi/agent/extensions/session-name/README.md41
-rw-r--r--pi/agent/extensions/session-name/index.ts18
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/README.md173
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/index.ts822
-rw-r--r--pi/agent/extensions/taskwarrior-plan-mode/utils.ts252
-rw-r--r--pi/agent/models.json196
-rw-r--r--pi/agent/settings.json5
27 files changed, 5582 insertions, 0 deletions
diff --git a/pi/agent/.gitignore b/pi/agent/.gitignore
new file mode 100644
index 0000000..d106817
--- /dev/null
+++ b/pi/agent/.gitignore
@@ -0,0 +1,2 @@
+/sessions
+/auth.json
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: stri