chore: checkpoint pi lab rust integration work

This commit is contained in:
Agent Board
2026-07-11 20:39:18 +08:00
parent d47f6447fd
commit d16eccfe10
39 changed files with 4843 additions and 455 deletions
+337 -26
View File
@@ -101,14 +101,12 @@ const DEFAULT_TOOLS: MnoteToolManifestEntry[] = [
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");
const EMBEDDED_CONTEXT: Record<string, unknown> | undefined = undefined;
const CONTEXT_FILE = env("PI_MNOTE_CONTEXT_FILE")
|| (fs.existsSync(DEFAULT_CONTEXT_FILE) ? 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);
@@ -119,6 +117,10 @@ function env(name: string): string {
return (process.env[name] || "").trim();
}
function contextFileExists(): boolean {
return Boolean(CONTEXT_FILE) && fs.existsSync(CONTEXT_FILE);
}
function parseJsonUnknown(raw: string): unknown {
if (!raw) return undefined;
try {
@@ -157,6 +159,23 @@ function stringField(record: Record<string, unknown>, key: string): string {
return typeof value === "string" ? value.trim() : "";
}
function recordField(record: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
if (!record) return undefined;
const value = record[key];
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
function selectedContextPart(context: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
return recordField(recordField(context, "selectedContext"), key);
}
function contextRefs(context: Record<string, unknown>): string[] {
const refs = context.contextRefs;
return Array.isArray(refs) ? refs.filter((ref): ref is string => typeof ref === "string") : [];
}
function toolResult(payload: Record<string, unknown>, isError = false) {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
@@ -201,10 +220,9 @@ function captureInputContext(event: Record<string, unknown>) {
};
}
function readContextSnapshot(): Record<string, unknown> {
if (liveContext) return liveContext;
if (!CONTEXT_FILE) {
throw new Error("PI_MNOTE_CONTEXT_FILE 未配置");
function readContextFileSnapshot(): Record<string, unknown> {
if (!contextFileExists()) {
throw new Error("MNote Pi 上下文快照不可用;Pi Rust bridge 需要 input hook、embedded context 或 context file");
}
const raw = fs.readFileSync(CONTEXT_FILE, "utf8");
const parsed = JSON.parse(raw);
@@ -214,6 +232,63 @@ function readContextSnapshot(): Record<string, unknown> {
return parsed as Record<string, unknown>;
}
function embeddedContextSnapshot(): Record<string, unknown> | undefined {
return EMBEDDED_CONTEXT && typeof EMBEDDED_CONTEXT === "object" && !Array.isArray(EMBEDDED_CONTEXT)
? EMBEDDED_CONTEXT
: undefined;
}
function activeContextSnapshot(): Record<string, unknown> | undefined {
try {
if (contextFileExists()) return readContextFileSnapshot();
} catch {
// Fall back to the input-hook snapshot when the host cannot read files.
}
return liveContext || embeddedContextSnapshot();
}
function readContextSnapshot(): Record<string, unknown> {
const snapshot = activeContextSnapshot();
if (snapshot) return snapshot;
return readContextFileSnapshot();
}
function bridgeToken(): string {
const fromEnv = env("PI_MNOTE_BRIDGE_TOKEN")
|| env("MNOTE_PI_BRIDGE_TOKEN")
|| env("MNOTE_PI_LAB_BRIDGE_TOKEN");
if (fromEnv) return fromEnv;
const fromContext = activeContextSnapshot();
if (fromContext) {
const value = stringField(fromContext, "bridgeToken");
if (value) return value;
}
return "";
}
function bridgeBaseUrl(): string {
const fromEnv = env("PI_MNOTE_BRIDGE_BASE_URL")
|| env("MNOTE_PI_BRIDGE_BASE_URL")
|| env("MNOTE_PI_LAB_BASE_URL");
if (fromEnv) return fromEnv;
const fromContext = activeContextSnapshot();
if (fromContext) {
const value = stringField(fromContext, "bridgeBaseUrl");
if (value) return value;
}
return "http://127.0.0.1:3000";
}
function bridgeSessionId(): string {
const fromContext = activeContextSnapshot();
const fromContextSession = fromContext ? stringField(fromContext, "sessionId") : "";
if (fromContextSession) return fromContextSession;
const fromEnv = env("PI_MNOTE_BRIDGE_SESSION_ID")
|| env("MNOTE_PI_BRIDGE_SESSION_ID");
if (fromEnv) return fromEnv;
return "";
}
function contextAllowedRoots(context: Record<string, unknown>): Record<string, unknown>[] {
const snapshot = context.allowedRoots;
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return [];
@@ -233,6 +308,7 @@ function resolveAllowedTarget(
context: Record<string, unknown>,
rootUri: string,
relativePath: string,
options?: { allowMissing?: boolean },
): { target: string; readPath: string } {
const roots = contextAllowedRoots(context);
const matchedRoot = roots.find((root) => stringField(root, "rootUri") === rootUri);
@@ -245,7 +321,9 @@ function resolveAllowedTarget(
const requestedTarget = path.isAbsolute(relativePath)
? path.resolve(relativePath)
: path.resolve(canonicalRoot, relativePath);
const canonicalTarget = fs.realpathSync(requestedTarget);
const canonicalTarget = options?.allowMissing
? canonicalOrParent(requestedTarget)
: fs.realpathSync(requestedTarget);
const allowedRoots = roots
.map((root) => stringField(root, "rootPath"))
.filter(Boolean)
@@ -261,16 +339,96 @@ function resolveAllowedTarget(
return { target: canonicalTarget, readPath };
}
function canonicalOrParent(target: string): string {
try {
return fs.realpathSync(target);
} catch {
const parent = path.dirname(target);
try {
return path.join(fs.realpathSync(parent), path.basename(target));
} catch {
return path.resolve(target);
}
}
}
function sessionRootUri(context: Record<string, unknown>, input: Record<string, unknown>): string {
return stringField(input, "rootUri")
|| stringField(input, "root_uri")
|| stringField(selectedContextPart(context, "currentPage") || {}, "rootUri")
|| stringField(selectedContextPart(context, "currentFolder") || {}, "rootUri")
|| stringField(context, "rootUri");
}
function joinRelativePath(basePath: string, childPath: string): string {
const normalizedChild = childPath.replace(/\\/g, "/").replace(/^\/+/, "");
const normalizedBase = basePath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
if (!normalizedBase || path.isAbsolute(childPath)) return normalizedChild;
if (!normalizedChild || normalizedChild === ".") return normalizedBase;
if (normalizedChild === normalizedBase || normalizedChild.startsWith(`${normalizedBase}/`)) return normalizedChild;
return `${normalizedBase}/${normalizedChild}`;
}
function sessionRelativePath(context: Record<string, unknown>, input: Record<string, unknown>, options?: { preferFolder?: boolean }): string {
const explicitPath = stringField(input, "relativePath")
|| stringField(input, "relative_path")
|| stringField(input, "path");
const folderPath = stringField(input, "folderPath")
|| stringField(input, "folder_path")
|| stringField(selectedContextPart(context, "currentFolder") || {}, "folderPath");
if (explicitPath) {
return options?.preferFolder ? joinRelativePath(folderPath, explicitPath) : explicitPath;
}
return stringField(input, "pagePath")
|| stringField(input, "page_path")
|| stringField(selectedContextPart(context, "currentPage") || {}, "pagePath")
|| folderPath
|| stringField(context, "pagePath");
}
function contextualToolParams(toolName: string, params: unknown): Record<string, unknown> {
const nextParams = { ...((params || {}) as Record<string, unknown>) };
const context = activeContextSnapshot();
if (!context) return nextParams;
const currentPage = selectedContextPart(context, "currentPage");
const currentFolder = selectedContextPart(context, "currentFolder");
const selection = selectedContextPart(context, "selection");
if (!stringField(nextParams, "rootUri")) {
nextParams.rootUri = stringField(currentPage || {}, "rootUri")
|| stringField(currentFolder || {}, "rootUri")
|| stringField(context, "rootUri")
|| undefined;
}
if (!stringField(nextParams, "workspaceId")) {
nextParams.workspaceId = stringField(currentPage || {}, "workspaceId")
|| stringField(currentFolder || {}, "workspaceId")
|| stringField(context, "workspaceId")
|| undefined;
}
if (!stringField(nextParams, "pagePath")) {
nextParams.pagePath = stringField(currentPage || {}, "pagePath") || undefined;
}
if (!stringField(nextParams, "folderPath")) {
nextParams.folderPath = stringField(currentFolder || {}, "folderPath") || undefined;
}
if (toolName === "mnote.selection.read") {
nextParams.selectionSource = "mnote_sidebar_host";
nextParams.selection = selection || null;
}
return nextParams;
}
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")
const currentPage = selectedContextPart(context, "currentPage");
const rootUri = stringField(currentPage || {}, "rootUri")
|| stringField(input, "rootUri")
|| stringField(input, "root_uri");
const pagePath = stringField(context, "pagePath")
const pagePath = stringField(currentPage || {}, "pagePath")
|| stringField(input, "pagePath")
|| stringField(input, "page_path")
|| stringField(input, "path");
@@ -291,6 +449,8 @@ function executeNativeCurrentPageRead(params: unknown) {
fileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
contextRefs: contextRefs(context),
selectedContextCurrentPage: currentPage ?? null,
});
} catch (error) {
return toolResult({
@@ -303,12 +463,118 @@ function executeNativeCurrentPageRead(params: unknown) {
}
}
function executeNativeLocalFileRead(params: unknown) {
try {
const input = params && typeof params === "object" && !Array.isArray(params)
? params as Record<string, unknown>
: {};
const context = readContextSnapshot();
const rootUri = sessionRootUri(context, input);
const relativePath = sessionRelativePath(context, input, { preferFolder: true });
if (!rootUri) throw new Error("读取文件缺少 rootUri");
if (!relativePath) throw new Error("读取文件缺少 relativePath/path");
const resolved = resolveAllowedTarget(context, rootUri, relativePath);
const content = fs.readFileSync(resolved.readPath, "utf8");
const stat = fs.statSync(resolved.readPath);
return toolResult({
ok: true,
rootUri,
relativePath,
path: resolved.target,
content,
contentLength: content.length,
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_local_file_read_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
}, true);
}
}
function applyTextOperations(current: string, operations: unknown): string {
if (!Array.isArray(operations)) throw new Error("operations 必须是数组");
let next = current;
for (const operation of operations) {
if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
throw new Error("operation 格式无效");
}
const op = operation as Record<string, unknown>;
const type = stringField(op, "type") || stringField(op, "op");
if (type === "replace") {
const oldText = typeof op.oldText === "string" ? op.oldText : typeof op.old_text === "string" ? op.old_text : "";
const newText = typeof op.newText === "string" ? op.newText : typeof op.new_text === "string" ? op.new_text : "";
if (!oldText) throw new Error("replace operation 缺少 oldText");
const index = next.indexOf(oldText);
if (index < 0) throw new Error("replace operation 未找到 oldText");
next = next.slice(0, index) + newText + next.slice(index + oldText.length);
} else if (type === "append") {
const text = typeof op.text === "string" ? op.text : "";
next += text;
} else {
throw new Error(`不支持的 operation: ${type || "unknown"}`);
}
}
return next;
}
function executeNativeLocalFilePatch(params: unknown) {
try {
const input = params && typeof params === "object" && !Array.isArray(params)
? params as Record<string, unknown>
: {};
const context = readContextSnapshot();
const rootUri = sessionRootUri(context, input);
const relativePath = sessionRelativePath(context, input, { preferFolder: true });
if (!rootUri) throw new Error("写入文件缺少 rootUri");
if (!relativePath) throw new Error("写入文件缺少 relativePath/path");
const resolved = resolveAllowedTarget(context, rootUri, relativePath, { allowMissing: true });
const before = fs.existsSync(resolved.target) ? fs.readFileSync(resolved.target, "utf8") : "";
const next = typeof input.content === "string"
? input.content
: applyTextOperations(before, input.operations);
fs.mkdirSync(path.dirname(resolved.target), { recursive: true });
fs.writeFileSync(resolved.target, next, "utf8");
const stat = fs.statSync(resolved.target);
return toolResult({
ok: true,
rootUri,
relativePath,
path: resolved.target,
beforeFileVersion: `pi-rust-native-before-${before.length}`,
afterFileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
oldSize: before.length,
newSize: next.length,
diffSummary: before === next ? "no_changes" : `bytes_delta=${next.length - before.length}`,
refresh: "mnote local-folder watcher / document-session external refresh",
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_local_file_patch_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
}, true);
}
}
function executeNativeSelectionRead() {
try {
const context = readContextSnapshot();
const selection = selectedContextPart(context, "selection");
return toolResult({
ok: true,
selection: context.selectedContext ?? null,
selection: selection ?? null,
text: stringField(selection || {}, "text"),
selectionSource: "mnote_sidebar_host_snapshot",
rootUri: context.rootUri ?? null,
pagePath: context.pagePath ?? null,
@@ -333,6 +599,9 @@ function executeNativeAllowedRootsDescribe() {
allowedRoots: context.allowedRoots ?? { roots: [] },
rootUri: context.rootUri ?? null,
pagePath: context.pagePath ?? null,
workspaceId: context.workspaceId ?? null,
contextRefs: context.contextRefs ?? [],
selectedContext: context.selectedContext ?? null,
primaryRootPath: context.primaryRootPath ?? process.cwd(),
transport: "pi-rust-native-context-file",
});
@@ -346,31 +615,63 @@ function executeNativeAllowedRootsDescribe() {
}
}
function isPiRustNativeRuntime(): boolean {
return runtimeImplementation() === "pi-rust" || Boolean(CONTEXT_FILE);
}
function executeNativeTool(tool: MnoteToolManifestEntry, params: unknown) {
if (runtimeImplementation() !== "pi-rust" || toolPolicy(tool.mnoteName) !== "allow") return undefined;
if (!isPiRustNativeRuntime() || 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();
if (tool.mnoteName === "mnote.local_file.read") return executeNativeLocalFileRead(params);
if (tool.mnoteName === "mnote.local_file.patch") return executeNativeLocalFilePatch(params);
return undefined;
}
async function callMnote(toolName: string, params: unknown) {
const response = await fetch(`${BASE_URL}/api/page-ai/pi/tool-call-bridge`, {
const sessionId = bridgeSessionId();
if (!sessionId) {
return toolResult({
ok: false,
code: "mnote_pi_bridge_session_id_missing",
message: "MNote Pi bridge 缺少有效的 pi_lab sessionId;已忽略 Pi runtime provider session id。",
toolName,
transport: "mnote-bridge-http",
}, true);
}
const response = await fetch(`${bridgeBaseUrl()}/api/page-ai/pi/tool-call-bridge`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN,
"x-mnote-pi-lab-bridge-token": bridgeToken(),
},
body: JSON.stringify({ sessionId: SESSION_ID, toolName, params: params || {} }),
body: JSON.stringify({ sessionId, 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);
const result = (payload as Record<string, unknown>).result || payload;
const text = JSON.stringify(modelSafeBridgeResult(toolName, result), null, 2);
return {
content: [{ type: "text", text }],
details: payload,
};
}
function modelSafeBridgeResult(toolName: string, value: unknown): unknown {
if (toolName !== "mnote.knowledge_rag.query" || !value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result = { ...(value as Record<string, unknown>) };
delete result.uiCitations;
delete result.citationMarkdowns;
delete result.citationRendering;
result.uiCitationCount = Array.isArray((value as Record<string, unknown>).uiCitations)
? ((value as Record<string, unknown>).uiCitations as unknown[]).length
: 0;
result.uiCitationDelivery = "Clickable citations are retained in tool details for the MNote UI and omitted from model context.";
return result;
}
function stableJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
@@ -394,8 +695,8 @@ function paramsHash(params: unknown): string {
}
function toolPolicy(toolName: string): string {
const contextPolicies = liveContext?.toolPolicies;
const contextValue = contextPolicies && typeof contextPolicies === "object" && !Array.isArray(contextPolicies)
const contextPolicies = activeContextSnapshot()?.toolPolicies;
let contextValue = contextPolicies && typeof contextPolicies === "object" && !Array.isArray(contextPolicies)
? (contextPolicies as Record<string, unknown>)[toolName]
: undefined;
const value = contextValue ?? TOOL_POLICIES[toolName];
@@ -403,17 +704,27 @@ function toolPolicy(toolName: string): string {
}
function runtimeImplementation(): string {
return liveContext ? stringField(liveContext, "runtimeImplementation") || RUNTIME_IMPL : RUNTIME_IMPL;
const fromContext = activeContextSnapshot();
return fromContext ? stringField(fromContext, "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`, {
const sessionId = bridgeSessionId();
if (!sessionId) {
return {
ok: false,
cancelled: true,
code: "mnote_pi_bridge_session_id_missing",
message: "MNote Pi bridge 缺少有效的 pi_lab sessionId",
};
}
const response = await fetch(`${bridgeBaseUrl()}/api/page-ai/pi/ui-request-bridge`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN,
"x-mnote-pi-lab-bridge-token": bridgeToken(),
},
body: JSON.stringify({ sessionId: SESSION_ID, ...request }),
body: JSON.stringify({ sessionId, ...request }),
});
return await response.json().catch(() => ({ ok: false, cancelled: true, code: "bad_json" }));
}
@@ -467,11 +778,11 @@ function register(pi: ExtensionAPI, tool: MnoteToolManifestEntry) {
}
const nativeResult = executeNativeTool(tool, params);
if (nativeResult) return nativeResult;
if (runtimeImplementation() === "pi-rust" && !HTTP_BRIDGE_AVAILABLE) {
if (runtimeImplementation() === "pi-rust" && !HTTP_BRIDGE_AVAILABLE && !bridgeToken()) {
return toolResult({
ok: false,
code: "mnote_pi_rust_service_bridge_unavailable",
message: `${tool.mnoteName} 仍依赖旧 HTTP bridge;Pi Rust 当前应通过原生文件工具或专用 MCP 扩展调用。`,
message: `${tool.mnoteName} 缺少 MNote Pi bridge token;Pi Rust 当前应通过原生文件工具或 MNote 受控 bridge 调用。`,
toolName: tool.mnoteName,
runtimeImplementation: runtimeImplementation(),
}, true);
@@ -487,7 +798,7 @@ async function executeBridgeTool(
ctx: ExtensionContext | undefined,
tool: MnoteToolManifestEntry,
) {
const nextParams = { ...((params || {}) as Record<string, unknown>) };
const nextParams = contextualToolParams(tool.mnoteName, params);
const approval = await requestMnoteApproval(ctx, String(toolCallId || ""), tool.mnoteName, tool.label, nextParams);
if (approval && approval.denied) {
return {
@@ -10,7 +10,6 @@
*/
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -62,9 +61,6 @@ function executeMcpRequest(params: {
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,
@@ -5,6 +5,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
export type AgentScope = "user" | "project" | "both";
@@ -97,9 +98,20 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
const bundledDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "agents");
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
const bundledAgents = loadAgentsFromDir(bundledDir, "user").map((agent) => ({
...agent,
model: undefined,
}));
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
const userAgents =
scope === "project"
? []
: [
...bundledAgents,
...loadAgentsFromDir(userDir, "user"),
];
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
const agentMap = new Map<string, AgentConfig>();
@@ -16,6 +16,7 @@ import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
import { StringEnum } from "@mariozechner/pi-ai";
@@ -27,6 +28,22 @@ import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
const MAX_PARALLEL_TASKS = 8;
const MAX_CONCURRENCY = 4;
const COLLAPSED_ITEM_COUNT = 10;
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
const MNOTE_CONTEXT_PATH = path.join(path.dirname(EXTENSION_DIR), "mnote-bridge", "mnote-context.json");
function readMnoteParentModel(): { provider?: string; id?: string } | undefined {
try {
const payload = JSON.parse(fs.readFileSync(MNOTE_CONTEXT_PATH, "utf-8")) as {
modelProvider?: unknown;
modelId?: unknown;
};
const provider = typeof payload.modelProvider === "string" ? payload.modelProvider.trim() : "";
const id = typeof payload.modelId === "string" ? payload.modelId.trim() : "";
return provider || id ? { provider: provider || undefined, id: id || undefined } : undefined;
} catch {
return undefined;
}
}
function formatTokens(count: number): string {
if (count < 1000) return count.toString();
@@ -227,6 +244,7 @@ async function runSingleAgent(
signal: AbortSignal | undefined,
onUpdate: OnUpdateCallback | undefined,
makeDetails: (results: SingleResult[]) => SubagentDetails,
parentModel: { provider?: string; id?: string } | undefined,
): Promise<SingleResult> {
const agent = agents.find((a) => a.name === agentName);
@@ -244,7 +262,12 @@ async function runSingleAgent(
}
const args: string[] = ["--mode", "json", "-p", "--no-session"];
if (agent.model) args.push("--model", agent.model);
if (agent.model) {
args.push("--model", agent.model);
} else {
if (parentModel?.provider) args.push("--provider", parentModel.provider);
if (parentModel?.id) args.push("--model", parentModel.id);
}
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
let tmpPromptDir: string | null = null;
@@ -258,7 +281,7 @@ async function runSingleAgent(
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
model: agent.model,
model: agent.model ?? (parentModel?.provider && parentModel.id ? `${parentModel.provider}/${parentModel.id}` : undefined),
step,
};
@@ -411,12 +434,17 @@ export default function (pi: ExtensionAPI) {
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).',
'Default agent scope is "user" (bundled agents plus ~/.pi/agent/agents overrides).',
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
].join(" "),
parameters: SubagentParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const mnoteModel = readMnoteParentModel();
const parentModel = {
provider: mnoteModel?.provider ?? process.env.MNOTE_PI_MODEL_PROVIDER ?? ctx.model?.provider,
id: mnoteModel?.id ?? process.env.MNOTE_PI_MODEL_ID ?? ctx.model?.id,
};
const agentScope: AgentScope = params.agentScope ?? "user";
const discovery = discoverAgents(ctx.cwd, agentScope);
const agents = discovery.agents;
@@ -504,10 +532,11 @@ export default function (pi: ExtensionAPI) {
taskWithContext,
step.cwd,
i + 1,
signal,
chainUpdate,
makeDetails("chain"),
);
signal,
chainUpdate,
makeDetails("chain"),
parentModel,
);
results.push(result);
const isError =
@@ -585,9 +614,10 @@ export default function (pi: ExtensionAPI) {
allResults[index] = partial.details.results[0];
emitParallelUpdate();
}
},
makeDetails("parallel"),
);
},
makeDetails("parallel"),
parentModel,
);
allResults[index] = result;
emitParallelUpdate();
return result;
@@ -618,10 +648,11 @@ export default function (pi: ExtensionAPI) {
params.task,
params.cwd,
undefined,
signal,
onUpdate,
makeDetails("single"),
);
signal,
onUpdate,
makeDetails("single"),
parentModel,
);
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) {
const errorMsg =