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/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, 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 }); + } +});