feat: integrate pi rust lab runtime
This commit is contained in:
@@ -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 打印 JSON,stderr 仅用于诊断
|
||||
*
|
||||
* 限制:不含 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
+10
@@ -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());
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user