summaryrefslogtreecommitdiff
path: root/pi
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 /pi
parentebe3566cefcccd288faa000cfe9bda298542cc5d (diff)
import pi.dev stuff
Diffstat (limited to 'pi')
-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: 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<void>;
+}
+
+interface RunFreshSubagentOptions {
+ cwd: string;
+ model?: string;
+ tools?: string[];
+ signal?: AbortSignal;
+ onUpdate?: (partial: AgentToolResult<FreshSubagentResult>) => 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<void> {
+ await writeFile(entry.metadataPath, `${JSON.stringify(entry, null, 2)}\n`, "utf8");
+}
+
+async function readHistoryEntries(): Promise<SubagentHistoryEntry[]> {
+ 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<SubagentHistoryEntry>;
+ 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<SubagentLog> {
+ 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<void>((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<number | null>((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<FreshSubagentResult> {
+ 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<void> = 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<string, string>();
+ 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<number>((resolve) => {
+ const proc = spawn("pi", args, {
+ cwd: options.cwd,
+ shell: false,
+ stdio: ["ignore", "pipe", "pipe"],
+ env: {
+ ...process.env,
+ [CHILD_ENV_FLAG]: "1",
+ },
+ });
+
+ let buffer = "";
+
+ const processLine = (line: string) => {
+ if (!line.trim()) return;
+
+ let event: any;
+ try {
+ event = JSON.parse(line);
+ } catch {
+ 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 <prompt>", "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 <prompt>", "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 <goal>`
+- 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, opens it for editing,
+creates a new session, and leaves the draft ready to submit.
+
+### Flow 2: Move into a review-only thread
+
+```text
+/handoff independently review the recent hyperstack changes for concrete bugs and missing tests
+```
+
+### Flow 3: Continue with a narrower subproblem
+
+```text
+/handoff investigate only the SSH host verification path and ignore the rest
+```
+
+## Notes And Limits
+
+- This is for interactive Pi sessions with UI support.
+- It uses the currently selected model to generate the handoff prompt.
+- It is a session-to-session context transfer helper, not the same thing as the
+ fresh subagent extension.
diff --git a/pi/agent/extensions/handoff/index.ts b/pi/agent/extensions/handoff/index.ts
new file mode 100644
index 0000000..6e9fab0
--- /dev/null
+++ b/pi/agent/extensions/handoff/index.ts
@@ -0,0 +1,130 @@
+import { complete, type Message } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
+import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+
+const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
+
+1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
+2. Lists any relevant files that were discussed or modified
+3. Clearly states the next task based on the user's goal
+4. Is self-contained - the new thread should be able to proceed without the old conversation
+
+Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
+
+Example output format:
+## Context
+We've been working on X. Key decisions:
+- Decision 1
+- Decision 2
+
+Files involved:
+- path/to/file1.ts
+- path/to/file2.ts
+
+## Task
+[Clear description of what to do next based on the user's goal]`;
+
+export default function (pi: ExtensionAPI) {
+ pi.registerCommand("handoff", {
+ description: "Transfer context to a new focused session",
+ handler: async (args, ctx) => {
+ if (!ctx.hasUI) {
+ ctx.ui.notify("handoff requires interactive mode", "error");
+ return;
+ }
+
+ if (!ctx.model) {
+ ctx.ui.notify("No model selected", "error");
+ return;
+ }
+
+ const goal = args.trim();
+ if (!goal) {
+ ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
+ return;
+ }
+
+ const branch = ctx.sessionManager.getBranch();
+ const messages = branch
+ .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
+ .map((entry) => entry.message);
+
+ if (messages.length === 0) {
+ ctx.ui.notify("No conversation to hand off", "error");
+ return;
+ }
+
+ const llmMessages = convertToLlm(messages);
+ const conversationText = serializeConversation(llmMessages);
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
+
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+ const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
+ loader.onAbort = () => done(null);
+
+ const doGenerate = async () => {
+ const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
+
+ const userMessage: Message = {
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
+ },
+ ],
+ timestamp: Date.now(),
+ };
+
+ const response = await complete(
+ ctx.model!,
+ { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
+ { apiKey, signal: loader.signal },
+ );
+
+ if (response.stopReason === "aborted") {
+ return null;
+ }
+
+ return response.content
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
+ .map((content) => content.text)
+ .join("\n");
+ };
+
+ doGenerate()
+ .then(done)
+ .catch((err) => {
+ console.error("Handoff generation failed:", err);
+ done(null);
+ });
+
+ return loader;
+ });
+
+ if (result === null) {
+ ctx.ui.notify("Cancelled", "info");
+ return;
+ }
+
+ const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result);
+
+ if (editedPrompt === undefined) {
+ ctx.ui.notify("Cancelled", "info");
+ return;
+ }
+
+ const newSessionResult = await ctx.newSession({
+ parentSession: currentSessionFile,
+ });
+
+ if (newSessionResult.cancelled) {
+ ctx.ui.notify("New session cancelled", "info");
+ return;
+ }
+
+ ctx.ui.setEditorText(editedPrompt);
+ ctx.ui.notify("Handoff ready. Submit when ready.", "info");
+ },
+ });
+}
diff --git a/pi/agent/extensions/inline-bash/README.md b/pi/agent/extensions/inline-bash/README.md
new file mode 100644
index 0000000..777f2fa
--- /dev/null
+++ b/pi/agent/extensions/inline-bash/README.md
@@ -0,0 +1,44 @@
+# Inline Bash
+
+Inline shell expansion for Pi prompts.
+
+This is the upstream `inline-bash.ts` example installed as a local extension in
+your dotfiles-backed Pi tree. It expands `!{...}` before the prompt is sent to
+the model.
+
+## What It Does
+
+- `!{command}` runs a shell command locally
+- the command output replaces the inline expression in your prompt
+- regular whole-line `!command` behavior stays unchanged
+
+## Usage Flows
+
+### Flow 1: Inline one value into a prompt
+
+```text
+What files are in !{pwd}?
+```
+
+Pi sends the expanded prompt after `pwd` runs locally.
+
+### Flow 2: Inline git state
+
+```text
+Summarize the current branch !{git branch --show-current} and these changes: !{git status --short}
+```
+
+### Flow 3: Inline system context
+
+```text
+I am on kernel !{uname -r} and hostname !{hostname}. Explain whether that matters for this bug.
+```
+
+## Notes And Limits
+
+- Commands run on your local machine, not on the model provider.
+- Expansion happens before the prompt is sent.
+- Each inline command has a 30 second timeout.
+- If a command fails, the prompt gets an inline error marker.
+- This is convenient, but it is still shell execution. Treat prompt text
+ accordingly.
diff --git a/pi/agent/extensions/inline-bash/index.ts b/pi/agent/extensions/inline-bash/index.ts
new file mode 100644
index 0000000..957c14c
--- /dev/null
+++ b/pi/agent/extensions/inline-bash/index.ts
@@ -0,0 +1,72 @@
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+export default function (pi: ExtensionAPI) {
+ const PATTERN = /!\{([^}]+)\}/g;
+ const TIMEOUT_MS = 30000;
+
+ pi.on("input", async (event, ctx) => {
+ const text = event.text;
+
+ // Preserve the existing whole-line !command behavior.
+ if (text.trimStart().startsWith("!") && !text.trimStart().startsWith("!{")) {
+ return { action: "continue" };
+ }
+
+ if (!PATTERN.test(text)) {
+ return { action: "continue" };
+ }
+
+ PATTERN.lastIndex = 0;
+
+ let result = text;
+ const expansions: Array<{ command: string; output: string; error?: string }> = [];
+ const matches: Array<{ full: string; command: string }> = [];
+ let match = PATTERN.exec(text);
+
+ while (match) {
+ matches.push({ full: match[0], command: match[1] });
+ match = PATTERN.exec(text);
+ }
+
+ for (const { full, command } of matches) {
+ try {
+ const bashResult = await pi.exec("bash", ["-c", command], {
+ timeout: TIMEOUT_MS,
+ });
+ const output = bashResult.stdout || bashResult.stderr || "";
+ const trimmed = output.trim();
+
+ if (bashResult.code !== 0 && bashResult.stderr) {
+ expansions.push({
+ command,
+ output: trimmed,
+ error: `exit code ${bashResult.code}`,
+ });
+ } else {
+ expansions.push({ command, output: trimmed });
+ }
+
+ result = result.replace(full, trimmed);
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ expansions.push({ command, output: "", error: errorMsg });
+ result = result.replace(full, `[error: ${errorMsg}]`);
+ }
+ }
+
+ if (ctx.hasUI && expansions.length > 0) {
+ const summary = expansions
+ .map((entry) => {
+ const status = entry.error ? ` (${entry.error})` : "";
+ const preview =
+ entry.output.length > 50 ? `${entry.output.slice(0, 50)}...` : entry.output;
+ return `!{${entry.command}}${status} -> "${preview}"`;
+ })
+ .join("\n");
+
+ ctx.ui.notify(`Expanded ${expansions.length} inline command(s):\n${summary}`, "info");
+ }
+
+ return { action: "transform", text: result, images: event.images };
+ });
+}
diff --git a/pi/agent/extensions/loop-scheduler/README.md b/pi/agent/extensions/loop-scheduler/README.md
new file mode 100644
index 0000000..78a6635
--- /dev/null
+++ b/pi/agent/extensions/loop-scheduler/README.md
@@ -0,0 +1,124 @@
+# Loop Scheduler
+
+Session-scoped recurring prompts for Pi.
+
+This extension adds a Claude-Code-style `/loop` command for interactive Pi
+sessions. It schedules a prompt to be re-sent on an interval while the current
+Pi process stays open.
+
+## Commands
+
+- `/loop 10m <prompt>`
+ Run a prompt every 10 minutes.
+- `/loop <prompt>`
+ Run a prompt every 10 minutes using the default interval.
+- `/loop <prompt> every 2h`
+ Alternative trailing interval form.
+- `/loop list`
+ Show the active loop jobs.
+- `/loop cancel <id>`
+ Cancel one loop job.
+- `/loop cancel all`
+ Cancel all loop jobs.
+
+Supported units:
+
+- `s`
+- `m`
+- `h`
+- `d`
+
+Examples:
+
+- `5s`
+- `10m`
+- `2h`
+- `1d`
+- `every 2 hours`
+- `hourly`
+- `daily`
+
+## Usage Flows
+
+### Flow 1: Poll something on an interval
+
+Start Pi in the repo, then run:
+
+```text
+/loop 10m check whether the deployment finished and summarize what changed
+```
+
+Pi will keep re-injecting that prompt every 10 minutes while the session stays
+open.
+
+### Flow 2: Loop another command
+
+The scheduled prompt can itself be a slash command or workflow:
+
+```text
+/loop 20m /work-on-tasks highest-impact 1
+```
+
+or:
+
+```text
+/loop 30m /subagent Review the current working tree for concrete regressions only
+```
+
+### Flow 3: Check what is scheduled
+
+```text
+/loop list
+```
+
+This prints the current loop IDs, cadence, next due time, and prompt preview.
+
+### Flow 4: Cancel a loop
+
+Cancel one loop:
+
+```text
+/loop cancel ab12cd34
+```
+
+Cancel everything:
+
+```text
+/loop cancel all
+```
+
+## Busy-Agent Behavior
+
+Loop jobs do not spam turns while Pi is busy.
+
+- if a job becomes due while the agent is running, it is marked pending
+- when the current work finishes, the next pending loop fires once
+- missed intervals do not stack into a catch-up storm
+
+## Session Model
+
+This extension is session-scoped, not durable scheduling.
+
+- loop jobs live only in the current Pi process
+- closing Pi ends all loop jobs
+- `/reload` or a restart drops the active schedules
+- this is for active coding sessions, not unattended automation
+
+## Good Uses
+
+- poll build or deployment status
+- re-run a review command every N minutes
+- check Taskwarrior progress during a work session
+- periodically ask for a summary while you are coding
+
+## Bad Uses
+
+- long-term unattended automation
+- guaranteed exact-time scheduling
+- anything that must survive terminal exit or Pi restart
+
+## Notes
+
+- `/loop` is intended for interactive or RPC sessions that remain open.
+- It is not useful in one-shot `pi -p` mode because the process exits before
+ later runs can fire.
diff --git a/pi/agent/extensions/loop-scheduler/index.ts b/pi/agent/extensions/loop-scheduler/index.ts
new file mode 100644
index 0000000..837214f
--- /dev/null
+++ b/pi/agent/extensions/loop-scheduler/index.ts
@@ -0,0 +1,380 @@
+import { randomUUID } from "node:crypto";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+
+const DEFAULT_INTERVAL_MS = 10 * 60 * 1000;
+const MAX_JOBS = 50;
+
+interface LoopJob {
+ id: string;
+ prompt: string;
+ intervalMs: number;
+ intervalLabel: string;
+ createdAt: number;
+ nextRunAt: number;
+ pending: boolean;
+ runs: number;
+ lastRunAt?: number;
+}
+
+type TimerHandle = ReturnType<typeof setTimeout>;
+
+function pluralize(value: number, singular: string): string {
+ return `${value}${singular}`;
+}
+
+function formatInterval(ms: number): string {
+ if (ms % (24 * 60 * 60 * 1000) === 0) return pluralize(ms / (24 * 60 * 60 * 1000), "d");
+ if (ms % (60 * 60 * 1000) === 0) return pluralize(ms / (60 * 60 * 1000), "h");
+ if (ms % (60 * 1000) === 0) return pluralize(ms / (60 * 1000), "m");
+ if (ms % 1000 === 0) return pluralize(ms / 1000, "s");
+ return `${ms}ms`;
+}
+
+function formatDelay(ms: number): string {
+ if (ms <= 0) return "due now";
+ if (ms < 60 * 1000) return `in ${Math.ceil(ms / 1000)}s`;
+ if (ms < 60 * 60 * 1000) return `in ${Math.ceil(ms / (60 * 1000))}m`;
+ if (ms < 24 * 60 * 60 * 1000) return `in ${Math.ceil(ms / (60 * 60 * 1000))}h`;
+ return `in ${Math.ceil(ms / (24 * 60 * 60 * 1000))}d`;
+}
+
+function shortenPrompt(prompt: string, limit = 72): string {
+ return prompt.length > limit ? `${prompt.slice(0, limit)}...` : prompt;
+}
+
+function parseDurationPhrase(raw: string): { intervalMs: number; label: string } | undefined {
+ const text = raw.trim().toLowerCase();
+ if (!text) return undefined;
+
+ if (text === "hourly" || text === "every hour") return { intervalMs: 60 * 60 * 1000, label: "1h" };
+ if (text === "daily" || text === "every day") return { intervalMs: 24 * 60 * 60 * 1000, label: "1d" };
+ if (text === "minutely" || text === "every minute") return { intervalMs: 60 * 1000, label: "1m" };
+
+ const match = text.match(
+ /^(?:every\s+)?(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i,
+ );
+ if (!match) return undefined;
+
+ const amount = Number(match[1]);
+ if (!Number.isFinite(amount) || amount <= 0) return undefined;
+
+ const unit = match[2].toLowerCase();
+ let intervalMs = 0;
+ let label = "";
+
+ if (["s", "sec", "secs", "second", "seconds"].includes(unit)) {
+ intervalMs = amount * 1000;
+ label = `${amount}s`;
+ } else if (["m", "min", "mins", "minute", "minutes"].includes(unit)) {
+ intervalMs = amount * 60 * 1000;
+ label = `${amount}m`;
+ } else if (["h", "hr", "hrs", "hour", "hours"].includes(unit)) {
+ intervalMs = amount * 60 * 60 * 1000;
+ label = `${amount}h`;
+ } else if (["d", "day", "days"].includes(unit)) {
+ intervalMs = amount * 24 * 60 * 60 * 1000;
+ label = `${amount}d`;
+ }
+
+ if (intervalMs <= 0) return undefined;
+ return { intervalMs, label };
+}
+
+function parseLoopRequest(rawArgs: string): { prompt: string; intervalMs: number; intervalLabel: string } | undefined {
+ const text = rawArgs.trim();
+ if (!text) return undefined;
+
+ const trailingEvery = text.match(/^(.*\S)\s+every\s+(.+)$/i);
+ if (trailingEvery) {
+ const prompt = trailingEvery[1].trim();
+ const duration = parseDurationPhrase(trailingEvery[2]);
+ if (prompt && duration) {
+ return { prompt, intervalMs: duration.intervalMs, intervalLabel: duration.label };
+ }
+ }
+
+ const words = text.split(/\s+/);
+ if (words.length > 1) {
+ const firstDuration = parseDurationPhrase(words[0] ?? "");
+ if (firstDuration) {
+ return {
+ prompt: words.slice(1).join(" "),
+ intervalMs: firstDuration.intervalMs,
+ intervalLabel: firstDuration.label,
+ };
+ }
+
+ if ((words[0] ?? "").toLowerCase() === "every") {
+ for (let i = 2; i <= Math.min(words.length - 1, 4); i++) {
+ const candidate = words.slice(0, i).join(" ");
+ const duration = parseDurationPhrase(candidate);
+ if (duration) {
+ return {
+ prompt: words.slice(i).join(" "),
+ intervalMs: duration.intervalMs,
+ intervalLabel: duration.label,
+ };
+ }
+ }
+ }
+ }
+
+ return {
+ prompt: text,
+ intervalMs: DEFAULT_INTERVAL_MS,
+ intervalLabel: formatInterval(DEFAULT_INTERVAL_MS),
+ };
+}
+
+function formatJobLine(job: LoopJob): string {
+ return `${job.id} every ${job.intervalLabel} ${job.pending ? "(pending)" : formatDelay(job.nextRunAt - Date.now())} ${shortenPrompt(job.prompt)}`;
+}
+
+export default function loopSchedulerExtension(pi: ExtensionAPI): void {
+ const jobs = new Map<string, LoopJob>();
+ const timers = new Map<string, TimerHandle>();
+ let lastCtx: ExtensionContext | undefined;
+ let agentBusy = false;
+
+ function rememberContext(ctx: ExtensionContext): void {
+ lastCtx = ctx;
+ }
+
+ function clearJobTimer(id: string): void {
+ const timer = timers.get(id);
+ if (timer) {
+ clearTimeout(timer);
+ timers.delete(id);
+ }
+ }
+
+ function clearAllTimers(): void {
+ for (const timer of timers.values()) {
+ clearTimeout(timer);
+ }
+ timers.clear();
+ }
+
+ function getOrderedJobs(): LoopJob[] {
+ return [...jobs.values()].sort((a, b) => a.nextRunAt - b.nextRunAt || a.createdAt - b.createdAt);
+ }
+
+ function writeCommandOutput(text: string): void {
+ process.stdout.write(`${text}\n`);
+ }
+
+ function updateUi(ctx: ExtensionContext | undefined = lastCtx): void {
+ if (!ctx?.hasUI) return;
+
+ const ordered = getOrderedJobs();
+ if (ordered.length === 0) {
+ ctx.ui.setStatus("loop-scheduler", undefined);
+ ctx.ui.setWidget("loop-scheduler", undefined);
+ return;
+ }
+
+ ctx.ui.setStatus("loop-scheduler", ctx.ui.theme.fg("accent", `loop:${ordered.length}`));
+ ctx.ui.setWidget(
+ "loop-scheduler",
+ [
+ ctx.ui.theme.fg("accent", "Scheduled loops"),
+ ...ordered.slice(0, 3).map((job) => `${job.pending ? "⏸" : "⟳"} ${formatJobLine(job)}`),
+ ...(ordered.length > 3 ? [ctx.ui.theme.fg("muted", `+${ordered.length - 3} more`)] : []),
+ ],
+ { placement: "belowEditor" },
+ );
+ }
+
+ function notify(message: string, level: "info" | "warning" | "error" | "success" = "info", ctx?: ExtensionContext): void {
+ const target = ctx ?? lastCtx;
+ if (target?.hasUI) {
+ target.ui.notify(message, level);
+ } else {
+ writeCommandOutput(message);
+ }
+ }
+
+ function scheduleJobTimer(job: LoopJob): void {
+ clearJobTimer(job.id);
+ const delayMs = Math.max(100, job.nextRunAt - Date.now());
+ const timer = setTimeout(() => {
+ void handleJobDue(job.id);
+ }, delayMs);
+ timers.set(job.id, timer);
+ }
+
+ function dispatchLoopJob(job: LoopJob, reason: "timer" | "pending-drain"): void {
+ if (agentBusy) {
+ job.pending = true;
+ updateUi();
+ return;
+ }
+
+ agentBusy = true;
+ job.pending = false;
+ job.runs += 1;
+ job.lastRunAt = Date.now();
+ updateUi();
+
+ try {
+ pi.sendUserMessage(job.prompt);
+ notify(`Loop ${job.id} fired (${reason}).`, "info");
+ } catch (error) {
+ agentBusy = false;
+ job.pending = true;
+ updateUi();
+ const message = error instanceof Error ? error.message : String(error);
+ notify(`Loop ${job.id} could not fire yet: ${message}`, "warning");
+ }
+ }
+
+ function drainPendingJobs(): void {
+ if (agentBusy) return;
+ const nextPending = getOrderedJobs().find((job) => job.pending);
+ if (!nextPending) return;
+ dispatchLoopJob(nextPending, "pending-drain");
+ }
+
+ async function handleJobDue(id: string): Promise<void> {
+ const job = jobs.get(id);
+ if (!job) return;
+
+ job.nextRunAt = Date.now() + job.intervalMs;
+ scheduleJobTimer(job);
+
+ if (agentBusy) {
+ job.pending = true;
+ updateUi();
+ return;
+ }
+
+ dispatchLoopJob(job, "timer");
+ }
+
+ function createJob(prompt: string, intervalMs: number, intervalLabel: string): LoopJob {
+ return {
+ id: randomUUID().replace(/-/g, "").slice(0, 8),
+ prompt,
+ intervalMs,
+ intervalLabel,
+ createdAt: Date.now(),
+ nextRunAt: Date.now() + intervalMs,
+ pending: false,
+ runs: 0,
+ };
+ }
+
+ function resolveJob(idOrPrefix: string): LoopJob | undefined {
+ const needle = idOrPrefix.trim().toLowerCase();
+ if (!needle) return undefined;
+
+ const exact = jobs.get(needle);
+ if (exact) return exact;
+
+ const matches = [...jobs.values()].filter((job) => job.id.startsWith(needle));
+ return matches.length === 1 ? matches[0] : undefined;
+ }
+
+ function formatJobList(): string {
+ const ordered = getOrderedJobs();
+ if (ordered.length === 0) return "No active loop jobs.";
+
+ return ordered.map((job) => `- ${formatJobLine(job)}`).join("\n");
+ }
+
+ function cancelJob(job: LoopJob): void {
+ clearJobTimer(job.id);
+ jobs.delete(job.id);
+ updateUi();
+ }
+
+ pi.registerCommand("loop", {
+ description: "Schedule a recurring prompt: /loop 10m <prompt>, /loop list, /loop cancel <id|all>",
+ handler: async (args, ctx) => {
+ rememberContext(ctx);
+
+ if (!ctx.hasUI) {
+ writeCommandOutput("The /loop command requires an interactive or RPC session that stays open.");
+ return;
+ }
+
+ const trimmed = args.trim();
+ if (!trimmed || trimmed.toLowerCase() === "help") {
+ notify("Usage: /loop <interval> <prompt> | /loop <prompt> | /loop list | /loop cancel <id|all>", "info", ctx);
+ return;
+ }
+
+ if (/^(list|ls)$/i.test(trimmed)) {
+ notify(formatJobList(), "info", ctx);
+ updateUi(ctx);
+ return;
+ }
+
+ const cancelAll = /^(cancel|clear)\s+all$/i.test(trimmed);
+ if (cancelAll) {
+ const count = jobs.size;
+ clearAllTimers();
+ jobs.clear();
+ updateUi(ctx);
+ notify(count > 0 ? `Canceled ${count} loop job(s).` : "No active loop jobs.", "info", ctx);
+ return;
+ }
+
+ const cancelMatch = trimmed.match(/^(?:cancel|rm|delete)\s+(\S+)$/i);
+ if (cancelMatch) {
+ const job = resolveJob(cancelMatch[1]);
+ if (!job) {
+ notify(`No loop job matched '${cancelMatch[1]}'.`, "warning", ctx);
+ return;
+ }
+ cancelJob(job);
+ notify(`Canceled loop ${job.id}.`, "info", ctx);
+ return;
+ }
+
+ if (jobs.size >= MAX_JOBS) {
+ notify(`Too many active loop jobs (${jobs.size}). Cancel one before adding another.`, "warning", ctx);
+ return;
+ }
+
+ const request = parseLoopRequest(trimmed);
+ if (!request || !request.prompt.trim()) {
+ notify("Could not parse /loop arguments. Example: /loop 10m check the build", "warning", ctx);
+ return;
+ }
+
+ const job = createJob(request.prompt.trim(), request.intervalMs, request.intervalLabel);
+ jobs.set(job.id, job);
+ scheduleJobTimer(job);
+ updateUi(ctx);
+ notify(`Scheduled loop ${job.id} every ${job.intervalLabel}: ${shortenPrompt(job.prompt)}`, "success", ctx);
+ },
+ });
+
+ pi.on("session_start", async (_event, ctx) => {
+ rememberContext(ctx);
+ agentBusy = false;
+ updateUi(ctx);
+ });
+
+ pi.on("agent_start", async (_event, ctx) => {
+ rememberContext(ctx);
+ agentBusy = true;
+ updateUi(ctx);
+ });
+
+ pi.on("agent_end", async (_event, ctx) => {
+ rememberContext(ctx);
+ agentBusy = false;
+ updateUi(ctx);
+ drainPendingJobs();
+ });
+
+ pi.on("session_shutdown", async (_event, ctx) => {
+ rememberContext(ctx);
+ clearAllTimers();
+ jobs.clear();
+ agentBusy = false;
+ updateUi(ctx);
+ });
+}
diff --git a/pi/agent/extensions/modal-editor/README.md b/pi/agent/extensions/modal-editor/README.md
new file mode 100644
index 0000000..bc5a86b
--- /dev/null
+++ b/pi/agent/extensions/modal-editor/README.md
@@ -0,0 +1,98 @@
+# Modal Editor
+
+Modal prompt editing for the Pi TUI.
+
+This is now a custom Helix-leaning modal editor for your dotfiles-backed Pi
+tree. It replaces the earlier upstream toy example with a more capable normal
+mode and a few prompt-editing operations that are actually useful in daily use.
+
+## What It Does
+
+- starts in `NORMAL` mode
+- `Esc` leaves `INSERT` mode and returns to `NORMAL`
+- `h`, `j`, `k`, `l` move in `NORMAL`
+- `b`, `w`, `e` handle word motions in `NORMAL`
+- `gh` goes to line start and `gl` goes to line end
+- `gg` goes to the start of the prompt and `ge` goes to the end
+- `i`, `a`, `I`, `A`, `o`, `O` enter insert mode in useful places
+- `x` deletes the current character
+- `D` deletes from the cursor to line end
+- `dd`, `dw`, `de`, `db`, `d0`, and `d$` handle common deletes
+- `u` undoes the last change
+
+## Usage Flows
+
+### Flow 1: Edit a prompt normally
+
+1. Start Pi in a real terminal session.
+2. You begin in `NORMAL`.
+3. Move with `h`, `j`, `k`, `l`.
+4. Jump by word with `b`, `w`, `e`.
+5. Press `i` to start inserting text.
+
+### Flow 2: Append instead of inserting
+
+1. Press `a` to append after the cursor.
+2. Press `A` to append at line end.
+3. Press `I` to insert at line start.
+
+### Flow 3: Use Helix-style line motions
+
+Move to line start:
+
+```text
+gh
+```
+
+Move to line end:
+
+```text
+gl
+```
+
+You still also have `0` and `$` if you want the Vim-style single-key versions.
+
+### Flow 4: Delete text without dropping into insert mode
+
+Delete a character:
+
+```text
+x
+```
+
+Delete the current line:
+
+```text
+dd
+```
+
+Delete to the next word boundary:
+
+```text
+dw
+```
+
+Delete to word end:
+
+```text
+de
+```
+
+Delete from the cursor to the end of the current line:
+
+```text
+D
+```
+
+### Flow 5: Abort agent work from normal mode
+
+When Pi is already running an agent action, `Esc` in `NORMAL` passes through to
+the app-level handling, so the usual abort behavior still works.
+
+## Notes And Limits
+
+- This only affects interactive Pi TUI sessions.
+- It does not matter in one-shot `pi -p` mode.
+- This is still constrained by Pi's underlying editor model. It is a
+ Helix-leaning prompt editor, not a full Helix clone with multiple selections,
+ text objects, or the entire command surface.
diff --git a/pi/agent/extensions/modal-editor/index.ts b/pi/agent/extensions/modal-editor/index.ts
new file mode 100644
index 0000000..abb660a
--- /dev/null
+++ b/pi/agent/extensions/modal-editor/index.ts
@@ -0,0 +1,512 @@
+import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
+
+type Mode = "normal" | "insert";
+type PendingAction = "g" | "d" | null;
+type CharKind = "whitespace" | "word" | "punct";
+
+type EditorStateAccess = {
+ state: {
+ lines: string[];
+ cursorLine: number;
+ cursorCol: number;
+ };
+ historyIndex: number;
+ lastAction: string | null;
+ preferredVisualCol: number | null;
+ setCursorCol(col: number): void;
+ pushUndoSnapshot(): void;
+ undo(): void;
+};
+
+type Position = {
+ line: number;
+ col: number;
+};
+
+const NORMAL_KEYS: Record<string, string> = {
+ h: "\x1b[D",
+ j: "\x1b[B",
+ k: "\x1b[A",
+ l: "\x1b[C",
+};
+
+function isWordChar(char: string): boolean {
+ return /[0-9A-Za-z_]/.test(char);
+}
+
+function charKind(char: string | null): CharKind {
+ if (char === null || char === "\n" || /\s/.test(char)) return "whitespace";
+ return isWordChar(char) ? "word" : "punct";
+}
+
+class ModalEditor extends CustomEditor {
+ private mode: Mode = "normal";
+ private pending: PendingAction = null;
+
+ private internals(): EditorStateAccess {
+ return this as unknown as EditorStateAccess;
+ }
+
+ private resetTransientState(): void {
+ const editor = this.internals();
+ editor.historyIndex = -1;
+ editor.lastAction = null;
+ editor.preferredVisualCol = null;
+ }
+
+ private setMode(mode: Mode): void {
+ this.mode = mode;
+ this.pending = null;
+ this.tui.requestRender();
+ }
+
+ private lines(): string[] {
+ return this.getLines();
+ }
+
+ private currentPosition(): Position {
+ const cursor = this.getCursor();
+ return { line: cursor.line, col: cursor.col };
+ }
+
+ private setPosition(position: Position): void {
+ const editor = this.internals();
+ editor.state.cursorLine = position.line;
+ editor.setCursorCol(position.col);
+ editor.preferredVisualCol = null;
+ this.tui.requestRender();
+ }
+
+ private withUndo(change: () => void): void {
+ const editor = this.internals();
+ editor.pushUndoSnapshot();
+ this.resetTransientState();
+ change();
+ this.onChange?.(this.getText());
+ this.tui.requestRender();
+ }
+
+ private moveLeft(position: Position): Position {
+ const lines = this.lines();
+ if (position.col > 0) return { line: position.line, col: position.col - 1 };
+ if (position.line > 0) {
+ return {
+ line: position.line - 1,
+ col: (lines[position.line - 1] || "").length,
+ };
+ }
+ return position;
+ }
+
+ private moveRight(position: Position): Position {
+ const lines = this.lines();
+ const line = lines[position.line] || "";
+ if (position.col < line.length) return { line: position.line, col: position.col + 1 };
+ if (position.line < lines.length - 1) return { line: position.line + 1, col: 0 };
+ return position;
+ }
+
+ private charAt(position: Position): string | null {
+ const lines = this.lines();
+ const line = lines[position.line] || "";
+ if (position.col < line.length) return line[position.col] || null;
+ if (position.line < lines.length - 1) return "\n";
+ return null;
+ }
+
+ private charBefore(position: Position): string | null {
+ const lines = this.lines();
+ if (position.col > 0) {
+ const line = lines[position.line] || "";
+ return line[position.col - 1] || null;
+ }
+ if (position.line > 0) return "\n";
+ return null;
+ }
+
+ private moveLineStart(): void {
+ const { line } = this.currentPosition();
+ this.setPosition({ line, col: 0 });
+ }
+
+ private moveLineEnd(): void {
+ const { line } = this.currentPosition();
+ this.setPosition({ line, col: (this.lines()[line] || "").length });
+ }
+
+ private moveFileStart(): void {
+ this.setPosition({ line: 0, col: 0 });
+ }
+
+ private moveFileEnd(): void {
+ const lines = this.lines();
+ const lastLine = Math.max(0, lines.length - 1);
+ this.setPosition({ line: lastLine, col: (lines[lastLine] || "").length });
+ }
+
+ private moveWordBackwardPosition(from: Position): Position {
+ let position = from;
+
+ while (true) {
+ const previous = this.charBefore(position);
+ if (previous === null || charKind(previous) !== "whitespace") break;
+ const next = this.moveLeft(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ }
+
+ while (true) {
+ const previous = this.charBefore(position);
+ if (previous === null) break;
+ const kind = charKind(previous);
+ if (kind === "whitespace") break;
+ const next = this.moveLeft(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ const beforeNext = this.charBefore(position);
+ if (beforeNext === null || charKind(beforeNext) !== kind) break;
+ }
+
+ return position;
+ }
+
+ private moveWordForwardPosition(from: Position): Position {
+ let position = from;
+ let current = this.charAt(position);
+
+ if (current !== null && charKind(current) !== "whitespace") {
+ const kind = charKind(current);
+ while (current !== null && charKind(current) === kind) {
+ const next = this.moveRight(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ current = this.charAt(position);
+ }
+ }
+
+ current = this.charAt(position);
+ while (current !== null && charKind(current) === "whitespace") {
+ const next = this.moveRight(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ current = this.charAt(position);
+ }
+
+ return position;
+ }
+
+ private moveWordEndPosition(from: Position): Position {
+ let position = from;
+ let current = this.charAt(position);
+
+ while (current !== null && charKind(current) === "whitespace") {
+ const next = this.moveRight(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ current = this.charAt(position);
+ }
+
+ if (current === null) return from;
+
+ const kind = charKind(current);
+ let last = position;
+
+ while (current !== null && charKind(current) === kind) {
+ last = position;
+ const next = this.moveRight(position);
+ if (next.line === position.line && next.col === position.col) break;
+ position = next;
+ current = this.charAt(position);
+ }
+
+ return last;
+ }
+
+ private moveWordBackward(): void {
+ this.setPosition(this.moveWordBackwardPosition(this.currentPosition()));
+ }
+
+ private moveWordForward(): void {
+ this.setPosition(this.moveWordForwardPosition(this.currentPosition()));
+ }
+
+ private moveWordEnd(): void {
+ this.setPosition(this.moveWordEndPosition(this.currentPosition()));
+ }
+
+ private comparePositions(a: Position, b: Position): number {
+ if (a.line !== b.line) return a.line - b.line;
+ return a.col - b.col;
+ }
+
+ private nextPosition(position: Position): Position {
+ return this.moveRight(position);
+ }
+
+ private deleteRange(start: Position, end: Position): void {
+ if (this.comparePositions(start, end) >= 0) return;
+
+ this.withUndo(() => {
+ const editor = this.internals();
+ const lines = [...editor.state.lines];
+
+ if (start.line === end.line) {
+ const line = lines[start.line] || "";
+ lines[start.line] = line.slice(0, start.col) + line.slice(end.col);
+ } else {
+ const first = (lines[start.line] || "").slice(0, start.col);
+ const last = (lines[end.line] || "").slice(end.col);
+ lines.splice(start.line, end.line - start.line + 1, first + last);
+ }
+
+ editor.state.lines = lines.length > 0 ? lines : [""];
+ editor.state.cursorLine = start.line;
+ editor.setCursorCol(start.col);
+ });
+ }
+
+ private deleteCurrentChar(): void {
+ const start = this.currentPosition();
+ const end = this.nextPosition(start);
+ if (start.line === end.line && start.col === end.col) return;
+ this.deleteRange(start, end);
+ }
+
+ private deleteToLineStart(): void {
+ const cursor = this.currentPosition();
+ this.deleteRange({ line: cursor.line, col: 0 }, cursor);
+ }
+
+ private deleteToLineEnd(): void {
+ const cursor = this.currentPosition();
+ this.deleteRange(cursor, { line: cursor.line, col: (this.lines()[cursor.line] || "").length });
+ }
+
+ private deleteWordBackward(): void {
+ const cursor = this.currentPosition();
+ this.deleteRange(this.moveWordBackwardPosition(cursor), cursor);
+ }
+
+ private deleteWordForward(): void {
+ const cursor = this.currentPosition();
+ this.deleteRange(cursor, this.moveWordForwardPosition(cursor));
+ }
+
+ private deleteToWordEnd(): void {
+ const cursor = this.currentPosition();
+ const end = this.nextPosition(this.moveWordEndPosition(cursor));
+ this.deleteRange(cursor, end);
+ }
+
+ private deleteCurrentLine(): void {
+ this.withUndo(() => {
+ const editor = this.internals();
+ const lines = [...editor.state.lines];
+ const line = editor.state.cursorLine;
+
+ if (lines.length === 1) {
+ lines[0] = "";
+ editor.state.lines = lines;
+ editor.state.cursorLine = 0;
+ editor.setCursorCol(0);
+ return;
+ }
+
+ lines.splice(line, 1);
+ const newLine = Math.min(line, lines.length - 1);
+ editor.state.lines = lines;
+ editor.state.cursorLine = newLine;
+ editor.setCursorCol(Math.min(editor.state.cursorCol, (lines[newLine] || "").length));
+ });
+ }
+
+ private openLineBelow(): void {
+ this.withUndo(() => {
+ const editor = this.internals();
+ const lines = [...editor.state.lines];
+ const line = editor.state.cursorLine;
+ lines.splice(line + 1, 0, "");
+ editor.state.lines = lines;
+ editor.state.cursorLine = line + 1;
+ editor.setCursorCol(0);
+ });
+ this.setMode("insert");
+ }
+
+ private openLineAbove(): void {
+ this.withUndo(() => {
+ const editor = this.internals();
+ const lines = [...editor.state.lines];
+ const line = editor.state.cursorLine;
+ lines.splice(line, 0, "");
+ editor.state.lines = lines;
+ editor.state.cursorLine = line;
+ editor.setCursorCol(0);
+ });
+ this.setMode("insert");
+ }
+
+ private handlePending(data: string): boolean {
+ if (this.pending === "g") {
+ this.pending = null;
+ switch (data) {
+ case "h":
+ case "0":
+ this.moveLineStart();
+ return true;
+ case "l":
+ case "$":
+ this.moveLineEnd();
+ return true;
+ case "g":
+ this.moveFileStart();
+ return true;
+ case "e":
+ this.moveFileEnd();
+ return true;
+ default:
+ this.tui.requestRender();
+ return data.length === 1;
+ }
+ }
+
+ if (this.pending === "d") {
+ this.pending = null;
+ switch (data) {
+ case "d":
+ this.deleteCurrentLine();
+ return true;
+ case "w":
+ this.deleteWordForward();
+ return true;
+ case "e":
+ this.deleteToWordEnd();
+ return true;
+ case "b":
+ this.deleteWordBackward();
+ return true;
+ case "0":
+ this.deleteToLineStart();
+ return true;
+ case "$":
+ this.deleteToLineEnd();
+ return true;
+ case "x":
+ this.deleteCurrentChar();
+ return true;
+ default:
+ this.tui.requestRender();
+ return data.length === 1;
+ }
+ }
+
+ return false;
+ }
+
+ private handleNormalMode(data: string): boolean {
+ if (this.handlePending(data)) return true;
+
+ if (data in NORMAL_KEYS) {
+ super.handleInput(NORMAL_KEYS[data]!);
+ return true;
+ }
+
+ switch (data) {
+ case "i":
+ this.setMode("insert");
+ return true;
+ case "a":
+ super.handleInput("\x1b[C");
+ this.setMode("insert");
+ return true;
+ case "I":
+ this.moveLineStart();
+ this.setMode("insert");
+ return true;
+ case "A":
+ this.moveLineEnd();
+ this.setMode("insert");
+ return true;
+ case "o":
+ this.openLineBelow();
+ return true;
+ case "O":
+ this.openLineAbove();
+ return true;
+ case "b":
+ this.moveWordBackward();
+ return true;
+ case "w":
+ this.moveWordForward();
+ return true;
+ case "e":
+ this.moveWordEnd();
+ return true;
+ case "0":
+ this.moveLineStart();
+ return true;
+ case "$":
+ this.moveLineEnd();
+ return true;
+ case "D":
+ this.deleteToLineEnd();
+ return true;
+ case "g":
+ case "d":
+ this.pending = data as PendingAction;
+ this.tui.requestRender();
+ return true;
+ case "x":
+ this.deleteCurrentChar();
+ return true;
+ case "u":
+ this.internals().undo();
+ this.tui.requestRender();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ handleInput(data: string): void {
+ if (matchesKey(data, "escape")) {
+ if (this.mode === "insert") {
+ this.setMode("normal");
+ } else {
+ this.pending = null;
+ super.handleInput(data);
+ }
+ return;
+ }
+
+ if (this.mode === "insert") {
+ super.handleInput(data);
+ return;
+ }
+
+ if (this.handleNormalMode(data)) return;
+
+ if (data.length === 1 && data.charCodeAt(0) >= 32) return;
+ super.handleInput(data);
+ }
+
+ render(width: number): string[] {
+ const lines = super.render(width);
+ if (lines.length === 0) return lines;
+
+ const pendingLabel = this.pending ? ` ${this.pending}` : "";
+ const label = this.mode === "normal" ? ` NORMAL${pendingLabel} ` : " INSERT ";
+ const last = lines.length - 1;
+ if (visibleWidth(lines[last]!) >= label.length) {
+ lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
+ }
+ return lines;
+ }
+}
+
+export default function (pi: ExtensionAPI) {
+ pi.on("session_start", (_event, ctx) => {
+ ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb));
+ });
+}
diff --git a/pi/agent/extensions/nemotron-tool-repair/README.md b/pi/agent/extensions/nemotron-tool-repair/README.md
new file mode 100644
index 0000000..69fcb27
--- /dev/null
+++ b/pi/agent/extensions/nemotron-tool-repair/README.md
@@ -0,0 +1,87 @@
+# Nemotron Tool Repair
+
+Makes Hyperstack Nemotron sessions more reliable inside Pi when tools are
+enabled.
+
+It does two things:
+
+- adds a Nemotron-specific tool-use hint to the system prompt so the model stops
+ narrating before acting
+- wraps the Hyperstack OpenAI-compatible providers and repairs raw
+ `<tool_call> ... </tool_call>` text into real Pi tool calls when vLLM misses it
+
+This keeps your existing model names and startup scripts unchanged.
+
+## What It Affects
+
+Only Hyperstack Nemotron models are changed:
+
+- `hyperstack1/cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit`
+- `hyperstack2/cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit`
+
+Other Hyperstack models such as Qwen3 Coder still use the same endpoints and
+same model IDs, but they do not go through the Nemotron repair path.
+
+## Usage Flow
+
+Start Pi the same way as before:
+
+```bash
+cd /home/paul/git/conf/snippets/hyperstack
+./pi-vm1
+```
+
+or explicitly:
+
+```bash
+pi --model 'hyperstack1/cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit'
+```
+
+Then use Pi normally. There are no new commands for this extension.
+
+When Nemotron behaves well:
+
+- Pi receives a normal structured tool call
+- the extension stays out of the way
+
+When Nemotron emits raw XML-like tool text instead:
+
+- the extension buffers that assistant turn
+- parses `<tool_call>`, `<function=...>`, and `<parameter=...>` blocks
+- converts them into real Pi tool calls
+- hands the repaired assistant message back to the agent loop
+
+## What The Repair Handles
+
+The repair path is aimed at outputs shaped like this:
+
+```text
+<tool_call>
+<function=bash>
+<parameter=command>
+pwd
+</parameter>
+</function>
+</tool_call>
+```
+
+It preserves surrounding text if Nemotron narrated before the tool call.
+
+## Practical Notes
+
+- The repair path only runs when tools are active.
+- Nemotron tool turns are buffered before they are shown, so those turns may
+ feel less streaming than Qwen or GPT.
+- The extension also disables `strict` in OpenAI-compatible tool schemas for
+ the Hyperstack providers, which removes the repeated vLLM warning about
+ ignored `strict` fields.
+
+## If You Want To Disable It
+
+Temporarily disable the extension by moving or renaming this directory:
+
+```text
+~/.pi/agent/extensions/nemotron-tool-repair
+```
+
+Then restart Pi or use `/reload` in an existing Pi session.
diff --git a/pi/agent/extensions/nemotron-tool-repair/index.ts b/pi/agent/extensions/nemotron-tool-repair/index.ts
new file mode 100644
index 0000000..e06609c
--- /dev/null
+++ b/pi/agent/extensions/nemotron-tool-repair/index.ts
@@ -0,0 +1,480 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ createAssistantMessageEventStream,
+ type AssistantMessage,
+ type Context,
+ type Model,
+ type OpenAICompletionsCompat,
+ type SimpleStreamOptions,
+ streamSimpleOpenAICompletions,
+ type TextContent,
+ type ThinkingContent,
+ type Tool,
+ type ToolCall,
+ type Usage,
+} from "@mariozechner/pi-ai";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+
+const CUSTOM_API = "hyperstack-openai-completions-repaired";
+const TARGET_PROVIDERS = new Set(["hyperstack1", "hyperstack2"]);
+const NEMOTRON_MODEL_PATTERN = /NVIDIA-Nemotron-3-Super/i;
+const MODELS_JSON_PATH = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "..",
+ "models.json",
+);
+
+const NEMOTRON_TOOL_DISCIPLINE = `
+Additional tool-use discipline for this model:
+- If a tool is needed, call it immediately.
+- Do not narrate that you are about to call a tool.
+- Do not emit example tool-call markup or pseudo-tool syntax for the user to read.
+- Emit at most one tool invocation at a time, then wait for the tool result.
+- After a tool result, continue from that result instead of restating the plan.
+`.trim();
+
+interface FileModelConfig {
+ id: string;
+ name: string;
+ reasoning: boolean;
+ input: ("text" | "image")[];
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
+ contextWindow: number;
+ maxTokens: number;
+ compat?: OpenAICompletionsCompat;
+}
+
+interface FileProviderConfig {
+ baseUrl: string;
+ apiKey: string;
+ api?: string;
+ compat?: OpenAICompletionsCompat;
+ models: FileModelConfig[];
+}
+
+interface FileConfig {
+ providers: Record<string, FileProviderConfig>;
+}
+
+type AssistantBlock = TextContent | ThinkingContent | ToolCall;
+
+function isNemotronModel(model: Pick<Model<any>, "id"> | undefined): boolean {
+ return Boolean(model && NEMOTRON_MODEL_PATTERN.test(model.id));
+}
+
+function withRepairedCompat(compat?: OpenAICompletionsCompat): OpenAICompletionsCompat {
+ return {
+ ...(compat || {}),
+ supportsStrictMode: false,
+ };
+}
+
+function loadProviderConfig(): FileConfig {
+ const raw = readFileSync(MODELS_JSON_PATH, "utf8");
+ return JSON.parse(raw) as FileConfig;
+}
+
+function cloneUsage(usage: Usage): Usage {
+ return {
+ input: usage.input,
+ output: usage.output,
+ cacheRead: usage.cacheRead,
+ cacheWrite: usage.cacheWrite,
+ totalTokens: usage.totalTokens,
+ cost: {
+ input: usage.cost.input,
+ output: usage.cost.output,
+ cacheRead: usage.cost.cacheRead,
+ cacheWrite: usage.cost.cacheWrite,
+ total: usage.cost.total,
+ },
+ };
+}
+
+function cloneBlock(block: AssistantBlock): AssistantBlock {
+ switch (block.type) {
+ case "text":
+ return { ...block };
+ case "thinking":
+ return { ...block };
+ case "toolCall":
+ return {
+ ...block,
+ arguments: { ...block.arguments },
+ };
+ }
+}
+
+function buildStreamingAssistantMessage(source: AssistantMessage): AssistantMessage {
+ return {
+ ...source,
+ content: [],
+ usage: cloneUsage(source.usage),
+ };
+}
+
+function emitAssistantMessage(
+ stream: ReturnType<typeof createAssistantMessageEventStream>,
+ source: AssistantMessage,
+): void {
+ const output = buildStreamingAssistantMessage(source);
+ stream.push({ type: "start", partial: output });
+
+ for (const sourceBlock of source.content) {
+ if (sourceBlock.type === "text") {
+ const block: TextContent = { type: "text", text: "" };
+ output.content.push(block);
+ const contentIndex = output.content.length - 1;
+ stream.push({ type: "text_start", contentIndex, partial: output });
+ if (sourceBlock.text) {
+ block.text = sourceBlock.text;
+ stream.push({
+ type: "text_delta",
+ contentIndex,
+ delta: sourceBlock.text,
+ partial: output,
+ });
+ }
+ stream.push({
+ type: "text_end",
+ contentIndex,
+ content: sourceBlock.text,
+ partial: output,
+ });
+ continue;
+ }
+
+ if (sourceBlock.type === "thinking") {
+ const block: ThinkingContent = {
+ type: "thinking",
+ thinking: "",
+ thinkingSignature: sourceBlock.thinkingSignature,
+ redacted: sourceBlock.redacted,
+ };
+ output.content.push(block);
+ const contentIndex = output.content.length - 1;
+ stream.push({ type: "thinking_start", contentIndex, partial: output });
+ if (sourceBlock.thinking) {
+ block.thinking = sourceBlock.thinking;
+ stream.push({
+ type: "thinking_delta",
+ contentIndex,
+ delta: sourceBlock.thinking,
+ partial: output,
+ });
+ }
+ stream.push({
+ type: "thinking_end",
+ contentIndex,
+ content: sourceBlock.thinking,
+ partial: output,
+ });
+ continue;
+ }
+
+ const block: ToolCall = {
+ type: "toolCall",
+ id: sourceBlock.id,
+ name: sourceBlock.name,
+ arguments: {},
+ thoughtSignature: sourceBlock.thoughtSignature,
+ };
+ output.content.push(block);
+ const contentIndex = output.content.length - 1;
+ stream.push({ type: "toolcall_start", contentIndex, partial: output });
+ const argsJson = JSON.stringify(sourceBlock.arguments || {});
+ if (argsJson && argsJson !== "{}") {
+ block.arguments = { ...sourceBlock.arguments };
+ stream.push({
+ type: "toolcall_delta",
+ contentIndex,
+ delta: argsJson,
+ partial: output,
+ });
+ } else {
+ block.arguments = { ...sourceBlock.arguments };
+ }
+ stream.push({
+ type: "toolcall_end",
+ contentIndex,
+ toolCall: block,
+ partial: output,
+ });
+ }
+
+ if (source.stopReason === "error" || source.stopReason === "aborted") {
+ stream.push({
+ type: "error",
+ reason: source.stopReason,
+ error: {
+ ...output,
+ stopReason: source.stopReason,
+ errorMessage: source.errorMessage,
+ },
+ });
+ stream.end();
+ return;
+ }
+
+ stream.push({
+ type: "done",
+ reason: source.stopReason,
+ message: {
+ ...output,
+ stopReason: source.stopReason,
+ },
+ });
+ stream.end();
+}
+
+function mergeAdjacentTextBlocks(blocks: AssistantBlock[]): AssistantBlock[] {
+ const merged: AssistantBlock[] = [];
+
+ for (const block of blocks) {
+ const previous = merged[merged.length - 1];
+ if (block.type === "text" && previous?.type === "text") {
+ previous.text += block.text;
+ continue;
+ }
+ merged.push(cloneBlock(block));
+ }
+
+ return merged;
+}
+
+function parseToolCallPayload(
+ payload: string,
+ runIdPrefix: string,
+ index: number,
+ allowedTools: Set<string>,
+): ToolCall | undefined {
+ const functionMatch = payload.match(/<function=([^>\s]+)>/i);
+ if (!functionMatch) return undefined;
+
+ const toolName = functionMatch[1].trim();
+ if (allowedTools.size > 0 && !allowedTools.has(toolName)) return undefined;
+
+ const args: Record<string, string> = {};
+ const parameterRegex = /<parameter=([^>\s]+)>\s*([\s\S]*?)\s*<\/parameter>/gi;
+ let parameterMatch: RegExpExecArray | null;
+
+ while ((parameterMatch = parameterRegex.exec(payload)) !== null) {
+ const key = parameterMatch[1].trim();
+ const value = parameterMatch[2].replace(/\r/g, "").trim();
+ args[key] = value;
+ }
+
+ if (Object.keys(args).length === 0) return undefined;
+
+ return {
+ type: "toolCall",
+ id: `${runIdPrefix}-repair-${index}`,
+ name: toolName,
+ arguments: args,
+ };
+}
+
+export function repairTextBlock(
+ text: string,
+ responseId: string | undefined,
+ allowedTools: Set<string>,
+): AssistantBlock[] | undefined {
+ const toolRegex = /<tool_call>\s*([\s\S]*?)<\/tool_call>/gi;
+ const repaired: AssistantBlock[] = [];
+ let lastIndex = 0;
+ let callCount = 0;
+ let matched = false;
+ let match: RegExpExecArray | null;
+
+ while ((match = toolRegex.exec(text)) !== null) {
+ matched = true;
+ const prefix = text.slice(lastIndex, match.index);
+ if (prefix) repaired.push({ type: "text", text: prefix });
+
+ callCount += 1;
+ const toolCall = parseToolCallPayload(
+ match[1],
+ responseId || `nemotron-${Date.now()}`,
+ callCount,
+ allowedTools,
+ );
+ if (!toolCall) return undefined;
+ repaired.push(toolCall);
+ lastIndex = toolRegex.lastIndex;
+ }
+
+ if (!matched) return undefined;
+
+ const suffix = text.slice(lastIndex);
+ if (suffix) repaired.push({ type: "text", text: suffix });
+
+ return mergeAdjacentTextBlocks(repaired);
+}
+
+export function repairNemotronAssistantMessage(
+ message: AssistantMessage,
+ context: Context,
+): AssistantMessage | undefined {
+ if (message.content.some((block) => block.type === "toolCall")) return undefined;
+ if (message.stopReason === "error" || message.stopReason === "aborted") return undefined;
+
+ const allowedTools = new Set((context.tools || []).map((tool: Tool) => tool.name));
+ const repairedContent: AssistantBlock[] = [];
+ let repaired = false;
+
+ for (const block of message.content) {
+ if (block.type !== "text" || !block.text.includes("<tool_call>")) {
+ repairedContent.push(cloneBlock(block));
+ continue;
+ }
+
+ const repairedBlocks = repairTextBlock(block.text, message.responseId, allowedTools);
+ if (!repairedBlocks) {
+ repairedContent.push(cloneBlock(block));
+ continue;
+ }
+
+ repaired = repairedBlocks.some((entry) => entry.type === "toolCall");
+ repairedContent.push(...repairedBlocks);
+ }
+
+ if (!repaired) return undefined;
+
+ return {
+ ...message,
+ content: mergeAdjacentTextBlocks(repairedContent),
+ stopReason: "toolUse",
+ };
+}
+
+function applyNemotronPromptHints(context: Context, model: Model<any>): Context {
+ if (!isNemotronModel(model) || !context.tools || context.tools.length === 0) return context;
+ const basePrompt = context.systemPrompt || "";
+ if (basePrompt.includes(NEMOTRON_TOOL_DISCIPLINE)) return context;
+
+ return {
+ ...context,
+ systemPrompt: basePrompt ? `${basePrompt}\n\n${NEMOTRON_TOOL_DISCIPLINE}` : NEMOTRON_TOOL_DISCIPLINE,
+ };
+}
+
+function createShadowModel(model: Model<any>): Model<"openai-completions"> {
+ return {
+ ...model,
+ api: "openai-completions",
+ compat: withRepairedCompat(model.compat as OpenAICompletionsCompat | undefined),
+ };
+}
+
+function streamHyperstackRepaired(
+ model: Model<typeof CUSTOM_API>,
+ context: Context,
+ options?: SimpleStreamOptions,
+) {
+ const shadowModel = createShadowModel(model);
+ const preparedContext = applyNemotronPromptHints(context, model);
+ const preparedOptions: SimpleStreamOptions = isNemotronModel(model) && preparedContext.tools?.length
+ ? { ...options, temperature: options?.temperature ?? 0 }
+ : { ...options };
+
+ if (!isNemotronModel(model) || !preparedContext.tools || preparedContext.tools.length === 0) {
+ return streamSimpleOpenAICompletions(shadowModel, preparedContext, preparedOptions);
+ }
+
+ const stream = createAssistantMessageEventStream();
+
+ (async () => {
+ try {
+ const inner = streamSimpleOpenAICompletions(shadowModel, preparedContext, preparedOptions);
+ let finalMessage: AssistantMessage | undefined;
+
+ for await (const event of inner) {
+ if (event.type === "done") {
+ finalMessage = event.message;
+ } else if (event.type === "error") {
+ finalMessage = event.error;
+ }
+ }
+
+ if (!finalMessage) {
+ throw new Error("Nemotron provider returned no final message.");
+ }
+
+ const repairedMessage = repairNemotronAssistantMessage(finalMessage, preparedContext) || finalMessage;
+ emitAssistantMessage(stream, repairedMessage);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ const output: AssistantMessage = {
+ role: "assistant",
+ content: [],
+ api: model.api,
+ provider: model.provider,
+ model: model.id,
+ usage: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: options?.signal?.aborted ? "aborted" : "error",
+ errorMessage: message,
+ timestamp: Date.now(),
+ };
+
+ stream.push({ type: "start", partial: output });
+ stream.push({
+ type: "error",
+ reason: output.stopReason,
+ error: output,
+ });
+ stream.end();
+ }
+ })();
+
+ return stream;
+}
+
+function registerHyperstackProviderOverrides(pi: ExtensionAPI): void {
+ const fileConfig = loadProviderConfig();
+
+ for (const [providerName, providerConfig] of Object.entries(fileConfig.providers)) {
+ if (!TARGET_PROVIDERS.has(providerName)) continue;
+
+ pi.registerProvider(providerName, {
+ baseUrl: providerConfig.baseUrl,
+ apiKey: providerConfig.apiKey,
+ api: CUSTOM_API,
+ compat: withRepairedCompat(providerConfig.compat),
+ models: providerConfig.models.map((modelConfig) => ({
+ ...modelConfig,
+ api: CUSTOM_API,
+ compat: withRepairedCompat(modelConfig.compat || providerConfig.compat),
+ })),
+ streamSimple: streamHyperstackRepaired,
+ });
+ }
+}
+
+function shouldAppendNemotronDiscipline(ctx: ExtensionContext): boolean {
+ return isNemotronModel(ctx.model) && ctx.model?.provider && TARGET_PROVIDERS.has(ctx.model.provider) && piHasTools();
+}
+
+let piHasTools = () => true;
+
+export default function nemotronToolRepairExtension(pi: ExtensionAPI): void {
+ piHasTools = () => pi.getActiveTools().length > 0;
+ registerHyperstackProviderOverrides(pi);
+
+ pi.on("before_agent_start", async (event, ctx) => {
+ if (!shouldAppendNemotronDiscipline(ctx)) return;
+ if (event.systemPrompt.includes(NEMOTRON_TOOL_DISCIPLINE)) return;
+ return {
+ systemPrompt: `${event.systemPrompt}\n\n${NEMOTRON_TOOL_DISCIPLINE}`,
+ };
+ });
+}
diff --git a/pi/agent/extensions/reload-runtime/README.md b/pi/agent/extensions/reload-runtime/README.md
new file mode 100644
index 0000000..1c209d5
--- /dev/null
+++ b/pi/agent/extensions/reload-runtime/README.md
@@ -0,0 +1,46 @@
+# Reload Runtime
+
+Runtime reload support for Pi.
+
+This is the upstream `reload-runtime.ts` example installed as a local extension
+in your dotfiles-backed Pi tree. Pi does not ship a `reload-runtime.sh`
+extension in the bundled examples; the actual upstream example is TypeScript and
+registers both a slash command and a tool.
+
+## What It Does
+
+- adds `/reload-runtime`
+- adds the `reload_runtime` tool
+- reloads extensions, skills, prompts, and themes in the current Pi session
+
+## Usage Flows
+
+### Flow 1: Reload after editing dotfiles
+
+1. Edit an extension, skill, prompt, or theme on disk.
+2. In the same Pi session, run:
+
+```text
+/reload-runtime
+```
+
+3. Pi reloads the runtime without restarting the process.
+
+### Flow 2: Let the agent request a reload
+
+Because this also exposes a tool, the agent can ask to reload the runtime when
+that makes sense:
+
+```text
+Use the reload_runtime tool after updating the extension files so the new command set is active.
+```
+
+The tool queues `/reload-runtime` as a follow-up command.
+
+## Notes And Limits
+
+- Reload affects the current Pi session only.
+- It is most useful in an interactive session that stays open while you are
+ editing your Pi configuration.
+- In one-shot `pi -p` usage, reload is usually not very interesting because the
+ process exits immediately after handling the prompt.
diff --git a/pi/agent/extensions/reload-runtime/index.ts b/pi/agent/extensions/reload-runtime/index.ts
new file mode 100644
index 0000000..ebe6b7f
--- /dev/null
+++ b/pi/agent/extensions/reload-runtime/index.ts
@@ -0,0 +1,26 @@
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import { Type } from "@sinclair/typebox";
+
+export default function (pi: ExtensionAPI) {
+ pi.registerCommand("reload-runtime", {
+ description: "Reload extensions, skills, prompts, and themes",
+ handler: async (_args, ctx) => {
+ await ctx.reload();
+ return;
+ },
+ });
+
+ pi.registerTool({
+ name: "reload_runtime",
+ label: "Reload Runtime",
+ description: "Reload extensions, skills, prompts, and themes",
+ parameters: Type.Object({}),
+ async execute() {
+ pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
+ return {
+ content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }],
+ details: {},
+ };
+ },
+ });
+}
diff --git a/pi/agent/extensions/session-name/README.md b/pi/agent/extensions/session-name/README.md
new file mode 100644
index 0000000..df2dc75
--- /dev/null
+++ b/pi/agent/extensions/session-name/README.md
@@ -0,0 +1,41 @@
+# Session Name
+
+Friendly session naming for Pi.
+
+This is the upstream `session-name.ts` example installed as a local extension in
+your dotfiles-backed Pi tree. It lets you label a session with something more
+useful than the first prompt line.
+
+## What It Does
+
+- adds `/session-name [name]`
+- shows the current session name when called without arguments
+- sets a custom session name when called with text
+
+## Usage Flows
+
+### Flow 1: Name the current session
+
+```text
+/session-name hyperstack vm bootstrap review
+```
+
+### Flow 2: Check the current name
+
+```text
+/session-name
+```
+
+### Flow 3: Keep many active sessions readable
+
+Use this when you are juggling separate Pi sessions for:
+
+- implementation
+- review
+- planning
+- VM1 versus VM2 work
+
+## Notes
+
+- This is most useful in interactive sessions where you use the session picker.
+- It changes the visible session label, not the underlying worktree or model.
diff --git a/pi/agent/extensions/session-name/index.ts b/pi/agent/extensions/session-name/index.ts
new file mode 100644
index 0000000..330a1ee
--- /dev/null
+++ b/pi/agent/extensions/session-name/index.ts
@@ -0,0 +1,18 @@
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+
+export default function (pi: ExtensionAPI) {
+ pi.registerCommand("session-name", {
+ description: "Set or show session name (usage: /session-name [new name])",
+ handler: async (args, ctx) => {
+ const name = args.trim();
+
+ if (name) {
+ pi.setSessionName(name);
+ ctx.ui.notify(`Session named: ${name}`, "info");
+ } else {
+ const current = pi.getSessionName();
+ ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
+ }
+ },
+ });
+}
diff --git a/pi/agent/extensions/taskwarrior-plan-mode/README.md b/pi/agent/extensions/taskwarrior-plan-mode/README.md
new file mode 100644
index 0000000..e75c4d5
--- /dev/null
+++ b/pi/agent/extensions/taskwarrior-plan-mode/README.md
@@ -0,0 +1,173 @@
+# Taskwarrior Plan Mode
+
+Taskwarrior-backed planning for Pi.
+
+This extension keeps planning and execution separate:
+
+- use `/plan` to enter read-only planning mode
+- ask Pi to produce a numbered `Plan:`
+- convert the extracted plan into Taskwarrior tasks explicitly
+- leave planning mode and continue execution against real tasks
+
+Taskwarrior remains the source of truth. This extension does not keep a private
+todo list.
+
+## Commands
+
+- `/plan`
+ Enter read-only planning mode. The active tool set is reduced to safe
+ exploration tools.
+- `/plan-exit`
+ Leave planning mode and restore the previous tool set.
+- `/plan-create-tasks [sequential|independent]`
+ Create Taskwarrior tasks from the last extracted `Plan:`.
+- `/task-sync [sequential|independent]`
+ Legacy alias for `/plan-create-tasks`.
+- `/task-update <selector> :: <new description>`
+ Replace a task description.
+- `/task-modify <selector> :: <mods>`
+ Apply raw `ask ... modify ...` arguments to a task.
+- `/tasks`
+ Show started and `+READY` tasks for the current repo.
+- `/task-next [run]`
+ Focus the started task, or start the next `+READY` task.
+- `/task-exit`
+ Leave Taskwarrior focus mode.
+- `/task-unfocus`
+ Alias for `/task-exit`.
+- `/work-on-tasks [strategy] [max]`
+ Kick off the Taskwarrior execution loop aligned to the
+ `taskwarrior-task-management` workflow.
+
+## Rules
+
+- all Taskwarrior operations go through `ask`, never raw `task`
+- tasks are scoped to the current git repo through your `ask` wrapper
+- use UUIDs for stable references
+- planning mode is read-only by design
+- the extracted plan is session-local, so `/plan`, the planning prompt,
+ `/plan-create-tasks`, and `/plan-exit` should happen in the same interactive
+ or continued Pi session
+
+## Usage Flows
+
+### Flow 1: Turn a plan into Taskwarrior tasks
+
+1. Start Pi in the project.
+2. Run:
+
+```text
+/plan
+```
+
+3. Ask for analysis and a numbered `Plan:`. Example:
+
+```text
+Analyze the current repo and propose a concise Plan: for fixing the SSH bootstrap trust model.
+```
+
+4. After Pi replies with a `Plan:`, create tasks:
+
+```text
+/plan-create-tasks sequential
+```
+
+5. Leave planning mode:
+
+```text
+/plan-exit
+```
+
+Use `sequential` when each step should depend on the previous one. Use
+`independent` when the planned tasks can be worked separately.
+
+### Flow 2: Adjust a task after planning
+
+Rewrite a task description:
+
+```text
+/task-update uuid:12345678-1234-1234-1234-123456789abc :: Restore SSH host verification during bootstrap
+```
+
+Apply standard modify arguments:
+
+```text
+/task-modify uuid:12345678-1234-1234-1234-123456789abc :: priority:H +security
+```
+
+Use Taskwarrior replacement syntax:
+
+```text
+/task-modify uuid:12345678-1234-1234-1234-123456789abc :: /bootstrap/provisioning/
+```
+
+### Flow 3: Start executing the real tasks
+
+See what is active:
+
+```text
+/tasks
+```
+
+Focus the current task:
+
+```text
+/task-next
+```
+
+Focus and immediately start execution:
+
+```text
+/task-next run
+```
+
+Leave focus mode again:
+
+```text
+/task-exit
+```
+
+Run the full repo task loop:
+
+```text
+/work-on-tasks highest-impact
+```
+
+### Flow 4: Planning session pattern
+
+This is the cleanest end-to-end interactive pattern:
+
+```text
+/plan
+```
+
+```text
+Analyze the repo and give me a Plan: for the next implementation slice.
+```
+
+```text
+/plan-create-tasks sequential
+```
+
+```text
+/plan-exit
+```
+
+```text
+/work-on-tasks
+```
+
+## Notes And Limits
+
+- Planning mode is read-only by design.
+- All Taskwarrior operations still go through `ask`, never raw `task`.
+- `ask` must use real Taskwarrior CLI syntax. It is not a natural-language
+ task assistant and should never be called like `ask taskwarrior-task-management ...`.
+- Execution mode injects the current Taskwarrior task back into the agent prompt
+ so the model works against the real task rather than an in-memory checklist.
+- Execution mode now treats the focused task as the already-selected starting
+ point and blocks repeated identical `ask uuid:<current>` lookups until the
+ agent has moved on to repo inspection, implementation, tests, review, or a
+ different command.
+- Full `/plan` state is not meant to be passed across unrelated one-shot `pi -p`
+ invocations. Use a real interactive or continued session for planning.
diff --git a/pi/agent/extensions/taskwarrior-plan-mode/index.ts b/pi/agent/extensions/taskwarrior-plan-mode/index.ts
new file mode 100644
index 0000000..0f8b12c
--- /dev/null
+++ b/pi/agent/extensions/taskwarrior-plan-mode/index.ts
@@ -0,0 +1,822 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
+import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { Key } from "@mariozechner/pi-tui";
+import {
+ containsRawTaskCommand,
+ dedupePlanItems,
+ extractPlanItems,
+ formatTaskDetails,
+ formatTaskLine,
+ isSafePlanCommand,
+ normalizeTaskText,
+ parseCreatedTaskId,
+ parseUuidList,
+ stripAnsi,
+ type PlanItem,
+ type TaskwarriorTask,
+} from "./utils.js";
+
+const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"];
+const STATE_TYPE = "taskwarrior-plan-mode";
+
+interface PlanModeState {
+ enabled: boolean;
+ executing: boolean;
+ planItems: PlanItem[];
+ createdTaskUuids: string[];
+ normalTools: string[];
+}
+
+interface WorkOnTasksArgs {
+ strategy: string;
+ maxTasks?: number;
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+function normalizeCommandText(command: string): string {
+ return command.trim().replace(/\s+/g, " ");
+}
+
+function isMutatingAskCommand(command: string): boolean {
+ return /\b(add|annotate|append|delete|denotate|done|log|modify|prepend|start|stop|undo)\b/.test(command);
+}
+
+function repeatedCurrentTaskLookupKey(command: string, currentTaskUuid?: string): string | undefined {
+ if (!currentTaskUuid) return undefined;
+
+ const normalized = normalizeCommandText(command);
+ if (!/^ask(?:\s|$)/.test(normalized)) return undefined;
+ if (isMutatingAskCommand(normalized)) return undefined;
+
+ const uuidPattern = new RegExp(`(?:^|\\s)["']?uuid:${escapeRegExp(currentTaskUuid)}["']?(?:\\s|$)`);
+ if (!uuidPattern.test(normalized)) return undefined;
+
+ return normalized;
+}
+
+function malformedAskReason(command: string): string | undefined {
+ const normalized = normalizeCommandText(command);
+ if (!/^ask(?:\s|$)/.test(normalized)) return undefined;
+
+ if (/\btaskwarrior-task-management\b/.test(normalized)) {
+ return "The 'ask' command is only a Taskwarrior CLI wrapper. Do not pass the skill name or natural-language workflow text to it. Use concrete Taskwarrior syntax such as 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate \"note\"', 'ask uuid:<uuid> modify priority:H', or 'ask uuid:<uuid> done'.";
+ }
+
+ return undefined;
+}
+
+function parseSelectorAndPayload(rawArgs: string): { selector: string; payload: string } | undefined {
+ const separator = rawArgs.indexOf("::");
+ if (separator === -1) return undefined;
+
+ const selector = rawArgs.slice(0, separator).trim();
+ const payload = rawArgs.slice(separator + 2).trim();
+ if (!selector || !payload) return undefined;
+
+ return { selector, payload };
+}
+
+function splitShellWords(input: string): string[] {
+ const words: string[] = [];
+ const pattern = /"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|(\S+)/g;
+
+ for (const match of input.matchAll(pattern)) {
+ const value = match[1] ?? match[2] ?? match[3];
+ if (!value) continue;
+ words.push(value.replace(/\\(["'\\])/g, "$1"));
+ }
+
+ return words;
+}
+
+function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
+ return message.role === "assistant" && Array.isArray(message.content);
+}
+
+function getTextContent(message: AssistantMessage): string {
+ return message.content
+ .filter((block): block is TextContent => block.type === "text")
+ .map((block) => block.text)
+ .join("\n");
+}
+
+function parseWorkOnTasksArgs(rawArgs: string): WorkOnTasksArgs {
+ const parts = rawArgs
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
+
+ let maxTasks: number | undefined;
+ if (parts.length > 0 && /^\d+$/.test(parts[parts.length - 1] ?? "")) {
+ const parsed = Number(parts.pop());
+ if (Number.isFinite(parsed) && parsed > 0) {
+ maxTasks = parsed;
+ }
+ }
+
+ return {
+ strategy: parts.join(" ") || "highest-impact",
+ maxTasks,
+ };
+}
+
+export default function taskwarriorPlanModeExtension(pi: ExtensionAPI): void {
+ let planModeEnabled = false;
+ let executionMode = false;
+ let planItems: PlanItem[] = [];
+ let createdTaskUuids: string[] = [];
+ let normalTools: string[] = [];
+ let executionTaskUuid: string | undefined;
+ let repeatedTaskLookups = new Set<string>();
+
+ pi.registerFlag("plan", {
+ description: "Start in Taskwarrior plan mode (read-only exploration)",
+ type: "boolean",
+ default: false,
+ });
+
+ async function runCommand(
+ command: string,
+ args: string[],
+ ctx: ExtensionContext,
+ signal?: AbortSignal,
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
+ const result = await pi.exec(command, args, {
+ cwd: ctx.cwd,
+ signal,
+ timeout: 30_000,
+ });
+ return {
+ stdout: stripAnsi(result.stdout ?? ""),
+ stderr: stripAnsi(result.stderr ?? ""),
+ code: result.code,
+ };
+ }
+
+ async function runAsk(
+ args: string[],
+ ctx: ExtensionContext,
+ signal?: AbortSignal,
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
+ return runCommand("ask", args, ctx, signal);
+ }
+
+ async function getProjectName(ctx: ExtensionContext): Promise<string> {
+ const command =
+ 'basename -s .git "$(git remote get-url origin 2>/dev/null)" 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"';
+ const result = await runCommand("bash", ["-lc", command], ctx);
+ return result.stdout.trim() || "unknown";
+ }
+
+ async function loadTasks(args: string[], ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask[]> {
+ const result = await runAsk(args, ctx, signal);
+ if (result.code !== 0 || !result.stdout.trim()) return [];
+
+ try {
+ const parsed = JSON.parse(result.stdout) as TaskwarriorTask[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+
+ async function getStartedTasks(ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask[]> {
+ return loadTasks(["start.any:", "export"], ctx, signal);
+ }
+
+ async function getReadyTasks(ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask[]> {
+ const tasks = await loadTasks(["+READY", "sort:priority-,urgency-", "limit:10", "export"], ctx, signal);
+ return tasks.filter((task) => !task.start);
+ }
+
+ async function getTaskByUuid(uuid: string, ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask | undefined> {
+ const tasks = await loadTasks([`uuid:${uuid}`, "export"], ctx, signal);
+ return tasks[0];
+ }
+
+ async function getCurrentTask(ctx: ExtensionContext, signal?: AbortSignal): Promise<TaskwarriorTask | undefined> {
+ const started = await getStartedTasks(ctx, signal);
+ if (started.length > 0) {
+ return started[0];
+ }
+
+ const ready = await getReadyTasks(ctx, signal);
+ return ready[0];
+ }
+
+ async function annotateTask(uuid: string, note: string, ctx: ExtensionContext, signal?: AbortSignal): Promise<void> {
+ await runAsk([`uuid:${uuid}`, "annotate", note], ctx, signal);
+ }
+
+ async function startTask(uuid: string, ctx: ExtensionContext, signal?: AbortSignal): Promise<void> {
+ await runAsk([`uuid:${uuid}`, "start"], ctx, signal);
+ }
+
+ async function createTask(
+ description: string,
+ ctx: ExtensionContext,
+ options?: { dependsOn?: string; annotation?: string; signal?: AbortSignal },
+ ): Promise<string | undefined> {
+ const args = ["add"];
+ if (options?.dependsOn) args.push(`depends:${options.dependsOn}`);
+ args.push(description);
+
+ const result = await runAsk(args, ctx, options?.signal);
+ if (result.code !== 0) return undefined;
+
+ const createdId = parseCreatedTaskId(result.stdout);
+ if (!createdId) return undefined;
+
+ const uuidResult = await runAsk([String(createdId), "_uuid"], ctx, options?.signal);
+ const uuid = parseUuidList(uuidResult.stdout)[0];
+ if (uuid && options?.annotation) {
+ await annotateTask(uuid, options.annotation, ctx, options.signal);
+ }
+ return uuid;
+ }
+
+ async function syncPlanToTaskwarrior(
+ mode: "sequential" | "independent",
+ ctx: ExtensionContext,
+ signal?: AbortSignal,
+ ): Promise<{ created: string[]; reused: string[] }> {
+ const existingTasks = await loadTasks(["status:pending", "export"], ctx, signal);
+ const existingByDescription = new Map<string, TaskwarriorTask>();
+ for (const task of existingTasks) {
+ existingByDescription.set(normalizeTaskText(task.description), task);
+ }
+
+ const created: string[] = [];
+ const reused: string[] = [];
+ let previousUuid: string | undefined;
+
+ for (const item of planItems) {
+ const key = normalizeTaskText(item.text);
+ const existing = existingByDescription.get(key);
+ if (existing) {
+ item.uuid = existing.uuid;
+ reused.push(existing.uuid);
+ if (mode === "sequential") previousUuid = existing.uuid;
+ continue;
+ }
+
+ const annotation = `Pi plan mode step ${item.step}`;
+ const uuid = await createTask(item.text, ctx, {
+ dependsOn: mode === "sequential" ? previousUuid : undefined,
+ annotation,
+ signal,
+ });
+
+ if (!uuid) continue;
+
+ item.uuid = uuid;
+ created.push(uuid);
+ existingByDescription.set(key, {
+ uuid,
+ description: item.text,
+ status: "pending",
+ });
+ if (mode === "sequential") previousUuid = uuid;
+ }
+
+ createdTaskUuids = dedupePlanItems(planItems)
+ .map((item) => item.uuid)
+ .filter((uuid): uuid is string => Boolean(uuid));
+ persistState();
+ return { created, reused };
+ }
+
+ async function buildTaskOverview(ctx: ExtensionContext, signal?: AbortSignal): Promise<string> {
+ const projectName = await getProjectName(ctx);
+ const started = await getStartedTasks(ctx, signal);
+ const ready = await getReadyTasks(ctx, signal);
+
+ const lines = [`Project: ${projectName}`];
+
+ if (started.length > 0) {
+ lines.push("", "Started tasks:");
+ for (const task of started.slice(0, 5)) {
+ lines.push(`- ${formatTaskLine(task)} (${task.uuid})`);
+ }
+ } else {
+ lines.push("", "Started tasks: none");
+ }
+
+ if (ready.length > 0) {
+ lines.push("", "Next READY tasks:");
+ for (const task of ready.slice(0, 5)) {
+ lines.push(`- ${formatTaskLine(task)} (${task.uuid})`);
+ }
+ } else {
+ lines.push("", "Next READY tasks: none");
+ }
+
+ return lines.join("\n");
+ }
+
+ function persistState(): void {
+ pi.appendEntry<PlanModeState>(STATE_TYPE, {
+ enabled: planModeEnabled,
+ executing: executionMode,
+ planItems,
+ createdTaskUuids,
+ normalTools,
+ });
+ }
+
+ async function updateStatus(ctx: ExtensionContext): Promise<void> {
+ if (planModeEnabled) {
+ ctx.ui.setStatus("task-plan-mode", ctx.ui.theme.fg("warning", "⏸ tw-plan"));
+ ctx.ui.setWidget("task-plan-mode", undefined);
+ return;
+ }
+
+ if (!executionMode) {
+ ctx.ui.setStatus("task-plan-mode", undefined);
+ ctx.ui.setWidget("task-plan-mode", undefined);
+ return;
+ }
+
+ const currentTask = await getCurrentTask(ctx);
+ if (!currentTask) {
+ executionTaskUuid = undefined;
+ ctx.ui.setStatus("task-plan-mode", ctx.ui.theme.fg("muted", "task: none"));
+ ctx.ui.setWidget("task-plan-mode", undefined);
+ return;
+ }
+
+ executionTaskUuid = currentTask.uuid;
+ ctx.ui.setStatus(
+ "task-plan-mode",
+ ctx.ui.theme.fg("accent", `task ${currentTask.priority ?? "-"} ${currentTask.id ?? "?"}`),
+ );
+ ctx.ui.setWidget("task-plan-mode", [
+ ctx.ui.theme.fg("accent", "Taskwarrior focus"),
+ `${currentTask.start ? "▶" : "○"} ${currentTask.description}`,
+ `${ctx.ui.theme.fg("muted", "uuid")} ${currentTask.uuid}`,
+ ]);
+ }
+
+ async function setPlanModeEnabled(enabled: boolean, ctx: ExtensionContext): Promise<void> {
+ if (enabled === planModeEnabled) return;
+
+ planModeEnabled = enabled;
+ executionMode = false;
+
+ if (enabled) {
+ normalTools = pi.getActiveTools();
+ pi.setActiveTools(PLAN_MODE_TOOLS);
+ executionTaskUuid = undefined;
+ repeatedTaskLookups.clear();
+ ctx.ui.notify(`Taskwarrior plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
+ } else {
+ pi.setActiveTools(normalTools);
+ ctx.ui.notify("Taskwarrior plan mode disabled. Restored previous tools.");
+ }
+
+ persistState();
+ await updateStatus(ctx);
+ }
+
+ async function enterPlanMode(ctx: ExtensionContext): Promise<void> {
+ if (planModeEnabled) {
+ ctx.ui.notify("Taskwarrior plan mode is already enabled.", "info");
+ return;
+ }
+ await setPlanModeEnabled(true, ctx);
+ }
+
+ async function exitPlanMode(ctx: ExtensionContext): Promise<void> {
+ if (!planModeEnabled) {
+ ctx.ui.notify("Taskwarrior plan mode is not enabled.", "info");
+ return;
+ }
+ await setPlanModeEnabled(false, ctx);
+ }
+
+ async function exitExecutionMode(ctx: ExtensionContext): Promise<void> {
+ if (!executionMode) {
+ ctx.ui.notify("Taskwarrior focus mode is not enabled.", "info");
+ return;
+ }
+
+ executionMode = false;
+ executionTaskUuid = undefined;
+ repeatedTaskLookups.clear();
+ pi.setActiveTools(normalTools);
+ persistState();
+ await updateStatus(ctx);
+ ctx.ui.notify("Taskwarrior focus mode disabled.", "info");
+ }
+
+ async function createTasksFromPlan(
+ mode: "sequential" | "independent",
+ ctx: ExtensionContext,
+ ): Promise<void> {
+ if (planItems.length === 0) {
+ ctx.ui.notify("No extracted plan available. Enable /plan and generate a plan first.", "warning");
+ return;
+ }
+
+ const { created, reused } = await syncPlanToTaskwarrior(mode, ctx);
+ ctx.ui.notify(
+ `Task sync complete. Created ${created.length}, reused ${reused.length} existing task(s).`,
+ "info",
+ );
+ await updateStatus(ctx);
+ }
+
+ async function replaceTaskDescription(selector: string, description: string, ctx: ExtensionContext): Promise<void> {
+ const result = await runAsk([selector, "modify", description], ctx);
+ if (result.code !== 0) {
+ ctx.ui.notify(result.stderr || result.stdout || "Task update failed.", "error");
+ return;
+ }
+
+ ctx.ui.notify(result.stdout.trim() || "Task description updated.", "info");
+ }
+
+ async function modifyTask(selector: string, modsText: string, ctx: ExtensionContext): Promise<void> {
+ const mods = splitShellWords(modsText);
+ if (mods.length === 0) {
+ ctx.ui.notify("No modify arguments provided.", "warning");
+ return;
+ }
+
+ const result = await runAsk([selector, "modify", ...mods], ctx);
+ if (result.code !== 0) {
+ ctx.ui.notify(result.stderr || result.stdout || "Task modify failed.", "error");
+ return;
+ }
+
+ ctx.ui.notify(result.stdout.trim() || "Task modified.", "info");
+ }
+
+ async function focusCurrentTask(runNow: boolean, ctx: ExtensionContext): Promise<void> {
+ const started = await getStartedTasks(ctx);
+ let task = started[0];
+
+ if (!task) {
+ const ready = await getReadyTasks(ctx);
+ task = ready[0];
+ if (!task) {
+ ctx.ui.notify("No started or READY Taskwarrior task found for this project.", "warning");
+ return;
+ }
+ await startTask(task.uuid, ctx);
+ task = await getTaskByUuid(task.uuid, ctx);
+ }
+
+ if (!task) {
+ ctx.ui.notify("Could not resolve the active Taskwarrior task.", "error");
+ return;
+ }
+
+ executionMode = true;
+ planModeEnabled = false;
+ pi.setActiveTools(normalTools);
+ executionTaskUuid = task.uuid;
+ repeatedTaskLookups.clear();
+ persistState();
+ await updateStatus(ctx);
+
+ const projectName = await getProjectName(ctx);
+ ctx.ui.notify(`Focused task ${task.id ?? "?"}: ${task.description}`, "info");
+
+ if (runNow) {
+ pi.sendUserMessage(
+ `Work on the current Taskwarrior task for project ${projectName}. Use ask for all task operations. Current task UUID: ${task.uuid}.`,
+ );
+ }
+ }
+
+ pi.registerCommand("plan", {
+ description: "Enter Taskwarrior plan mode (read-only exploration)",
+ handler: async (_args, ctx) => enterPlanMode(ctx),
+ });
+
+ pi.registerCommand("plan-exit", {
+ description: "Leave Taskwarrior plan mode and restore normal tools",
+ handler: async (_args, ctx) => exitPlanMode(ctx),
+ });
+
+ pi.registerCommand("tasks", {
+ description: "Show started and READY Taskwarrior tasks for this project",
+ handler: async (_args, ctx) => {
+ ctx.ui.notify(await buildTaskOverview(ctx), "info");
+ },
+ });
+
+ pi.registerCommand("plan-create-tasks", {
+ description: "Create Taskwarrior tasks from the last extracted plan",
+ handler: async (args, ctx) => {
+ const mode = args.trim().toLowerCase() === "independent" ? "independent" : "sequential";
+ await createTasksFromPlan(mode, ctx);
+ },
+ });
+
+ pi.registerCommand("task-sync", {
+ description: "Legacy alias for /plan-create-tasks",
+ handler: async (args, ctx) => {
+ const mode = args.trim().toLowerCase() === "independent" ? "independent" : "sequential";
+ await createTasksFromPlan(mode, ctx);
+ },
+ });
+
+ pi.registerCommand("task-next", {
+ description: "Focus the started task, or start the next READY task",
+ handler: async (args, ctx) => {
+ await focusCurrentTask(args.trim().toLowerCase() === "run", ctx);
+ },
+ });
+
+ pi.registerCommand("task-exit", {
+ description: "Leave Taskwarrior focus mode",
+ handler: async (_args, ctx) => {
+ await exitExecutionMode(ctx);
+ },
+ });
+
+ pi.registerCommand("task-unfocus", {
+ description: "Alias for /task-exit",
+ handler: async (_args, ctx) => {
+ await exitExecutionMode(ctx);
+ },
+ });
+
+ pi.registerCommand("task-update", {
+ description: "Replace a task description: /task-update <selector> :: <new description>",
+ handler: async (args, ctx) => {
+ const parsed = parseSelectorAndPayload(args);
+ if (!parsed) {
+ ctx.ui.notify("Usage: /task-update <selector> :: <new description>", "warning");
+ return;
+ }
+ await replaceTaskDescription(parsed.selector, parsed.payload, ctx);
+ },
+ });
+
+ pi.registerCommand("task-modify", {
+ description: "Run ask modify args: /task-modify <selector> :: <mods>",
+ handler: async (args, ctx) => {
+ const parsed = parseSelectorAndPayload(args);
+ if (!parsed) {
+ ctx.ui.notify("Usage: /task-modify <selector> :: <mods>", "warning");
+ return;
+ }
+ await modifyTask(parsed.selector, parsed.payload, ctx);
+ },
+ });
+
+ pi.registerCommand("work-on-tasks", {
+ description: "Run the Taskwarrior task workflow for this repo",
+ handler: async (args, ctx) => {
+ const parsed = parseWorkOnTasksArgs(args);
+ await focusCurrentTask(false, ctx);
+
+ const currentTask = await getCurrentTask(ctx);
+ if (!currentTask) {
+ ctx.ui.notify("No started or READY Taskwarrior task found for this project.", "warning");
+ return;
+ }
+
+ const projectName = await getProjectName(ctx);
+ const maxTasksText = parsed.maxTasks ? String(parsed.maxTasks) : "none";
+
+ pi.sendUserMessage(`Use the Taskwarrior workflow rules below for the current git project.
+
+Project: ${projectName}
+Selection strategy: ${parsed.strategy}
+Max tasks: ${maxTasksText}
+
+Current focused task:
+${formatTaskDetails(currentTask)}
+
+Workflow:
+1. Treat the current focused task above as the already-selected starting point for this run.
+2. Only use ask to load project-scoped tasks when the current task is missing, blocked, completed, or you are ready to pick the next task.
+3. Use priority first, then urgency, as the stable ordering rule. Use the requested selection strategy only as a tie-breaker or framing hint.
+4. Start and execute the chosen task.
+5. Annotate meaningful implementation progress back to Taskwarrior using UUID selectors.
+6. Self-review your own changes before any completion step.
+7. After self-review, if the subagent tool is available, use it to run an independent fresh-context review of the completed changes.
+8. Address all review findings, repeat the independent review if needed, and only then commit all changes.
+9. Mark the task complete only when implementation, tests, self-review, independent subagent review, and required fixes are complete.
+10. Immediately return to started tasks, then READY tasks, and continue until there are no actionable tasks, max_tasks is reached, or a hard blocker is encountered.
+11. If blocked, annotate the blocker to the task and stop.
+
+Rules:
+- Never use raw task; always use ask.
+- 'ask' is a thin Taskwarrior CLI wrapper, not a natural-language interface and not a skill runner.
+- Valid examples: 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate "note"', 'ask uuid:<uuid> modify priority:H', 'ask uuid:<uuid> done'.
+- Invalid examples: 'ask taskwarrior-task-management ...', 'ask list tasks', 'ask show task 298', or any other natural-language phrasing.
+- Scope all work to project:${projectName} +agent tasks only.
+- Use UUIDs for all long-lived references.
+- Do not repeat the same ask lookup for the current task unless task state may have changed or required information is still missing.
+- After one task lookup, move into repo inspection, implementation, testing, review, or annotation before refreshing Taskwarrior again.
+- Do not ask the user to choose a task unless there is a real ambiguity or risk.
+- Keep working autonomously until the workflow reaches a stop condition.
+
+Begin with the current focused task now. Do not re-check Taskwarrior immediately just to confirm the same task again.`, {
+ deliverAs: ctx.isIdle() ? undefined : "steer",
+ });
+ },
+ });
+
+ pi.registerShortcut(Key.ctrlAlt("p"), {
+ description: "Toggle Taskwarrior plan mode",
+ handler: async (ctx) => togglePlanMode(ctx),
+ });
+
+ pi.on("tool_call", async (event) => {
+ if (!executionMode) {
+ if (event.toolName !== "bash") return;
+ } else if (event.toolName !== "bash") {
+ repeatedTaskLookups.clear();
+ return;
+ }
+
+ const command = String(event.input.command ?? "");
+ const repeatedLookupKey = executionMode ? repeatedCurrentTaskLookupKey(command, executionTaskUuid) : undefined;
+ if (executionMode && repeatedLookupKey) {
+ if (repeatedTaskLookups.has(repeatedLookupKey)) {
+ return {
+ block: true,
+ reason:
+ "Repeated lookup of the same current Taskwarrior task was blocked. Use the task details already in context and move to code inspection, implementation, tests, review, or an annotation before refreshing the same task again.",
+ };
+ }
+ repeatedTaskLookups.add(repeatedLookupKey);
+ } else if (executionMode) {
+ repeatedTaskLookups.clear();
+ }
+
+ const malformedAsk = malformedAskReason(command);
+ if (malformedAsk) {
+ return {
+ block: true,
+ reason: malformedAsk,
+ };
+ }
+
+ if (containsRawTaskCommand(command)) {
+ return {
+ block: true,
+ reason: "Use 'ask ...' for all Taskwarrior operations. Raw 'task' is blocked by taskwarrior-plan-mode.",
+ };
+ }
+
+ if (planModeEnabled && !isSafePlanCommand(command)) {
+ return {
+ block: true,
+ reason: `Taskwarrior plan mode blocks mutating shell commands.\nCommand: ${command}`,
+ };
+ }
+ });
+
+ pi.on("context", async (event) => {
+ return {
+ messages: event.messages.filter((message) => {
+ const candidate = message as AgentMessage & { customType?: string };
+ if (!planModeEnabled && candidate.customType === "taskwarrior-plan-mode-context") return false;
+ if (!executionMode && candidate.customType === "taskwarrior-execution-mode-context") return false;
+ return true;
+ }),
+ };
+ });
+
+ pi.on("before_agent_start", async (_event, ctx) => {
+ const projectName = await getProjectName(ctx);
+
+ if (planModeEnabled) {
+ const overview = await buildTaskOverview(ctx);
+ return {
+ message: {
+ customType: "taskwarrior-plan-mode-context",
+ content: `[TASKWARRIOR PLAN MODE ACTIVE]
+You are in read-only planning mode for project ${projectName}.
+
+Rules:
+- Use only read, bash, grep, find, and ls.
+- For Taskwarrior operations, always use 'ask ...'. Never use raw 'task'.
+- Read existing started tasks first; if none, inspect the next READY tasks.
+- Do not modify files or create Taskwarrior tasks yourself while planning.
+- Avoid duplicating tasks that already exist.
+
+Current Taskwarrior overview:
+${overview}
+
+Create a concise numbered plan under a "Plan:" header. Each step must be a single actionable task suitable for Taskwarrior:
+
+Plan:
+1. First actionable task
+2. Second actionable task
+3. Third actionable task`,
+ display: false,
+ },
+ };
+ }
+
+ if (executionMode) {
+ const currentTask = await getCurrentTask(ctx);
+ if (!currentTask) return;
+ executionTaskUuid = currentTask.uuid;
+
+ return {
+ message: {
+ customType: "taskwarrior-execution-mode-context",
+ content: `[TASKWARRIOR EXECUTION MODE]
+Project: ${projectName}
+
+Use the Taskwarrior workflow rules below:
+- Use 'ask ...' for all task operations. Never use raw 'task'.
+- 'ask' is only a Taskwarrior CLI wrapper. It does not understand the skill name or natural-language requests.
+- Valid examples: 'ask start.any: export', 'ask +READY export', 'ask uuid:<uuid> annotate "note"', 'ask uuid:<uuid> modify priority:H', 'ask uuid:<uuid> done'.
+- Invalid examples: 'ask taskwarrior-task-management ...', 'ask list tasks', 'ask show task 298', or any other natural-language phrasing.
+- Continue an already-started task before starting a new one.
+- Use UUIDs for long-lived references and follow-up commands.
+- The current task below is already the selected task for this turn. Do not immediately query the same UUID again unless required details are missing or task state changed.
+- After one Taskwarrior lookup, move to repo inspection or implementation work before refreshing Taskwarrior again.
+- Do not mark a task done until implementation, tests, and commit are complete.
+- Annotate meaningful progress back to the task with 'ask uuid:<uuid> annotate ...' when appropriate.
+- Self-review first, then if the subagent tool is available use it for an independent fresh-context review before the task is marked done.
+
+Current task:
+${formatTaskDetails(currentTask)}`,
+ display: false,
+ },
+ };
+ }
+ });
+
+ pi.on("turn_end", async (_event, ctx) => {
+ repeatedTaskLookups.clear();
+ if (executionMode) {
+ await updateStatus(ctx);
+ }
+ });
+
+ pi.on("agent_end", async (event, ctx) => {
+ repeatedTaskLookups.clear();
+ if (executionMode) {
+ await updateStatus(ctx);
+ return;
+ }
+
+ if (!planModeEnabled) return;
+
+ const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
+ if (!lastAssistant) return;
+
+ planItems = dedupePlanItems(extractPlanItems(getTextContent(lastAssistant)));
+ persistState();
+
+ if (planItems.length === 0) return;
+
+ const todoListText = planItems.map((item) => `${item.step}. ${item.text}`).join("\n");
+ pi.sendMessage(
+ {
+ customType: "taskwarrior-plan-items",
+ content: `**Extracted Taskwarrior plan (${planItems.length} steps):**\n\n${todoListText}`,
+ display: true,
+ },
+ { triggerTurn: false },
+ );
+
+ if (ctx.hasUI) {
+ ctx.ui.notify("Plan extracted. Run /plan-create-tasks or /plan-exit when ready.", "info");
+ }
+ });
+
+ pi.on("session_start", async (_event, ctx) => {
+ if (pi.getFlag("plan") === true) {
+ planModeEnabled = true;
+ }
+
+ const entries = ctx.sessionManager.getEntries();
+ const planStateEntry = entries
+ .filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === STATE_TYPE)
+ .pop() as { data?: PlanModeState } | undefined;
+
+ if (planStateEntry?.data) {
+ planModeEnabled = planStateEntry.data.enabled ?? planModeEnabled;
+ executionMode = planStateEntry.data.executing ?? executionMode;
+ planItems = planStateEntry.data.planItems ?? planItems;
+ createdTaskUuids = planStateEntry.data.createdTaskUuids ?? createdTaskUuids;
+ normalTools = planStateEntry.data.normalTools?.length ? planStateEntry.data.normalTools : normalTools;
+ } else {
+ normalTools = pi.getActiveTools();
+ }
+ repeatedTaskLookups.clear();
+
+ if (planModeEnabled) {
+ pi.setActiveTools(PLAN_MODE_TOOLS);
+ }
+
+ await updateStatus(ctx);
+ });
+}
diff --git a/pi/agent/extensions/taskwarrior-plan-mode/utils.ts b/pi/agent/extensions/taskwarrior-plan-mode/utils.ts
new file mode 100644
index 0000000..cfaba15
--- /dev/null
+++ b/pi/agent/extensions/taskwarrior-plan-mode/utils.ts
@@ -0,0 +1,252 @@
+export interface PlanItem {
+ step: number;
+ text: string;
+ uuid?: string;
+}
+
+export interface TaskwarriorAnnotation {
+ entry?: string;
+ description: string;
+}
+
+export interface TaskwarriorTask {
+ id?: number;
+ uuid: string;
+ description: string;
+ status?: string;
+ priority?: string;
+ start?: string;
+ project?: string;
+ urgency?: number;
+ depends?: string[];
+ annotations?: TaskwarriorAnnotation[];
+}
+
+const ANSI_PATTERN =
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips terminal escape sequences from command output
+ /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
+
+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/,
+];
+
+const MUTATING_TASK_PATTERNS = [
+ /\badd\b/i,
+ /\bannotate\b/i,
+ /\bappend\b/i,
+ /\bdelete\b/i,
+ /\bdenotate\b/i,
+ /\bdone\b/i,
+ /\bduplicate\b/i,
+ /\bedit\b/i,
+ /\bimport\b/i,
+ /\blog\b/i,
+ /\bmodify\b/i,
+ /\bprepend\b/i,
+ /\bpurge\b/i,
+ /\bstart\b/i,
+ /\bstop\b/i,
+ /\bsynchronize\b/i,
+ /\bundo\b/i,
+];
+
+export function stripAnsi(text: string): string {
+ return text.replace(ANSI_PATTERN, "");
+}
+
+export function containsRawTaskCommand(command: string): boolean {
+ return /(^|[;&|]\s*)task\b/.test(command);
+}
+
+export function isSafeAskCommand(command: string): boolean {
+ const trimmed = command.trim();
+ if (!trimmed.startsWith("ask ")) return false;
+ if (containsRawTaskCommand(trimmed)) return false;
+ if (/[;&]/.test(trimmed) || /(^|[^|])\|([^|]|$)/.test(trimmed)) return false;
+ return !MUTATING_TASK_PATTERNS.some((pattern) => pattern.test(trimmed));
+}
+
+export function isSafePlanCommand(command: string): boolean {
+ if (containsRawTaskCommand(command)) return false;
+ if (isSafeAskCommand(command)) return true;
+
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command));
+ const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command));
+ return !isDestructive && isSafe;
+}
+
+export function cleanPlanStep(text: string): string {
+ return text
+ .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\[[^\]]+\]\([^)]+\)/g, "$1")
+ .replace(/\s+/g, " ")
+ .trim()
+ .replace(/[.;:]+$/, "");
+}
+
+export function normalizeTaskText(text: string): string {
+ return cleanPlanStep(text).toLowerCase();
+}
+
+export function extractPlanItems(message: string): PlanItem[] {
+ const items: PlanItem[] = [];
+ const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
+ if (!headerMatch) return items;
+
+ const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
+ const numberedPattern = /^\s*(\d+)[.)]\s+(.+)$/gm;
+
+ for (const match of planSection.matchAll(numberedPattern)) {
+ const cleaned = cleanPlanStep(match[2] ?? "");
+ if (cleaned.length < 4) continue;
+ if (cleaned.startsWith("-") || cleaned.startsWith("/")) continue;
+ items.push({
+ step: items.length + 1,
+ text: cleaned.slice(0, 240),
+ });
+ }
+
+ return dedupePlanItems(items);
+}
+
+export function dedupePlanItems(items: PlanItem[]): PlanItem[] {
+ const seen = new Set<string>();
+ const deduped: PlanItem[] = [];
+
+ for (const item of items) {
+ const key = normalizeTaskText(item.text);
+ if (!key || seen.has(key)) continue;
+ seen.add(key);
+ deduped.push({
+ step: deduped.length + 1,
+ text: item.text,
+ uuid: item.uuid,
+ });
+ }
+
+ return deduped;
+}
+
+export function parseUuidList(text: string): string[] {
+ return stripAnsi(text)
+ .match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)
+ ?.map((value) => value.toLowerCase()) ?? [];
+}
+
+export function parseCreatedTaskId(text: string): number | undefined {
+ const match = stripAnsi(text).match(/Created task (\d+)/i);
+ return match ? Number(match[1]) : undefined;
+}
+
+export function formatTaskLine(task: TaskwarriorTask): string {
+ const bits = [
+ task.priority ? `[${task.priority}]` : undefined,
+ task.start ? "started" : "ready",
+ task.description,
+ ];
+ return bits.filter(Boolean).join(" ");
+}
+
+export function formatTaskDetails(task: TaskwarriorTask): string {
+ const annotations = (task.annotations ?? [])
+ .map((annotation) => `- ${annotation.description}`)
+ .join("\n");
+
+ const lines = [
+ `UUID: ${task.uuid}`,
+ `Description: ${task.description}`,
+ task.priority ? `Priority: ${task.priority}` : undefined,
+ task.status ? `Status: ${task.status}` : undefined,
+ task.start ? "Active: yes" : "Active: no",
+ annotations ? `Annotations:\n${annotations}` : undefined,
+ ];
+
+ return lines.filter(Boolean).join("\n");
+}
+
diff --git a/pi/agent/models.json b/pi/agent/models.json
new file mode 100644
index 0000000..768be9c
--- /dev/null
+++ b/pi/agent/models.json
@@ -0,0 +1,196 @@
+{
+ "providers": {
+ "hyperstack1": {
+ "baseUrl": "http://hyperstack1.wg1:11434/v1",
+ "apiKey": "EMPTY",
+ "api": "openai-completions",
+ "compat": {
+ "supportsStore": false,
+ "supportsDeveloperRole": false,
+ "maxTokensField": "max_tokens"
+ },
+ "models": [
+ {
+ "id": "cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit",
+ "name": "Nemotron 3 Super 120B [vm1]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 262144,
+ "maxTokens": 8192
+ },
+ {
+ "id": "bullpoint/Qwen3-Coder-Next-AWQ-4bit",
+ "name": "Qwen3 Coder Next [vm1]",
+ "reasoning": true,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 262144,
+ "maxTokens": 8192,
+ "compat": { "thinkingFormat": "qwen-chat-template" }
+ },
+ {
+ "id": "openai/gpt-oss-20b",
+ "name": "GPT-OSS 20B [vm1]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 65536,
+ "maxTokens": 8192
+ },
+ {
+ "id": "openai/gpt-oss-120b",
+ "name": "GPT-OSS 120B [vm1]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 131072,
+ "maxTokens": 8192
+ },
+ {
+ "id": "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ",
+ "name": "Qwen2.5 Coder 32B [vm1]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 32768,
+ "maxTokens": 8192
+ },
+ {
+ "id": "QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ",
+ "name": "Qwen3 Coder 30B [vm1]",
+ "reasoning": true,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 65536,
+ "maxTokens": 8192,
+ "compat": { "thinkingFormat": "qwen-chat-template" }
+ },
+ {
+ "id": "casperhansen/deepseek-r1-distill-qwen-32b-awq",
+ "name": "DeepSeek-R1-Distill 32B [vm1]",
+ "reasoning": true,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 32768,
+ "maxTokens": 8192
+ },
+ {
+ "id": "Qwen/Qwen3-32B-AWQ",
+ "name": "Qwen3 32B [vm1]",
+ "reasoning": true,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 32768,
+ "maxTokens": 8192,
+ "compat": { "thinkingFormat": "qwen-chat-template" }
+ },
+ {
+ "id": "cyankiwi/Devstral-Small-2507-AWQ-4bit",
+ "name": "Devstral Small 2507 [vm1]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 32768,
+ "maxTokens": 8192
+ }
+ ]
+ },
+ "hyperstack2": {
+ "baseUrl": "http://hyperstack2.wg1:11434/v1",
+ "apiKey": "EMPTY",
+ "api": "openai-completions",
+ "compat": {
+ "supportsStore": false,
+ "supportsDeveloperRole": false,
+ "maxTokensField": "max_tokens"
+ },
+ "models": [
+ {
+ "id": "bullpoint/Qwen3-Coder-Next-AWQ-4bit",
+ "name": "Qwen3 Coder Next [vm2]",
+ "reasoning": true,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 262144,
+ "maxTokens": 8192,
+ "compat": { "thinkingFormat": "qwen-chat-template" }
+ },
+ {
+ "id": "cyankiwi/NVIDIA-Nemotron-3-Super-120B-A12B-AWQ-4bit",
+ "name": "Nemotron 3 Super 120B [vm2]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 262144,
+ "maxTokens": 8192
+ },
+ {
+ "id": "openai/gpt-oss-20b",
+ "name": "GPT-OSS 20B [vm2]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 65536,
+ "maxTokens": 8192
+ },
+ {
+ "id": "openai/gpt-oss-120b",
+ "name": "GPT-OSS 120B [vm2]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 131072,
+ "maxTokens": 8192
+ },
+ {
+ "id": "Qwen/Qwen2.5-Coder-32B-Instruct-AWQ",
+ "name": "Qwen2.5 Coder 32B [vm2]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 32768,
+ "maxTokens": 8192
+ },
+ {
+ "id": "QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ",
+ "name": "Qwen3 Coder 30B [vm2]",
+ "reasoning": true,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 65536,
+ "maxTokens": 8192,
+ "compat": { "thinkingFormat": "qwen-chat-template" }
+ },
+ {
+ "id": "casperhansen/deepseek-r1-distill-qwen-32b-awq",
+ "name": "DeepSeek-R1-Distill 32B [vm2]",
+ "reasoning": true,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 32768,
+ "maxTokens": 8192
+ },
+ {
+ "id": "Qwen/Qwen3-32B-AWQ",
+ "name": "Qwen3 32B [vm2]",
+ "reasoning": true,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 32768,
+ "maxTokens": 8192,
+ "compat": { "thinkingFormat": "qwen-chat-template" }
+ },
+ {
+ "id": "cyankiwi/Devstral-Small-2507-AWQ-4bit",
+ "name": "Devstral Small 2507 [vm2]",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 32768,
+ "maxTokens": 8192
+ }
+ ]
+ }
+ }
+}
diff --git a/pi/agent/settings.json b/pi/agent/settings.json
new file mode 100644
index 0000000..972476b
--- /dev/null
+++ b/pi/agent/settings.json
@@ -0,0 +1,5 @@
+{
+ "lastChangelogVersion": "0.61.1",
+ "defaultProvider": "openai",
+ "defaultModel": "gpt-4.1"
+} \ No newline at end of file