/** * pi-coop extension:让 pi 适配 blackboard 协作协议。 * * 做两件事: * 1. 注册 "coop" provider —— 从环境变量 BASE_URL / API_KEY / PROTOCOL / MODEL * 构造一个 OpenAI 兼容或 Anthropic 兼容的 provider,使 supervisor 注入的 * 模型配置(与 pigo-coop 完全相同的环境变量)能直接被 pi 使用。 * 2. 注册 "blackboard" 工具 —— 复刻 pigo 的 blackboard 工具(read / post / done), * 让 agent 通过 action=done 原子创建 /blackboard/DONE 标记,supervisor 据此判定 * 任务完成。AGENTS.md 协议因此无需改动。 * * 与 pigo blackboard_tool.go 行为对齐:post 用 O_APPEND 单次写(原子), * done 用 O_CREAT|O_EXCL('wx',保证唯一创建),路径全部做 traversal 校验。 */ import { Type } from "@earendil-works/pi-ai"; import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { openSync, closeSync, writeFileSync, appendFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync } from "node:fs"; import { join, resolve, sep, isAbsolute, clean, basename } from "node:path"; // 单条 post / 单文件 read 上限:放开到 128KB,复杂任务的脚本与日志能整段读写。 const MAX_POST_BYTES = 128 * 1024; const MAX_READ_BYTES = 128 * 1024; // provider 协议 → pi api 类型映射。PROTOCOL 由 supervisor 从外部注入 // (取值 openai | anthropic,见 agent api_config.go Provider())。 function protocolToApi(protocol: string): "openai-completions" | "anthropic-messages" { return protocol === "anthropic" ? "anthropic-messages" : "openai-completions"; } export default function (pi: ExtensionAPI) { const baseUrl = process.env.BASE_URL ?? ""; const apiKey = process.env.API_KEY ?? ""; const protocol = process.env.PROTOCOL ?? "openai"; const modelId = process.env.MODEL ?? ""; const bbRoot = process.env.BB ?? "/blackboard"; if (!baseUrl || !apiKey || !modelId) { // supervisor 已在前置校验里保证这三个变量非空,这里只做兜底: // 若 provider 注册失败,pi 启动时会报 model 无法解析,比这里静默更好。 throw new Error(`coop extension: BASE_URL / API_KEY / MODEL 环境变量必须同时提供`); } // 注册 coop provider。models 数组在工厂里动态构造(id 取自 MODEL), // 这样 --provider coop --model "$MODEL" 即可命中。 pi.registerProvider("coop", { baseUrl, apiKey, api: protocolToApi(protocol), models: [ { id: modelId, name: modelId, reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, // 完全放开上下文与输出限制,让复杂任务能充分展开思考与多轮工具调用。 contextWindow: 128000, maxTokens: 65536, }, ], }); // ---- blackboard 工具 ---- const blackboardTool = defineTool({ name: "blackboard", label: "Blackboard", description: "Read and write the task blackboard used by the coop runner. This is the ONLY tool that may touch shared blackboard files atomically. " + "Actions: read (no path: global snapshot of task.md, workspace listing, DONE state; with path like \"workspace/exploit.py\": contents of that one file); " + "post (atomically append a progress note; file must be a bare .md name under messages/, e.g. \"round-1-a.md\"); " + "done (atomically create the DONE marker with a final delivery summary, only when the deliverable is truly complete; fails if DONE already exists). " + "The blackboard root, current round and your name are available in the environment as BB, ROUND, NAME.", parameters: Type.Object({ action: Type.String({ description: "read | post | done" }), path: Type.Optional(Type.String({ description: 'For read: a path under the blackboard root to fetch (e.g. messages/round-1-a.md). Omit for the global snapshot.' })), file: Type.Optional(Type.String({ description: 'For post: bare file name under messages/, must end in .md (e.g. round-1-a.md).' })), content: Type.Optional(Type.String({ description: "For post: the message body (max 128 KiB)." })), summary: Type.Optional(Type.String({ description: "For done: final delivery summary written into DONE." })), }), async execute(_toolCallId, params) { const action = String(params.action ?? ""); try { switch (action) { case "read": return read(params.path); case "post": return post(params.file, params.content); case "done": return done(params.summary); default: return err(`blackboard: unknown action "${action}" (want read|post|done)`); } } catch (e) { return err(`blackboard ${action}: ${e instanceof Error ? e.message : String(e)}`); } }, }); pi.registerTool(blackboardTool); // ---- 实现部分 ---- // safePath:把相对路径解析到 blackboard 根内,拒绝绝对路径与 .. 越界。 // 与 pigo blackboard_tool.go safePath 行为一致。 function safePath(p: string): string { const trimmed = (p ?? "").trim(); if (trimmed === "") throw new Error("empty path"); const c = clean(trimmed); if (isAbsolute(c)) throw new Error(`path "${p}" must be relative to the blackboard root`); const rootClean = clean(bbRoot); const full = join(rootClean, c); if (full !== rootClean && !full.startsWith(rootClean + sep)) { throw new Error(`path "${p}" escapes the blackboard root`); } return full; } function ok(text: string) { return { content: [{ type: "text" as const, text }] }; } function err(text: string) { return { content: [{ type: "text" as const, text }], isError: true }; } function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "\n..." : s; } // read:无 path → 全局快照(task.md / messages 列表 / workspace 列表 / DONE 状态); // 有 path → 单文件内容。 function read(pathParam: unknown) { const p = pathParam != null ? String(pathParam) : ""; if (p.trim() !== "") { return readFile(p); } const b: string[] = []; // task.md try { const data = readFileSync(join(bbRoot, "task.md"), "utf8"); b.push("# task.md\n" + truncate(data, MAX_READ_BYTES) + "\n"); } catch { b.push("# task.md\n\n"); } // messages/ b.push("\n# messages/ (" + listNames("messages").join(", ") + ")\n"); for (const name of listListing("messages")) b.push(" - " + name + "\n"); // workspace/ b.push("\n# workspace/ (your workspace)\n"); for (const name of listListing("workspace")) b.push(" - " + name + "\n"); // DONE let doneText = ""; try { doneText = truncate(readFileSync(join(bbRoot, "DONE"), "utf8"), MAX_READ_BYTES); } catch {} b.push("\n# DONE\n"); b.push(doneText ? "EXISTS:\n" + doneText + "\n" : "\n"); // environment const env: string[] = []; for (const k of ["BB", "ROUND", "NAME"]) { const v = (process.env[k] ?? "").trim(); if (v) env.push(` ${k}=${v}`); } if (env.length) b.push("\n# environment\n" + env.join("\n") + "\n"); return ok(b.join("")); } function readFile(p: string) { const full = safePath(p); let info; try { info = statSync(full); } catch (e) { return err(`blackboard read: ${p}: ${e instanceof Error ? e.message : String(e)}`); } if (info.isDirectory()) return err(`blackboard read: ${p} is a directory; only files can be read`); const data = readFileSync(full, "utf8"); return ok("# " + p + "\n" + truncate(data, MAX_READ_BYTES)); } // post:原子追加一条消息到 messages/。file 必须是裸 *.md 名。 function post(fileParam: unknown, contentParam: unknown) { const name = String(fileParam ?? "").trim(); if (!validMessageName(name)) { return err('blackboard post: file must be a bare name ending in .md (e.g. "round-1-a.md"), no path separators, no ".."'); } const content = String(contentParam ?? "").trim(); if (content === "") return err("blackboard post: content must not be empty"); if (Buffer.byteLength(content) > MAX_POST_BYTES) { return err(`blackboard post: content too large (${Buffer.byteLength(content)} bytes, max ${MAX_POST_BYTES})`); } const dir = join(bbRoot, "messages"); mkdirSync(dir, { recursive: true }); // O_APPEND 单次写:POSIX 下并发 post 不会交错字节。 appendFileSync(join(dir, name), content + "\n"); return ok(`Message appended to messages/${name}`); } // done:原子创建 DONE 标记。'wx' = O_CREAT|O_EXCL|O_WRONLY,保证唯一创建。 function done(summaryParam: unknown) { const summary = String(summaryParam ?? "").trim(); if (summary === "") return err("blackboard done: summary must not be empty (include the final delivery summary)"); const header = "Blackboard cooperation DONE\ncreated: " + new Date().toISOString() + "\n\n"; const target = join(bbRoot, "DONE"); let fd: number; try { fd = openSync(target, "wx"); } catch (e) { if (existsSync(target)) { const existing = readFileSync(target, "utf8"); return err("blackboard done: DONE already exists — cooperation already finished:\n" + truncate(existing, MAX_READ_BYTES)); } return err("blackboard done: " + (e instanceof Error ? e.message : String(e))); } try { writeFileSync(fd, header + summary); } finally { closeSync(fd); } return ok("DONE marker created. Cooperation finished."); } // 校验 post 文件名:裸 *.md,无分隔符,无 . / .. function validMessageName(name: string): boolean { if (name === "" || !name.endsWith(".md")) return false; if (/[\\/]/.test(name) || name === "." || name === "..") return false; const base = name.slice(0, -3); if (base === "" || base.startsWith(".") || base.includes("..")) return false; return true; } function listNames(sub: string): string[] { try { return readdirSync(join(bbRoot, sub)).sort(); } catch { return []; } } function listListing(sub: string): string[] { try { return readdirSync(join(bbRoot, sub)) .sort() .map((name) => { try { const info = statSync(join(bbRoot, sub, name)); if (info.isDirectory()) return `${name} (dir)`; return `${name} (${info.size} bytes, ${info.mtime.toISOString().slice(11, 19)})`; } catch { return name; } }); } catch { return []; } } }