Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ The control endpoint supports project, session, and Agent workflows plus a revie

Extensions written for the [pi](https://github.com/badlogic/pi-mono) CLI can run inside PI-Desktop's agent unchanged.

A plugin can list them under `contributes.agentExtensions`, or **Plugins → Import pi extension** can wrap an existing extension file or directory in a plugin.
A plugin can list them under `contributes.agentExtensions`, or **Plugins → Import pi extension** can wrap an existing extension file or directory in a plugin. If the directory declares npm `dependencies`, they are installed into the generated plugin before its first load (`--ignore-scripts`, so no third-party install script ever runs).

They can register tools, slash commands, and hooks on every turn, tool call, and provider request. They run with the same access as the agent's own tools, gated by the `agent.extension` permission.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ PI_DESKTOP_MCP_CONTROL=1

**Plugins → Import pi extension**

直接把现有 extension 文件或目录包装成 PI-Desktop 插件。
直接把现有 extension 文件或目录包装成 PI-Desktop 插件。若目录声明了 npm `dependencies`,会在首次加载前安装到生成的插件中(`--ignore-scripts`,绝不运行第三方安装脚本)。

这些扩展可以注册:

Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/electron/main/agent-extensions-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
*/
import { dialog, type BrowserWindow } from "electron";
import { ErrorCodes, IPC, type TrustedExtensionUiPromptResponse } from "@pi-desktop/shared";
import { generateImportedExtensionPlugin, type AgentExtensionBridge } from "./agent-extensions.js";
import {
generateImportedExtensionPlugin,
installExtensionDependencies,
type AgentExtensionBridge,
} from "./agent-extensions.js";

export type AgentExtensionIpcDeps = {
handle: (channel: string, fn: (...args: any[]) => Promise<any>) => void;
Expand Down Expand Up @@ -71,7 +75,10 @@ export function registerAgentExtensionIpc(deps: AgentExtensionIpcDeps): void {
const source = picked.canceled ? undefined : picked.filePaths[0];
if (!source) return { canceled: true };
const generated = generateImportedExtensionPlugin(source, deps.importRoot);
// Install before registration so the extension's first load already sees
// its dependencies; a failed install registers anyway and is reported.
const dependencies = await installExtensionDependencies(generated.path);
const loaded = await deps.loadDevPlugin(generated.path);
return { canceled: false, ...generated, loaded };
return { canceled: false, ...generated, loaded, dependencies };
});
}
151 changes: 149 additions & 2 deletions apps/desktop/electron/main/agent-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
* the modal prompts between the sidecar and the renderer. Discovery and
* enablement are the plugin system's job; nothing here touches the filesystem.
*/
import { spawn } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { cpSync, existsSync, lstatSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { assertImportedPackagePath, discoverImportedPackageSkills } from "./imported-package-skills";
import { discoverManualPath } from "@pi-desktop/agent-runtime";
Expand Down Expand Up @@ -237,6 +238,127 @@ export class AgentExtensionBridge {
}
}

export type ExtensionDependencyInstallResult =
| { state: "skipped"; reason: "no-package-json" | "no-dependencies" }
| { state: "installed" }
| { state: "failed"; error: string };

/** Injectable so tests never run npm. Resolves with the exit code and captured stderr. */
export type DependencyCommandRunner = (
command: string,
args: string[],
cwd: string,
timeoutMs: number,
) => Promise<{ code: number; stderr: string }>;

const NPM_INSTALL_TIMEOUT_MS = 120_000;
/** npm output is not toast-shaped; the tail carries the actual failure. */
const DEPENDENCY_ERROR_TAIL_CHARS = 200;
/** Rolling cap so a chatty npm cannot balloon the main process's memory. */
const DEPENDENCY_STDERR_KEEP_CHARS = 8192;

function dependencyErrorTail(text: string): string {
return text.length > DEPENDENCY_ERROR_TAIL_CHARS
? `…${text.slice(-DEPENDENCY_ERROR_TAIL_CHARS)}`
: text;
}

export function defaultDependencyRunner(
command: string,
args: string[],
cwd: string,
timeoutMs: number,
): Promise<{ code: number; stderr: string }> {
return new Promise((resolve, reject) => {
// Shell only where npm is a .cmd shim (Windows); every arg is a literal.
// A shell kill on Windows terminates the shim, possibly leaving npm
// itself running — accepted for v1, the timeout result still resolves.
const child = spawn(command, args, {
cwd,
shell: process.platform === "win32",
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
if (stderr.length > DEPENDENCY_STDERR_KEEP_CHARS * 2) {
stderr = stderr.slice(-DEPENDENCY_STDERR_KEEP_CHARS);
}
});
let killTimer: ReturnType<typeof setTimeout> | undefined;
const timer = setTimeout(() => {
stderr += `\nnpm install exceeded ${timeoutMs}ms and was terminated`;
child.kill("SIGTERM");
killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000);
}, timeoutMs);
const settle = (fn: () => void) => {
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
fn();
};
child.on("error", (err) => {
settle(() => reject(err));
});
child.on("close", (code) => {
settle(() => resolve({ code: code ?? 1, stderr }));
});
});
}

