feat: integrate pi rust lab runtime

This commit is contained in:
Agent Board
2026-07-10 10:54:34 +08:00
parent c8472bb898
commit 896c94b696
73 changed files with 19852 additions and 1152 deletions
@@ -0,0 +1,514 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
type ExtensionContext = {
hasUI?: boolean;
ui?: {
confirm?: (title: string, message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
};
type ExtensionAPI = {
registerTool: (spec: Record<string, unknown>) => void;
on?: (
eventName: string,
handler: (event: Record<string, unknown>) => Record<string, unknown> | void,
) => void;
};
type MnoteToolManifestEntry = {
piName: string;
mnoteName: string;
label: string;
description: string;
};
const DEFAULT_TOOLS: MnoteToolManifestEntry[] = [
{
piName: "mnote_current_page_read",
mnoteName: "mnote.current_page.read",
label: "MNote current page read",
description: "Read the current MNote page through MNote access scope.",
},
{
piName: "mnote_selection_read",
mnoteName: "mnote.selection.read",
label: "MNote selection read",
description: "Read the current MNote editor selection snapshot supplied by MNote.",
},
{
piName: "mnote_allowed_roots_describe",
mnoteName: "mnote.allowed_roots.describe",
label: "MNote allowed roots describe",
description: "Describe MNote allowed roots and disabled raw tools.",
},
{
piName: "mnote_local_file_read",
mnoteName: "mnote.local_file.read",
label: "MNote local file read",
description: "Read a file only through MNote allowed roots.",
},
{
piName: "mnote_local_file_patch",
mnoteName: "mnote.local_file.patch",
label: "MNote local file patch",
description: "Patch a file only through MNote allowed roots and watcher refresh.",
},
{
piName: "mnote_knowledge_rag_status",
mnoteName: "mnote.knowledge_rag.status",
label: "MNote LightRAG status",
description: "Check the MNote LightRAG knowledge provider status, indexed source registry, dashboard URL, and sync state. Use before knowledge-base questions when availability is uncertain. Params: optional workspaceId/rootUri; MNote fills the current Pi session context when omitted.",
},
{
piName: "mnote_knowledge_rag_query",
mnoteName: "mnote.knowledge_rag.query",
label: "MNote LightRAG query",
description: "Ask the MNote LightRAG knowledge library across indexed books, papers, Office files, PDFs, images, and attachments. Use this for knowledge-base questions and answers that require sources, citations, or evidence. For book or long-document questions, pass query, mode='naive' or 'mix', topK, chunkTopK, includeChunkContent=true, and includeDocumentStructureIndex=true. Use returned references/citations quotes as evidence; do not invent page numbers or hand-write /documents/mnote:// links.",
},
{
piName: "mnote_knowledge_rag_section_context",
mnoteName: "mnote.knowledge_rag.section_context",
label: "MNote LightRAG section context",
description: "Read bounded section blocks/chunks from a LightRAG sidecar using documentStructureIndex ranges returned by mnote_knowledge_rag_query. Use this for second-pass reading of large books or long documents when query references are not enough. Params include sourcePath/sourceId/lightRagDocId/filePath/sectionId, block or paragraph ordinal range, contextBefore/contextAfter, maxBlocks, maxChars.",
},
{
piName: "mnote_knowledge_rag_open_reference",
mnoteName: "mnote.knowledge_rag.open_reference",
label: "MNote LightRAG open reference",
description: "Convert a LightRAG reference, filePath, or chunkId returned by mnote_knowledge_rag_query into a MNote clickable local resource locator. Use when the user asks to open or verify a cited source.",
},
{
piName: "mnote_reference_open",
mnoteName: "mnote.reference.open",
label: "MNote reference open",
description: "Legacy alias for opening a citation/reference through MNote mapping. Prefer mnote_knowledge_rag_open_reference for LightRAG references.",
},
{
piName: "mnote_codex_rescue_request",
mnoteName: "mnote.codex_rescue.request",
label: "MNote Codex rescue",
description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.",
},
{
piName: "mnote_tool_receipt_write",
mnoteName: "mnote.tool_receipt.write",
label: "MNote tool receipt write",
description: "Write a provider-neutral MNote tool receipt.",
},
];
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");
const CONTEXT_FILE = env("PI_MNOTE_CONTEXT_FILE")
|| (fs.existsSync(DEFAULT_CONTEXT_FILE) ? DEFAULT_CONTEXT_FILE : "");
const RUNTIME_IMPL = env("PI_MNOTE_RUNTIME_IMPL")
|| env("MNOTE_PI_RUNTIME_IMPL")
|| (CONTEXT_FILE ? "pi-rust" : "");
const BASE_URL = env("PI_MNOTE_BRIDGE_BASE_URL") || env("MNOTE_PI_BRIDGE_BASE_URL") || env("MNOTE_PI_LAB_BASE_URL") || "http://127.0.0.1:3000";
const SESSION_ID = env("PI_MNOTE_BRIDGE_SESSION_ID") || env("MNOTE_PI_BRIDGE_SESSION_ID") || env("MNOTE_PI_LAB_SESSION_ID") || "";
const BRIDGE_TOKEN = env("PI_MNOTE_BRIDGE_TOKEN") || env("MNOTE_PI_BRIDGE_TOKEN") || env("MNOTE_PI_LAB_BRIDGE_TOKEN") || "";
const HTTP_BRIDGE_AVAILABLE = env("PI_MNOTE_HTTP_BRIDGE_AVAILABLE") === "1";
const TOOL_POLICIES = parseJsonRecord(env("PI_MNOTE_BRIDGE_TOOL_POLICIES") || env("MNOTE_PI_BRIDGE_TOOL_POLICIES"), {});
const TOOLS = normalizeTools(parseJsonUnknown(env("PI_MNOTE_BRIDGE_TOOLS") || env("MNOTE_PI_BRIDGE_TOOLS")) ?? DEFAULT_TOOLS);
const CONTEXT_PREFIX = "[[MNOTE_PI_CONTEXT_V1:";
let liveContext: Record<string, unknown> | undefined;
function env(name: string): string {
return (process.env[name] || "").trim();
}
function parseJsonUnknown(raw: string): unknown {
if (!raw) return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function parseJsonRecord(raw: string, fallback: Record<string, unknown>): Record<string, unknown> {
const parsed = parseJsonUnknown(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: fallback;
}
function normalizeTools(value: unknown): MnoteToolManifestEntry[] {
if (!Array.isArray(value)) return DEFAULT_TOOLS;
const tools = value
.map((entry) => {
if (!entry || typeof entry !== "object") return undefined;
const record = entry as Record<string, unknown>;
const piName = stringField(record, "piName") || stringField(record, "pi_name");
const mnoteName = stringField(record, "mnoteName") || stringField(record, "mnote_name");
const label = stringField(record, "label") || piName;
const description = stringField(record, "description") || label;
if (!piName || !mnoteName) return undefined;
return { piName, mnoteName, label, description };
})
.filter((entry): entry is MnoteToolManifestEntry => Boolean(entry));
return tools.length ? tools : DEFAULT_TOOLS;
}
function stringField(record: Record<string, unknown>, key: string): string {
const value = record[key];
return typeof value === "string" ? value.trim() : "";
}
function toolResult(payload: Record<string, unknown>, isError = false) {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
isError,
};
}
function decodeHexJson(value: string): Record<string, unknown> {
if (!value || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) {
throw new Error("MNote Pi context hex 无效");
}
let jsonText = "";
for (let index = 0; index < value.length; index += 2) {
jsonText += String.fromCharCode(Number.parseInt(value.slice(index, index + 2), 16));
}
const parsed = JSON.parse(decodeURIComponent(escape(jsonText)));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("MNote Pi context payload 格式无效");
}
return parsed as Record<string, unknown>;
}
function captureInputContext(event: Record<string, unknown>) {
const text = typeof event.text === "string" ? event.text : "";
const start = text.indexOf(CONTEXT_PREFIX);
if (start < 0) return { action: "continue" };
const end = text.indexOf("]]\n", start);
if (end < 0) return { action: "continue" };
const encoded = text.slice(start + CONTEXT_PREFIX.length, end);
try {
liveContext = decodeHexJson(encoded);
} catch (error) {
liveContext = {
contextError: error instanceof Error ? error.message : String(error),
};
}
return {
action: "transform",
text: `${text.slice(0, start)}${text.slice(end + 3)}`,
images: event.images,
};
}
function readContextSnapshot(): Record<string, unknown> {
if (liveContext) return liveContext;
if (!CONTEXT_FILE) {
throw new Error("PI_MNOTE_CONTEXT_FILE 未配置");
}
const raw = fs.readFileSync(CONTEXT_FILE, "utf8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("MNote Pi 上下文文件格式无效");
}
return parsed as Record<string, unknown>;
}
function contextAllowedRoots(context: Record<string, unknown>): Record<string, unknown>[] {
const snapshot = context.allowedRoots;
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return [];
const roots = (snapshot as Record<string, unknown>).roots;
if (!Array.isArray(roots)) return [];
return roots.filter((root): root is Record<string, unknown> => (
Boolean(root) && typeof root === "object" && !Array.isArray(root)
));
}
function pathIsInside(target: string, root: string): boolean {
const relative = path.relative(root, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function resolveAllowedTarget(
context: Record<string, unknown>,
rootUri: string,
relativePath: string,
): { target: string; readPath: string } {
const roots = contextAllowedRoots(context);
const matchedRoot = roots.find((root) => stringField(root, "rootUri") === rootUri);
const rootPath = matchedRoot
? stringField(matchedRoot, "rootPath")
: stringField(context, "primaryRootPath") || process.cwd();
if (!rootPath) throw new Error("MNote allowed root 缺少 rootPath");
const canonicalRoot = fs.realpathSync(rootPath);
const requestedTarget = path.isAbsolute(relativePath)
? path.resolve(relativePath)
: path.resolve(canonicalRoot, relativePath);
const canonicalTarget = fs.realpathSync(requestedTarget);
const allowedRoots = roots
.map((root) => stringField(root, "rootPath"))
.filter(Boolean)
.map((root) => fs.realpathSync(root));
const candidates = allowedRoots.length ? allowedRoots : [canonicalRoot];
if (!candidates.some((root) => pathIsInside(canonicalTarget, root))) {
throw new Error(`路径超出 MNote allowed roots: ${relativePath}`);
}
const processRoot = fs.realpathSync(process.cwd());
const readPath = pathIsInside(canonicalTarget, processRoot)
? path.relative(processRoot, canonicalTarget) || "."
: canonicalTarget;
return { target: canonicalTarget, readPath };
}
function executeNativeCurrentPageRead(params: unknown) {
try {
const input = params && typeof params === "object" && !Array.isArray(params)
? params as Record<string, unknown>
: {};
const context = readContextSnapshot();
const rootUri = stringField(context, "rootUri")
|| stringField(input, "rootUri")
|| stringField(input, "root_uri");
const pagePath = stringField(context, "pagePath")
|| stringField(input, "pagePath")
|| stringField(input, "page_path")
|| stringField(input, "path");
if (!rootUri) throw new Error("读取当前页缺少 rootUri");
if (!pagePath) throw new Error("读取当前页缺少 pagePath");
const resolved = resolveAllowedTarget(context, rootUri, pagePath);
const content = fs.readFileSync(resolved.readPath, "utf8");
const stat = fs.statSync(resolved.readPath);
return toolResult({
ok: true,
rootUri,
pagePath,
path: resolved.target,
content,
contentLength: content.length,
format: "markdown",
fileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_current_page_read_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
}, true);
}
}
function executeNativeSelectionRead() {
try {
const context = readContextSnapshot();
return toolResult({
ok: true,
selection: context.selectedContext ?? null,
selectionSource: "mnote_sidebar_host_snapshot",
rootUri: context.rootUri ?? null,
pagePath: context.pagePath ?? null,
contextRefs: context.contextRefs ?? [],
transport: "pi-rust-native-context-file",
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_selection_read_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-context-file",
}, true);
}
}
function executeNativeAllowedRootsDescribe() {
try {
const context = readContextSnapshot();
return toolResult({
ok: true,
allowedRoots: context.allowedRoots ?? { roots: [] },
rootUri: context.rootUri ?? null,
pagePath: context.pagePath ?? null,
primaryRootPath: context.primaryRootPath ?? process.cwd(),
transport: "pi-rust-native-context-file",
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_allowed_roots_read_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-context-file",
}, true);
}
}
function executeNativeTool(tool: MnoteToolManifestEntry, params: unknown) {
if (runtimeImplementation() !== "pi-rust" || toolPolicy(tool.mnoteName) !== "allow") return undefined;
if (tool.mnoteName === "mnote.current_page.read") return executeNativeCurrentPageRead(params);
if (tool.mnoteName === "mnote.selection.read") return executeNativeSelectionRead();
if (tool.mnoteName === "mnote.allowed_roots.describe") return executeNativeAllowedRootsDescribe();
return undefined;
}
async function callMnote(toolName: string, params: unknown) {
const response = await fetch(`${BASE_URL}/api/page-ai/pi/tool-call-bridge`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN,
},
body: JSON.stringify({ sessionId: SESSION_ID, toolName, params: params || {} }),
});
const payload = await response.json().catch(() => ({ ok: false, code: "bad_json" }));
const text = JSON.stringify((payload as Record<string, unknown>).result || payload, null, 2);
return {
content: [{ type: "text", text }],
details: payload,
};
}
function stableJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
}
function stableHash(value: string): string {
let hash = 5381 >>> 0;
for (let index = 0; index < value.length; index += 1) {
hash = (((hash << 5) + hash) + value.charCodeAt(index)) >>> 0;
}
return hash.toString(16);
}
function paramsHash(params: unknown): string {
const copy = { ...((params || {}) as Record<string, unknown>) };
delete copy.mnoteApproval;
delete copy.mnote_approval;
return stableHash(stableJson(copy));
}
function toolPolicy(toolName: string): string {
const contextPolicies = liveContext?.toolPolicies;
const contextValue = contextPolicies && typeof contextPolicies === "object" && !Array.isArray(contextPolicies)
? (contextPolicies as Record<string, unknown>)[toolName]
: undefined;
const value = contextValue ?? TOOL_POLICIES[toolName];
return typeof value === "string" && value.trim() ? value.trim() : "allow";
}
function runtimeImplementation(): string {
return liveContext ? stringField(liveContext, "runtimeImplementation") || RUNTIME_IMPL : RUNTIME_IMPL;
}
async function requestMnoteApprovalViaBridge(request: Record<string, unknown>): Promise<Record<string, unknown>> {
const response = await fetch(`${BASE_URL}/api/page-ai/pi/ui-request-bridge`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN,
},
body: JSON.stringify({ sessionId: SESSION_ID, ...request }),
});
return await response.json().catch(() => ({ ok: false, cancelled: true, code: "bad_json" }));
}
async function requestMnoteApproval(
ctx: ExtensionContext | undefined,
toolCallId: string,
toolName: string,
label: string,
params: unknown,
): Promise<null | Record<string, unknown>> {
if (toolPolicy(toolName) !== "ask") return null;
const approvalId = toolCallId || `approval_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const approval = { approvalId, toolName, paramsHash: paramsHash(params) };
const summary = JSON.stringify(params || {}, null, 2).slice(0, 1200);
const message = `${label}\n${toolName}\n\n${summary}`;
if (ctx?.hasUI && ctx.ui && typeof ctx.ui.confirm === "function") {
const result = await ctx.ui.confirm("审批 Pi 工具调用", message, { timeout: 60000 });
if (result === true) return approval;
return { ...approval, denied: true };
}
const result = await requestMnoteApprovalViaBridge({
id: approvalId,
method: "confirm",
title: "审批 Pi 工具调用",
message,
timeoutMs: 60000,
mnoteApproval: approval,
});
if (result && result.confirmed === true) return approval;
return { ...approval, denied: true };
}
function register(pi: ExtensionAPI, tool: MnoteToolManifestEntry) {
pi.registerTool({
name: tool.piName,
label: tool.label,
description: tool.description,
promptSnippet: `${tool.label}: ${tool.description}`,
parameters: { type: "object", additionalProperties: true },
execute(toolCallId: unknown, params: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext | undefined) {
if (toolPolicy(tool.mnoteName) === "deny") {
return toolResult({
ok: false,
code: "mnote_tool_denied_by_permission_mode",
message: `当前 Pi 模式禁止调用工具 ${tool.mnoteName}`,
toolName: tool.mnoteName,
}, true);
}
const nativeResult = executeNativeTool(tool, params);
if (nativeResult) return nativeResult;
if (runtimeImplementation() === "pi-rust" && !HTTP_BRIDGE_AVAILABLE) {
return toolResult({
ok: false,
code: "mnote_pi_rust_service_bridge_unavailable",
message: `${tool.mnoteName} 仍依赖旧 HTTP bridge;Pi Rust 当前应通过原生文件工具或专用 MCP 扩展调用。`,
toolName: tool.mnoteName,
runtimeImplementation: runtimeImplementation(),
}, true);
}
return executeBridgeTool(toolCallId, params, ctx, tool);
},
});
}
async function executeBridgeTool(
toolCallId: unknown,
params: unknown,
ctx: ExtensionContext | undefined,
tool: MnoteToolManifestEntry,
) {
const nextParams = { ...((params || {}) as Record<string, unknown>) };
const approval = await requestMnoteApproval(ctx, String(toolCallId || ""), tool.mnoteName, tool.label, nextParams);
if (approval && approval.denied) {
return {
content: [{ type: "text", text: JSON.stringify({ ok: false, code: "mnote_tool_approval_cancelled", message: "用户拒绝或未完成 MNote 工具审批", toolName: tool.mnoteName }, null, 2) }],
details: { ok: false, code: "mnote_tool_approval_cancelled", toolName: tool.mnoteName },
};
}
if (toolPolicy(tool.mnoteName) === "ask") {
nextParams.mnoteApproval = {
...approval,
confirmed: true,
method: "extension_ui_confirm",
toolCallId: String(toolCallId || ""),
toolName: tool.mnoteName,
approvedAt: new Date().toISOString(),
};
}
return callMnote(tool.mnoteName, nextParams);
}
export default function mnotePi(pi: ExtensionAPI) {
pi.on?.("input", captureInputContext);
for (const tool of TOOLS) register(pi, tool);
}
@@ -0,0 +1,9 @@
{
"mcpServers": {
"example-filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"disabled": true
}
}
}
@@ -0,0 +1,521 @@
/**
* MNote MCP Client — 最小 MCP client,仅使用 Node 内置模块
*
* 支持协议:
* - stdio JSONL (child_process.spawn)
* - streamable-http POST (http/https 模块)
*
* 请求输入格式 (JSON)
* { "server": "name", "mode": "list|status|call",
* "tool": "toolName", "arguments": {} }
* 默认读取 argv[2] 指向的文件;argv[2] 为 "-" 时从 stdin 读取;
* argv[2] 为 "--request-json" 时直接读取 argv[3]。
*
* 输出:stdout 打印 JSONstderr 仅用于诊断
*
* 限制:不含 OAuth/UI/资源写入
*/
import { spawn } from "node:child_process";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { readFileSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
/* ============================================================
* 常量
* ============================================================ */
const MCP_VERSION = "2024-11-05";
const CLIENT_NAME = "mnote-mcp-client";
const CLIENT_VERSION = "0.1.0";
const REQUEST_TIMEOUT = 30_000;
const SSE_TIMEOUT = 60_000;
/* ============================================================
* 工具
* ============================================================ */
function resolveEnv(value) {
if (typeof value !== "string") return value;
return value.replace(/\$\{(\w+)\}/g, (_, key) => process.env[key] ?? "");
}
let _reqId = 0;
function nextId() { return ++_reqId; }
function errorResult(message, details = null) {
return { ok: false, error: message, details, timestamp: new Date().toISOString() };
}
function okResult(data) {
return { ok: true, data, timestamp: new Date().toISOString() };
}
function extractResult(resp) {
if (resp == null) throw new Error("Empty response from server");
if (resp.error) throw new Error(`JSON-RPC error: ${resp.error.message || JSON.stringify(resp.error)}`);
if (resp.result !== undefined) return resp.result;
return resp;
}
/* ============================================================
* 读取 MCP 配置
* ============================================================ */
function loadMcpConfig(extDir) {
const configPath = process.env.MNOTE_MCP_CONFIG_PATH || resolve(extDir, ".pi", "mcp.json");
if (!existsSync(configPath)) return { servers: {} };
try {
const raw = readFileSync(configPath, "utf-8").trim();
if (!raw) return { servers: {} };
const parsed = JSON.parse(raw);
const servers = parsed.mcpServers || parsed.servers || parsed;
if (typeof servers !== "object" || Array.isArray(servers)) {
return { servers: {}, _parseError: "Config root must be an object with mcpServers key" };
}
return { servers };
} catch (e) {
return { servers: {}, _parseError: e.message };
}
}
/* ============================================================
* stdio transport
* 行读取器 + 响应队列,支持 send(等响应)和 sendNotify(不等)
* ============================================================ */
function createStdioTransport(serverConfig, timeout = REQUEST_TIMEOUT) {
const cmd = resolveEnv(serverConfig.command);
if (!cmd) throw new Error("stdio transport requires command");
const args = (serverConfig.args || []).map(resolveEnv);
const env = serverConfig.env
? { ...process.env, ...Object.fromEntries(
Object.entries(serverConfig.env).map(([k, v]) => [k, resolveEnv(v)])
)}
: process.env;
return new Promise((resolvePromise, rejectPromise) => {
const detached = process.platform !== "win32";
const child = spawn(cmd, args, {
env,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
detached,
});
let buf = "";
let stderrBuf = "";
let pending = null; // { resolve, reject, timer }
let closed = false;
const startTimer = () => {
return setTimeout(() => {
if (pending) {
const p = pending;
pending = null;
p.reject(new Error(`Response timeout after ${timeout}ms`));
clearTimeout(p.timer);
}
}, timeout);
};
child.stdout.on("data", (chunk) => {
buf += chunk.toString();
if (!pending) return;
const nl = buf.indexOf("\n");
if (nl < 0) return;
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
const p = pending;
pending = null;
clearTimeout(p.timer);
try { p.resolve(JSON.parse(line)); }
catch { p.reject(new Error(`Invalid JSON: ${line.slice(0, 200)}`)); }
});
child.stderr.on("data", (chunk) => { stderrBuf += chunk.toString(); });
child.on("error", (err) => {
if (closed) return;
closed = true;
if (pending) { pending.reject(new Error(`Spawn error: ${err.message}`)); clearTimeout(pending.timer); pending = null; }
rejectPromise(new Error(`Spawn error: ${err.message}`));
});
child.on("close", (code) => {
if (closed) return;
closed = true;
if (pending) {
pending.reject(new Error(`Process exited (${code}) before response: ${stderrBuf.slice(0, 300)}`));
clearTimeout(pending.timer);
pending = null;
}
});
const transport = {
_transport: "stdio",
_child: child,
/** 发送并等待响应 */
async send(msg) {
if (closed) throw new Error("Transport closed");
if (pending) throw new Error("Concurrent stdio MCP requests are not supported");
const line = JSON.stringify(msg) + "\n";
return new Promise((resolve, reject) => {
const timer = startTimer();
const current = { resolve, reject, timer };
pending = current;
child.stdin.write(line, (err) => {
if (!err || pending !== current) return;
pending = null;
clearTimeout(timer);
reject(new Error(`Write error: ${err.message}`));
});
// 尝试立即读取(数据可能已在缓冲区)
if (!pending) return;
const nl = buf.indexOf("\n");
if (nl < 0) return;
const p = pending;
pending = null;
clearTimeout(p.timer);
const line2 = buf.slice(0, nl);
buf = buf.slice(nl + 1);
try { p.resolve(JSON.parse(line2)); }
catch { p.reject(new Error(`Invalid JSON: ${line2.slice(0, 200)}`)); }
});
},
/** 发送通知(不等待响应) */
async sendNotify(msg) {
if (closed) return;
const line = JSON.stringify(msg) + "\n";
return new Promise((resolve, reject) => {
child.stdin.write(line, (err) => {
if (err) reject(new Error(`Write error: ${err.message}`));
else resolve();
});
});
},
async close() {
if (pending) { pending.reject(new Error("Transport closed")); clearTimeout(pending.timer); pending = null; }
if (closed) return;
closed = true;
child.stdin.end();
const kill = (signal) => {
try {
if (detached && child.pid) process.kill(-child.pid, signal);
else child.kill(signal);
} catch {}
};
kill("SIGTERM");
await new Promise((resolve) => {
if (child.exitCode !== null) {
resolve();
return;
}
const forceTimer = setTimeout(() => {
kill("SIGKILL");
resolve();
}, 1000);
child.once("close", () => {
clearTimeout(forceTimer);
resolve();
});
});
},
};
resolvePromise(transport);
});
}
/* ============================================================
* HTTP transport
* ============================================================ */
function createHttpTransport(serverConfig) {
const urlStr = resolveEnv(serverConfig.url);
if (!urlStr) throw new Error("HTTP transport requires url");
const url = new URL(urlStr);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error(`Unsupported HTTP URL scheme: ${url.protocol}`);
}
const isHttps = url.protocol === "https:";
const requester = isHttps ? httpsRequest : httpRequest;
const configuredHeaders = Object.fromEntries(
Object.entries(serverConfig.headers || {}).map(([key, value]) => [key, resolveEnv(value)]),
);
let sessionId = null;
function parseSseResponse(raw, requestId) {
const payloads = [];
let dataLines = [];
const flush = () => {
if (dataLines.length === 0) return;
const data = dataLines.join("\n");
dataLines = [];
try {
payloads.push(JSON.parse(data));
} catch {
payloads.push({ _rawData: data });
}
};
for (const rawLine of raw.split(/\r?\n/)) {
if (rawLine === "") {
flush();
continue;
}
if (rawLine.startsWith("data:")) {
dataLines.push(rawLine.slice(5).trimStart());
}
}
flush();
return payloads.find((payload) => payload?.id === requestId)
|| payloads.find((payload) => payload?.result !== undefined || payload?.error)
|| payloads.at(-1)
|| { _sseRaw: raw };
}
return {
_transport: "http",
_url: urlStr,
async send(msg, t = SSE_TIMEOUT) {
const body = JSON.stringify(msg);
return new Promise((resolve, reject) => {
let settled = false;
let req;
const finishResolve = (value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(value);
};
const finishReject = (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(error);
};
const timer = setTimeout(() => {
req?.destroy();
finishReject(new Error(`HTTP timeout after ${t}ms`));
}, t);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": MCP_VERSION,
...configuredHeaders,
};
if (sessionId) headers["Mcp-Session-Id"] = sessionId;
const opts = {
hostname: url.hostname, port: url.port, path: url.pathname + url.search,
method: "POST",
headers,
};
req = requester(opts, (res) => {
sessionId = res.headers["mcp-session-id"] || sessionId;
const ct = res.headers["content-type"] || "";
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const raw = Buffer.concat(chunks).toString();
if ((res.statusCode || 500) >= 400) {
finishReject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 500)}`));
return;
}
if (!raw.trim()) {
finishResolve({});
return;
}
if (ct.includes("text/event-stream")) {
finishResolve(parseSseResponse(raw, msg.id));
} else {
try { finishResolve(JSON.parse(raw)); }
catch { finishResolve({ _raw: raw }); }
}
});
res.on("error", (err) => finishReject(new Error(`HTTP response error: ${err.message}`)));
});
req.on("error", (err) => finishReject(new Error(`HTTP error: ${err.message}`)));
req.write(body);
req.end();
});
},
async sendNotify(msg) {
await this.send(msg, 5000);
},
async close() {},
};
}
async function createTransport(serverConfig) {
const transport = String(serverConfig.transport || "").trim().toLowerCase();
if (serverConfig.url || transport === "streamable-http" || transport === "sse" || transport === "http") {
return createHttpTransport(serverConfig);
}
if (serverConfig.command || transport === "stdio" || !transport) {
return createStdioTransport(serverConfig);
}
throw new Error(`Unsupported MCP transport: ${transport}`);
}
/* ============================================================
* MCP 会话
* ============================================================ */
class McpSession {
constructor(transport, serverName) {
this.transport = transport;
this.serverName = serverName;
this.initialized = false;
this.serverCapabilities = null;
this.serverVersion = null;
}
async initialize() {
const initMsg = {
jsonrpc: "2.0", id: nextId(), method: "initialize",
params: {
protocolVersion: MCP_VERSION,
capabilities: { tools: {} },
clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
},
};
const resp = await this.transport.send(initMsg, REQUEST_TIMEOUT);
const result = extractResult(resp);
if (result.protocolVersion) {
this.serverCapabilities = result.capabilities || {};
this.serverVersion = result.serverInfo?.name
? `${result.serverInfo.name} ${result.serverInfo.version || ""}`
: "unknown";
this.initialized = true;
// 通知(不等待响应)
const notif = { jsonrpc: "2.0", method: "notifications/initialized" };
this.transport.sendNotify(notif).catch(() => {});
} else {
throw new Error(`Unexpected initialize result: ${JSON.stringify(result).slice(0, 300)}`);
}
}
async listTools() {
if (!this.initialized) await this.initialize();
const msg = { jsonrpc: "2.0", id: nextId(), method: "tools/list", params: {} };
const resp = await this.transport.send(msg, REQUEST_TIMEOUT);
const result = extractResult(resp);
if (result.tools) return result.tools;
throw new Error(`tools/list missing 'tools': ${JSON.stringify(result).slice(0, 300)}`);
}
async callTool(name, args = {}) {
if (!this.initialized) await this.initialize();
const msg = {
jsonrpc: "2.0", id: nextId(), method: "tools/call",
params: { name, arguments: args },
};
const resp = await this.transport.send(msg, REQUEST_TIMEOUT);
return extractResult(resp);
}
async close() { await this.transport.close(); }
}
async function readRequestInput(requestArg, inlineJson) {
if (requestArg === "--request-json") {
if (!inlineJson) throw new Error("--request-json 缺少 JSON 参数");
return inlineJson;
}
if (requestArg !== "-") {
return readFileSync(requestArg, "utf-8");
}
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf-8");
}
/* ============================================================
* 主流程
* ============================================================ */
async function main() {
const requestArg = process.argv[2];
if (!requestArg) {
console.log(JSON.stringify(errorResult(
"Usage: node client.mjs <request.json|-> | --request-json '<json>'",
)));
process.exit(1);
}
let request;
try { request = JSON.parse(await readRequestInput(requestArg, process.argv[3])); }
catch (e) { console.log(JSON.stringify(errorResult(`Cannot read request: ${e.message}`))); process.exit(1); }
const { server: serverName, mode } = request;
if (!serverName) { console.log(JSON.stringify(errorResult("Missing 'server'"))); process.exit(1); }
const extDir = dirname(fileURLToPath(import.meta.url));
const config = loadMcpConfig(extDir);
const serverConfig = config.servers[serverName];
if (mode === "status") {
const result = {
configured: !!serverConfig, serverName,
configError: config._parseError || null,
config: serverConfig ? {
transport: serverConfig.transport || null,
command: serverConfig.command || null, url: serverConfig.url || null,
argsCount: serverConfig.args?.length || 0, hasEnv: !!serverConfig.env, disabled: !!serverConfig.disabled,
} : null,
availableServers: Object.keys(config.servers),
};
if (serverConfig && !serverConfig.disabled) {
try {
const transport = await createTransport(serverConfig);
const session = new McpSession(transport, serverName);
await session.initialize();
result.connected = true;
result.serverVersion = session.serverVersion;
result.serverCapabilities = session.serverCapabilities;
await session.close();
} catch (e) { result.connected = false; result.connectError = e.message; }
} else if (serverConfig?.disabled) { result.statusNote = "disabled"; }
console.log(JSON.stringify(okResult(result)));
return;
}
if (!serverConfig) {
console.log(JSON.stringify(errorResult(`Server "${serverName}" not found. Available: ${Object.keys(config.servers).join(", ") || "(none)"}`)));
process.exit(1);
}
if (serverConfig.disabled) {
console.log(JSON.stringify(errorResult(`Server "${serverName}" is disabled`)));
process.exit(1);
}
let transport;
try { transport = await createTransport(serverConfig); }
catch (e) { console.log(JSON.stringify(errorResult(`Transport error: ${e.message}`))); process.exit(1); }
const session = new McpSession(transport, serverName);
try {
if (mode === "list") {
const tools = await session.listTools();
console.log(JSON.stringify(okResult({ server: serverName, tools, toolCount: tools.length })));
} else if (mode === "call") {
const { tool, arguments: args } = request;
if (!tool) { console.log(JSON.stringify(errorResult("Missing 'tool'"))); process.exit(1); }
const result = await session.callTool(tool, args || {});
console.log(JSON.stringify(okResult({ server: serverName, tool, result })));
} else {
console.log(JSON.stringify(errorResult(`Unknown mode: ${mode}`)));
process.exit(1);
}
} catch (e) {
console.log(JSON.stringify(errorResult(`MCP ${mode} error: ${e.message}`)));
} finally {
await session.close();
}
}
main().catch((e) => {
console.log(JSON.stringify(errorResult(`Fatal: ${e.message}`)));
process.exit(1);
});
@@ -0,0 +1,154 @@
/**
* MNote MCP 扩展 — Pi Rust MCP 工具门面
*
* Pi Rust 0.1.21 的异步 pi.exec()/嵌套 HTTP hostcall 在真实网页工具调用中
* 可能卡到扩展任务超时。官方 node:child_process shim 的 execFileSync 通过
* __pi_exec_sync_native 执行,因此这里同步启动相邻 client.mjs。
*
* client.mjs 只读取扩展目录相邻的 .pi/mcp.json,并且请求只能选择其中已配置
* 的 server,不接受任意命令或配置路径。
*/
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
interface ExtensionAPI {
registerTool: (spec: Record<string, unknown>) => void;
}
interface ToolResult {
content?: Array<{ type: string; text?: string; [key: string]: unknown }>;
details?: Record<string, unknown>;
isError?: boolean;
[key: string]: unknown;
}
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
const MCP_CLIENT_PATH = path.join(EXTENSION_DIR, "client.mjs");
const MCP_REQUEST_MAX_CHARS = 128 * 1024;
const MCP_CLIENT_TIMEOUT_MS = 90_000;
const MCP_CLIENT_MAX_BUFFER = 2 * 1024 * 1024;
const mcpToolParameters = {
type: "object",
properties: {
server: {
type: "string",
description: "MCP 服务器名称(对应当前 MNote Pi session 的 mcp.json",
},
mode: {
type: "string",
enum: ["list", "status", "call"],
description: "操作模式:list=列出工具,status=检查服务器状态,call=调用工具",
},
tool: {
type: "string",
description: "mode=call 时需要调用的工具名称",
},
arguments: {
type: "object",
description: "mode=call 时传入工具的参数对象",
additionalProperties: true,
},
},
required: ["server", "mode"],
};
function executeMcpRequest(params: {
server: string;
mode: string;
tool?: string;
arguments?: Record<string, unknown>;
}): Record<string, unknown> {
if (!existsSync(MCP_CLIENT_PATH)) {
throw new Error(`MNote MCP client 不存在: ${MCP_CLIENT_PATH}`);
}
const requestJson = JSON.stringify({
server: params.server,
mode: params.mode,
tool: params.tool,
arguments: params.arguments || {},
});
if (requestJson.length > MCP_REQUEST_MAX_CHARS) {
throw new Error(`MCP 请求超过 ${MCP_REQUEST_MAX_CHARS} 字符限制`);
}
const stdout = execFileSync("node", [
MCP_CLIENT_PATH,
"--request-json",
requestJson,
], {
cwd: EXTENSION_DIR,
timeout: MCP_CLIENT_TIMEOUT_MS,
maxBuffer: MCP_CLIENT_MAX_BUFFER,
});
const text = String(stdout || "").trim();
if (!text) {
throw new Error("MNote MCP client 未返回结果");
}
try {
const payload = JSON.parse(text);
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("返回值不是 JSON 对象");
}
return payload as Record<string, unknown>;
} catch (error) {
throw new Error(
`MNote MCP client 返回非 JSON: ${text.slice(0, 500)}`
+ (error instanceof Error ? ` (${error.message})` : ""),
);
}
}
export default function mnoteMcpExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "mcp",
label: "MNote MCP",
description: "通过 Pi Rust 同步本地 client 调用当前 session 配置的 MCP 服务器。" +
"支持 list(列出工具)、status(检查连通性)、call(调用工具)三种模式。",
parameters: mcpToolParameters,
async execute(
_toolCallId: string,
params: { server: string; mode: string; tool?: string; arguments?: Record<string, unknown> },
): Promise<ToolResult> {
const { server, mode, tool, arguments: args } = params;
if (!["list", "status", "call"].includes(mode)) {
return {
content: [{ type: "text", text: `无效 mode: "${mode}"。可选值: list, status, call` }],
isError: true,
};
}
if (mode === "call" && !tool) {
return {
content: [{ type: "text", text: 'mode=call 时缺少必填参数 "tool"' }],
isError: true,
};
}
try {
const result = executeMcpRequest({ server, mode, tool, arguments: args });
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
details: {
server,
mode,
tool: tool || null,
result,
transport: "pi-rust-sync-client",
},
isError: result.ok === false,
};
} catch (error) {
return {
content: [{
type: "text",
text: `MCP 调用失败: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
},
});
}
@@ -0,0 +1,34 @@
/**
* Permission Gate Extension
*
* Prompts for confirmation before running potentially dangerous bash commands.
* Patterns checked: rm -rf, sudo, chmod/chown 777
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return undefined;
const command = event.input.command as string;
const isDangerous = dangerousPatterns.some((p) => p.test(command));
if (isDangerous) {
if (!ctx.hasUI) {
// In non-interactive mode, block by default
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
}
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
if (choice !== "Yes") {
return { block: true, reason: "Blocked by user" };
}
}
return undefined;
});
}
@@ -0,0 +1,65 @@
# Plan Mode Extension
Read-only exploration mode for safe code analysis.
## Features
- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
- **Bash allowlist**: Only read-only bash commands are allowed
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
- **Progress tracking**: Widget shows completion status during execution
- **[DONE:n] markers**: Explicit step completion tracking
- **Session persistence**: State survives session resume
## Commands
- `/plan` - Toggle plan mode
- `/todos` - Show current plan progress
- `Ctrl+Alt+P` - Toggle plan mode (shortcut)
## Usage
1. Enable plan mode with `/plan` or `--plan` flag
2. Ask the agent to analyze code and create a plan
3. The agent should output a numbered plan under a `Plan:` header:
```
Plan:
1. First step description
2. Second step description
3. Third step description
```
4. Choose "Execute the plan" when prompted
5. During execution, the agent marks steps complete with `[DONE:n]` tags
6. Progress widget shows completion status
## How It Works
### Plan Mode (Read-Only)
- Only read-only tools available
- Bash commands filtered through allowlist
- Agent creates a plan without making changes
### Execution Mode
- Full tool access restored
- Agent executes steps in order
- `[DONE:n]` markers track completion
- Widget shows progress
### Command Allowlist
Safe commands (allowed):
- File inspection: `cat`, `head`, `tail`, `less`, `more`
- Search: `grep`, `find`, `rg`, `fd`
- Directory: `ls`, `pwd`, `tree`
- Git read: `git status`, `git log`, `git diff`, `git branch`
- Package info: `npm list`, `npm outdated`, `yarn info`
- System info: `uname`, `whoami`, `date`, `uptime`
Blocked commands:
- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch`
- Git write: `git add`, `git commit`, `git push`
- Package install: `npm install`, `yarn add`, `pip install`
- System: `sudo`, `kill`, `reboot`
- Editors: `vim`, `nano`, `code`
@@ -0,0 +1,340 @@
/**
* Plan Mode Extension
*
* Read-only exploration mode for safe code analysis.
* When enabled, only read-only tools are available.
*
* Features:
* - /plan command or Ctrl+Alt+P to toggle
* - Bash restricted to allowlisted read-only commands
* - Extracts numbered plan steps from "Plan:" sections
* - [DONE:n] markers to complete steps during execution
* - Progress tracking widget during execution
*/
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Key } from "@mariozechner/pi-tui";
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
// Tools
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
// Type guard for assistant messages
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
return m.role === "assistant" && Array.isArray(m.content);
}
// Extract text content from an assistant message
function getTextContent(message: AssistantMessage): string {
return message.content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}
export default function planModeExtension(pi: ExtensionAPI): void {
let planModeEnabled = false;
let executionMode = false;
let todoItems: TodoItem[] = [];
pi.registerFlag("plan", {
description: "Start in plan mode (read-only exploration)",
type: "boolean",
default: false,
});
function updateStatus(ctx: ExtensionContext): void {
// Footer status
if (executionMode && todoItems.length > 0) {
const completed = todoItems.filter((t) => t.completed).length;
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
} else if (planModeEnabled) {
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
} else {
ctx.ui.setStatus("plan-mode", undefined);
}
// Widget showing todo list
if (executionMode && todoItems.length > 0) {
const lines = todoItems.map((item) => {
if (item.completed) {
return (
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
);
}
return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
});
ctx.ui.setWidget("plan-todos", lines);
} else {
ctx.ui.setWidget("plan-todos", undefined);
}
}
function togglePlanMode(ctx: ExtensionContext): void {
planModeEnabled = !planModeEnabled;
executionMode = false;
todoItems = [];
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
} else {
pi.setActiveTools(NORMAL_MODE_TOOLS);
ctx.ui.notify("Plan mode disabled. Full access restored.");
}
updateStatus(ctx);
}
function persistState(): void {
pi.appendEntry("plan-mode", {
enabled: planModeEnabled,
todos: todoItems,
executing: executionMode,
});
}
pi.registerCommand("plan", {
description: "Toggle plan mode (read-only exploration)",
handler: async (_args, ctx) => togglePlanMode(ctx),
});
pi.registerCommand("todos", {
description: "Show current plan todo list",
handler: async (_args, ctx) => {
if (todoItems.length === 0) {
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
return;
}
const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
ctx.ui.notify(`Plan Progress:\n${list}`, "info");
},
});
pi.registerShortcut(Key.ctrlAlt("p"), {
description: "Toggle plan mode",
handler: async (ctx) => togglePlanMode(ctx),
});
// Block destructive bash commands in plan mode
pi.on("tool_call", async (event) => {
if (!planModeEnabled || event.toolName !== "bash") return;
const command = event.input.command as string;
if (!isSafeCommand(command)) {
return {
block: true,
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
};
}
});
// Filter out stale plan mode context when not in plan mode
pi.on("context", async (event) => {
if (planModeEnabled) return;
return {
messages: event.messages.filter((m) => {
const msg = m as AgentMessage & { customType?: string };
if (msg.customType === "plan-mode-context") return false;
if (msg.role !== "user") return true;
const content = msg.content;
if (typeof content === "string") {
return !content.includes("[PLAN MODE ACTIVE]");
}
if (Array.isArray(content)) {
return !content.some(
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
);
}
return true;
}),
};
});
// Inject plan/execution context before agent starts
pi.on("before_agent_start", async () => {
if (planModeEnabled) {
return {
message: {
customType: "plan-mode-context",
content: `[PLAN MODE ACTIVE]
You are in plan mode - a read-only exploration mode for safe code analysis.
Restrictions:
- You can only use: read, bash, grep, find, ls, questionnaire
- You CANNOT use: edit, write (file modifications are disabled)
- Bash is restricted to an allowlist of read-only commands
Ask clarifying questions using the questionnaire tool.
Use brave-search skill via bash for web research.
Create a detailed numbered plan under a "Plan:" header:
Plan:
1. First step description
2. Second step description
...
Do NOT attempt to make changes - just describe what you would do.`,
display: false,
},
};
}
if (executionMode && todoItems.length > 0) {
const remaining = todoItems.filter((t) => !t.completed);
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
return {
message: {
customType: "plan-execution-context",
content: `[EXECUTING PLAN - Full tool access enabled]
Remaining steps:
${todoList}
Execute each step in order.
After completing a step, include a [DONE:n] tag in your response.`,
display: false,
},
};
}
});
// Track progress after each turn
pi.on("turn_end", async (event, ctx) => {
if (!executionMode || todoItems.length === 0) return;
if (!isAssistantMessage(event.message)) return;
const text = getTextContent(event.message);
if (markCompletedSteps(text, todoItems) > 0) {
updateStatus(ctx);
}
persistState();
});
// Handle plan completion and plan mode UI
pi.on("agent_end", async (event, ctx) => {
// Check if execution is complete
if (executionMode && todoItems.length > 0) {
if (todoItems.every((t) => t.completed)) {
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
pi.sendMessage(
{ customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
{ triggerTurn: false },
);
executionMode = false;
todoItems = [];
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
persistState(); // Save cleared state so resume doesn't restore old execution mode
}
return;
}
if (!planModeEnabled || !ctx.hasUI) return;
// Extract todos from last assistant message
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
if (lastAssistant) {
const extracted = extractTodoItems(getTextContent(lastAssistant));
if (extracted.length > 0) {
todoItems = extracted;
}
}
// Show plan steps and prompt for next action
if (todoItems.length > 0) {
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
pi.sendMessage(
{
customType: "plan-todo-list",
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
display: true,
},
{ triggerTurn: false },
);
}
const choice = await ctx.ui.select("Plan mode - what next?", [
todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
"Stay in plan mode",
"Refine the plan",
]);
if (choice?.startsWith("Execute")) {
planModeEnabled = false;
executionMode = todoItems.length > 0;
pi.setActiveTools(NORMAL_MODE_TOOLS);
updateStatus(ctx);
const execMessage =
todoItems.length > 0
? `Execute the plan. Start with: ${todoItems[0].text}`
: "Execute the plan you just created.";
pi.sendMessage(
{ customType: "plan-mode-execute", content: execMessage, display: true },
{ triggerTurn: true },
);
} else if (choice === "Refine the plan") {
const refinement = await ctx.ui.editor("Refine the plan:", "");
if (refinement?.trim()) {
pi.sendUserMessage(refinement.trim());
}
}
});
// Restore state on session start/resume
pi.on("session_start", async (_event, ctx) => {
if (pi.getFlag("plan") === true) {
planModeEnabled = true;
}
const entries = ctx.sessionManager.getEntries();
// Restore persisted state
const planModeEntry = entries
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
if (planModeEntry?.data) {
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
todoItems = planModeEntry.data.todos ?? todoItems;
executionMode = planModeEntry.data.executing ?? executionMode;
}
// On resume: re-scan messages to rebuild completion state
// Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
const isResume = planModeEntry !== undefined;
if (isResume && executionMode && todoItems.length > 0) {
// Find the index of the last plan-mode-execute entry (marks when current execution started)
let executeIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i] as { type: string; customType?: string };
if (entry.customType === "plan-mode-execute") {
executeIndex = i;
break;
}
}
// Only scan messages after the execute marker
const messages: AssistantMessage[] = [];
for (let i = executeIndex + 1; i < entries.length; i++) {
const entry = entries[i];
if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
messages.push(entry.message as AssistantMessage);
}
}
const allText = messages.map(getTextContent).join("\n");
markCompletedSteps(allText, todoItems);
}
if (planModeEnabled) {
pi.setActiveTools(PLAN_MODE_TOOLS);
}
updateStatus(ctx);
});
}
@@ -0,0 +1,168 @@
/**
* Pure utility functions for plan mode.
* Extracted for testability.
*/
// Destructive commands blocked in plan mode
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,
];
// Safe read-only commands allowed in plan mode
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((p) => p.test(command));
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
return !isDestructive && isSafe;
}
export interface TodoItem {
step: number;
text: string;
completed: boolean;
}
export function cleanStepText(text: string): string {
let cleaned = text
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
.replace(/`([^`]+)`/g, "$1") // Remove code
.replace(
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
"",
)
.replace(/\s+/g, " ")
.trim();
if (cleaned.length > 0) {
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
if (cleaned.length > 50) {
cleaned = `${cleaned.slice(0, 47)}...`;
}
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+\*{0,2}([^*\n]+)/gm;
for (const match of planSection.matchAll(numberedPattern)) {
const text = match[2]
.trim()
.replace(/\*{1,2}$/, "")
.trim();
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
const cleaned = cleanStepText(text);
if (cleaned.length > 3) {
items.push({ step: items.length + 1, text: cleaned, 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((t) => t.step === step);
if (item) item.completed = true;
}
return doneSteps.length;
}
@@ -0,0 +1,283 @@
/**
* Question Tool - Single question with options
* Full custom UI: options list + inline editor for "Type something..."
* Escape in editor returns to options, Escape in options cancels
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
interface OptionWithDesc {
label: string;
description?: string;
}
type DisplayOption = OptionWithDesc & { isOther?: boolean };
interface QuestionDetails {
question: string;
options: string[];
answer: string | null;
wasCustom?: boolean;
}
// Options with labels and optional descriptions
const OptionSchema = Type.Object({
label: Type.String({ description: "Display label for the option" }),
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
});
const QuestionParams = Type.Object({
question: Type.String({ description: "The question to ask the user" }),
options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }),
});
export default function question(pi: ExtensionAPI) {
pi.registerTool({
name: "question",
label: "Question",
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
parameters: QuestionParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
return {
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
details: {
question: params.question,
options: params.options.map((o) => o.label),
answer: null,
} as QuestionDetails,
};
}
if (params.options.length === 0) {
return {
content: [{ type: "text", text: "Error: No options provided" }],
details: { question: params.question, options: [], answer: null } as QuestionDetails,
};
}
const simpleOptions = params.options.map((o) => o.label);
if (ctx.ui && typeof ctx.ui.select === "function") {
const answer = await ctx.ui.select(params.question, simpleOptions);
if (answer === undefined || answer === null) {
return {
content: [{ type: "text", text: "User cancelled the selection" }],
details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails,
};
}
const selected = String(answer);
const selectedIndex = simpleOptions.indexOf(selected);
return {
content: [{ type: "text", text: `User selected: ${selectedIndex + 1}. ${selected}` }],
details: {
question: params.question,
options: simpleOptions,
answer: selected,
wasCustom: false,
} as QuestionDetails,
};
}
const allOptions: DisplayOption[] = [...params.options, { label: "Type something.", isOther: true }];
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
(tui, theme, _kb, done) => {
let optionIndex = 0;
let editMode = false;
let cachedLines: string[] | undefined;
const editorTheme: EditorTheme = {
borderColor: (s) => theme.fg("accent", s),
selectList: {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
},
};
const editor = new Editor(tui, editorTheme);
editor.onSubmit = (value) => {
const trimmed = value.trim();
if (trimmed) {
done({ answer: trimmed, wasCustom: true });
} else {
editMode = false;
editor.setText("");
refresh();
}
};
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function handleInput(data: string) {
if (editMode) {
if (matchesKey(data, Key.escape)) {
editMode = false;
editor.setText("");
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}
if (matchesKey(data, Key.up)) {
optionIndex = Math.max(0, optionIndex - 1);
refresh();
return;
}
if (matchesKey(data, Key.down)) {
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
refresh();
return;
}
if (matchesKey(data, Key.enter)) {
const selected = allOptions[optionIndex];
if (selected.isOther) {
editMode = true;
refresh();
} else {
done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
}
return;
}
if (matchesKey(data, Key.escape)) {
done(null);
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
add(theme.fg("text", ` ${params.question}`));
lines.push("");
for (let i = 0; i < allOptions.length; i++) {
const opt = allOptions[i];
const selected = i === optionIndex;
const isOther = opt.isOther === true;
const prefix = selected ? theme.fg("accent", "> ") : " ";
if (isOther && editMode) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else if (selected) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else {
add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`);
}
// Show description if present
if (opt.description) {
add(` ${theme.fg("muted", opt.description)}`);
}
}
if (editMode) {
lines.push("");
add(theme.fg("muted", " Your answer:"));
for (const line of editor.render(width - 2)) {
add(` ${line}`);
}
}
lines.push("");
if (editMode) {
add(theme.fg("dim", " Enter to submit • Esc to go back"));
} else {
add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel"));
}
add(theme.fg("accent", "─".repeat(width)));
cachedLines = lines;
return lines;
}
return {
render,
invalidate: () => {
cachedLines = undefined;
},
handleInput,
};
},
);
if (!result) {
return {
content: [{ type: "text", text: "User cancelled the selection" }],
details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails,
};
}
if (result.wasCustom) {
return {
content: [{ type: "text", text: `User wrote: ${result.answer}` }],
details: {
question: params.question,
options: simpleOptions,
answer: result.answer,
wasCustom: true,
} as QuestionDetails,
};
}
return {
content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }],
details: {
question: params.question,
options: simpleOptions,
answer: result.answer,
wasCustom: false,
} as QuestionDetails,
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
const opts = Array.isArray(args.options) ? args.options : [];
if (opts.length) {
const labels = opts.map((o: OptionWithDesc) => o.label);
const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`);
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as QuestionDetails | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.answer === null) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
if (details.wasCustom) {
return new Text(
theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer),
0,
0,
);
}
const idx = details.options.indexOf(details.answer) + 1;
const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer;
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
},
});
}
@@ -0,0 +1,453 @@
/**
* Questionnaire Tool - Unified tool for asking single or multiple questions
*
* Single question: simple options list
* Multiple questions: tab bar navigation between questions
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
// Types
interface QuestionOption {
value: string;
label: string;
description?: string;
}
type RenderOption = QuestionOption & { isOther?: boolean };
interface Question {
id: string;
label: string;
prompt: string;
options: QuestionOption[];
allowOther: boolean;
}
interface Answer {
id: string;
value: string;
label: string;
wasCustom: boolean;
index?: number;
}
interface QuestionnaireResult {
questions: Question[];
answers: Answer[];
cancelled: boolean;
}
// Schema
const QuestionOptionSchema = Type.Object({
value: Type.String({ description: "The value returned when selected" }),
label: Type.String({ description: "Display label for the option" }),
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
});
const QuestionSchema = Type.Object({
id: Type.String({ description: "Unique identifier for this question" }),
label: Type.Optional(
Type.String({
description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
}),
),
prompt: Type.String({ description: "The full question text to display" }),
options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
});
const QuestionnaireParams = Type.Object({
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
});
function errorResult(
message: string,
questions: Question[] = [],
): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
return {
content: [{ type: "text", text: message }],
details: { questions, answers: [], cancelled: true },
};
}
export default function questionnaire(pi: ExtensionAPI) {
pi.registerTool({
name: "questionnaire",
label: "Questionnaire",
description:
"Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
parameters: QuestionnaireParams,
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
if (!ctx.hasUI) {
return errorResult("Error: UI not available (running in non-interactive mode)");
}
if (params.questions.length === 0) {
return errorResult("Error: No questions provided");
}
// Normalize questions with defaults
const questions: Question[] = params.questions.map((q, i) => ({
...q,
label: q.label || `Q${i + 1}`,
allowOther: q.allowOther !== false,
}));
if (questions.length === 1 && ctx.ui && typeof ctx.ui.select === "function") {
const q = questions[0];
const labels = q.options.map((option) => option.label);
const answer = await ctx.ui.select(q.prompt, labels);
if (answer === undefined || answer === null) {
return errorResult("User cancelled the selection", questions);
}
const selected = String(answer);
const optionIndex = labels.indexOf(selected);
const option = optionIndex >= 0 ? q.options[optionIndex] : undefined;
return {
content: [{ type: "text", text: `User selected: ${selected}` }],
details: {
questions,
answers: [{
id: q.id,
value: option ? option.value : selected,
label: selected,
wasCustom: false,
index: optionIndex >= 0 ? optionIndex + 1 : undefined,
}],
cancelled: false,
} as QuestionnaireResult,
};
}
const isMulti = questions.length > 1;
const totalTabs = questions.length + 1; // questions + Submit
const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
// State
let currentTab = 0;
let optionIndex = 0;
let inputMode = false;
let inputQuestionId: string | null = null;
let cachedLines: string[] | undefined;
const answers = new Map<string, Answer>();
// Editor for "Type something" option
const editorTheme: EditorTheme = {
borderColor: (s) => theme.fg("accent", s),
selectList: {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => theme.fg("muted", t),
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
},
};
const editor = new Editor(tui, editorTheme);
// Helpers
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
function submit(cancelled: boolean) {
done({ questions, answers: Array.from(answers.values()), cancelled });
}
function currentQuestion(): Question | undefined {
return questions[currentTab];
}
function currentOptions(): RenderOption[] {
const q = currentQuestion();
if (!q) return [];
const opts: RenderOption[] = [...q.options];
if (q.allowOther) {
opts.push({ value: "__other__", label: "Type something.", isOther: true });
}
return opts;
}
function allAnswered(): boolean {
return questions.every((q) => answers.has(q.id));
}
function advanceAfterAnswer() {
if (!isMulti) {
submit(false);
return;
}
if (currentTab < questions.length - 1) {
currentTab++;
} else {
currentTab = questions.length; // Submit tab
}
optionIndex = 0;
refresh();
}
function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
answers.set(questionId, { id: questionId, value, label, wasCustom, index });
}
// Editor submit callback
editor.onSubmit = (value) => {
if (!inputQuestionId) return;
const trimmed = value.trim() || "(no response)";
saveAnswer(inputQuestionId, trimmed, trimmed, true);
inputMode = false;
inputQuestionId = null;
editor.setText("");
advanceAfterAnswer();
};
function handleInput(data: string) {
// Input mode: route to editor
if (inputMode) {
if (matchesKey(data, Key.escape)) {
inputMode = false;
inputQuestionId = null;
editor.setText("");
refresh();
return;
}
editor.handleInput(data);
refresh();
return;
}
const q = currentQuestion();
const opts = currentOptions();
// Tab navigation (multi-question only)
if (isMulti) {
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
currentTab = (currentTab + 1) % totalTabs;
optionIndex = 0;
refresh();
return;
}
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
optionIndex = 0;
refresh();
return;
}
}
// Submit tab
if (currentTab === questions.length) {
if (matchesKey(data, Key.enter) && allAnswered()) {
submit(false);
} else if (matchesKey(data, Key.escape)) {
submit(true);
}
return;
}
// Option navigation
if (matchesKey(data, Key.up)) {
optionIndex = Math.max(0, optionIndex - 1);
refresh();
return;
}
if (matchesKey(data, Key.down)) {
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
refresh();
return;
}
// Select option
if (matchesKey(data, Key.enter) && q) {
const opt = opts[optionIndex];
if (opt.isOther) {
inputMode = true;
inputQuestionId = q.id;
editor.setText("");
refresh();
return;
}
saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
advanceAfterAnswer();
return;
}
// Cancel
if (matchesKey(data, Key.escape)) {
submit(true);
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
const lines: string[] = [];
const q = currentQuestion();
const opts = currentOptions();
// Helper to add truncated line
const add = (s: string) => lines.push(truncateToWidth(s, width));
add(theme.fg("accent", "─".repeat(width)));
// Tab bar (multi-question only)
if (isMulti) {
const tabs: string[] = ["← "];
for (let i = 0; i < questions.length; i++) {
const isActive = i === currentTab;
const isAnswered = answers.has(questions[i].id);
const lbl = questions[i].label;
const box = isAnswered ? "■" : "□";
const color = isAnswered ? "success" : "muted";
const text = ` ${box} ${lbl} `;
const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
tabs.push(`${styled} `);
}
const canSubmit = allAnswered();
const isSubmitTab = currentTab === questions.length;
const submitText = " ✓ Submit ";
const submitStyled = isSubmitTab
? theme.bg("selectedBg", theme.fg("text", submitText))
: theme.fg(canSubmit ? "success" : "dim", submitText);
tabs.push(`${submitStyled}`);
add(` ${tabs.join("")}`);
lines.push("");
}
// Helper to render options list
function renderOptions() {
for (let i = 0; i < opts.length; i++) {
const opt = opts[i];
const selected = i === optionIndex;
const isOther = opt.isOther === true;
const prefix = selected ? theme.fg("accent", "> ") : " ";
const color = selected ? "accent" : "text";
// Mark "Type something" differently when in input mode
if (isOther && inputMode) {
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
} else {
add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
}
if (opt.description) {
add(` ${theme.fg("muted", opt.description)}`);
}
}
}
// Content
if (inputMode && q) {
add(theme.fg("text", ` ${q.prompt}`));
lines.push("");
// Show options for reference
renderOptions();
lines.push("");
add(theme.fg("muted", " Your answer:"));
for (const line of editor.render(width - 2)) {
add(` ${line}`);
}
lines.push("");
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
} else if (currentTab === questions.length) {
add(theme.fg("accent", theme.bold(" Ready to submit")));
lines.push("");
for (const question of questions) {
const answer = answers.get(question.id);
if (answer) {
const prefix = answer.wasCustom ? "(wrote) " : "";
add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
}
}
lines.push("");
if (allAnswered()) {
add(theme.fg("success", " Press Enter to submit"));
} else {
const missing = questions
.filter((q) => !answers.has(q.id))
.map((q) => q.label)
.join(", ");
add(theme.fg("warning", ` Unanswered: ${missing}`));
}
} else if (q) {
add(theme.fg("text", ` ${q.prompt}`));
lines.push("");
renderOptions();
}
lines.push("");
if (!inputMode) {
const help = isMulti
? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
: " ↑↓ navigate • Enter select • Esc cancel";
add(theme.fg("dim", help));
}
add(theme.fg("accent", "─".repeat(width)));
cachedLines = lines;
return lines;
}
return {
render,
invalidate: () => {
cachedLines = undefined;
},
handleInput,
};
});
if (result.cancelled) {
return {
content: [{ type: "text", text: "User cancelled the questionnaire" }],
details: result,
};
}
const answerLines = result.answers.map((a) => {
const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
if (a.wasCustom) {
return `${qLabel}: user wrote: ${a.label}`;
}
return `${qLabel}: user selected: ${a.index}. ${a.label}`;
});
return {
content: [{ type: "text", text: answerLines.join("\n") }],
details: result,
};
},
renderCall(args, theme) {
const qs = (args.questions as Question[]) || [];
const count = qs.length;
const labels = qs.map((q) => q.label || q.id).join(", ");
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
if (labels) {
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
}
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as QuestionnaireResult | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.cancelled) {
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
}
const lines = details.answers.map((a) => {
if (a.wasCustom) {
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
}
const display = a.index ? `${a.index}. ${a.label}` : a.label;
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
});
return new Text(lines.join("\n"), 0, 0);
},
});
}
@@ -0,0 +1,172 @@
# Subagent Example
Delegate tasks to specialized subagents with isolated context windows.
## Features
- **Isolated context**: Each subagent runs in a separate `pi` process
- **Streaming output**: See tool calls and progress as they happen
- **Parallel streaming**: All parallel tasks stream updates simultaneously
- **Markdown rendering**: Final output rendered with proper formatting (expanded view)
- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
- **Abort support**: Ctrl+C propagates to kill subagent processes
## Structure
```
subagent/
├── README.md # This file
├── index.ts # The extension (entry point)
├── agents.ts # Agent discovery logic
├── agents/ # Sample agent definitions
│ ├── scout.md # Fast recon, returns compressed context
│ ├── planner.md # Creates implementation plans
│ ├── reviewer.md # Code review
│ └── worker.md # General-purpose (full capabilities)
└── prompts/ # Workflow presets (prompt templates)
├── implement.md # scout -> planner -> worker
├── scout-and-plan.md # scout -> planner (no implementation)
└── implement-and-review.md # worker -> reviewer -> worker
```
## Installation
From the repository root, symlink the files:
```bash
# Symlink the extension (must be in a subdirectory with index.ts)
mkdir -p ~/.pi/agent/extensions/subagent
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts
# Symlink agents
mkdir -p ~/.pi/agent/agents
for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do
ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f")
done
# Symlink workflow prompts
mkdir -p ~/.pi/agent/prompts
for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do
ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f")
done
```
## Security Model
This tool executes a separate `pi` subprocess with a delegated system prompt and tool/model configuration.
**Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc.
**Default behavior:** Only loads **user-level agents** from `~/.pi/agent/agents`.
To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable.
## Usage
### Single agent
```
Use scout to find all authentication code
```
### Parallel execution
```
Run 2 scouts in parallel: one to find models, one to find providers
```
### Chained workflow
```
Use a chain: first have scout find the read tool, then have planner suggest improvements
```
### Workflow prompts
```
/implement add Redis caching to the session store
/scout-and-plan refactor auth to support OAuth
/implement-and-review add input validation to API endpoints
```
## Tool Modes
| Mode | Parameter | Description |
|------|-----------|-------------|
| Single | `{ agent, task }` | One agent, one task |
| Parallel | `{ tasks: [...] }` | Multiple agents run concurrently (max 8, 4 concurrent) |
| Chain | `{ chain: [...] }` | Sequential with `{previous}` placeholder |
## Output Display
**Collapsed view** (default):
- Status icon (✓/✗/⏳) and agent name
- Last 5-10 items (tool calls and text)
- Usage stats: `3 turns ↑input ↓output RcacheRead WcacheWrite $cost ctx:contextTokens model`
**Expanded view** (Ctrl+O):
- Full task text
- All tool calls with formatted arguments
- Final output rendered as Markdown
- Per-task usage (for chain/parallel)
**Parallel mode streaming**:
- Shows all tasks with live status (⏳ running, ✓ done, ✗ failed)
- Updates as each task makes progress
- Shows "2/3 done, 1 running" status
**Tool call formatting** (mimics built-in tools):
- `$ command` for bash
- `read ~/path:1-10` for read
- `grep /pattern/ in ~/path` for grep
- etc.
## Agent Definitions
Agents are markdown files with YAML frontmatter:
```markdown
---
name: my-agent
description: What this agent does
tools: read, grep, find, ls
model: claude-haiku-4-5
---
System prompt for the agent goes here.
```
**Locations:**
- `~/.pi/agent/agents/*.md` - User-level (always loaded)
- `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`)
Project agents override user agents with the same name when `agentScope: "both"`.
## Sample Agents
| Agent | Purpose | Model | Tools |
|-------|---------|-------|-------|
| `scout` | Fast codebase recon | Haiku | read, grep, find, ls, bash |
| `planner` | Implementation plans | Sonnet | read, grep, find, ls |
| `reviewer` | Code review | Sonnet | read, grep, find, ls, bash |
| `worker` | General-purpose | Sonnet | (all default) |
## Workflow Prompts
| Prompt | Flow |
|--------|------|
| `/implement <query>` | scout → planner → worker |
| `/scout-and-plan <query>` | scout → planner |
| `/implement-and-review <query>` | worker → reviewer → worker |
## Error Handling
- **Exit code != 0**: Tool returns error with stderr/output
- **stopReason "error"**: LLM error propagated with error message
- **stopReason "aborted"**: User abort (Ctrl+C) kills subprocess, throws error
- **Chain mode**: Stops at first failing step, reports which step failed
## Limitations
- Output truncated to last 10 items in collapsed view (expand to see all)
- Agents discovered fresh on each invocation (allows editing mid-session)
- Parallel mode limited to 8 tasks, 4 concurrent
@@ -0,0 +1,127 @@
/**
* Agent discovery and configuration
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
export type AgentScope = "user" | "project" | "both";
export interface AgentConfig {
name: string;
description: string;
tools?: string[];
model?: string;
systemPrompt: string;
source: "user" | "project";
filePath: string;
}
export interface AgentDiscoveryResult {
agents: AgentConfig[];
projectAgentsDir: string | null;
}
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, string>>(content);
if (!frontmatter.name || !frontmatter.description) {
continue;
}
const tools = frontmatter.tools
?.split(",")
.map((t: string) => t.trim())
.filter(Boolean);
agents.push({
name: frontmatter.name,
description: frontmatter.description,
tools: tools && tools.length > 0 ? tools : undefined,
model: frontmatter.model,
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(os.homedir(), ".pi", "agent", "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((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
remaining,
};
}
@@ -0,0 +1,37 @@
---
name: planner
description: Creates implementation plans from context and requirements
tools: read, grep, find, ls
model: claude-sonnet-4-5
---
You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan.
You must NOT make any changes. Only read, analyze, and plan.
Input format you'll receive:
- Context/findings from a scout agent
- Original query or requirements
Output format:
## Goal
One sentence summary of what needs to be done.
## Plan
Numbered steps, each small and actionable:
1. Step one - specific file/function to modify
2. Step two - what to add/change
3. ...
## Files to Modify
- `path/to/file.ts` - what changes
- `path/to/other.ts` - what changes
## New Files (if any)
- `path/to/new.ts` - purpose
## Risks
Anything to watch out for.
Keep the plan concrete. The worker agent will execute it verbatim.
@@ -0,0 +1,35 @@
---
name: reviewer
description: Code review specialist for quality and security analysis
tools: read, grep, find, ls, bash
model: claude-sonnet-4-5
---
You are a senior code reviewer. Analyze code for quality, security, and maintainability.
Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds.
Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only.
Strategy:
1. Run `git diff` to see recent changes (if applicable)
2. Read the modified files
3. Check for bugs, security issues, code smells
Output format:
## Files Reviewed
- `path/to/file.ts` (lines X-Y)
## Critical (must fix)
- `file.ts:42` - Issue description
## Warnings (should fix)
- `file.ts:100` - Issue description
## Suggestions (consider)
- `file.ts:150` - Improvement idea
## Summary
Overall assessment in 2-3 sentences.
Be specific with file paths and line numbers.
@@ -0,0 +1,50 @@
---
name: scout
description: Fast codebase recon that returns compressed context for handoff to other agents
tools: read, grep, find, ls, bash
model: claude-haiku-4-5
---
You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
Your output will be passed to an agent who has NOT seen the files you explored.
Thoroughness (infer from task, default medium):
- Quick: Targeted lookups, key files only
- Medium: Follow imports, read critical sections
- Thorough: Trace all dependencies, check tests/types
Strategy:
1. grep/find to locate relevant code
2. Read key sections (not entire files)
3. Identify types, interfaces, key functions
4. Note dependencies between files
Output format:
## Files Retrieved
List with exact line ranges:
1. `path/to/file.ts` (lines 10-50) - Description of what's here
2. `path/to/other.ts` (lines 100-150) - Description
3. ...
## Key Code
Critical types, interfaces, or functions:
```typescript
interface Example {
// actual code from the files
}
```
```typescript
function keyFunction() {
// actual implementation
}
```
## Architecture
Brief explanation of how the pieces connect.
## Start Here
Which file to look at first and why.
@@ -0,0 +1,24 @@
---
name: worker
description: General-purpose subagent with full capabilities, isolated context
model: claude-sonnet-4-5
---
You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation.
Work autonomously to complete the assigned task. Use all available tools as needed.
Output format when finished:
## Completed
What was done.
## Files Changed
- `path/to/file.ts` - what changed
## Notes (if any)
Anything the main agent should know.
If handing off to another agent (e.g. reviewer), include:
- Exact file paths changed
- Key functions/types touched (short list)
@@ -0,0 +1,963 @@
/**
* Subagent Tool - Delegate tasks to specialized agents
*
* Spawns a separate `pi` process for each subagent invocation,
* giving it an isolated context window.
*
* Supports three modes:
* - Single: { agent: "name", task: "..." }
* - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
* - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
*
* Uses JSON mode to capture structured output from subagents.
*/
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 type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
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 } from "./agents.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}`);
}
}
}
interface UsageStats {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextTokens: number;
turns: number;
}
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;
}
interface SubagentDetails {
mode: "single" | "parallel" | "chain";
agentScope: AgentScope;
projectAgentsDir: string | null;
results: SingleResult[];
}
function getFinalOutput(messages: Message[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") {
for (const part of msg.content) {
if (part.type === "text") return part.text;
}
}
}
return "";
}
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
function getDisplayItems(messages: Message[]): DisplayItem[] {
const items: DisplayItem[] = [];
for (const msg of messages) {
if (msg.role === "assistant") {
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;
}
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 };
}
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
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) {
return {
agent: agentName,
agentSource: "unknown",
task,
exitCode: 1,
messages: [],
stderr: `Unknown agent: ${agentName}`,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
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: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
model: agent.model,
step,
};
const emitUpdate = () => {
if (onUpdate) {
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 as Message;
currentResult.messages.push(msg);
if (msg.role === "assistant") {
currentResult.usage.turns++;
const usage = msg.usage;
if (usage) {
currentResult.usage.input += usage.input || 0;
currentResult.usage.output += usage.output || 0;
currentResult.usage.cacheRead += usage.cacheRead || 0;
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
currentResult.usage.cost += usage.cost?.total || 0;
currentResult.usage.contextTokens = usage.totalTokens || 0;
}
if (!currentResult.model && msg.model) currentResult.model = msg.model;
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
}
emitUpdate();
}
if (event.type === "tool_result_end" && event.message) {
currentResult.messages.push(event.message as 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 {
/* ignore */
}
if (tmpPromptDir)
try {
fs.rmdirSync(tmpPromptDir);
} catch {
/* ignore */
}
}
}
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 (for single mode)" })),
task: Type.Optional(Type.String({ description: "Task to delegate (for 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.registerTool({
name: "subagent",
label: "Subagent",
description: [
"Delegate tasks to specialized subagents with isolated context.",
"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
'Default agent scope is "user" (from ~/.pi/agent/agents).',
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
].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 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) {
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
return {
content: [
{
type: "text",
text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`,
},
],
details: makeDetails("single")([]),
};
}
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 t of params.tasks) requestedAgentNames.add(t.agent);
if (params.agent) requestedAgentNames.add(params.agent);
const projectAgentsRequested = Array.from(requestedAgentNames)
.map((name) => agents.find((a) => a.name === name))
.filter((a): a is AgentConfig => a?.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. Only continue for trusted repositories.`,
);
if (!ok)
return {
content: [{ type: "text", text: "Canceled: project-local agents 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);
// Create update callback that includes all previous results
const chainUpdate: OnUpdateCallback | undefined = onUpdate
? (partial) => {
// Combine completed results with current streaming result
const currentResult = partial.details?.results[0];
if (currentResult) {
const allResults = [...results, currentResult];
onUpdate({
content: partial.content,
details: makeDetails("chain")(allResults),
});
}
}
: 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")([]),
};
// Track all results for streaming updates
const allResults: SingleResult[] = new Array(params.tasks.length);
// Initialize placeholder results
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, // -1 = still running
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
};
}
const emitParallelUpdate = () => {
if (onUpdate) {
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 (t, index) => {
const result = await runSingleAgent(
ctx.cwd,
agents,
t.agent,
t.task,
t.cwd,
undefined,
signal,
// Per-task update callback
(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 result = await runSingleAgent(
ctx.cwd,
agents,
params.agent,
params.task,
params.cwd,
undefined,
signal,
onUpdate,
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]),
};
}
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
return {
content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
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];
// Clean up {previous} placeholder for display
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 t of args.tasks.slice(0, 3)) {
const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
text += `\n ${theme.fg("accent", t.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();
};
if (details.mode === "single" && details.results.length === 1) {
const r = details.results[0];
const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
const displayItems = getDisplayItems(r.messages);
const finalOutput = getFinalOutput(r.messages);
if (expanded) {
const container = new Container();
let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
container.addChild(new Text(header, 0, 0));
if (isError && r.errorMessage)
container.addChild(new Text(theme.fg("error", `Error: ${r.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", r.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 item of displayItems) {
if (item.type === "toolCall")
container.addChild(
new Text(
theme.fg("muted", "→ ") + formatToolCall(item.name, item.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(r.usage, r.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(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.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(r.usage, r.model);
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
return new Text(text, 0, 0);
}
const aggregateUsage = (results: SingleResult[]) => {
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
for (const r of results) {
total.input += r.usage.input;
total.output += r.usage.output;
total.cacheRead += r.usage.cacheRead;
total.cacheWrite += r.usage.cacheWrite;
total.cost += r.usage.cost;
total.turns += r.usage.turns;
}
return total;
};
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 r of details.results) {
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
const displayItems = getDisplayItems(r.messages);
const finalOutput = getFinalOutput(r.messages);
container.addChild(new Spacer(1));
container.addChild(
new Text(
`${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
0,
0,
),
);
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
// Show tool calls
for (const item of displayItems) {
if (item.type === "toolCall") {
container.addChild(
new Text(
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
0,
0,
),
);
}
}
// Show final output as markdown
if (finalOutput) {
container.addChild(new Spacer(1));
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
}
const stepUsage = formatUsageStats(r.usage, r.model);
if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
}
const usageStr = formatUsageStats(aggregateUsage(details.results));
if (usageStr) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
}
return container;
}
// Collapsed view
let text =
icon +
" " +
theme.fg("toolTitle", theme.bold("chain ")) +
theme.fg("accent", `${successCount}/${details.results.length} steps`);
for (const r of details.results) {
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
const displayItems = getDisplayItems(r.messages);
text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
else text += `\n${renderDisplayItems(displayItems, 5)}`;
}
const usageStr = formatUsageStats(aggregateUsage(details.results));
if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
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 r of details.results) {
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
const displayItems = getDisplayItems(r.messages);
const finalOutput = getFinalOutput(r.messages);
container.addChild(new Spacer(1));
container.addChild(
new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
);
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
// Show tool calls
for (const item of displayItems) {
if (item.type === "toolCall") {
container.addChild(
new Text(
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
0,
0,
),
);
}
}
// Show final output as markdown
if (finalOutput) {
container.addChild(new Spacer(1));
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
}
const taskUsage = formatUsageStats(r.usage, r.model);
if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
}
const usageStr = formatUsageStats(aggregateUsage(details.results));
if (usageStr) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
}
return container;
}
// Collapsed view (or still running)
let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
for (const r of details.results) {
const rIcon =
r.exitCode === -1
? theme.fg("warning", "⏳")
: r.exitCode === 0
? theme.fg("success", "✓")
: theme.fg("error", "✗");
const displayItems = getDisplayItems(r.messages);
text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
if (displayItems.length === 0)
text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
else text += `\n${renderDisplayItems(displayItems, 5)}`;
}
if (!isRunning) {
const usageStr = formatUsageStats(aggregateUsage(details.results));
if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
}
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);
},
});
}
@@ -0,0 +1,10 @@
---
description: Worker implements, reviewer reviews, worker applies feedback
---
Use the subagent tool with the chain parameter to execute this workflow:
1. First, use the "worker" agent to implement: $@
2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder)
3. Finally, use the "worker" agent to apply the feedback from the review (use {previous} placeholder)
Execute this as a chain, passing output between steps via {previous}.
@@ -0,0 +1,10 @@
---
description: Full implementation workflow - scout gathers context, planner creates plan, worker implements
---
Use the subagent tool with the chain parameter to execute this workflow:
1. First, use the "scout" agent to find all code relevant to: $@
2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
3. Finally, use the "worker" agent to implement the plan from the previous step (use {previous} placeholder)
Execute this as a chain, passing output between steps via {previous}.
@@ -0,0 +1,9 @@
---
description: Scout gathers context, planner creates implementation plan (no implementation)
---
Use the subagent tool with the chain parameter to execute this workflow:
1. First, use the "scout" agent to find all code relevant to: $@
2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan.
@@ -0,0 +1,299 @@
/**
* Todo Extension - Demonstrates state management via session entries
*
* This extension:
* - Registers a `todo` tool for the LLM to manage todos
* - Registers a `/todos` command for users to view the list
*
* State is stored in tool result details (not external files), which allows
* proper branching - when you branch, the todo state is automatically
* correct for that point in history.
*/
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
interface Todo {
id: number;
text: string;
done: boolean;
}
interface TodoDetails {
action: "list" | "add" | "toggle" | "clear";
todos: Todo[];
nextId: number;
error?: string;
}
const TodoParams = Type.Object({
action: StringEnum(["list", "add", "toggle", "clear"] as const),
text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })),
});
/**
* UI component for the /todos command
*/
class TodoListComponent {
private todos: Todo[];
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
this.todos = todos;
this.theme = theme;
this.onClose = onClose;
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.onClose();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const lines: string[] = [];
const th = this.theme;
lines.push("");
const title = th.fg("accent", " Todos ");
const headerLine =
th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
lines.push(truncateToWidth(headerLine, width));
lines.push("");
if (this.todos.length === 0) {
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
} else {
const done = this.todos.filter((t) => t.done).length;
const total = this.todos.length;
lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width));
lines.push("");
for (const todo of this.todos) {
const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
const id = th.fg("accent", `#${todo.id}`);
const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
lines.push(truncateToWidth(` ${check} ${id} ${text}`, width));
}
}
lines.push("");
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
lines.push("");
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
export default function (pi: ExtensionAPI) {
// In-memory state (reconstructed from session on load)
let todos: Todo[] = [];
let nextId = 1;
/**
* Reconstruct state from session entries.
* Scans tool results for this tool and applies them in order.
*/
const reconstructState = (ctx: ExtensionContext) => {
todos = [];
nextId = 1;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
const details = msg.details as TodoDetails | undefined;
if (details) {
todos = details.todos;
nextId = details.nextId;
}
}
};
// Reconstruct state on session events
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// Register the todo tool for the LLM
pi.registerTool({
name: "todo",
label: "Todo",
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
parameters: TodoParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
switch (params.action) {
case "list":
return {
content: [
{
type: "text",
text: todos.length
? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n")
: "No todos",
},
],
details: { action: "list", todos: [...todos], nextId } as TodoDetails,
};
case "add": {
if (!params.text) {
return {
content: [{ type: "text", text: "Error: text required for add" }],
details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails,
};
}
const newTodo: Todo = { id: nextId++, text: params.text, done: false };
todos.push(newTodo);
return {
content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }],
details: { action: "add", todos: [...todos], nextId } as TodoDetails,
};
}
case "toggle": {
if (params.id === undefined) {
return {
content: [{ type: "text", text: "Error: id required for toggle" }],
details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails,
};
}
const todo = todos.find((t) => t.id === params.id);
if (!todo) {
return {
content: [{ type: "text", text: `Todo #${params.id} not found` }],
details: {
action: "toggle",
todos: [...todos],
nextId,
error: `#${params.id} not found`,
} as TodoDetails,
};
}
todo.done = !todo.done;
return {
content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }],
details: { action: "toggle", todos: [...todos], nextId } as TodoDetails,
};
}
case "clear": {
const count = todos.length;
todos = [];
nextId = 1;
return {
content: [{ type: "text", text: `Cleared ${count} todos` }],
details: { action: "clear", todos: [], nextId: 1 } as TodoDetails,
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
details: {
action: "list",
todos: [...todos],
nextId,
error: `unknown action: ${params.action}`,
} as TodoDetails,
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as TodoDetails | undefined;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
if (details.error) {
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
}
const todoList = details.todos;
switch (details.action) {
case "list": {
if (todoList.length === 0) {
return new Text(theme.fg("dim", "No todos"), 0, 0);
}
let listText = theme.fg("muted", `${todoList.length} todo(s):`);
const display = expanded ? todoList : todoList.slice(0, 5);
for (const t of display) {
const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
}
if (!expanded && todoList.length > 5) {
listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`;
}
return new Text(listText, 0, 0);
}
case "add": {
const added = todoList[todoList.length - 1];
return new Text(
theme.fg("success", "✓ Added ") +
theme.fg("accent", `#${added.id}`) +
" " +
theme.fg("muted", added.text),
0,
0,
);
}
case "toggle": {
const text = result.content[0];
const msg = text?.type === "text" ? text.text : "";
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
}
case "clear":
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
}
},
});
// Register the /todos command for users
pi.registerCommand("todos", {
description: "Show all todos on the current branch",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("/todos requires interactive mode", "error");
return;
}
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
return new TodoListComponent(todos, theme, () => done());
});
},
});
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@mnote/pi",
"version": "0.1.0",
"private": true,
"description": "MNote official Pi package for local-first page tools, LightRAG, citations, and rescue workflows.",
"keywords": [
"pi-package",
"mnote",
"lightrag",
"knowledge-rag"
],
"license": "UNLICENSED",
"pi": {
"extensions": [
"./extensions/mnote-bridge.ts",
"./extensions/mnote-mcp/index.ts"
],
"skills": [
"./skills"
],
"prompts": [
"./prompts"
]
}
}
+3
View File
@@ -0,0 +1,3 @@
# MNote LightRAG Answer
Answer with MNote LightRAG evidence. Query the MNote knowledge library first, use returned citations, and open or expand references only when needed.
+12
View File
@@ -0,0 +1,12 @@
---
name: mnote
description: Use MNote page, local file, citation, and LightRAG tools from Pi while respecting MNote access policy.
---
# MNote
Use MNote tools when the task depends on the current MNote page, selection, allowed local roots, MNote citations, or the configured MNote knowledge library.
Prefer `mnote_current_page_read` for current-page context and `mnote_knowledge_rag_query` for source-grounded knowledge answers. For long books or documents, use `mnote_knowledge_rag_section_context` after a query returns a relevant section or document structure index.
Do not invent MNote citation URLs. Use returned citations or `mnote_knowledge_rag_open_reference`.