From 2036461a5e9b0bc8e86312158648585f4df7c446 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 30 Sep 2026 20:12:07 +0800 Subject: [PATCH 1/2] feat(backend): upgrade Pi to 0.99.1 and load codemode, tool_search and MCP Pi 0.99 ships codemode, tool_search and MCP as CLI built-in extensions that SDK sessions do not load. Pace now supplies them in the CLI's builtin shape so -builtin: and third-party replacement behave as in the CLI, and emits the codemode worker and QuickJS wasm beside Pi's config chunk so the bundled and packaged backend can run codemode scripts without node_modules. --- apps/desktop/electron.vite.config.ts | 90 +++++++++++ bun.lock | 24 +-- docs/adr/0046-load-pi-builtin-extensions.md | 29 ++++ packages/backend/package.json | 4 +- .../src/drivers/pi-builtin-extensions.test.ts | 57 +++++++ .../src/drivers/pi-builtin-extensions.ts | 22 +++ .../src/drivers/pi-sdk-runtime-adapter.ts | 4 +- .../src/drivers/session-process-entry.ts | 6 +- packages/backend/src/service.ts | 2 + .../backend/src/workspace/prompt-commands.ts | 6 +- packages/core/src/prompt-catalog.ts | 4 +- scripts/test-bundled-runtime.mjs | 152 ++++++++++++++++++ 12 files changed, 381 insertions(+), 19 deletions(-) create mode 100644 docs/adr/0046-load-pi-builtin-extensions.md create mode 100644 packages/backend/src/drivers/pi-builtin-extensions.test.ts create mode 100644 packages/backend/src/drivers/pi-builtin-extensions.ts diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 18ddb918..23629790 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -25,6 +25,25 @@ const photonWasmPath = requireFromPi.resolve( "@silvia-odwyer/photon-node/photon_rs_bg.wasm", ); +// Pi's config.js is the anchor for codemode's bundled runtime: the emitted +// codemode-worker.js and quickjs.wasm resolve relative to whichever chunk ends +// up containing it, so both the transform and the guard below key off its id. +const piConfigModuleId = realpathSync(join(piPackageDirectory, "dist/config.js")); +const piCodemodeWorkerEntry = join( + piPackageDirectory, + "dist/extensions/codemode/worker.js", +); +// quickjs-wasi is a dependency of pi-codemode, not pi-coding-agent, so resolve +// the wasm through pi-codemode's own package the way its imports do: pi's deps +// sit beside it in the same scope directory (bun isolated install), which is +// also where Node's upward node_modules walk finds it. +const piCodemodeDirectory = realpathSync( + join(dirname(piPackageDirectory), "pi-codemode"), +); +const quickJsWasmPath = createRequire( + join(piCodemodeDirectory, "package.json"), +).resolve("quickjs-wasi/quickjs.wasm"); + function copyMainRuntimeAssets(): Plugin { return { name: "pigui-copy-main-runtime-assets", @@ -38,6 +57,12 @@ function copyMainRuntimeAssets(): Plugin { await mkdir(dirname(outputPath), { recursive: true }); await copyFile(photonWasmPath, outputPath); + // Codemode's QuickJS runtime is looked up beside the chunk that contains + // pi's dist/config.js (see rewritePiQuickJsWasmResolution). + await copyFile( + quickJsWasmPath, + resolve(options.dir, "chunks/quickjs.wasm"), + ); // Pi resolves built-in themes through its public package asset directory. const themes = "dist/modes/interactive/theme"; const themeDirectory = resolve(options.dir, "pi-assets", themes); @@ -67,6 +92,66 @@ const __dirname = import.meta.dirname; const require = __cjs_mod__.createRequire(import.meta.url); `; +// Pi's dist/config.js resolves the codemode QuickJS wasm through createRequire, +// which cannot work in the packaged app: electron-builder ships no node_modules. +// Point it at the copy emitted beside the chunk instead. The replacement must +// match exactly once so a Pi upgrade that rewrites this lookup fails the build +// loudly instead of silently breaking codemode. +const QUICKJS_WASM_REQUIRE = + 'createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm")'; + +function rewritePiQuickJsWasmResolution(): Plugin { + return { + name: "pigui-pi-quickjs-wasm-path", + apply: "build", + transform(code, id) { + if (id !== piConfigModuleId) { + return null; + } + const occurrences = code.split(QUICKJS_WASM_REQUIRE).length - 1; + if (occurrences !== 1) { + throw new Error( + `Expected exactly one "${QUICKJS_WASM_REQUIRE}" in ${id}, found ${occurrences}.`, + ); + } + return { + code: + 'import { fileURLToPath as __paceFileURLToPath } from "node:url";\n' + + code.replace( + QUICKJS_WASM_REQUIRE, + '__paceFileURLToPath(new URL("./quickjs.wasm", import.meta.url))', + ), + map: null, + }; + }, + }; +} + +// getCodemodeWorkerUrl() and the wasm lookup above both resolve relative to the +// emitted chunk that contains pi's dist/config.js. If chunk splitting ever moves +// it out of chunks/, both files land in the wrong place — fail the build. +function assertPiConfigChunkLocation(): Plugin { + return { + name: "pigui-pi-config-chunk-location", + apply: "build", + generateBundle(_options, bundle) { + const chunk = Object.values(bundle).find( + (output) => + output.type === "chunk" && output.moduleIds.includes(piConfigModuleId), + ); + if (!chunk) { + throw new Error("No emitted main chunk contains pi's dist/config.js."); + } + if (!chunk.fileName.startsWith("chunks/")) { + throw new Error( + `Pi's dist/config.js must stay under chunks/ (emitted as ${chunk.fileName}); ` + + "codemode-worker.js and quickjs.wasm resolve relative to it.", + ); + } + }, + }; +} + function hoistCommonJsShim(): Plugin { return { name: "pigui-hoist-cjs-shim", @@ -86,6 +171,9 @@ const mainBuild = { main: resolve(__dirname, "electron/main.ts"), backend: resolve(__dirname, "electron/backend.ts"), "session-worker": resolve(__dirname, "electron/session-worker.ts"), + // Pi's bundled codemode entry; getCodemodeWorkerUrl() resolves + // ./codemode-worker.js beside the chunk containing dist/config.js. + "chunks/codemode-worker": piCodemodeWorkerEntry, }, output: { entryFileNames: "[name].js", @@ -148,6 +236,8 @@ export default defineConfig({ plugins: [ externalizeDepsPlugin({ exclude: [...internalPackages, "electron-updater"] }), copyMainRuntimeAssets(), + rewritePiQuickJsWasmResolution(), + assertPiConfigChunkLocation(), hoistCommonJsShim(), ], build: mainBuild as any, diff --git a/bun.lock b/bun.lock index 5f1b61ce..61d46828 100644 --- a/bun.lock +++ b/bun.lock @@ -81,8 +81,8 @@ "name": "@pace/backend", "version": "0.1.0", "dependencies": { - "@earendil-works/pi-ai": "0.87.1", - "@earendil-works/pi-coding-agent": "0.87.1", + "@earendil-works/pi-ai": "0.99.1", + "@earendil-works/pi-coding-agent": "0.99.1", "@pace/core": "workspace:*", }, }, @@ -278,17 +278,21 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@earendil-works/chord": ["@earendil-works/chord@0.87.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg=="], + "@earendil-works/chord": ["@earendil-works/chord@0.99.1", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-4xyn0IBzJ+Xu/iOGi2hjXJGAR61QEhEWZsIqTDqr+GmItdquYwBO5jYFnqGiBaTqlY12/EpM7QHoEKSHbyvOug=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-telemetry": "^0.87.1", "diff": "8.0.4", "ignore": "7.0.8", "typebox": "1.3.27", "yaml": "2.9.0" } }, "sha512-Zev3B0HK7YS5A4EZQ2XnEqiJuirx6QBiltJ+LpmjV5a/+2IU0cfKtIfnkNkORK707XOvKBY2WRtk7cAwHpbh2Q=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.99.1", "", { "dependencies": { "@earendil-works/chord": "^0.99.1", "@earendil-works/pi-ai": "^0.99.1", "@earendil-works/pi-telemetry": "^0.99.1", "diff": "8.0.4", "ignore": "7.0.8", "typebox": "1.3.27", "yaml": "2.9.0" } }, "sha512-zywvWnj5FujeuFI/x/CJHwwxhcLIQgjqseTA+bQgX4O8gJTcgjRd/I8SZnQDqJvxC9QcV12ujiGLviv6EgwcCg=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.87.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^0.87.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "6.40.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.99.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.124.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^0.99.1", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-4nV9JKc94iPX8bwdGPc2nTuVPKIPsffhnp3WoN9NYCNqbtoOF8LhYcIs/+Sn/alroqJK/5QRu6/Z6Ck+n0hyBA=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.87.1", "", { "dependencies": { "@earendil-works/chord": "^0.87.1", "@earendil-works/pi-agent-core": "^0.87.1", "@earendil-works/pi-ai": "^0.87.1", "@earendil-works/pi-tui": "^0.87.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-m8ArJUtVcQMSe1lLE/Ei7vX/JV7O39sWmWBsXV2NOU70F0qCp8GubA24pT3LnwTmM6LL2xV80/h6sQg85n69ew=="], + "@earendil-works/pi-codemode": ["@earendil-works/pi-codemode@0.99.1", "", { "dependencies": { "quickjs-wasi": "3.6.2" } }, "sha512-oh8TMsBI3SWTN3xTQtX8u5n+BKhnVXcFagroWumfn6/WWfBnDYL/LmeQtjLb83WRTb9rcu+ZdK8rFa4vggvCJg=="], - "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.87.1", "", {}, "sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.99.1", "", { "dependencies": { "@earendil-works/chord": "^0.99.1", "@earendil-works/pi-agent-core": "^0.99.1", "@earendil-works/pi-ai": "^0.99.1", "@earendil-works/pi-codemode": "^0.99.1", "@earendil-works/pi-mcp": "^0.99.1", "@earendil-works/pi-tui": "^0.99.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-cWUrTOqA5M73cOYMgsh9PlhDrsBhavd+n5kVY6F7BGbGl1RjqCteVCoeVMVqhngoGACVDyw1tbLjajL8l9jrHg=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.87.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-YEH2vRyOeiO7hhN6j6AE6YwKSq2Kz2f3XR8bj1TbR+aGE/JsnY1hLPMI2pvaZfRM1n9Y00tejxFQ4zbzvF7nkQ=="], + "@earendil-works/pi-mcp": ["@earendil-works/pi-mcp@0.99.1", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-YCFGPkmDzLwQuIzwfbP6Vuk/g/ukKpZhwTpbcfzomuI1Fkiu6hHRkOGwAqsO3G8cTkZWkM8vmOkFJjStQNC4qA=="], + + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@0.99.1", "", {}, "sha512-9PBPjGk+TXRtuMianpqBbHBpYpyKusESF6rwdmgD0WTZSTUQXhcKEO0hAINRLuSwy4V7yPvXV+EVV0ONY7mbpQ=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.99.1", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-gZp0Guat96Fr1AuC/xqVz5B2lulZakp/PxD1lXx3lSgBdjiqmwYhJbcQ0HRrGAfy0WtMGn9b05RJr5qJf7oIuw=="], "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.4", "", {}, "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg=="], @@ -2030,7 +2034,7 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "openai": ["openai@6.40.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA=="], + "openai": ["openai@7.19.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "undici": ">=5 <9", "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "undici", "ws", "zod"] }, "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -2142,6 +2146,8 @@ "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "quickjs-wasi": ["quickjs-wasi@3.6.2", "", {}, "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg=="], + "rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="], "rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="], diff --git a/docs/adr/0046-load-pi-builtin-extensions.md b/docs/adr/0046-load-pi-builtin-extensions.md new file mode 100644 index 00000000..4458b36e --- /dev/null +++ b/docs/adr/0046-load-pi-builtin-extensions.md @@ -0,0 +1,29 @@ +# ADR-0046:在 SDK 会话中加载 Pi 内建扩展 + +- 状态:Accepted +- 日期:2026-09-30 +- 来源:Pi 0.99 升级 + +## 背景 + +Pi 0.99 新增了 codemode、tool_search、MCP 三项能力,形态是 CLI 内建扩展:`pi` 二进制在启动时以 `{ name, factory, replaceable: true, builtin: true }` 的形式注册它们,但 SDK 会话(`createAgentSession` / `DefaultResourceLoader`)不会自动加载。Pace 每个 root Session 都跑在独立进程里嵌 Pi SDK(`SessionProcessDriver` + `pi-sdk-driver`,ADR-0040/0041),不补上这三个工厂,Pace 会话里就没有 codemode、`/mcp`、tool_search,`mcp.json` 里配置的服务器也不会连接。 + +Pi 同时改了打包形态:codemode 的脚本在 QuickJS WASM VM 里跑(`quickjs-wasi`),执行发生在独立 worker 线程里。`dist/config.js` 用 `createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm")` 找 wasm,并用 `new URL("./codemode-worker.js", import.meta.url)` 找打包构建里与 config 同 chunk 目录的 worker 入口(`dist/extensions/codemode/worker.js`,只做 `import "@earendil-works/pi-codemode/worker"`)。Pace 把 Pi SDK 打进 `apps/desktop/out/main`(electron-vite),打包产物里没有任何 node_modules,所以这两个相对资源都得由我们的构建产出。 + +## 决策 + +1. 新增 `packages/backend/src/drivers/pi-builtin-extensions.ts` 导出 `createPaceBuiltInExtensions(): InlineExtension[]`,按 CLI 的形状返回 codemode、tool-search、mcp 三条(各自 `replaceable: true, builtin: true`,工厂为无参 `createCodemodeExtension()` / `createToolSearchExtension()` / `createMcpExtension()`,让用户设置如 `codemode.mode` 生效)。两处构造 `DefaultResourceLoader` 的地方——`service.ts` 与 `session-process-entry.ts` 的 `sessionOptionsFor`——都传 `extensionFactories: createPaceBuiltInExtensions()`。`builtin: true` 让这些条目以 `builtin:` 资源加载:默认启用、`settings.json` 里 `-builtin:` 可禁用、`pi config` 可见;`replaceable: true` 让注册了同名 tool/command 的第三方扩展接管而不是报冲突,与 CLI 行为一致。llama.cpp 内建(`builtin:llama.cpp`)刻意先不加载。 +2. 打包(`apps/desktop/electron.vite.config.ts`): + - main 构建新增 rollup input `chunks/codemode-worker` → Pi 包的 `dist/extensions/codemode/worker.js`,配合 `entryFileNames: "[name].js"` 产出 `out/main/chunks/codemode-worker.js`,正好落在 `getCodemodeWorkerUrl()` 的相对 URL 上。 + - 一个 `transform` 插件对 pi 的 `dist/config.js`(按 realpath 匹配)把 `createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm")` 替换为相对同 chunk 的 `new URL("./quickjs.wasm", import.meta.url)`;目标文本必须恰好出现一次,否则构建报错,Pi 升级改动这段代码时会立刻暴露。 + - `copyMainRuntimeAssets` 另外把 `quickjs.wasm`(经 pi-codemode 包解析,因为它是 pi-codemode 的依赖而非 pi-coding-agent 的)复制到 `chunks/quickjs.wasm`。 + - `generateBundle` 里加守卫:找到包含 pi `dist/config.js` 的输出 chunk,若不在 `chunks/` 下则构建失败——两个相对 URL 都依赖这个位置。 + +## 后果 + +- Pace 会话恢复 codemode / tool_search / MCP,且 `-builtin:` 禁用与第三方替换语义与 CLI 完全一致。 +- `scripts/test-bundled-runtime.mjs` 新增一条端到端断言:打出的 `out/main` 在无 node_modules、空 PATH 下跑通「模型发起 codemode 调用 → 脚本调用 `mcp__probe__echo` → 拿到回显」,回归覆盖 worker/wasm 的相对布局。 +- 已知缺口,留给后续 PR: + - MCP 扩展的状态与错误只经 `ctx.ui.notify` 上报,OAuth 登录要求 `ctx.hasUI`;Pace 的 `bindExtensions` 目前只传 `onError`,这些提示会被丢弃,`/mcp login` 在 Pace 里不可用(用户可在 shell 里跑 `pi mcp login`,凭据共享同一 agent dir)。 + - 嵌套工具调用带 `parentToolCallId`,Pace 尚未投影,嵌套调用在 UI 上不呈现父子关系。 + - llama.cpp 内建未加载。 diff --git a/packages/backend/package.json b/packages/backend/package.json index d77c8bde..b89a0b21 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -12,8 +12,8 @@ ".": "./src/index.ts" }, "dependencies": { - "@earendil-works/pi-ai": "0.87.1", - "@earendil-works/pi-coding-agent": "0.87.1", + "@earendil-works/pi-ai": "0.99.1", + "@earendil-works/pi-coding-agent": "0.99.1", "@pace/core": "workspace:*" } } diff --git a/packages/backend/src/drivers/pi-builtin-extensions.test.ts b/packages/backend/src/drivers/pi-builtin-extensions.test.ts new file mode 100644 index 00000000..9ea517e4 --- /dev/null +++ b/packages/backend/src/drivers/pi-builtin-extensions.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DefaultResourceLoader, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPaceBuiltInExtensions } from "./pi-builtin-extensions"; + +const tempDirs: string[] = []; + +async function tempDir() { + const dir = await mkdtemp(join(tmpdir(), "pace-builtin-extensions-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); + +async function loadExtensionPaths(options?: { settings?: unknown }) { + const cwd = await tempDir(); + const agentDir = await tempDir(); + if (options?.settings !== undefined) { + await writeFile(join(agentDir, "settings.json"), JSON.stringify(options.settings)); + } + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager: SettingsManager.create(cwd, agentDir), + extensionFactories: createPaceBuiltInExtensions(), + }); + await loader.reload(); + return loader.getExtensions().extensions.map((extension) => extension.path); +} + +describe("createPaceBuiltInExtensions", () => { + it("loads codemode, tool-search, and mcp as builtin extensions", async () => { + const paths = await loadExtensionPaths(); + expect(paths).toContain("builtin:codemode"); + expect(paths).toContain("builtin:tool-search"); + expect(paths).toContain("builtin:mcp"); + }); + + it("honors -builtin: in the extensions setting", async () => { + const paths = await loadExtensionPaths({ + settings: { extensions: ["-builtin:mcp"] }, + }); + expect(paths).not.toContain("builtin:mcp"); + expect(paths).toContain("builtin:codemode"); + expect(paths).toContain("builtin:tool-search"); + }); +}); diff --git a/packages/backend/src/drivers/pi-builtin-extensions.ts b/packages/backend/src/drivers/pi-builtin-extensions.ts new file mode 100644 index 00000000..e3644e64 --- /dev/null +++ b/packages/backend/src/drivers/pi-builtin-extensions.ts @@ -0,0 +1,22 @@ +import { + createCodemodeExtension, + createMcpExtension, + createToolSearchExtension, + type InlineExtension, +} from "@earendil-works/pi-coding-agent"; + +/** + * SDK sessions do not load Pi's built-in extensions, so Pace supplies the same + * set the CLI registers (llama.cpp is intentionally left out for now). Keeping + * the CLI's `builtin: true` + `replaceable: true` shape makes `-builtin:` + * in the `extensions` setting and third-party replacement (e.g. an extension + * registering `codemode` or `/mcp`) behave exactly like the CLI. Factories take + * no options so user settings (`codemode.mode`, MCP servers, ...) apply. + */ +export function createPaceBuiltInExtensions(): InlineExtension[] { + return [ + { name: "codemode", factory: createCodemodeExtension(), replaceable: true, builtin: true }, + { name: "tool-search", factory: createToolSearchExtension(), replaceable: true, builtin: true }, + { name: "mcp", factory: createMcpExtension(), replaceable: true, builtin: true }, + ]; +} diff --git a/packages/backend/src/drivers/pi-sdk-runtime-adapter.ts b/packages/backend/src/drivers/pi-sdk-runtime-adapter.ts index 65d65a3b..e8f475cf 100644 --- a/packages/backend/src/drivers/pi-sdk-runtime-adapter.ts +++ b/packages/backend/src/drivers/pi-sdk-runtime-adapter.ts @@ -101,8 +101,8 @@ export type PublicPiSdkAgentSession = { }; setSessionName?(name: string): void; prompt(text: string, options?: { images?: ReturnType[] }): Promise; - followUp?(message: string, images?: ReturnType[]): Promise; - steer?(message: string, images?: ReturnType[]): Promise; + followUp?(message: string, images?: ReturnType[]): Promise; + steer?(message: string, images?: ReturnType[]): Promise; abort(): Promise; dispose(): void; extensionRunner?: { diff --git a/packages/backend/src/drivers/session-process-entry.ts b/packages/backend/src/drivers/session-process-entry.ts index 3ed0bac4..18dfc114 100644 --- a/packages/backend/src/drivers/session-process-entry.ts +++ b/packages/backend/src/drivers/session-process-entry.ts @@ -2,6 +2,7 @@ import { homedir } from "node:os"; import * as piSdk from "@earendil-works/pi-coding-agent"; import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth"; import { createPiSdkDriver } from "./pi-sdk-driver"; +import { createPaceBuiltInExtensions } from "./pi-builtin-extensions"; import { createPublicPiSdkRuntimeFactory, createPublicPiSdkRuntimeForker, @@ -24,7 +25,10 @@ const options = { // so changing cwd before loading extensions cannot affect other Sessions. process.chdir(input.cwd); const settingsManager = piSdk.SettingsManager.create(input.cwd, agentDir); - const resourceLoader = new piSdk.DefaultResourceLoader({ cwd: input.cwd, agentDir, settingsManager }); + const resourceLoader = new piSdk.DefaultResourceLoader({ + cwd: input.cwd, agentDir, settingsManager, + extensionFactories: createPaceBuiltInExtensions(), + }); await resourceLoader.reload(); const modelRuntime = await createPaceModelRuntime({ agentDir, dataDir }); return { agentDir, settingsManager, resourceLoader, modelRuntime }; diff --git a/packages/backend/src/service.ts b/packages/backend/src/service.ts index 343df0a3..0b9a02e1 100644 --- a/packages/backend/src/service.ts +++ b/packages/backend/src/service.ts @@ -55,6 +55,7 @@ import { type WorkspaceFileSearcher, } from "./workspace/workspace-file-search"; import { createPiSdkDriver } from "./drivers/pi-sdk-driver"; +import { createPaceBuiltInExtensions } from "./drivers/pi-builtin-extensions"; import { inspectRuntime } from "./drivers/pi-runtime-info"; import { createTerminalManager, @@ -255,6 +256,7 @@ export function createBackendService(options: BackendServiceOptions = {}): Backe const settingsManager = piSdk.SettingsManager.create(input.cwd, agentDir); const resourceLoader = new piSdk.DefaultResourceLoader({ cwd: input.cwd, agentDir, settingsManager, + extensionFactories: createPaceBuiltInExtensions(), }); await resourceLoader.reload(); const modelRuntime = await createPaceModelRuntime({ agentDir, dataDir }); diff --git a/packages/backend/src/workspace/prompt-commands.ts b/packages/backend/src/workspace/prompt-commands.ts index 9f97af80..7fbfa5df 100644 --- a/packages/backend/src/workspace/prompt-commands.ts +++ b/packages/backend/src/workspace/prompt-commands.ts @@ -14,7 +14,7 @@ export type ResolveStaticPromptCommandsInput = { agentDir: string; }; -// Mirrors Pi 0.87.1's private loadTemplateFromFile (core/prompt-templates.js): +// Mirrors Pi 0.99.1's private loadTemplateFromFile (core/prompt-templates.js): // the command name is the basename without ".md" and the description comes // from frontmatter, falling back to the first non-empty body line truncated // to 60 characters. Unreadable or unparseable files are skipped. @@ -43,7 +43,7 @@ function readPromptTemplate(filePath: string): { name: string; description: stri return { name: basename(filePath).replace(/\.md$/, ""), description }; } -// Mirrors Pi 0.87.1's private loadTemplatesFromDir: non-recursive, ".md" +// Mirrors Pi 0.99.1's private loadTemplatesFromDir: non-recursive, ".md" // only, and symlinks must resolve to a file. function promptTemplateFiles(resourcePath: string): string[] { let stats; @@ -82,7 +82,7 @@ function promptTemplateFiles(resourcePath: string): string[] { * packageManager.resolve() with no onMissing, which installs any missing — * or merely version-mismatched — npm/git package. A read-only completion * query must never trigger an install, so this walks the resolved resource - * list directly, replicating Pi 0.87.1's loading rules instead. Settings are + * list directly, replicating Pi 0.99.1's loading rules instead. Settings are * still read through the file-backed manager (verified read-only) so trust, * package filters and "!name" disables behave exactly as at runtime. * Extension code is never touched, so extension commands only exist on the diff --git a/packages/core/src/prompt-catalog.ts b/packages/core/src/prompt-catalog.ts index 70ac1e95..50a405d5 100644 --- a/packages/core/src/prompt-catalog.ts +++ b/packages/core/src/prompt-catalog.ts @@ -31,7 +31,7 @@ export type WorkspaceFileSearchResult = { }; /** - * Verbatim copy of `autocompleteSeparatorRegex` from pi-tui 0.87.1 + * Verbatim copy of `autocompleteSeparatorRegex` from pi-tui 0.99.1 * `dist/utils.js` (it composes cjkPunctuationRegex there). A file reference * whose path matches must serialize as `@""` — the same rule the Pi * TUI's `buildCompletionValue` applies. Drift-guarded by @@ -47,7 +47,7 @@ const PROMPT_COMMAND_KIND_ORDER: Record = { }; /** - * Names declared by Pi 0.87.1's `dist/core/slash-commands.js`. These are TUI + * Names declared by Pi 0.99.1's `dist/core/slash-commands.js`. These are TUI * commands — Pi's session prompt path never sees them, so Pace must not send * them as prompts (the composer blocks submit instead). Not publicly exported * by Pi; `packages/backend/src/workspace/pi-tui-builtins.test.ts` guards diff --git a/scripts/test-bundled-runtime.mjs b/scripts/test-bundled-runtime.mjs index b37cd58e..069083dc 100644 --- a/scripts/test-bundled-runtime.mjs +++ b/scripts/test-bundled-runtime.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -141,3 +142,154 @@ test("the shipped backend works without global pi or repository node_modules", a await rm(root, { recursive: true, force: true }); } }); + +test("codemode scripts reach MCP tools in the shipped backend", async () => { + const root = await mkdtemp(join(tmpdir(), "pigui-bundled-codemode-")); + const requests = []; + let serial = 0; + // Same SSE shape as the openai-completions mock in e2e/smoke/extension-runner.spec.ts. + const emit = (response, text, tool) => { + const id = `fixture-${++serial}`; + const base = { id, object: "chat.completion.chunk", created: 1, model: "probe" }; + const delta = tool + ? { role: "assistant", tool_calls: [{ index: 0, id, type: "function", function: { name: tool.name, arguments: JSON.stringify(tool.args) } }] } + : { role: "assistant", content: text }; + response.write(`data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`); + response.write(`data: ${JSON.stringify({ ...base, choices: [{ index: 0, delta: {}, finish_reason: tool ? "tool_calls" : "stop" }], usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 } })}\n\n`); + response.end("data: [DONE]\n\n"); + }; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString()); + const last = body.messages?.at(-1); + const isToolResult = last?.role === "tool"; + requests.push({ index: requests.length, isToolResult, body }); + response.writeHead(200, { "content-type": "text/event-stream" }); + // Requests without a tools array are side calls like session auto-title. + if (!Array.isArray(body.tools) || isToolResult) emit(response, "DONE"); + else emit(response, "", { name: "codemode", args: { code: 'const r = await tools.mcp__probe__echo({ text: "bundled" }); return r.content[0].text;' } }); + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing server address"); + try { + const appDir = join(root, "app"); + const agentDir = join(root, "agent"); + const cwd = join(root, "project"); + await cp(join(repo, "apps/desktop/out/main"), join(appDir, "out/main"), { recursive: true }); + await cp(join(repo, "apps/desktop/package.json"), join(appDir, "package.json")); + await mkdir(agentDir, { recursive: true }); + await mkdir(cwd); + // A newline-delimited JSON-RPC stdio MCP server. `process.execPath` is an + // absolute path because the probe environment empties PATH. + const mcpServer = join(root, "probe-mcp.mjs"); + await writeFile(mcpServer, ` + import { createInterface } from "node:readline"; + createInterface({ input: process.stdin }).on("line", (line) => { + const message = JSON.parse(line); + if (message.id === undefined) return; + const respond = (result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) + "\\n"); + const fail = (code, text) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code, message: text } }) + "\\n"); + if (message.method === "initialize") respond({ protocolVersion: message.params?.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "probe", version: "1.0.0" } }); + else if (message.method === "ping") respond({}); + else if (message.method === "tools/list") respond({ tools: [{ name: "echo", description: "Echo text back", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] }); + else if (message.method === "tools/call") respond({ content: [{ type: "text", text: "ECHO:" + (message.params?.arguments?.text ?? "") }] }); + else fail(-32601, "Method not found"); + }); + `); + // Default exposure ("codemode") keeps the MCP tool out of the model's tool + // declarations and auto-activates the codemode tool, so the model sees + // `codemode` and reaches `mcp__probe__echo` only from scripts. + await writeFile(join(agentDir, "mcp.json"), JSON.stringify({ + mcpServers: { probe: { command: process.execPath, args: [mcpServer] } }, + })); + await writeFile(join(agentDir, "models.json"), JSON.stringify({ + providers: { + "pace-test": { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + api: "openai-completions", + apiKey: "bundled-runtime-test-placeholder", + models: [{ id: "probe", name: "Probe", reasoning: false, input: ["text"], contextWindow: 16000, maxTokens: 1024, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }], + }, + }, + })); + await writeFile(join(agentDir, "settings.json"), JSON.stringify({ + defaultProvider: "pace-test", + defaultModel: "probe", + defaultThinkingLevel: "off", + })); + const { stdout, stderr } = await run(process.execPath, ["--input-type=module", "-e", ` + import { pathToFileURL } from "node:url"; + const pending = new Map(); + const events = []; + let receive; + let control; + let connected; + const ready = new Promise(resolve => { connected = resolve; }); + process.parentPort = { on(_name, connect) { + control = connect; + connect({ data: { type: "connect" }, ports: [{ + on(event, handler) { if (event === "message") { receive = handler; connected(); } }, start() {}, + postMessage(message) { + if (message.type === "event") events.push(message.event); + else { pending.get(message.id)?.(message); pending.delete(message.id); } + }, + }] }); + }, postMessage() {} }; + await import(pathToFileURL(process.env.PROBE_BACKEND)); + await ready; + let sequence = 0; + const request = (method, params) => new Promise(resolve => { + const id = String(++sequence); pending.set(id, resolve); receive({ data: { id, method, params } }); + }); + const created = await request("create_session", { sessionId: "probe", projectId: "probe", cwd: process.env.PROBE_CWD }); + const piSessionId = created.result?.piSessionId; + let prompted; + let snapshot; + if (piSessionId) { + prompted = request("send_prompt", { piSessionId, prompt: "run the probe" }); + const deadline = Date.now() + 45000; + do { + await new Promise(resolve => setTimeout(resolve, 250)); + snapshot = await request("get_runtime_snapshot", { piSessionId }); + } while (snapshot.result?.status === "running" && Date.now() < deadline); + prompted = await prompted; + } + await control({ data: { type: "shutdown" } }); + console.log("PROBE_RESULT " + JSON.stringify({ created, prompted, snapshot, events })); + `], { + cwd, + env: { + ...process.env, + PATH: "", + HOME: root, + PI_CODING_AGENT_DIR: agentDir, + PACE_DATA_DIR: join(root, "data"), + PROBE_BACKEND: join(appDir, "out/main/backend.js"), + PI_PACKAGE_DIR: join(appDir, "out/main/pi-assets"), + PROBE_CWD: cwd, + }, + timeout: 60_000, + maxBuffer: 2 * 1024 * 1024, + }); + const result = JSON.parse(stdout.split("\n").find(line => line.startsWith("PROBE_RESULT ")).slice(13)); + if (stderr) console.error(stderr); + assert.equal(result.created.error, undefined); + assert.equal(result.prompted?.error, undefined, JSON.stringify(result.snapshot).slice(0, 2000)); + // Side calls like session auto-title hit the same endpoint without tools; + // the run's own model calls always carry the tool declarations. + const runRequests = requests.filter((entry) => Array.isArray(entry.body.tools)); + const declaredTools = runRequests[0]?.body.tools.map((tool) => tool.function?.name ?? tool.name) ?? []; + assert.ok(declaredTools.includes("codemode"), `the first request must declare the codemode tool, got ${JSON.stringify(declaredTools)}`); + assert.equal(runRequests.length, 2, `expected a tool call and a follow-up request, got ${runRequests.length}`); + const codemodeEnd = result.events.find((event) => + event.payload?.type === "tool" && event.payload?.phase === "end" && event.payload?.name === "codemode"); + assert.ok(codemodeEnd, "the runtime events must contain a codemode tool end"); + assert.ok(!codemodeEnd.payload.isError, `codemode must not error: ${JSON.stringify(codemodeEnd.payload.result).slice(0, 2000)}`); + assert.ok(JSON.stringify(codemodeEnd.payload.result).includes("ECHO:bundled"), `the codemode result must include the MCP echo: ${JSON.stringify(codemodeEnd.payload.result).slice(0, 2000)}`); + } finally { + server.close(); + await rm(root, { recursive: true, force: true }); + } +}); From 431a75f39d49fb72ae6c50dc0bf676fe8165e597 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Wed, 30 Sep 2026 20:38:57 +0800 Subject: [PATCH 2/2] fix(backend): keep tool structuredContent out of journal and IPC Pi 0.99 bash results always carry up to 1 MiB of output in structuredContent for codemode scripts. Pace only displays the model-facing content, so the normalizer drops it before the event is journaled or sent to the renderer. --- .../agent-runtime-event-normalizer.test.ts | 63 +++++++++++++++++++ .../gateway/agent-runtime-event-normalizer.ts | 18 +++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/gateway/agent-runtime-event-normalizer.test.ts b/packages/backend/src/gateway/agent-runtime-event-normalizer.test.ts index 107090da..a5d15e84 100644 --- a/packages/backend/src/gateway/agent-runtime-event-normalizer.test.ts +++ b/packages/backend/src/gateway/agent-runtime-event-normalizer.test.ts @@ -474,6 +474,69 @@ describe("agent runtime event normalizer", () => { ]); }); + it("strips structuredContent from tool results on the way to the journal and renderer", () => { + const normalizer = createAgentRuntimeEventNormalizer({ piSessionId }); + // Pi 0.99 bash results carry a programmatic-caller payload of up to 1 MiB in + // structuredContent that Pace never reads; only `content` is model-facing. + const details = { command: "echo ok", exitCode: 0 }; + const structuredContent = { + output: "x".repeat(1000), + truncated: false, + exit_code: 0, + wall_time_seconds: 0.1, + }; + + const events = normalizeAll(normalizer, [ + { type: "agent_start" }, + { type: "turn_start" }, + { + type: "tool_execution_start", + toolCallId: "call-1", + toolName: "bash", + args: { command: "echo ok" }, + }, + { + type: "tool_execution_update", + toolCallId: "call-1", + toolName: "bash", + args: { command: "echo ok" }, + partialResult: { + content: [{ type: "text", text: "ok" }], + details, + structuredContent, + }, + }, + { + type: "tool_execution_end", + toolCallId: "call-1", + toolName: "bash", + result: { + content: [{ type: "text", text: "ok" }], + details, + structuredContent, + }, + isError: false, + }, + ]); + + const toolEvents = events.filter((event) => event.type === "tool"); + expect(toolEvents).toHaveLength(3); + + const update = toolEvents.find((event) => event.phase === "update"); + expect(update?.result).toEqual({ + content: [{ type: "text", text: "ok" }], + details, + }); + expect(update?.result).not.toHaveProperty("structuredContent"); + + const end = toolEvents.find((event) => event.phase === "end"); + expect(end?.result).toEqual({ + content: [{ type: "text", text: "ok" }], + details, + }); + expect(end?.result).not.toHaveProperty("structuredContent"); + }); + it("names the tool on the opening tool_call part, before execution starts", () => { const normalizer = createAgentRuntimeEventNormalizer({ piSessionId }); // The SDK's partial message already carries the parsed ToolCall block at diff --git a/packages/backend/src/gateway/agent-runtime-event-normalizer.ts b/packages/backend/src/gateway/agent-runtime-event-normalizer.ts index 26031c89..8b81ad04 100644 --- a/packages/backend/src/gateway/agent-runtime-event-normalizer.ts +++ b/packages/backend/src/gateway/agent-runtime-event-normalizer.ts @@ -114,6 +114,20 @@ function toolNameFromPartial(partial: unknown, contentIndex: number) { return toolName ? { toolName } : {}; } +// Pi tool results can carry a `structuredContent` payload for programmatic +// callers (codemode scripts) — bash's alone reaches 1 MiB. The model-facing +// `content` is what Pace displays, so the payload must not reach the Session +// Event Journal or cross IPC to the renderer. +function withoutStructuredContent(result: unknown): unknown { + if (!isRecord(result) || !("structuredContent" in result)) { + return result; + } + + const { structuredContent: _dropped, ...rest } = result; + + return rest; +} + // Token/cost truth rides on the assistant message's usage block; it becomes a // hidden usage event so projections can aggregate without parsing messages. function usageSummaryFromMessage(message: Record) { @@ -347,7 +361,7 @@ export function createAgentRuntimeEventNormalizer( phase: "update", name, args: rawEvent.args, - result: rawEvent.partialResult, + result: withoutStructuredContent(rawEvent.partialResult), surface: "trace", origin, }, @@ -362,7 +376,7 @@ export function createAgentRuntimeEventNormalizer( toolCallId: rawEvent.toolCallId, phase: "end", name, - result: rawEvent.result, + result: withoutStructuredContent(rawEvent.result), isError: typeof rawEvent.isError === "boolean" ? rawEvent.isError : undefined, surface: "trace", origin,