pi: replace vendored extensions with pi-task and herdr state

This commit is contained in:
Rasyidan Akbar F. 2026-09-23 11:02:23 +07:00
commit 4f55b7cc72
20 changed files with 464 additions and 6906 deletions

View file

@ -1,39 +0,0 @@
/**
* Clipboard Image Labels
*
* Adds [Image #N] labels to messages that include pasted images,
* matching the Claude Code / Codex visual style.
*
* Usage:
* - Paste: Ctrl+V (Alt+V on Windows)
* - Drag: drag any image file onto the terminal
*
* When images are detected, [Image #1], [Image #2], … labels are
* appended to the message text so they appear in the conversation
* history and are visible to the LLM alongside the actual image data.
*
* A small notification confirms how many images were attached.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("input", async (event, ctx) => {
// Nothing to do when no images are attached
if (!event.images || event.images.length === 0) {
return { action: "continue" };
}
// Build [Image #N] labels – one per attached image
const labels = event.images.map((_img, i) => `[Image #${i + 1}]`).join(" ");
// Append labels after any typed text (or use them alone if no text)
const newText = event.text?.trim() ? `${event.text.trim()}\n\n${labels}` : labels;
// Brief confirmation so the user knows the paste registered
const count = event.images.length;
ctx.ui.notify(`📎 ${count} image${count > 1 ? "s" : ""} attached`, "info");
// Transform keeps the original images intact; only the text changes
return { action: "transform", text: newText };
});
}

View file

@ -0,0 +1,261 @@
// installed by herdr
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=pi
// HERDR_INTEGRATION_VERSION=9
// @ts-nocheck
import net from "node:net";
import path from "node:path";
const HERDR_ENV = process.env.HERDR_ENV;
const socketPath = process.env.HERDR_SOCKET_PATH;
const socketEndpoint =
process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
const paneId = process.env.HERDR_PANE_ID;
const source = "herdr:pi";
function enabled() {
return HERDR_ENV === "1" && !!socketPath && !!paneId;
}
function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
if (!enabled()) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
let done = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
const finish = (delivered: boolean) => {
if (done) return;
done = true;
if (timeout) {
clearTimeout(timeout);
}
socket.destroy();
resolve(delivered);
};
const socket = net.createConnection(socketEndpoint!);
socket.on("error", () => finish(false));
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
socket.on("data", () => finish(true));
socket.on("end", () => finish(false));
timeout = setTimeout(() => finish(false), timeoutMs);
timeout.unref?.();
});
}
async function sendRequest(request: unknown): Promise<void> {
if (await sendRequestAttempt(request, 500)) {
return;
}
await sendRequestAttempt(request, 1500);
}
type AgentState = "working" | "blocked" | "idle";
type QueuedState = {
state: AgentState;
message?: string;
seq: number;
};
let reportSeq = Date.now() * 1000;
let currentAgentSessionId: string | undefined;
let currentAgentSessionPath: string | undefined;
function nextReportSeq(): number {
reportSeq += 1;
return reportSeq;
}
function updateSessionRef(ctx: any): void {
try {
const file = ctx?.sessionManager?.getSessionFile?.();
currentAgentSessionPath =
typeof file === "string" &&
(path.posix.isAbsolute(file) || path.win32.isAbsolute(file))
? file
: undefined;
} catch {
currentAgentSessionPath = undefined;
}
try {
const id = ctx?.sessionManager?.getSessionId?.();
currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
} catch {
currentAgentSessionId = undefined;
}
}
function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
if (currentAgentSessionPath) {
return { ...params, agent_session_path: currentAgentSessionPath };
}
if (currentAgentSessionId) {
return { ...params, agent_session_id: currentAgentSessionId };
}
return params;
}
function currentSessionRef(): Record<string, unknown> | undefined {
if (currentAgentSessionPath) {
return { agent_session_path: currentAgentSessionPath };
}
if (currentAgentSessionId) {
return { agent_session_id: currentAgentSessionId };
}
return undefined;
}
function reportSession(sessionStartSource?: string): Promise<void> {
const sessionRef = currentSessionRef();
if (!sessionRef) {
return Promise.resolve();
}
return sendRequest({
id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent_session",
params: {
pane_id: paneId,
source,
agent: "pi",
seq: nextReportSeq(),
session_start_source: sessionStartSource,
...sessionRef,
},
});
}
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
return sendRequest({
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent",
params: withSessionRef({
pane_id: paneId,
source,
agent: "pi",
state,
message,
seq,
}),
});
}
let sendInFlight = false;
let queuedState: QueuedState | undefined;
function queueState(state: AgentState, message?: string): void {
queuedState = { state, message, seq: nextReportSeq() };
if (!sendInFlight) {
void drainStateQueue();
}
}
async function drainStateQueue(): Promise<void> {
if (sendInFlight) {
return;
}
sendInFlight = true;
try {
while (queuedState) {
const next = queuedState;
queuedState = undefined;
await sendState(next.state, next.message, next.seq);
}
} finally {
sendInFlight = false;
if (queuedState) {
void drainStateQueue();
}
}
}
export default function (pi) {
if (!enabled()) {
return;
}
let agentActive = false;
let blockedCount = 0;
let blockedMessage: string | undefined;
let lastState: AgentState | undefined;
let lastMessage: string | undefined;
let rootSession = false;
function desiredState() {
if (blockedCount > 0) {
return { state: "blocked" as const, message: blockedMessage };
}
if (agentActive) {
return { state: "working" as const, message: undefined };
}
return { state: "idle" as const, message: undefined };
}
function publishState(force = false) {
const next = desiredState();
if (!force && next.state === lastState && next.message === lastMessage) {
return;
}
lastState = next.state;
lastMessage = next.message;
queueState(next.state, next.message);
}
pi.events.on("herdr:blocked", (data) => {
if (!rootSession) {
return;
}
if (!data?.active) {
blockedCount = Math.max(0, blockedCount - 1);
if (blockedCount === 0) {
blockedMessage = undefined;
}
publishState();
return;
}
blockedCount += 1;
blockedMessage = data.label;
publishState();
});
pi.on("session_start", async (event, ctx) => {
// TUI only: RPC/JSON/print modes are headless (no PTY herdr can display),
// and RPC still reports hasUI=true, so mode is the reliable gate.
if (ctx?.mode !== "tui") {
return;
}
rootSession = true;
updateSessionRef(ctx);
await reportSession(event?.reason);
// A reload can replace this extension mid-run without emitting another agent_start.
agentActive = ctx?.isIdle?.() === false;
publishState(true);
});
pi.on("agent_start", (_event, ctx) => {
if (!rootSession) {
return;
}
updateSessionRef(ctx);
void reportSession();
agentActive = true;
publishState();
});
pi.on("agent_settled", (_event, ctx) => {
if (!rootSession || ctx?.isIdle?.() !== true) {
return;
}
agentActive = false;
publishState();
});
}

View file

@ -1,31 +0,0 @@
# pi lsp-feedback
Auto-detected defaults for this machine:
- `.nix` -> `~/.nix-profile/bin/nixd` + `alejandra`
- `.sh`, `.bash` -> `~/.nix-profile/bin/bash-language-server start` + `shfmt -w`
- `.lua` -> `~/.nix-profile/bin/lua-language-server` + `stylua`
## What it does
- Overrides pi `write` and `edit`
- After a file change, optionally formats the file
- Re-syncs the final file content into the language server
- Adds a compact diagnostics summary to tool output
- Shows a compact footer status only after supported LSP activity
## Commands
- `/diag path/to/file` - compact diagnostics for one file
- `/lsp-restart` - restart managed language servers
## Tool
- `lsp_diagnostics` - on-demand diagnostics for supported files
## Notes
- This MVP is intentionally compact and context-efficient.
- It stays silent on startup and only becomes visible after you touch or check a supported file.
- Whole-project diagnostics are not implemented yet.
- If you change this extension, run `/reload` in pi.

View file

@ -1,606 +0,0 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { extname, isAbsolute, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { createEditTool, createWriteTool, truncateHead } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { StreamMessageReader, StreamMessageWriter, createMessageConnection } from "vscode-jsonrpc/lib/node/main.js";
type Diagnostic = {
range?: {
start?: { line?: number; character?: number };
end?: { line?: number; character?: number };
};
severity?: number;
code?: string | number;
source?: string;
message?: string;
};
type FormatterConfig = {
command: string;
args: string[];
};
type ServerConfig = {
name: string;
command: string;
args?: string[];
extensions: string[];
languageId: string;
formatter?: FormatterConfig;
};
type FeedbackSummary = {
text: string;
status: string;
severity: "info" | "warning" | "error";
errors: number;
warnings: number;
};
const MAX_INLINE_DIAGNOSTICS = 3;
const DIAGNOSTIC_TIMEOUT_MS = 1500;
const STATUS_KEY = "lsp-feedback";
const profileBin = resolve(homedir(), ".nix-profile/bin");
const perUserProfileBin = process.env.USER ? `/etc/profiles/per-user/${process.env.USER}/bin` : undefined;
function expandHome(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
return path;
}
function normalizePath(cwd: string, input: string): string {
const raw = input.startsWith("@") ? input.slice(1) : input;
const expanded = expandHome(raw);
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
}
function shortPath(cwd: string, absolutePath: string): string {
if (absolutePath.startsWith(`${cwd}/`)) return absolutePath.slice(cwd.length + 1);
return absolutePath;
}
function formatCommandArgs(args: string[], file: string): string[] {
return args.map((arg) => arg.replaceAll("{file}", file));
}
function commandExists(path: string | undefined): path is string {
return !!path && existsSync(expandHome(path));
}
function defaultServers(): ServerConfig[] {
const candidates: ServerConfig[] = [
{
name: "nixd",
command: `${profileBin}/nixd`,
extensions: [".nix"],
languageId: "nix",
formatter: commandExists(`${profileBin}/alejandra`)
? { command: `${profileBin}/alejandra`, args: ["{file}"] }
: undefined,
},
{
name: "bash-language-server",
command: `${profileBin}/bash-language-server`,
args: ["start"],
extensions: [".sh", ".bash"],
languageId: "shellscript",
formatter: commandExists(`${profileBin}/shfmt`)
? { command: `${profileBin}/shfmt`, args: ["-w", "{file}"] }
: undefined,
},
{
name: "lua-language-server",
command: `${profileBin}/lua-language-server`,
extensions: [".lua"],
languageId: "lua",
formatter: commandExists(`${profileBin}/stylua`)
? { command: `${profileBin}/stylua`, args: ["{file}"] }
: undefined,
},
];
return candidates.filter((server) => commandExists(server.command));
}
function findServer(servers: ServerConfig[], filePath: string): ServerConfig | undefined {
const extension = extname(filePath).toLowerCase();
return servers.find((server) => server.extensions.includes(extension));
}
function severityName(severity?: number): string {
switch (severity) {
case 1:
return "E";
case 2:
return "W";
case 3:
return "I";
case 4:
return "H";
default:
return "?";
}
}
function compactMessage(message: string | undefined): string {
const singleLine = (message ?? "Unknown diagnostic").replace(/\s+/g, " ").trim();
return singleLine.length > 180 ? `${singleLine.slice(0, 177)}...` : singleLine;
}
function summarizeDiagnostics(fileLabel: string, diagnostics: Diagnostic[]): FeedbackSummary {
const errors = diagnostics.filter((diag) => diag.severity === 1).length;
const warnings = diagnostics.filter((diag) => diag.severity === 2).length;
const severity: "info" | "warning" | "error" = errors > 0 ? "error" : warnings > 0 ? "warning" : "info";
if (diagnostics.length === 0) {
return {
text: `LSP ${fileLabel}: clean`,
status: "clean",
severity,
errors,
warnings,
};
}
const head = diagnostics.slice(0, MAX_INLINE_DIAGNOSTICS).map((diag) => {
const line = (diag.range?.start?.line ?? 0) + 1;
const character = (diag.range?.start?.character ?? 0) + 1;
const source = diag.source ? ` ${diag.source}` : "";
const code = diag.code !== undefined ? ` ${String(diag.code)}` : "";
return `${severityName(diag.severity)} ${line}:${character}${source}${code} ${compactMessage(diag.message)}`;
});
const remaining = diagnostics.length - head.length;
const counts = `${errors} error(s), ${warnings} warning(s), ${diagnostics.length} total`;
const remainder = remaining > 0 ? `\n… ${remaining} more diagnostic(s)` : "";
return {
text: `LSP ${fileLabel}: ${counts}\n${head.join("\n")}${remainder}`,
status: `${errors}E ${warnings}W`,
severity,
errors,
warnings,
};
}
function compactStatus(server: ServerConfig, summary: FeedbackSummary): string {
return summary.errors === 0 && summary.warnings === 0 ? `${server.name} clean` : `${server.name} ${summary.status}`;
}
function buildEnv(): NodeJS.ProcessEnv {
const pathParts = [profileBin, perUserProfileBin, process.env.PATH].filter(Boolean);
return {
...process.env,
PATH: pathParts.join(":"),
};
}
async function runCommand(command: string, args: string[], cwd: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: buildEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => resolve({ code, stdout, stderr }));
});
}
function getPrimaryText(content: Array<{ type: string; text?: string }>): string {
const firstText = content.find((item) => item.type === "text" && typeof item.text === "string");
return firstText?.text ?? "";
}
class ManagedLspClient {
private process?: ChildProcessWithoutNullStreams;
private connection?: ReturnType<typeof createMessageConnection>;
private initializePromise?: Promise<void>;
private openDocuments = new Set<string>();
private documentVersions = new Map<string, number>();
private diagnostics = new Map<string, Diagnostic[]>();
private waiters = new Map<string, Set<(diagnostics: Diagnostic[]) => void>>();
constructor(private readonly rootDir: string, private readonly server: ServerConfig) {}
private async start(): Promise<void> {
if (this.initializePromise) return this.initializePromise;
this.process = spawn(this.server.command, this.server.args ?? [], {
cwd: this.rootDir,
env: buildEnv(),
stdio: "pipe",
});
this.connection = createMessageConnection(
new StreamMessageReader(this.process.stdout),
new StreamMessageWriter(this.process.stdin),
);
this.connection.onNotification("textDocument/publishDiagnostics", (params: any) => {
const uri = String(params?.uri ?? "");
const diagnostics = Array.isArray(params?.diagnostics) ? (params.diagnostics as Diagnostic[]) : [];
this.diagnostics.set(uri, diagnostics);
const waiters = this.waiters.get(uri);
if (waiters) {
for (const resolve of waiters) resolve(diagnostics);
this.waiters.delete(uri);
}
});
this.connection.listen();
this.initializePromise = this.connection
.sendRequest("initialize", {
processId: process.pid,
rootUri: pathToFileURL(this.rootDir).toString(),
capabilities: {
textDocument: {
publishDiagnostics: {
relatedInformation: true,
},
},
},
clientInfo: {
name: "pi-lsp-feedback",
version: "0.1.0",
},
})
.then(() => {
this.connection?.sendNotification("initialized", {});
});
return this.initializePromise;
}
private waitForNextDiagnostics(uri: string): Promise<Diagnostic[]> {
return new Promise((resolve) => {
const existing = this.waiters.get(uri) ?? new Set<(diagnostics: Diagnostic[]) => void>();
this.waiters.set(uri, existing);
existing.add(resolve);
setTimeout(() => {
const waiters = this.waiters.get(uri);
if (waiters?.has(resolve)) {
waiters.delete(resolve);
if (waiters.size === 0) this.waiters.delete(uri);
resolve(this.diagnostics.get(uri) ?? []);
}
}, DIAGNOSTIC_TIMEOUT_MS);
});
}
async syncFile(filePath: string, text: string): Promise<Diagnostic[]> {
await this.start();
const uri = pathToFileURL(filePath).toString();
const nextVersion = (this.documentVersions.get(uri) ?? 0) + 1;
this.documentVersions.set(uri, nextVersion);
const waitForDiagnostics = this.waitForNextDiagnostics(uri);
if (!this.openDocuments.has(uri)) {
this.connection?.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
languageId: this.server.languageId,
version: nextVersion,
text,
},
});
this.openDocuments.add(uri);
} else {
this.connection?.sendNotification("textDocument/didChange", {
textDocument: { uri, version: nextVersion },
contentChanges: [{ text }],
});
}
this.connection?.sendNotification("textDocument/didSave", {
textDocument: { uri },
text,
});
return waitForDiagnostics;
}
async stop(): Promise<void> {
try {
await this.connection?.sendRequest("shutdown");
} catch {
// ignore
}
try {
this.connection?.sendNotification("exit");
} catch {
// ignore
}
this.connection?.dispose();
this.connection = undefined;
this.initializePromise = undefined;
this.openDocuments.clear();
this.documentVersions.clear();
this.waiters.clear();
this.diagnostics.clear();
if (this.process && !this.process.killed) {
this.process.kill();
}
this.process = undefined;
}
}
export default function lspFeedbackExtension(pi: ExtensionAPI) {
const cwd = process.cwd();
const servers = defaultServers();
const clients = new Map<string, ManagedLspClient>();
const writeTool = createWriteTool(cwd);
const editTool = createEditTool(cwd);
const getClient = (server: ServerConfig): ManagedLspClient => {
const key = `${server.name}:${cwd}`;
let client = clients.get(key);
if (!client) {
client = new ManagedLspClient(cwd, server);
clients.set(key, client);
}
return client;
};
const applyFormatter = async (server: ServerConfig, filePath: string): Promise<{ ran: boolean; changed: boolean; error?: string }> => {
if (!server.formatter) return { ran: false, changed: false };
let before = "";
try {
before = await readFile(filePath, "utf-8");
} catch (error: any) {
return { ran: false, changed: false, error: `formatter pre-read failed: ${error.message}` };
}
const result = await runCommand(server.formatter.command, formatCommandArgs(server.formatter.args, filePath), cwd);
if (result.code !== 0) {
return {
ran: true,
changed: false,
error: (result.stderr || result.stdout || `formatter exited with ${result.code}`).trim(),
};
}
try {
const after = await readFile(filePath, "utf-8");
return { ran: true, changed: before !== after };
} catch (error: any) {
return { ran: true, changed: false, error: `formatter post-read failed: ${error.message}` };
}
};
const collectFeedback = async (absolutePath: string) => {
const server = findServer(servers, absolutePath);
if (!server) return undefined;
let formatterNote: string | undefined;
const formatter = await applyFormatter(server, absolutePath);
if (formatter.error) formatterNote = `Formatter error: ${formatter.error}`;
else if (formatter.ran && formatter.changed) formatterNote = `Formatted with ${server.formatter?.command.split("/").pop()}`;
const text = await readFile(absolutePath, "utf-8");
const diagnostics = await getClient(server).syncFile(absolutePath, text);
const summary = summarizeDiagnostics(shortPath(cwd, absolutePath), diagnostics);
return {
summary,
diagnostics,
formatterNote,
server,
};
};
const appendFeedback = (baseText: string, formatterNote: string | undefined, summary: FeedbackSummary): string => {
const extra = [formatterNote, summary.text].filter(Boolean).join("\n");
return extra ? `${baseText}\n${extra}` : baseText;
};
pi.on("session_start", async (_event, ctx) => {
// Stay silent until a supported file is actually touched or checked.
ctx.ui.setStatus(STATUS_KEY, undefined);
});
pi.on("session_shutdown", async () => {
await Promise.all([...clients.values()].map((client) => client.stop()));
clients.clear();
});
pi.registerTool({
...writeTool,
async execute(id, params, signal, onUpdate, ctx) {
const result = await writeTool.execute(id, params, signal, onUpdate);
const absolutePath = normalizePath(ctx.cwd, params.path);
const feedback = await collectFeedback(absolutePath).catch((error: any) => ({
summary: undefined,
diagnostics: [],
formatterNote: `LSP feedback failed: ${error.message}`,
}));
if (!feedback?.summary) {
if (feedback?.formatterNote) {
ctx.ui.notify(feedback.formatterNote, "warning");
return {
...result,
content: [{ type: "text", text: appendFeedback(getPrimaryText(result.content), feedback.formatterNote, {
text: "",
status: "",
severity: "warning",
errors: 0,
warnings: 0,
}) }],
};
}
return result;
}
ctx.ui.setStatus(STATUS_KEY, compactStatus(feedback.server, feedback.summary));
if (feedback.summary.severity !== "info" || feedback.formatterNote) {
ctx.ui.notify([feedback.formatterNote, feedback.summary.text].filter(Boolean).join("\n"), feedback.summary.severity);
}
return {
...result,
content: [{ type: "text", text: appendFeedback(getPrimaryText(result.content), feedback.formatterNote, feedback.summary) }],
};
},
});
pi.registerTool({
...editTool,
async execute(id, params, signal, onUpdate, ctx) {
const result = await editTool.execute(id, params, signal, onUpdate);
const absolutePath = normalizePath(ctx.cwd, params.path);
const feedback = await collectFeedback(absolutePath).catch((error: any) => ({
summary: undefined,
diagnostics: [],
formatterNote: `LSP feedback failed: ${error.message}`,
}));
if (!feedback?.summary) {
if (feedback?.formatterNote) {
ctx.ui.notify(feedback.formatterNote, "warning");
return {
...result,
content: [{ type: "text", text: appendFeedback(getPrimaryText(result.content), feedback.formatterNote, {
text: "",
status: "",
severity: "warning",
errors: 0,
warnings: 0,
}) }],
};
}
return result;
}
ctx.ui.setStatus(STATUS_KEY, compactStatus(feedback.server, feedback.summary));
if (feedback.summary.severity !== "info" || feedback.formatterNote) {
ctx.ui.notify([feedback.formatterNote, feedback.summary.text].filter(Boolean).join("\n"), feedback.summary.severity);
}
return {
...result,
content: [{ type: "text", text: appendFeedback(getPrimaryText(result.content), feedback.formatterNote, feedback.summary) }],
};
},
});
pi.registerTool({
name: "lsp_diagnostics",
label: "LSP Diagnostics",
description: "Get current diagnostics for a supported file (.nix, .sh, .bash, .lua). Syncs the file through the local language server and returns a compact report.",
promptSnippet: "Get current LSP diagnostics for a supported file after edits",
promptGuidelines: ["Use this tool when you need the current diagnostics for a Nix, shell, or Lua file."],
parameters: Type.Object({
path: Type.String({ description: "Path to the file to check" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const absolutePath = normalizePath(ctx.cwd, params.path);
const feedback = await collectFeedback(absolutePath);
if (!feedback) {
return {
content: [{ type: "text", text: `No configured LSP server for ${params.path}` }],
details: { supported: false },
};
}
const lines = feedback.diagnostics.map((diag) => {
const line = (diag.range?.start?.line ?? 0) + 1;
const character = (diag.range?.start?.character ?? 0) + 1;
const source = diag.source ? ` ${diag.source}` : "";
const code = diag.code !== undefined ? ` ${String(diag.code)}` : "";
return `${severityName(diag.severity)} ${line}:${character}${source}${code} ${compactMessage(diag.message)}`;
});
const body = [
`File: ${shortPath(ctx.cwd, absolutePath)}`,
feedback.formatterNote,
feedback.summary.text,
...(lines.length > 0 ? [""] : []),
...lines,
]
.filter(Boolean)
.join("\n");
const truncated = truncateHead(body, { maxLines: 120, maxBytes: 24 * 1024 });
const text = truncated.truncated ? `${truncated.content}\n\n[Diagnostics truncated. ${feedback.diagnostics.length} total item(s)]` : body;
ctx.ui.setStatus(STATUS_KEY, compactStatus(feedback.server, feedback.summary));
return {
content: [{ type: "text", text }],
details: {
server: feedback.server.name,
diagnostics: feedback.diagnostics,
formatterNote: feedback.formatterNote,
},
};
},
});
pi.registerCommand("lsp-restart", {
description: "Restart all managed language servers",
handler: async (_args, ctx) => {
await Promise.all([...clients.values()].map((client) => client.stop()));
clients.clear();
ctx.ui.setStatus(STATUS_KEY, undefined);
ctx.ui.notify("LSP feedback servers restarted", "info");
},
});
pi.registerCommand("diag", {
description: "Show compact diagnostics for a supported file: /diag path/to/file",
handler: async (args, ctx) => {
const input = args?.trim();
if (!input) {
ctx.ui.notify("Usage: /diag path/to/file", "warning");
return;
}
const absolutePath = normalizePath(ctx.cwd, input);
try {
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) {
ctx.ui.notify(`Not a file: ${input}`, "warning");
return;
}
} catch (error: any) {
ctx.ui.notify(`Cannot read ${input}: ${error.message}`, "error");
return;
}
const feedback = await collectFeedback(absolutePath);
if (!feedback) {
ctx.ui.notify(`No configured LSP server for ${input}`, "warning");
return;
}
ctx.ui.setStatus(STATUS_KEY, compactStatus(feedback.server, feedback.summary));
ctx.ui.notify([feedback.formatterNote, feedback.summary.text].filter(Boolean).join("\n"), feedback.summary.severity);
},
});
}

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
{
"name": "pi-lsp-feedback",
"private": true,
"version": "0.1.0",
"type": "module",
"pi": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"@mariozechner/pi-coding-agent": "^0.57.1",
"@sinclair/typebox": "^0.34.48",
"vscode-jsonrpc": "^8.2.1"
},
"devDependencies": {
"@types/node": "^25.4.0",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,24 @@
# pi-task
A small, session-scoped task list for Pi. It adds one agent tool, `task`, a
compact widget above the editor, and a progress indicator in the footer.
The tool accepts the complete ordered task list, like Claude's todo tool or
Codex's plan tool. The agent adds, updates, removes, or reorders tasks by
submitting a new list. Omitting the list reads the current state; submitting an
empty list clears it.
Statuses are `pending`, `in_progress`, and `completed`. At most one task can be
in progress at a time.
State is stored in Pi tool-result details. It survives reloads and compaction,
follows session branches correctly, and does not create project files. A new Pi
session starts with an empty list.
While work is active, the widget uses themed symbols and colors to distinguish
the current task (`●`) from pending tasks (`○`). The footer shows compact
overall progress. When every task is complete, Pi displays a success
notification and removes both the widget and footer indicator to reclaim space.
The completed list remains available in session history.
Run `/tasks` to refresh the task UI manually.

View file

@ -0,0 +1,179 @@
import type {
ExtensionAPI,
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import { StringEnum } from "@earendil-works/pi-ai";
import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
type TaskStatus = "pending" | "in_progress" | "completed";
interface Task {
text: string;
status: TaskStatus;
}
interface TaskDetails {
tasks: Task[];
}
const TaskParams = Type.Object({
tasks: Type.Optional(
Type.Array(
Type.Object({
text: Type.String({ description: "Short, outcome-oriented task description" }),
status: StringEnum(["pending", "in_progress", "completed"] as const),
}),
{
description:
"Complete ordered task list. Submit the full list to add, update, reorder, or remove tasks. Omit to read it.",
},
),
),
});
function cloneTasks(tasks: Task[]): Task[] {
return tasks.map((task) => ({ ...task }));
}
function formatTask(task: Task): string {
const marker = {
pending: "○",
in_progress: "●",
completed: "✓",
}[task.status];
return `${marker} ${task.text}`;
}
function formatThemedTask(task: Task, theme: Theme): string {
const marker = {
pending: theme.fg("dim", "○"),
in_progress: theme.fg("accent", "●"),
completed: theme.fg("success", "✓"),
}[task.status];
const text = task.status === "pending" ? theme.fg("muted", task.text) : theme.fg("text", task.text);
return `${marker} ${text}`;
}
export default function (pi: ExtensionAPI) {
let tasks: Task[] = [];
const refreshTaskUI = (ctx: ExtensionContext) => {
if (!ctx.hasUI) return;
const completed = tasks.filter((task) => task.status === "completed").length;
const remaining = tasks.filter((task) => task.status !== "completed");
if (remaining.length === 0) {
ctx.ui.setWidget("pi-task", undefined);
ctx.ui.setStatus("pi-task", undefined);
return;
}
ctx.ui.setStatus(
"pi-task",
ctx.ui.theme.fg("accent", `● Tasks ${completed}/${tasks.length}`),
);
ctx.ui.setWidget("pi-task", (_tui, theme) => {
const visible = remaining.slice(0, 7);
const lines = [
`${theme.fg("accent", theme.bold("Tasks"))} ${theme.fg("muted", `${completed}/${tasks.length}`)}`,
...visible.map((task) => formatThemedTask(task, theme)),
];
const hidden = remaining.length - visible.length;
if (hidden > 0) lines.push(theme.fg("dim", `… ${hidden} more`));
return new Text(lines.join("\n"), 0, 0);
});
};
const reconstructState = (ctx: ExtensionContext) => {
tasks = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const message = entry.message;
if (message.role !== "toolResult" || message.toolName !== "task") continue;
const saved = message.details as TaskDetails | undefined;
if (saved && Array.isArray(saved.tasks)) tasks = cloneTasks(saved.tasks);
}
refreshTaskUI(ctx);
};
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
pi.registerTool({
name: "task",
label: "Task",
description:
"Read or replace the complete ordered task list. Submit the full list to add, update, reorder, remove, or clear tasks. At most one task may be in_progress.",
promptSnippet: "Maintain an ordered task list for multi-step work",
promptGuidelines: [
"Use task for work with multiple meaningful steps; skip it for simple one-step requests.",
"Keep task items short and outcome-oriented, and submit the complete ordered list whenever it changes.",
"Keep exactly one task in_progress while actively working, and complete all tasks before finishing.",
],
parameters: TaskParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const wasComplete = tasks.length > 0 && tasks.every((task) => task.status === "completed");
if (params.tasks !== undefined) {
const normalized = params.tasks.map((task) => ({
text: task.text.trim(),
status: task.status,
}));
if (normalized.some((task) => task.text.length === 0)) {
throw new Error("Task text cannot be empty");
}
const active = normalized.filter((task) => task.status === "in_progress").length;
if (active > 1) {
throw new Error("Only one task may be in_progress");
}
tasks = normalized;
}
const isComplete = tasks.length > 0 && tasks.every((task) => task.status === "completed");
refreshTaskUI(ctx);
if (ctx.hasUI && isComplete && !wasComplete) {
ctx.ui.notify(`✓ All ${tasks.length} tasks completed`, "info");
}
return {
content: [
{
type: "text",
text: tasks.length > 0 ? tasks.map(formatTask).join("\n") : "No tasks",
},
],
details: { tasks: cloneTasks(tasks) } satisfies TaskDetails,
};
},
});
pi.registerCommand("tasks", {
description: "Refresh and show the current task list",
handler: async (_args, ctx) => {
refreshTaskUI(ctx);
if (ctx.hasUI) {
const remaining = tasks.filter((task) => task.status !== "completed").length;
const message =
tasks.length === 0
? "No tasks"
: remaining === 0
? `✓ All ${tasks.length} tasks completed; task UI is hidden`
: `${remaining} task(s) remaining; task UI refreshed`;
ctx.ui.notify(message, "info");
}
},
});
}

View file

@ -1,42 +0,0 @@
# Plan mode extension
Claude Code-style plan mode for pi.
## Features
- `/plan` toggles read-only planning mode
- `Ctrl+Alt+P` toggles plan mode on/off
- `/plan execute` runs the last approved plan in the current session with full tools restored
- `/plan execute fresh` starts a fresh session and carries the approved plan into it for execution
- extracts numbered steps from a `Plan:` section
- tracks progress with `[DONE:n]` markers during execution
- restores your previous active tool set after plan mode ends
- keeps `subagent` available in plan mode when you intentionally want to use helper agents during planning
## Commands
- `/plan`
- `/plan on`
- `/plan off`
- `/plan execute`
- `/plan execute fresh`
- `/plan status`
- `/plan clear`
## Shortcut
- `Ctrl+Alt+P` — toggle plan mode
## Behavior
When plan mode is enabled, pi switches to read-only exploration and asks the model to produce a numbered `Plan:` section.
After planning finishes, the extension shows a chooser so you can:
- execute now
- execute in a fresh session
- send custom feedback / continue discussion
- stay in plan mode
When you execute the plan in the current session, the extension restores your previous tools and asks the model to complete the steps while emitting `[DONE:n]` markers.
If you use `/plan execute fresh`, the extension creates a new session, carries over the approved plan and planner notes, restores the full tool set there, and starts execution in that fresh context.

View file

@ -1,410 +0,0 @@
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
const PLAN_FLAG = "plan-mode";
const PLAN_TODOS_WIDGET = "plan-todos";
const PLAN_CONTEXT_TYPE = "plan-mode-context";
const PLAN_EXECUTION_CONTEXT_TYPE = "plan-execution-context";
const PLAN_STATE_ENTRY = "plan-mode-state";
const PREFERRED_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", "lsp_diagnostics", "subagent"];
type PlanModeState = {
enabled: boolean;
executing: boolean;
savedTools: string[] | null;
todos: TodoItem[];
};
function getAssistantText(message: any): string {
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return "";
return message.content
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("\n")
.trim();
}
export default function planModeExtension(pi: ExtensionAPI): void {
let planModeEnabled = false;
let executionMode = false;
let savedTools: string[] | null = null;
let todoItems: TodoItem[] = [];
function persistState() {
const state: PlanModeState = {
enabled: planModeEnabled,
executing: executionMode,
savedTools,
todos: todoItems,
};
pi.appendEntry(PLAN_STATE_ENTRY, state);
}
function getPlanTools(): string[] {
const allToolNames = new Set(pi.getAllTools().map((tool) => tool.name));
const tools = new Set<string>();
for (const name of PREFERRED_PLAN_TOOLS) {
if (allToolNames.has(name)) tools.add(name);
}
if (savedTools) {
for (const name of savedTools) {
if (allToolNames.has(name) && PREFERRED_PLAN_TOOLS.includes(name)) tools.add(name);
}
}
return Array.from(tools);
}
function restoreSavedTools() {
if (savedTools && savedTools.length > 0) {
pi.setActiveTools(savedTools);
}
}
function updateUi(ctx: ExtensionContext) {
if (executionMode && todoItems.length > 0) {
const completed = todoItems.filter((item) => item.completed).length;
ctx.ui.setStatus(PLAN_FLAG, `📋 ${completed}/${todoItems.length}`);
} else if (planModeEnabled) {
ctx.ui.setStatus(PLAN_FLAG, "⏸ PLAN");
} else {
ctx.ui.setStatus(PLAN_FLAG, undefined);
}
if ((planModeEnabled || executionMode) && todoItems.length > 0) {
ctx.ui.setWidget(
PLAN_TODOS_WIDGET,
todoItems.map((item) => `${item.completed ? "☑" : "☐"} ${item.step}. ${item.text}`),
);
} else {
ctx.ui.setWidget(PLAN_TODOS_WIDGET, undefined);
}
}
function enablePlanMode(ctx: ExtensionContext) {
if (!savedTools) savedTools = pi.getActiveTools();
planModeEnabled = true;
executionMode = false;
pi.setActiveTools(getPlanTools());
updateUi(ctx);
persistState();
ctx.ui.notify(`Plan mode enabled. Active tools: ${getPlanTools().join(", ")}`, "info");
}
function disablePlanMode(ctx: ExtensionContext, notify = true) {
planModeEnabled = false;
executionMode = false;
restoreSavedTools();
updateUi(ctx);
persistState();
if (notify) ctx.ui.notify("Plan mode disabled. Previous tools restored.", "info");
}
function clearPlan(ctx: ExtensionContext) {
executionMode = false;
todoItems = [];
updateUi(ctx);
persistState();
ctx.ui.notify("Stored plan cleared.", "info");
}
function showStatus(ctx: ExtensionContext) {
if (!planModeEnabled && !executionMode && todoItems.length === 0) {
ctx.ui.notify("Plan mode is off and no stored plan exists.", "info");
return;
}
const lines = [
`plan mode: ${planModeEnabled ? "on" : "off"}`,
`execution: ${executionMode ? "on" : "off"}`,
`stored steps: ${todoItems.length}`,
];
if (todoItems.length > 0) {
for (const item of todoItems) lines.push(`${item.step}. ${item.completed ? "✓" : "○"} ${item.text}`);
}
ctx.ui.notify(lines.join("\n"), "info");
}
function getLatestPlannedAssistantText(ctx: ExtensionContext): string {
const branch = ctx.sessionManager.getBranch();
for (let i = branch.length - 1; i >= 0; i--) {
const entry: any = branch[i];
if (entry?.type !== "message" || !entry.message) continue;
const text = getAssistantText(entry.message);
if (text && extractTodoItems(text).length > 0) return text;
}
return "";
}
function buildExecutionPrompt(ctx: ExtensionContext, fresh: boolean): string {
const remaining = todoItems.filter((item) => !item.completed);
const planLines = todoItems.map((item) => `${item.step}. ${item.text}${item.completed ? " [already done]" : ""}`);
const latestPlanText = getLatestPlannedAssistantText(ctx);
const sections = [
fresh
? "You are executing an approved plan in a fresh session. The earlier planning conversation is not available, so rely only on the context below."
: "Execute the approved plan in this session.",
"## Approved Plan",
planLines.join("\n"),
];
if (remaining.length > 0) {
sections.push("## Remaining Steps", remaining.map((item) => `${item.step}. ${item.text}`).join("\n"));
}
if (latestPlanText) {
sections.push("## Planner Notes", latestPlanText);
}
sections.push(
"## Execution Instructions",
[
"Inspect files before editing.",
"If the approved plan needs adjustment, explain why before making a larger change.",
"Complete the remaining steps in order.",
"After completing a step, include a [DONE:n] marker in your response.",
].join("\n"),
);
return sections.join("\n\n");
}
function startExecution(ctx: ExtensionContext) {
if (todoItems.length === 0) {
ctx.ui.notify("No stored plan found. Create one in /plan mode first.", "warning");
return;
}
planModeEnabled = false;
executionMode = true;
restoreSavedTools();
updateUi(ctx);
persistState();
ctx.ui.notify("Executing stored plan with full tools restored.", "info");
pi.sendUserMessage(buildExecutionPrompt(ctx, false));
}
async function startFreshExecution(ctx: any) {
if (todoItems.length === 0) {
ctx.ui.notify("No stored plan found. Create one in /plan mode first.", "warning");
return;
}
await ctx.waitForIdle();
const executionPrompt = buildExecutionPrompt(ctx, true);
const executionTools = savedTools && savedTools.length > 0 ? [...savedTools] : [...pi.getActiveTools()];
const carriedTodos = todoItems.map((item) => ({ ...item }));
const parentSession = ctx.sessionManager.getSessionFile();
const result = await ctx.newSession({
parentSession,
setup: async (sm: any) => {
sm.appendCustomEntry(PLAN_STATE_ENTRY, {
enabled: false,
executing: true,
savedTools: executionTools,
todos: carriedTodos,
});
},
});
if (result.cancelled) {
ctx.ui.notify("Fresh execution session cancelled.", "info");
return;
}
pi.sendUserMessage(executionPrompt);
}
pi.registerFlag("plan", {
description: "Start in read-only plan mode",
type: "boolean",
default: false,
});
pi.registerCommand("plan", {
description: "Toggle plan mode, or use /plan on|off|execute [fresh]|status|clear",
handler: async (args, ctx) => {
const action = args.trim().toLowerCase();
switch (action) {
case "":
case "toggle":
if (planModeEnabled) disablePlanMode(ctx);
else enablePlanMode(ctx);
return;
case "on":
enablePlanMode(ctx);
return;
case "off":
disablePlanMode(ctx);
return;
case "execute":
startExecution(ctx);
return;
case "execute fresh":
await startFreshExecution(ctx);
return;
case "status":
showStatus(ctx);
return;
case "clear":
clearPlan(ctx);
return;
default:
ctx.ui.notify("Usage: /plan [on|off|execute|execute fresh|status|clear]", "warning");
}
},
});
pi.registerShortcut("ctrl+alt+p", {
description: "Toggle plan mode on/off",
handler: async (ctx) => {
if (planModeEnabled) disablePlanMode(ctx);
else enablePlanMode(ctx);
},
});
pi.on("tool_call", async (event) => {
if (!planModeEnabled) return;
if (event.toolName === "edit" || event.toolName === "write") {
return { block: true, reason: "Plan mode is read-only. Disable it before modifying files." };
}
if (event.toolName === "bash") {
const command = typeof event.input?.command === "string" ? event.input.command : "";
if (!isSafeCommand(command)) {
return {
block: true,
reason: `Plan mode blocked a non read-only bash command: ${command}`,
};
}
}
});
pi.on("context", async (event) => {
if (planModeEnabled || executionMode) return;
return {
messages: event.messages.filter((message: any) => {
if (message?.customType === PLAN_CONTEXT_TYPE) return false;
if (message?.customType === PLAN_EXECUTION_CONTEXT_TYPE) return false;
return true;
}),
};
});
pi.on("before_agent_start", async () => {
if (planModeEnabled) {
return {
message: {
customType: PLAN_CONTEXT_TYPE,
display: false,
content: `[PLAN MODE ACTIVE]\nYou are in read-only planning mode.\n\nRules:\n- Do not modify files.\n- Do not use edit or write.\n- Use only read-only investigation.\n- You may use subagent when it helps planning. Prefer read-only helpers like librarian, reviewer, or uiux-designer when appropriate.\n- If requirements are unclear, ask clarifying questions before planning.\n- Produce a numbered plan under a \"Plan:\" header.\n- Call out risks, dependencies, and validation steps.`,
},
};
}
if (executionMode && todoItems.length > 0) {
const remaining = todoItems.filter((item) => !item.completed);
return {
message: {
customType: PLAN_EXECUTION_CONTEXT_TYPE,
display: false,
content:
`[EXECUTING APPROVED PLAN]\nComplete the remaining plan steps in order.\n\nRemaining steps:\n${remaining.map((item) => `${item.step}. ${item.text}`).join("\n")}\n\nAfter completing a step, include [DONE:n] in your response.`,
},
};
}
});
pi.on("turn_end", async (event, ctx) => {
if (!executionMode || todoItems.length === 0) return;
const text = getAssistantText(event.message);
if (!text) return;
if (markCompletedSteps(text, todoItems) > 0) {
updateUi(ctx);
persistState();
}
if (todoItems.length > 0 && todoItems.every((item) => item.completed)) {
executionMode = false;
updateUi(ctx);
persistState();
ctx.ui.notify("Plan execution completed.", "success");
}
});
pi.on("agent_end", async (event, ctx) => {
if (!planModeEnabled) return;
const lastAssistant = [...event.messages].reverse().find((message: any) => message?.role === "assistant");
const text = getAssistantText(lastAssistant);
if (!text) return;
const extracted = extractTodoItems(text);
if (extracted.length === 0) return;
todoItems = extracted;
updateUi(ctx);
persistState();
if (!ctx.hasUI) return;
const choice = await ctx.ui.select("Plan ready - what next?", [
"Execute the plan now",
"Execute in a fresh session",
"Send custom feedback / continue discussion",
"Stay in plan mode",
]);
if (choice === "Execute the plan now") {
startExecution(ctx);
return;
}
if (choice === "Execute in a fresh session") {
await startFreshExecution(ctx);
return;
}
if (choice === "Send custom feedback / continue discussion") {
const feedback = await ctx.ui.editor(
"What should Pi do next?",
"Refine the plan by ...",
);
if (feedback?.trim()) {
pi.sendUserMessage(feedback.trim());
}
}
});
pi.on("session_start", async (_event, ctx) => {
const branch = ctx.sessionManager.getBranch();
const stateEntry = branch
.filter((entry: any) => entry.type === "custom" && entry.customType === PLAN_STATE_ENTRY)
.pop() as { data?: PlanModeState } | undefined;
if (stateEntry?.data) {
planModeEnabled = stateEntry.data.enabled ?? false;
executionMode = stateEntry.data.executing ?? false;
savedTools = stateEntry.data.savedTools ?? null;
todoItems = stateEntry.data.todos ?? [];
} else if (pi.getFlag("--plan") === true || pi.getFlag("plan") === true) {
planModeEnabled = true;
executionMode = false;
savedTools = pi.getActiveTools();
}
if (!savedTools) savedTools = pi.getActiveTools();
if (planModeEnabled) {
pi.setActiveTools(getPlanTools());
} else if (executionMode) {
restoreSavedTools();
}
updateUi(ctx);
});
}

View file

@ -1,11 +0,0 @@
{
"name": "pi-plan-mode",
"private": true,
"version": "0.1.0",
"type": "module",
"pi": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -1,152 +0,0 @@
export interface TodoItem {
step: number;
text: string;
completed: boolean;
}
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 isSafeCommand(command: string): boolean {
const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command));
const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command));
return !isDestructive && isSafe;
}
function cleanStepText(text: string): string {
let cleaned = text
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/\s+/g, " ")
.trim();
if (cleaned.length > 0) cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
return cleaned;
}
export function extractTodoItems(message: string): TodoItem[] {
const items: TodoItem[] = [];
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 rawText = match[2]?.trim();
if (!rawText || rawText.startsWith("-") || rawText.startsWith("/")) continue;
const text = cleanStepText(rawText);
if (text.length < 4) continue;
items.push({
step: items.length + 1,
text,
completed: false,
});
}
return items;
}
export function extractDoneSteps(message: string): number[] {
const steps: number[] = [];
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
const step = Number(match[1]);
if (Number.isFinite(step)) steps.push(step);
}
return steps;
}
export function markCompletedSteps(text: string, items: TodoItem[]): number {
const doneSteps = extractDoneSteps(text);
for (const step of doneSteps) {
const item = items.find((todo) => todo.step === step);
if (item) item.completed = true;
}
return doneSteps.length;
}

View file

@ -1,48 +0,0 @@
# Subagent roles for pi
Installed roles:
- `reviewer` — review changes, bugs, risks, maintainability
- `librarian` — explore and map a codebase
- `design-engineer` — frontend-aware UI/UX direction, design systems, and implementation-ready recommendations
- `executor` — implement ordered coding tasks from a defined todo list
- `planner` — interactive planning specialist that can ask clarifying questions and return an ordered executor-ready todo list
## Commands
- `/subagents` — list discovered user-level agents
- `/subagents both` — include project-local `.pi/agents`
## Example prompts
- `Use the subagent tool with reviewer to inspect the auth refactor.`
- `Use subagent in parallel: librarian maps the settings flow, design-engineer proposes an improved UX direction, reviewer lists product and implementation risks.`
- `Chain librarian -> design-engineer to redesign onboarding. Use {previous} in the second task.`
- `Use executor to implement this ordered todo list exactly as written, then summarize what was completed and validated.`
- `Use planner to clarify ambiguity only when needed and output an ordered implementation todo list for executor handoff.`
## Interactive planner + ask_user
`planner` is marked `interactive: true` and runs through an RPC-backed child agent path.
- Interactive child agents use a helper extension tool: `ask_user`
- `ask_user` supports `input`, `confirm`, and `select` modes (plus `editor`)
- Child tool activation is applied on `session_start` via `PI_SUBAGENT_TOOLS` so custom tools like `ask_user` are reliably active
## Notes
- User agents live in `~/.pi/agent/agents`
- Project-local agents can live in `.pi/agents`
- Project agents require `agentScope: "project"` or `"both"`
- Parallel mode allows up to 8 tasks and runs up to 4 concurrently
- `reviewer` uses `openai-codex/gpt-5.4:xhigh`
- `librarian` uses `anthropic/claude-sonnet-4-6`
- `design-engineer` uses `google-antigravity/gemini-3-flash`
- `executor` uses `openai-codex/gpt-5.3-codex:high`
- `planner` uses `anthropic/claude-sonnet-4-6`
## Current interactive limitations
- Interactive subagents are currently supported only in **single mode** (`{ agent, task }`)
- Interactive subagents are currently rejected in **parallel** and **chain** modes
- Interactive subagents require `ctx.hasUI` (interactive TUI or RPC mode with extension UI handling)

View file

@ -1,163 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
export type AgentScope = "user" | "project" | "both";
export interface AgentConfig {
name: string;
description: string;
tools?: string[];
model?: string;
interactive?: boolean;
systemPrompt: string;
source: "user" | "project";
filePath: string;
}
export interface AgentDiscoveryResult {
agents: AgentConfig[];
projectAgentsDir: string | null;
}
function normalizeString(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (typeof value === "number" || typeof value === "boolean") return String(value);
return undefined;
}
function normalizeTools(value: unknown): string[] | undefined {
if (Array.isArray(value)) {
const tools = value
.flatMap((entry) => {
if (typeof entry !== "string") return [];
return entry
.split(",")
.map((part) => part.trim())
.filter(Boolean);
})
.filter(Boolean);
return tools.length > 0 ? tools : undefined;
}
if (typeof value === "string") {
const tools = value
.split(",")
.map((tool) => tool.trim())
.filter(Boolean);
return tools.length > 0 ? tools : undefined;
}
return undefined;
}
function normalizeBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
return undefined;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
}
return undefined;
}
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
const agents: AgentConfig[] = [];
if (!fs.existsSync(dir)) return agents;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return agents;
}
for (const entry of entries) {
if (!entry.name.endsWith(".md")) continue;
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const filePath = path.join(dir, entry.name);
let content: string;
try {
content = fs.readFileSync(filePath, "utf-8");
} catch {
continue;
}
const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
const name = normalizeString(frontmatter.name);
const description = normalizeString(frontmatter.description);
if (!name || !description) continue;
agents.push({
name,
description,
tools: normalizeTools(frontmatter.tools),
model: normalizeString(frontmatter.model),
interactive: normalizeBoolean(frontmatter.interactive),
systemPrompt: body,
source,
filePath,
});
}
return agents;
}
function isDirectory(p: string): boolean {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
}
function findNearestProjectAgentsDir(cwd: string): string | null {
let currentDir = cwd;
while (true) {
const candidate = path.join(currentDir, ".pi", "agents");
if (isDirectory(candidate)) return candidate;
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) return null;
currentDir = parentDir;
}
}
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
const userDir = path.join(getAgentDir(), "agents");
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
const agentMap = new Map<string, AgentConfig>();
if (scope === "both") {
for (const agent of userAgents) agentMap.set(agent.name, agent);
for (const agent of projectAgents) agentMap.set(agent.name, agent);
} else if (scope === "user") {
for (const agent of userAgents) agentMap.set(agent.name, agent);
} else {
for (const agent of projectAgents) agentMap.set(agent.name, agent);
}
return { agents: Array.from(agentMap.values()), projectAgentsDir };
}
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
if (agents.length === 0) return { text: "none", remaining: 0 };
const listed = agents.slice(0, maxItems);
const remaining = agents.length - listed.length;
return {
text: listed.map((agent) => `${agent.name} (${agent.source}): ${agent.description}`).join("; "),
remaining,
};
}

View file

@ -1,818 +0,0 @@
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { StringEnum } from "@mariozechner/pi-ai";
import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents.js";
import { runInteractiveAgentRpc } from "./rpc-runner.js";
import {
applyAssistantUsage,
createEmptyUsageStats,
getDisplayItems,
getFinalOutput,
type DisplayItem,
type OnUpdateCallback,
type SingleResult,
type SubagentDetails,
} from "./subagent-types.js";
const MAX_PARALLEL_TASKS = 8;
const MAX_CONCURRENCY = 4;
const COLLAPSED_ITEM_COUNT = 10;
function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
function formatUsageStats(
usage: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextTokens?: number;
turns?: number;
},
model?: string,
): string {
const parts: string[] = [];
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
if (model) parts.push(model);
return parts.join(" ");
}
function formatToolCall(
toolName: string,
args: Record<string, unknown>,
themeFg: (color: any, text: string) => string,
): string {
const shortenPath = (p: string) => {
const home = os.homedir();
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
};
switch (toolName) {
case "bash": {
const command = (args.command as string) || "...";
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
}
case "read": {
const rawPath = (args.file_path || args.path || "...") as string;
const filePath = shortenPath(rawPath);
const offset = args.offset as number | undefined;
const limit = args.limit as number | undefined;
let text = themeFg("accent", filePath);
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
return themeFg("muted", "read ") + text;
}
case "write": {
const rawPath = (args.file_path || args.path || "...") as string;
const filePath = shortenPath(rawPath);
const content = (args.content || "") as string;
const lines = content.split("\n").length;
let text = themeFg("muted", "write ") + themeFg("accent", filePath);
if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
return text;
}
case "edit": {
const rawPath = (args.file_path || args.path || "...") as string;
return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
}
case "ls": {
const rawPath = (args.path || ".") as string;
return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
}
case "find": {
const pattern = (args.pattern || "*") as string;
const rawPath = (args.path || ".") as string;
return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
}
case "grep": {
const pattern = (args.pattern || "") as string;
const rawPath = (args.path || ".") as string;
return themeFg("muted", "grep ") + themeFg("accent", `/${pattern}/`) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
}
default: {
const argsStr = JSON.stringify(args);
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
}
}
}
async function mapWithConcurrencyLimit<TIn, TOut>(
items: TIn[],
concurrency: number,
fn: (item: TIn, index: number) => Promise<TOut>,
): Promise<TOut[]> {
if (items.length === 0) return [];
const limit = Math.max(1, Math.min(concurrency, items.length));
const results: TOut[] = new Array(items.length);
let nextIndex = 0;
const workers = new Array(limit).fill(null).map(async () => {
while (true) {
const current = nextIndex++;
if (current >= items.length) return;
results[current] = await fn(items[current], current);
}
});
await Promise.all(workers);
return results;
}
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
const safeName = agentName.replace(/[^\w.-]+/g, "_");
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
return { dir: tmpDir, filePath };
}
async function runSingleAgent(
defaultCwd: string,
agents: AgentConfig[],
agentName: string,
task: string,
cwd: string | undefined,
step: number | undefined,
signal: AbortSignal | undefined,
onUpdate: OnUpdateCallback | undefined,
makeDetails: (results: SingleResult[]) => SubagentDetails,
): Promise<SingleResult> {
const agent = agents.find((a) => a.name === agentName);
if (!agent) {
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
return {
agent: agentName,
agentSource: "unknown",
task,
exitCode: 1,
messages: [],
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
usage: createEmptyUsageStats(),
step,
};
}
const args: string[] = ["--mode", "json", "-p", "--no-session"];
if (agent.model) args.push("--model", agent.model);
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
let tmpPromptDir: string | null = null;
let tmpPromptPath: string | null = null;
const currentResult: SingleResult = {
agent: agentName,
agentSource: agent.source,
task,
exitCode: 0,
messages: [],
stderr: "",
usage: createEmptyUsageStats(),
model: agent.model,
step,
};
const emitUpdate = () => {
if (!onUpdate) return;
onUpdate({
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
details: makeDetails([currentResult]),
});
};
try {
if (agent.systemPrompt.trim()) {
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
tmpPromptDir = tmp.dir;
tmpPromptPath = tmp.filePath;
args.push("--append-system-prompt", tmpPromptPath);
}
args.push(`Task: ${task}`);
let wasAborted = false;
const exitCode = await new Promise<number>((resolve) => {
const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
let buffer = "";
const processLine = (line: string) => {
if (!line.trim()) return;
let event: any;
try {
event = JSON.parse(line);
} catch {
return;
}
if (event.type === "message_end" && event.message) {
const msg = event.message;
currentResult.messages.push(msg);
applyAssistantUsage(currentResult, msg);
emitUpdate();
}
if (event.type === "tool_result_end" && event.message) {
currentResult.messages.push(event.message);
emitUpdate();
}
};
proc.stdout.on("data", (data) => {
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) processLine(line);
});
proc.stderr.on("data", (data) => {
currentResult.stderr += data.toString();
});
proc.on("close", (code) => {
if (buffer.trim()) processLine(buffer);
resolve(code ?? 0);
});
proc.on("error", () => resolve(1));
if (signal) {
const killProc = () => {
wasAborted = true;
proc.kill("SIGTERM");
setTimeout(() => {
if (!proc.killed) proc.kill("SIGKILL");
}, 5000);
};
if (signal.aborted) killProc();
else signal.addEventListener("abort", killProc, { once: true });
}
});
currentResult.exitCode = exitCode;
if (wasAborted) throw new Error("Subagent was aborted");
return currentResult;
} finally {
if (tmpPromptPath) {
try {
fs.unlinkSync(tmpPromptPath);
} catch {}
}
if (tmpPromptDir) {
try {
fs.rmdirSync(tmpPromptDir);
} catch {}
}
}
}
function findAgentConfig(agents: AgentConfig[], name: string): AgentConfig | undefined {
return agents.find((agent) => agent.name === name);
}
const TaskItem = Type.Object({
agent: Type.String({ description: "Name of the agent to invoke" }),
task: Type.String({ description: "Task to delegate to the agent" }),
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
});
const ChainItem = Type.Object({
agent: Type.String({ description: "Name of the agent to invoke" }),
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
});
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
default: "user",
});
const SubagentParams = Type.Object({
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
agentScope: Type.Optional(AgentScopeSchema),
confirmProjectAgents: Type.Optional(
Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
),
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
});
export default function (pi: ExtensionAPI) {
pi.registerCommand("subagents", {
description: "List discovered subagent roles",
handler: async (args, ctx) => {
const requestedScope = (args.trim() as AgentScope) || "user";
const scope: AgentScope = ["user", "project", "both"].includes(requestedScope) ? requestedScope : "user";
const discovery = discoverAgents(ctx.cwd, scope);
const list = discovery.agents.map((agent) => `- ${agent.name} (${agent.source}): ${agent.description}`).join("\n");
ctx.ui.notify(list || `No agents found for scope: ${scope}`, "info");
},
});
pi.registerTool({
name: "subagent",
label: "Subagent",
description: [
"Delegate work to specialized subagents with isolated context.",
"Supports single, parallel, and chained execution.",
"Useful built-in roles: reviewer, librarian, design-engineer, executor.",
'Default scope is "user" (~/.pi/agent/agents). Set agentScope to "both" or "project" to include repo-local agents.',
].join(" "),
parameters: SubagentParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const agentScope: AgentScope = params.agentScope ?? "user";
const discovery = discoverAgents(ctx.cwd, agentScope);
const agents = discovery.agents;
const confirmProjectAgents = params.confirmProjectAgents ?? true;
const availableAgents = formatAgentList(agents, 8);
const hasChain = (params.chain?.length ?? 0) > 0;
const hasTasks = (params.tasks?.length ?? 0) > 0;
const hasSingle = Boolean(params.agent && params.task);
const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
const makeDetails =
(mode: "single" | "parallel" | "chain") =>
(results: SingleResult[]): SubagentDetails => ({
mode,
agentScope,
projectAgentsDir: discovery.projectAgentsDir,
results,
});
if (modeCount !== 1) {
return {
content: [{ type: "text", text: `Invalid parameters. Available agents: ${availableAgents.text}` }],
details: makeDetails("single")([]),
};
}
if (hasTasks && params.tasks?.some((task) => findAgentConfig(agents, task.agent)?.interactive === true)) {
return {
content: [{ type: "text", text: "Interactive subagents are currently supported only in single mode." }],
details: makeDetails("parallel")([]),
isError: true,
};
}
if (hasChain && params.chain?.some((step) => findAgentConfig(agents, step.agent)?.interactive === true)) {
return {
content: [{ type: "text", text: "Interactive subagents are currently supported only in single mode." }],
details: makeDetails("chain")([]),
isError: true,
};
}
if (hasSingle) {
const selectedAgent = params.agent ? findAgentConfig(agents, params.agent) : undefined;
if (selectedAgent?.interactive === true && !ctx.hasUI) {
return {
content: [{ type: "text", text: "Interactive subagents require a UI-enabled session." }],
details: makeDetails("single")([]),
isError: true,
};
}
}
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
const requestedAgentNames = new Set<string>();
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
if (params.tasks) for (const task of params.tasks) requestedAgentNames.add(task.agent);
if (params.agent) requestedAgentNames.add(params.agent);
const projectAgentsRequested = Array.from(requestedAgentNames)
.map((name) => agents.find((a) => a.name === name))
.filter((agent): agent is AgentConfig => agent?.source === "project");
if (projectAgentsRequested.length > 0) {
const names = projectAgentsRequested.map((a) => a.name).join(", ");
const dir = discovery.projectAgentsDir ?? "(unknown)";
const ok = await ctx.ui.confirm(
"Run project-local agents?",
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Continue only for trusted repositories.`,
);
if (!ok) {
return {
content: [{ type: "text", text: "Canceled: project-local agents were not approved." }],
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
};
}
}
}
if (params.chain && params.chain.length > 0) {
const results: SingleResult[] = [];
let previousOutput = "";
for (let i = 0; i < params.chain.length; i++) {
const step = params.chain[i];
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
const chainUpdate: OnUpdateCallback | undefined = onUpdate
? (partial) => {
const currentResult = partial.details?.results[0];
if (!currentResult) return;
onUpdate({
content: partial.content,
details: makeDetails("chain")([...results, currentResult]),
});
}
: undefined;
const result = await runSingleAgent(
ctx.cwd,
agents,
step.agent,
taskWithContext,
step.cwd,
i + 1,
signal,
chainUpdate,
makeDetails("chain"),
);
results.push(result);
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) {
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
return {
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
details: makeDetails("chain")(results),
isError: true,
};
}
previousOutput = getFinalOutput(result.messages);
}
return {
content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
details: makeDetails("chain")(results),
};
}
if (params.tasks && params.tasks.length > 0) {
if (params.tasks.length > MAX_PARALLEL_TASKS) {
return {
content: [{ type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.` }],
details: makeDetails("parallel")([]),
};
}
const allResults: SingleResult[] = new Array(params.tasks.length);
for (let i = 0; i < params.tasks.length; i++) {
allResults[i] = {
agent: params.tasks[i].agent,
agentSource: "unknown",
task: params.tasks[i].task,
exitCode: -1,
messages: [],
stderr: "",
usage: createEmptyUsageStats(),
};
}
const emitParallelUpdate = () => {
if (!onUpdate) return;
const running = allResults.filter((r) => r.exitCode === -1).length;
const done = allResults.filter((r) => r.exitCode !== -1).length;
onUpdate({
content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
details: makeDetails("parallel")([...allResults]),
});
};
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (task, index) => {
const result = await runSingleAgent(
ctx.cwd,
agents,
task.agent,
task.task,
task.cwd,
undefined,
signal,
(partial) => {
if (partial.details?.results[0]) {
allResults[index] = partial.details.results[0];
emitParallelUpdate();
}
},
makeDetails("parallel"),
);
allResults[index] = result;
emitParallelUpdate();
return result;
});
const successCount = results.filter((r) => r.exitCode === 0).length;
const summaries = results.map((r) => {
const output = getFinalOutput(r.messages);
const preview = output.slice(0, 100) + (output.length > 100 ? "..." : "");
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
});
return {
content: [{ type: "text", text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}` }],
details: makeDetails("parallel")(results),
};
}
if (params.agent && params.task) {
const selectedAgent = findAgentConfig(agents, params.agent);
const result = selectedAgent?.interactive
? await runInteractiveAgentRpc(
ctx.cwd,
agents,
params.agent,
params.task,
params.cwd,
undefined,
signal,
onUpdate as OnUpdateCallback | undefined,
makeDetails("single"),
ctx,
)
: await runSingleAgent(
ctx.cwd,
agents,
params.agent,
params.task,
params.cwd,
undefined,
signal,
onUpdate as OnUpdateCallback | undefined,
makeDetails("single"),
);
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) {
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
return {
content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
details: makeDetails("single")([result]),
isError: true,
};
}
return {
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
details: makeDetails("single")([result]),
};
}
return {
content: [{ type: "text", text: `Invalid parameters. Available agents: ${availableAgents.text}` }],
details: makeDetails("single")([]),
};
},
renderCall(args, theme) {
const scope: AgentScope = args.agentScope ?? "user";
if (args.chain && args.chain.length > 0) {
let text =
theme.fg("toolTitle", theme.bold("subagent ")) +
theme.fg("accent", `chain (${args.chain.length} steps)`) +
theme.fg("muted", ` [${scope}]`);
for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
const step = args.chain[i];
const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
text += `\n ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("accent", step.agent)}${theme.fg("dim", ` ${preview}`)}`;
}
if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
return new Text(text, 0, 0);
}
if (args.tasks && args.tasks.length > 0) {
let text =
theme.fg("toolTitle", theme.bold("subagent ")) +
theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
theme.fg("muted", ` [${scope}]`);
for (const task of args.tasks.slice(0, 3)) {
const preview = task.task.length > 40 ? `${task.task.slice(0, 40)}...` : task.task;
text += `\n ${theme.fg("accent", task.agent)}${theme.fg("dim", ` ${preview}`)}`;
}
if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
return new Text(text, 0, 0);
}
const agentName = args.agent || "...";
const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
let text = theme.fg("toolTitle", theme.bold("subagent ")) + theme.fg("accent", agentName) + theme.fg("muted", ` [${scope}]`);
text += `\n ${theme.fg("dim", preview)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as SubagentDetails | undefined;
if (!details || details.results.length === 0) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
}
const mdTheme = getMarkdownTheme();
const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
const toShow = limit ? items.slice(-limit) : items;
const skipped = limit && items.length > limit ? items.length - limit : 0;
let text = "";
if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
for (const item of toShow) {
if (item.type === "text") {
const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
text += `${theme.fg("toolOutput", preview)}\n`;
} else {
text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
}
}
return text.trimEnd();
};
const aggregateUsage = (results: SingleResult[]) => {
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
for (const item of results) {
total.input += item.usage.input;
total.output += item.usage.output;
total.cacheRead += item.usage.cacheRead;
total.cacheWrite += item.usage.cacheWrite;
total.cost += item.usage.cost;
total.turns += item.usage.turns;
}
return total;
};
if (details.mode === "single" && details.results.length === 1) {
const item = details.results[0];
const isError = item.exitCode !== 0 || item.stopReason === "error" || item.stopReason === "aborted";
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
const displayItems = getDisplayItems(item.messages);
const finalOutput = getFinalOutput(item.messages);
if (expanded) {
const container = new Container();
let header = `${icon} ${theme.fg("toolTitle", theme.bold(item.agent))}${theme.fg("muted", ` (${item.agentSource})`)}`;
if (isError && item.stopReason) header += ` ${theme.fg("error", `[${item.stopReason}]`)}`;
container.addChild(new Text(header, 0, 0));
if (isError && item.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${item.errorMessage}`), 0, 0));
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
container.addChild(new Text(theme.fg("dim", item.task), 0, 0));
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
if (displayItems.length === 0 && !finalOutput) {
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
} else {
for (const displayItem of displayItems) {
if (displayItem.type === "toolCall") {
container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(displayItem.name, displayItem.args, theme.fg.bind(theme)), 0, 0));
}
}
if (finalOutput) {
container.addChild(new Spacer(1));
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
}
}
const usageStr = formatUsageStats(item.usage, item.model);
if (usageStr) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
}
return container;
}
let text = `${icon} ${theme.fg("toolTitle", theme.bold(item.agent))}${theme.fg("muted", ` (${item.agentSource})`)}`;
if (isError && item.stopReason) text += ` ${theme.fg("error", `[${item.stopReason}]`)}`;
if (isError && item.errorMessage) text += `\n${theme.fg("error", `Error: ${item.errorMessage}`)}`;
else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
else {
text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
}
const usageStr = formatUsageStats(item.usage, item.model);
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
return new Text(text, 0, 0);
}
if (details.mode === "chain") {
const successCount = details.results.filter((r) => r.exitCode === 0).length;
const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
if (expanded) {
const container = new Container();
container.addChild(new Text(icon + " " + theme.fg("toolTitle", theme.bold("chain ")) + theme.fg("accent", `${successCount}/${details.results.length} steps`), 0, 0));
for (const item of details.results) {
const itemIcon = item.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
const displayItems = getDisplayItems(item.messages);
const finalOutput = getFinalOutput(item.messages);
container.addChild(new Spacer(1));
container.addChild(new Text(`${theme.fg("muted", `─── Step ${item.step}: `)}${theme.fg("accent", item.agent)} ${itemIcon}`, 0, 0));
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", item.task), 0, 0));
for (const displayItem of displayItems) {
if (displayItem.type === "toolCall") {
container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(displayItem.name, displayItem.args, theme.fg.bind(theme)), 0, 0));
}
}
if (finalOutput) {
container.addChild(new Spacer(1));
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
}
const usageStr = formatUsageStats(item.usage, item.model);
if (usageStr) container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
}
const totalUsageStr = formatUsageStats(aggregateUsage(details.results));
if (totalUsageStr) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", `Total: ${totalUsageStr}`), 0, 0));
}
return container;
}
let text = icon + " " + theme.fg("toolTitle", theme.bold("chain ")) + theme.fg("accent", `${successCount}/${details.results.length} steps`);
for (const item of details.results) {
const itemIcon = item.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
const displayItems = getDisplayItems(item.messages);
text += `\n\n${theme.fg("muted", `─── Step ${item.step}: `)}${theme.fg("accent", item.agent)} ${itemIcon}`;
if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
else text += `\n${renderDisplayItems(displayItems, 5)}`;
}
const totalUsageStr = formatUsageStats(aggregateUsage(details.results));
if (totalUsageStr) text += `\n\n${theme.fg("dim", `Total: ${totalUsageStr}`)}`;
text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
return new Text(text, 0, 0);
}
if (details.mode === "parallel") {
const running = details.results.filter((r) => r.exitCode === -1).length;
const successCount = details.results.filter((r) => r.exitCode === 0).length;
const failCount = details.results.filter((r) => r.exitCode > 0).length;
const isRunning = running > 0;
const icon = isRunning ? theme.fg("warning", "⏳") : failCount > 0 ? theme.fg("warning", "◐") : theme.fg("success", "✓");
const status = isRunning ? `${successCount + failCount}/${details.results.length} done, ${running} running` : `${successCount}/${details.results.length} tasks`;
if (expanded && !isRunning) {
const container = new Container();
container.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`, 0, 0));
for (const item of details.results) {
const itemIcon = item.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
const displayItems = getDisplayItems(item.messages);
const finalOutput = getFinalOutput(item.messages);
container.addChild(new Spacer(1));
container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", item.agent)} ${itemIcon}`, 0, 0));
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", item.task), 0, 0));
for (const displayItem of displayItems) {
if (displayItem.type === "toolCall") {
container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(displayItem.name, displayItem.args, theme.fg.bind(theme)), 0, 0));
}
}
if (finalOutput) {
container.addChild(new Spacer(1));
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
}
const usageStr = formatUsageStats(item.usage, item.model);
if (usageStr) container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
}
const totalUsageStr = formatUsageStats(aggregateUsage(details.results));
if (totalUsageStr) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", `Total: ${totalUsageStr}`), 0, 0));
}
return container;
}
let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
for (const item of details.results) {
const itemIcon = item.exitCode === -1 ? theme.fg("warning", "⏳") : item.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
const displayItems = getDisplayItems(item.messages);
text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", item.agent)} ${itemIcon}`;
if (displayItems.length === 0) text += `\n${theme.fg("muted", item.exitCode === -1 ? "(running...)" : "(no output)")}`;
else text += `\n${renderDisplayItems(displayItems, 5)}`;
}
if (!isRunning) {
const totalUsageStr = formatUsageStats(aggregateUsage(details.results));
if (totalUsageStr) text += `\n\n${theme.fg("dim", `Total: ${totalUsageStr}`)}`;
}
if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
return new Text(text, 0, 0);
}
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
},
});
}

View file

@ -1,130 +0,0 @@
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
const AskUserParams = Type.Object({
mode: Type.Optional(
StringEnum(["input", "confirm", "select", "editor"] as const, {
description: "Question mode",
default: "input",
}),
),
question: Type.String({ description: "Question title to show the user" }),
message: Type.Optional(Type.String({ description: "Optional body text (confirm mode)" })),
placeholder: Type.Optional(Type.String({ description: "Placeholder text (input mode)" })),
options: Type.Optional(Type.Array(Type.String(), { description: "Options (select mode)" })),
prefill: Type.Optional(Type.String({ description: "Prefilled text (editor mode)" })),
});
function parseToolNames(raw: string | undefined): string[] {
if (!raw) return [];
const value = raw.trim();
if (!value) return [];
if (value.startsWith("[")) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.map((entry) => String(entry).trim()).filter(Boolean);
}
} catch {
// ignore invalid JSON and fall back to comma-separated parsing
}
}
return value
.split(",")
.map((tool) => tool.trim())
.filter(Boolean);
}
export default function interactiveTools(pi: ExtensionAPI) {
pi.registerTool({
name: "ask_user",
label: "Ask User",
description:
"Ask the user an interactive clarification question. Supports input, confirm, select, and editor modes.",
parameters: AskUserParams,
promptGuidelines: [
"Use ask_user only when required information is missing or ambiguous.",
"Ask one focused question at a time and continue once answered.",
],
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
return {
content: [{ type: "text", text: "Error: ask_user requires an interactive UI." }],
isError: true,
details: { mode: params.mode ?? "input", cancelled: true },
};
}
const mode = params.mode ?? "input";
if (mode === "confirm") {
const confirmed = await ctx.ui.confirm(params.question, params.message ?? "Please confirm.");
return {
content: [{ type: "text", text: `User confirmation: ${confirmed ? "yes" : "no"}` }],
details: { mode, confirmed },
};
}
if (mode === "select") {
const options = (params.options ?? []).map((option) => option.trim()).filter(Boolean);
if (options.length === 0) {
return {
content: [{ type: "text", text: "Error: select mode requires a non-empty options array." }],
isError: true,
details: { mode, cancelled: true },
};
}
const value = await ctx.ui.select(params.question, options);
if (value === undefined) {
return {
content: [{ type: "text", text: "User cancelled the selection." }],
details: { mode, cancelled: true },
};
}
return {
content: [{ type: "text", text: `User selected: ${value}` }],
details: { mode, value, cancelled: false },
};
}
if (mode === "editor") {
const value = await ctx.ui.editor(params.question, params.prefill ?? "");
if (value === undefined) {
return {
content: [{ type: "text", text: "User cancelled editor input." }],
details: { mode, cancelled: true },
};
}
return {
content: [{ type: "text", text: `User input: ${value}` }],
details: { mode, value, cancelled: false },
};
}
const value = await ctx.ui.input(params.question, params.placeholder);
if (value === undefined) {
return {
content: [{ type: "text", text: "User cancelled input." }],
details: { mode: "input", cancelled: true },
};
}
return {
content: [{ type: "text", text: `User input: ${value}` }],
details: { mode: "input", value, cancelled: false },
};
},
});
pi.on("session_start", () => {
const requestedTools = parseToolNames(process.env.PI_SUBAGENT_TOOLS);
if (requestedTools.length === 0) return;
const available = new Set(pi.getAllTools().map((tool) => tool.name));
const validTools = requestedTools.filter((tool) => available.has(tool));
if (validTools.length > 0) {
pi.setActiveTools(validTools);
}
});
}

View file

@ -1,40 +0,0 @@
import { StringDecoder } from "node:string_decoder";
export function serializeJsonLine(value: unknown): string {
return `${JSON.stringify(value)}\n`;
}
export function attachJsonlLineReader(stream: NodeJS.ReadableStream, onLine: (line: string) => void): () => void {
const decoder = new StringDecoder("utf8");
let buffer = "";
const emitLine = (line: string) => {
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
};
const onData = (chunk: Buffer | string) => {
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
while (true) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) return;
emitLine(buffer.slice(0, newlineIndex));
buffer = buffer.slice(newlineIndex + 1);
}
};
const onEnd = () => {
buffer += decoder.end();
if (buffer.length > 0) {
emitLine(buffer);
buffer = "";
}
};
stream.on("data", onData);
stream.on("end", onEnd);
return () => {
stream.off("data", onData);
stream.off("end", onEnd);
};
}

View file

@ -1,11 +0,0 @@
{
"name": "pi-subagent-roles",
"private": true,
"version": "0.1.0",
"type": "module",
"pi": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -1,336 +0,0 @@
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import type { AgentConfig } from "./agents.js";
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
import {
applyAssistantUsage,
createEmptyUsageStats,
getFinalOutput,
type OnUpdateCallback,
type SingleResult,
type SubagentDetails,
} from "./subagent-types.js";
interface RpcResponse {
type: "response";
id?: string;
command: string;
success: boolean;
error?: string;
}
interface PendingRequest {
resolve: (response: RpcResponse) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
type RpcExtensionUIRequest =
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
| { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number }
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
| { type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" }
| { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText?: string }
| {
type: "extension_ui_request";
id: string;
method: "setWidget";
widgetKey: string;
widgetLines?: string[];
widgetPlacement?: "aboveEditor" | "belowEditor";
}
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-rpc-"));
const safeName = agentName.replace(/[^\w.-]+/g, "_");
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
return { dir: tmpDir, filePath };
}
function terminateProcess(proc: ReturnType<typeof spawn>): void {
if (proc.exitCode !== null) return;
proc.kill("SIGTERM");
setTimeout(() => {
if (proc.exitCode === null) proc.kill("SIGKILL");
}, 3000);
}
function safeJsonParse(line: string): any | undefined {
if (!line.trim()) return undefined;
try {
return JSON.parse(line);
} catch {
return undefined;
}
}
async function bridgeExtensionUiRequest(
request: RpcExtensionUIRequest,
ctx: ExtensionContext,
signal: AbortSignal | undefined,
sendJson: (obj: unknown) => void,
): Promise<void> {
const sendCancelled = () => sendJson({ type: "extension_ui_response", id: request.id, cancelled: true });
try {
switch (request.method) {
case "select": {
const value = await ctx.ui.select(request.title, request.options, { signal, timeout: request.timeout });
if (value === undefined) sendCancelled();
else sendJson({ type: "extension_ui_response", id: request.id, value });
return;
}
case "confirm": {
const confirmed = await ctx.ui.confirm(request.title, request.message, { signal, timeout: request.timeout });
sendJson({ type: "extension_ui_response", id: request.id, confirmed });
return;
}
case "input": {
const value = await ctx.ui.input(request.title, request.placeholder, { signal, timeout: request.timeout });
if (value === undefined) sendCancelled();
else sendJson({ type: "extension_ui_response", id: request.id, value });
return;
}
case "editor": {
const value = await ctx.ui.editor(request.title, request.prefill ?? "");
if (value === undefined) sendCancelled();
else sendJson({ type: "extension_ui_response", id: request.id, value });
return;
}
case "notify":
ctx.ui.notify(request.message, request.notifyType);
return;
case "setStatus":
ctx.ui.setStatus(request.statusKey, request.statusText);
return;
case "setWidget":
if (request.widgetLines === undefined || Array.isArray(request.widgetLines)) {
ctx.ui.setWidget(request.widgetKey, request.widgetLines, { placement: request.widgetPlacement });
}
return;
case "setTitle":
ctx.ui.setTitle(request.title);
return;
case "set_editor_text":
ctx.ui.setEditorText(request.text);
return;
}
} catch {
if (request.method === "select" || request.method === "confirm" || request.method === "input" || request.method === "editor") {
sendCancelled();
}
}
}
export async function runInteractiveAgentRpc(
defaultCwd: string,
agents: AgentConfig[],
agentName: string,
task: string,
cwd: string | undefined,
step: number | undefined,
signal: AbortSignal | undefined,
onUpdate: OnUpdateCallback | undefined,
makeDetails: (results: SingleResult[]) => SubagentDetails,
ctx: ExtensionContext,
): Promise<SingleResult> {
const agent = agents.find((entry) => entry.name === agentName);
if (!agent) {
const available = agents.map((entry) => `"${entry.name}"`).join(", ") || "none";
return {
agent: agentName,
agentSource: "unknown",
task,
exitCode: 1,
messages: [],
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
usage: createEmptyUsageStats(),
step,
};
}
const args: string[] = ["--mode", "rpc", "--no-session"];
if (agent.model) args.push("--model", agent.model);
let tmpPromptDir: string | null = null;
let tmpPromptPath: string | null = null;
if (agent.systemPrompt.trim()) {
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
tmpPromptDir = tmp.dir;
tmpPromptPath = tmp.filePath;
args.push("--append-system-prompt", tmpPromptPath);
}
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const helperExtensionPath = path.join(extensionDir, "interactive-tools.ts");
args.push("--extension", helperExtensionPath);
const env = { ...process.env };
if (agent.tools && agent.tools.length > 0) {
env.PI_SUBAGENT_TOOLS = JSON.stringify(agent.tools);
}
const currentResult: SingleResult = {
agent: agentName,
agentSource: agent.source,
task,
exitCode: 0,
messages: [],
stderr: "",
usage: createEmptyUsageStats(),
model: agent.model,
step,
};
const emitUpdate = () => {
if (!onUpdate) return;
onUpdate({
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
details: makeDetails([currentResult]),
});
};
const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env });
const pendingRequests = new Map<string, PendingRequest>();
let requestCount = 0;
let agentEnded = false;
let resolveAgentEnd: (() => void) | undefined;
const agentEndPromise = new Promise<void>((resolve) => {
resolveAgentEnd = resolve;
});
const processExitPromise = new Promise<number>((resolve) => {
proc.on("close", (code) => {
if (!agentEnded) resolveAgentEnd?.();
for (const [id, pending] of pendingRequests.entries()) {
clearTimeout(pending.timeout);
pending.reject(new Error(`RPC process exited before response: ${id}`));
}
pendingRequests.clear();
resolve(code ?? 0);
});
});
let wasAborted = false;
const sendJson = (obj: unknown) => {
if (proc.stdin.destroyed || proc.stdin.writableEnded) return;
proc.stdin.write(serializeJsonLine(obj));
};
const sendCommand = (command: Record<string, unknown>): Promise<RpcResponse> => {
const id = `subagent_${++requestCount}`;
return new Promise<RpcResponse>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`RPC command timed out: ${command.type}`));
}, 30000);
pendingRequests.set(id, { resolve, reject, timeout });
sendJson({ ...command, id });
});
};
const detachStdout = attachJsonlLineReader(proc.stdout, (line) => {
const event = safeJsonParse(line);
if (!event) {
currentResult.stderr += `[rpc] Ignored non-JSON output: ${line}\n`;
return;
}
if (event.type === "response" && event.id && pendingRequests.has(event.id)) {
const pending = pendingRequests.get(event.id)!;
pendingRequests.delete(event.id);
clearTimeout(pending.timeout);
pending.resolve(event as RpcResponse);
return;
}
if (event.type === "extension_ui_request") {
void bridgeExtensionUiRequest(event as RpcExtensionUIRequest, ctx, signal, sendJson);
return;
}
if (event.type === "message_end" && event.message) {
const msg = event.message;
currentResult.messages.push(msg);
applyAssistantUsage(currentResult, msg);
emitUpdate();
return;
}
if (event.type === "agent_end") {
agentEnded = true;
resolveAgentEnd?.();
return;
}
if (event.type === "extension_error") {
const extensionPath = typeof event.extensionPath === "string" ? event.extensionPath : "unknown";
const error = typeof event.error === "string" ? event.error : "unknown extension error";
currentResult.stderr += `[extension:${extensionPath}] ${error}\n`;
}
});
proc.stderr.on("data", (data) => {
currentResult.stderr += data.toString();
});
proc.on("error", (error) => {
currentResult.stderr += `${error.message}\n`;
});
const abortHandler = () => {
wasAborted = true;
try {
sendJson({ type: "abort" });
} catch {}
terminateProcess(proc);
};
if (signal) {
if (signal.aborted) abortHandler();
else signal.addEventListener("abort", abortHandler, { once: true });
}
try {
const promptResponse = await sendCommand({ type: "prompt", message: `Task: ${task}` });
if (!promptResponse.success) {
currentResult.exitCode = 1;
currentResult.stderr += `Prompt failed: ${promptResponse.error || "unknown error"}`;
return currentResult;
}
await agentEndPromise;
if (currentResult.stopReason === "error") currentResult.exitCode = 1;
if (wasAborted) throw new Error("Subagent was aborted");
return currentResult;
} finally {
detachStdout();
for (const [id, pending] of pendingRequests.entries()) {
clearTimeout(pending.timeout);
pending.reject(new Error(`RPC command interrupted: ${id}`));
}
pendingRequests.clear();
if (signal) signal.removeEventListener("abort", abortHandler);
terminateProcess(proc);
const processCode = await processExitPromise;
if (!wasAborted && currentResult.exitCode === 0 && processCode !== 0 && !agentEnded) {
currentResult.exitCode = processCode;
}
if (tmpPromptPath) {
try {
fs.unlinkSync(tmpPromptPath);
} catch {}
}
if (tmpPromptDir) {
try {
fs.rmdirSync(tmpPromptDir);
} catch {}
}
}
}

View file

@ -1,90 +0,0 @@
import type { Message } from "@mariozechner/pi-ai";
import type { AgentScope } from "./agents.js";
export interface UsageStats {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextTokens: number;
turns: number;
}
export interface SingleResult {
agent: string;
agentSource: "user" | "project" | "unknown";
task: string;
exitCode: number;
messages: Message[];
stderr: string;
usage: UsageStats;
model?: string;
stopReason?: string;
errorMessage?: string;
step?: number;
}
export interface SubagentDetails {
mode: "single" | "parallel" | "chain";
agentScope: AgentScope;
projectAgentsDir: string | null;
results: SingleResult[];
}
export type ToolContent = Array<{ type: "text"; text: string }>;
export type ToolUpdate = { content: ToolContent; details?: SubagentDetails; isError?: boolean };
export type OnUpdateCallback = (partial: ToolUpdate) => void;
export type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
export function createEmptyUsageStats(): UsageStats {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: 0,
contextTokens: 0,
turns: 0,
};
}
export function applyAssistantUsage(result: SingleResult, msg: Message): void {
if (msg.role !== "assistant") return;
result.usage.turns++;
const usage = msg.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;
result.usage.contextTokens = usage.totalTokens || 0;
}
if (!result.model && msg.model) result.model = msg.model;
if (msg.stopReason) result.stopReason = msg.stopReason;
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
}
export function getFinalOutput(messages: Message[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
for (const part of msg.content) {
if (part.type === "text") return part.text;
}
}
return "";
}
export function getDisplayItems(messages: Message[]): DisplayItem[] {
const items: DisplayItem[] = [];
for (const msg of messages) {
if (msg.role !== "assistant") continue;
for (const part of msg.content) {
if (part.type === "text") items.push({ type: "text", text: part.text });
else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
}
}
return items;
}