Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -148,6 +236,8 @@ export default defineConfig({
plugins: [
externalizeDepsPlugin({ exclude: [...internalPackages, "electron-updater"] }),
copyMainRuntimeAssets(),
rewritePiQuickJsWasmResolution(),
assertPiConfigChunkLocation(),
hoistCommonJsShim(),
],
build: mainBuild as any,
Expand Down
24 changes: 15 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions docs/adr/0046-load-pi-builtin-extensions.md
Original file line number Diff line number Diff line change
@@ -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:<name>` 资源加载:默认启用、`settings.json` 里 `-builtin:<name>` 可禁用、`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:<name>` 禁用与第三方替换语义与 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 内建未加载。
4 changes: 2 additions & 2 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
}
}
57 changes: 57 additions & 0 deletions packages/backend/src/drivers/pi-builtin-extensions.test.ts
Original file line number Diff line number Diff line change
@@ -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:<name> 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");
});
});
Loading
Loading