/**
* Install an imported extension's npm dependencies inside the generated plugin
* directory (spec 07-plugins/16 §3): the sidecar's jiti resolves bare imports
* from the plugin root's `node_modules`, and the kernel packages (`pi-ai`,
* `pi-coding-agent`, `pi-tui`) keep winning through virtual modules, so
* installing them is harmless. `--legacy-peer-deps` keeps `pi-coding-agent`
* peers out of the tree; `--ignore-scripts` means no third-party install
* script ever runs here — a native module that needs one fails to load with a
* diagnostic instead (documented workaround: rebuild against Electron
* headers). A failure never blocks the import; the extension reports its own
* load error and the renderer surfaces this result.
*/
export async function installExtensionDependencies(
pluginDir: string,
options?: { runner?: DependencyCommandRunner; timeoutMs?: number },
): Promise<ExtensionDependencyInstallResult> {
const packageJsonPath = join(pluginDir, "package.json");
if (!existsSync(packageJsonPath)) {
return { state: "skipped", reason: "no-package-json" };
}
let manifest: { dependencies?: Record<string, unknown> };
try {
manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
} catch (err) {
return {
state: "failed",
error: `package.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
};
}
if (!manifest.dependencies || Object.keys(manifest.dependencies).length === 0) {
return { state: "skipped", reason: "no-dependencies" };
}
try {
const result = await (options?.runner ?? defaultDependencyRunner)(
"npm",
["install", "--omit=dev", "--legacy-peer-deps", "--no-audit", "--no-fund", "--ignore-scripts"],
pluginDir,
options?.timeoutMs ?? NPM_INSTALL_TIMEOUT_MS,
);
if (result.code !== 0) {
return {
state: "failed",
error: dependencyErrorTail(`npm install exited ${result.code}: ${result.stderr.trim()}`),
};
}
return { state: "installed" };
} catch (err) {
return {
state: "failed",
error: dependencyErrorTail(err instanceof Error ? err.message : String(err)),
};
}
}

const PLUGIN_ID_PREFIX = "imported.";

function slugFor(path: string): string {
Expand All @@ -250,7 +372,10 @@ function slugFor(path: string): string {
/**
* Build a plugin directory from a pi extension file or directory (spec §3):
* copies the source under `src/`, writes a manifest that declares the entry
* files as `contributes.agentExtensions`, and a no-op `main.js`.
* files as `contributes.agentExtensions`, and a no-op `main.js`. A directory
* that ships a `package.json` also gets it (plus its lockfile) at the plugin
* root so {@link installExtensionDependencies} can resolve its dependencies
* there; `node_modules` itself is never copied — it is reinstalled.
*/
export function generateImportedExtensionPlugin(
source: string,
Expand Down Expand Up @@ -330,5 +455,27 @@ export function generateImportedExtensionPlugin(
"// Generated by PI-Desktop: declarative skills and/or agent extensions.\nmodule.exports = {};\n",
"utf8",
);
if (isDirectory) {
const rootPackageJson = join(resolved, "package.json");
if (existsSync(rootPackageJson)) {
// A `workspaces` field would send npm into the copied sources under
// src/; strip it so the install sees only the declared dependencies.
try {
const pkg = JSON.parse(readFileSync(rootPackageJson, "utf8")) as Record<string, unknown>;
if (pkg && typeof pkg === "object" && "workspaces" in pkg) {
delete pkg.workspaces;
writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n", "utf8");
} else {
copyFileSync(rootPackageJson, join(dir, "package.json"));
}
} catch {
// Not valid JSON: copy verbatim; the install step reports the failure.
copyFileSync(rootPackageJson, join(dir, "package.json"));
}
for (const file of ["package-lock.json", "npm-shrinkwrap.json"]) {
if (existsSync(join(resolved, file))) copyFileSync(join(resolved, file), join(dir, file));
}
}
}
return { path: dir, id, entries };
}
10 changes: 9 additions & 1 deletion apps/desktop/src/features/plugins/usePluginsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,13 +294,21 @@ export function usePluginsPage() {
});

// A pi CLI extension becomes a development plugin holding `agent.extension`
// (spec 07-plugins/16 §3); the confirm is the trust decision.
// (spec 07-plugins/16 §3); the confirm is the trust decision. Declared npm
// dependencies are installed (scripts disabled) before the first load.
const importExtension = () =>
run(async () => {
if (!window.confirm(t("plugins.agentExtension.importConfirm"))) return;
const result = await api.importPiExtension();
if (result.canceled) return;
await refreshPlugins();
if (result.dependencies.state === "failed") {
showToast(
t("plugins.importExtensionDepsFailed", { id: result.id, error: result.dependencies.error }),
{ variant: "warning" },
);
return;
}
showToast(t("plugins.importExtensionDone", { id: result.id }), { variant: "success" });
});

Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,9 +789,19 @@ export const api = {
),
/** Import a pi CLI extension file or directory as a development plugin (spec 16 §3). */
importPiExtension: () =>
invoke<{ canceled: true } | { canceled: false; id: string; path: string; entries: string[] }>(
IPC.invoke.pluginImportExtension,
),
invoke<
| { canceled: true }
| {
canceled: false;
id: string;
path: string;
entries: string[];
dependencies:
| { state: "skipped"; reason: "no-package-json" | "no-dependencies" }
| { state: "installed" }
| { state: "failed"; error: string };
}
>(IPC.invoke.pluginImportExtension),
runExtensionCommand: (input: { sessionId: string; name: string; args: string }) =>
invoke<{ ok: boolean }>(IPC.invoke.extensionsCommandRun, input),
respondExtensionPrompt: (response: TrustedExtensionUiPromptResponse) =>
Expand Down
Loading