Add managed local plugin lifecycle (#3222)

* feat(plugins): add local plugin surfaces and RPCs

Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.

* feat(plugins): add Linear attachment source

* fix(nix): refresh npm dependency hash

* test(server): exclude internal sessions from Hub counts

* test(server): distinguish Hub execution sessions

* feat(plugins): make local plugins explicitly opt-in

Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.

* refactor(plugins): establish host boundaries

* fix(nix): refresh plugin dependency hash

* fix(plugins): complete packaging and startup rollback

* feat(plugins): add managed local lifecycle

Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.

* fix(nix): refresh plugin dependency hash
This commit is contained in:
Mohamed Boudra
2026-08-14 17:12:49 +02:00
committed by GitHub
parent f0bd2c8483
commit 23853a1515
115 changed files with 7481 additions and 17 deletions
+3
View File
@@ -71,6 +71,7 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.4.0",
"@getpaseo/highlight": "0.4.0",
"@getpaseo/protocol": "0.4.0",
@@ -78,11 +79,13 @@
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
"@paseo/plugin": "*",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
@@ -1,7 +1,7 @@
import os from "node:os";
import http from "node:http";
import path from "node:path";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
@@ -42,6 +42,16 @@ type WebSocketProbeResult =
| { status: "connected" }
| { status: "rejected"; statusCode: number | null };
function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
throw error;
}
}
describe("paseo daemon bootstrap", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -398,7 +408,24 @@ describe("paseo daemon bootstrap", () => {
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const pluginDirectory = path.join(paseoHomeRoot, "plugin");
const pluginPidPath = path.join(pluginDirectory, "plugin.pid");
await mkdir(paseoHome, { recursive: true });
if (!isPlatform("win32")) {
await mkdir(pluginDirectory);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id: "startup-rollback" }),
);
await writeFile(
path.join(pluginDirectory, "index.tsx"),
`import { writeFileSync } from "node:fs";
export default function contribute(plugin: unknown) {
void plugin;
writeFileSync(${JSON.stringify(pluginPidPath)}, String(process.pid));
}`,
);
}
const config: PaseoDaemonConfig = {
listen: `127.0.0.1:${mainPort}`,
paseoHome,
@@ -414,12 +441,22 @@ describe("paseo daemon bootstrap", () => {
openai: undefined,
speech: undefined,
serviceProxy: { standaloneListen: `127.0.0.1:${standalonePort}` },
pluginsEnabled: !isPlatform("win32"),
plugins: isPlatform("win32")
? {}
: { "startup-rollback": { source: "directory", path: pluginDirectory } },
};
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await expect(daemon.start()).rejects.toThrow();
await expect(fetch(`http://127.0.0.1:${standalonePort}/api/health`)).rejects.toThrow();
if (!isPlatform("win32")) {
const pluginPid = Number(await readFile(pluginPidPath, "utf8"));
await vi.waitFor(() => {
expect(processExists(pluginPid)).toBe(false);
});
}
} finally {
await daemon.stop().catch(() => undefined);
await new Promise<void>((resolve) => occupiedMain.close(() => resolve()));
+16 -1
View File
@@ -168,7 +168,12 @@ import type { PushNotificationSender } from "./push/index.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProfile, FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages";
import type {
AgentProfile,
FirstAgentContext,
PluginSource,
TerminalProfile,
} from "@getpaseo/protocol/messages";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -216,6 +221,7 @@ import {
type HubRelationshipRemote,
} from "./hub/relationship-remote.js";
import { DaemonExecutions } from "./hub/daemon-executions.js";
import { PluginService } from "./plugins/index.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
@@ -398,6 +404,8 @@ export interface PaseoDaemonConfig {
appendSystemPrompt?: string;
terminalProfiles?: TerminalProfile[];
agentProfiles?: AgentProfile[];
pluginsEnabled?: boolean;
plugins?: Record<string, PluginSource>;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -532,6 +540,8 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
appendSystemPrompt: config.appendSystemPrompt ?? "",
pluginsEnabled: config.pluginsEnabled ?? false,
plugins: config.plugins ?? {},
};
if (config.terminalProfiles !== undefined) {
@@ -563,6 +573,7 @@ export async function createPaseoDaemon(
);
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({});
const pluginRuntime = new PluginService(logger, daemonConfigStore);
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
@@ -1431,6 +1442,7 @@ export async function createPaseoDaemon(
const start = async () => {
let mainStarted = false;
try {
await pluginRuntime.start();
if (serviceProxyListenTarget) {
const boundServiceProxyTarget = await serviceProxy.startStandalone({
listenTarget: serviceProxyListenTarget,
@@ -1560,6 +1572,7 @@ export async function createPaseoDaemon(
browserToolsBroker,
hubRelationships,
workspaceSetupRuntime,
pluginRuntime,
);
relayRuntime = createRelayRuntime({
config: {
@@ -1603,6 +1616,7 @@ export async function createPaseoDaemon(
speechService.start();
scriptHealthMonitor.start();
} catch (error) {
await pluginRuntime.stopAllPlugins().catch(() => undefined);
await serviceProxy.stopStandalone().catch(() => undefined);
if (mainStarted) {
httpServer.closeAllConnections();
@@ -1613,6 +1627,7 @@ export async function createPaseoDaemon(
};
const stop = async () => {
await pluginRuntime.stopAllPlugins();
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
@@ -0,0 +1,34 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { loadConfig } from "./config.js";
const roots: string[] = [];
async function createPaseoHome(config: unknown): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-plugins-"));
roots.push(root);
const paseoHome = path.join(root, ".paseo");
await mkdir(paseoHome, { recursive: true });
await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2));
return paseoHome;
}
describe("daemon plugin config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("defaults plugins off when config is absent", async () => {
const home = await createPaseoHome({ version: 1 });
expect(loadConfig(home, { env: {} }).pluginsEnabled).toBe(false);
});
test("loads the explicit plugin opt-in", async () => {
const home = await createPaseoHome({ version: 1, pluginsEnabled: true });
expect(loadConfig(home, { env: {} }).pluginsEnabled).toBe(true);
});
});
+2
View File
@@ -534,6 +534,8 @@ export function loadConfig(
appendSystemPrompt,
terminalProfiles,
agentProfiles,
pluginsEnabled: persisted.pluginsEnabled ?? false,
plugins: persisted.plugins,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -198,6 +198,7 @@ export class DaemonConfigStore {
const { removeProviders = [], ...configPatch } = parsedPatch;
const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch);
if (parsedPatch.plugins !== undefined) merged.plugins = parsedPatch.plugins;
const next = MutableDaemonConfigSchema.parse(
omitMetadataGenerationProvidersFromConfig(
omitProvidersFromConfig(merged, removedProviders),
@@ -335,6 +336,8 @@ function mergeMutableConfigIntoPersistedConfig(params: {
return {
...persisted,
pluginsEnabled: mutable.pluginsEnabled,
plugins: mutable.plugins,
daemon: {
...persisted.daemon,
...(persistRelayEnabled
@@ -187,7 +187,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution"
type: "hub.execution.agent.create.response",
payload: { success: true, executionId: "replayed-execution" },
});
const providerCreations = hub.providerCreations();
const executionProviderCreations = hub.executionProviderCreations();
hub.beginOwnedCreate("replay-create", "replayed-execution", {
mcpServers: {
@@ -204,7 +204,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution"
agentId: original.payload.agentId,
},
});
expect(hub.providerCreations()).toBe(providerCreations);
expect(hub.executionProviderCreations()).toBe(executionProviderCreations);
expect(await hub.durableOwnedAgentIds()).toEqual([original.payload.agentId]);
});
@@ -475,7 +475,7 @@ describe("Hub relationship", () => {
});
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
expect(durableAgentIds).toEqual([created.payload.agentId]);
expect(relationship.providerCreations()).toBe(1);
expect(relationship.executionProviderCreations()).toBe(1);
expect(relationship.providerResumes()).toBe(0);
expect(relationship.providerPromptTexts()).toEqual([prompt]);
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
@@ -640,7 +640,7 @@ describe("Hub relationship", () => {
agentId: durableAgentIds[0],
},
});
expect(relationship.providerCreations()).toBe(1);
expect(relationship.executionProviderCreations()).toBe(1);
expect(durableAgentIds).toHaveLength(1);
});
@@ -665,7 +665,7 @@ describe("Hub relationship", () => {
agent: { id: expect.any(String) },
},
});
expect(relationship.providerCreations()).toBe(1);
expect(relationship.executionProviderCreations()).toBe(1);
expect(await relationship.durableOwnedAgentIds()).toHaveLength(1);
});
@@ -862,6 +862,10 @@ export class HubRelationshipHarness {
return this.codex.creations;
}
executionProviderCreations(): number {
return this.codex.createdConfigs.filter((config) => config.internal !== true).length;
}
providerResumes(): number {
return this.codex.resumes;
}
@@ -9,7 +9,12 @@ import {
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
import { AgentProfileSchema, TerminalProfileSchema } from "@getpaseo/protocol/messages";
import {
AgentProfileSchema,
PluginIdSchema,
PluginSourceSchema,
TerminalProfileSchema,
} from "@getpaseo/protocol/messages";
import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema";
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
@@ -304,6 +309,8 @@ export const PersistedConfigSchema = z
.optional(),
providers: ProvidersSchema.optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
worktrees: WorktreesConfigSchema.optional(),
agents: z
.object({
@@ -0,0 +1,206 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { parse } from "@babel/parser";
import { build, type Plugin } from "esbuild";
type PluginBuildTarget = "client" | "server";
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
client: new Set(["handle"]),
server: new Set(["addSurface", "addSidebarItem", "addAttachmentSource"]),
};
function registrationName(statement: unknown, contextName: string): string | null {
if (!statement || typeof statement !== "object") return null;
const expression = Reflect.get(statement, "expression");
if (
!expression ||
typeof expression !== "object" ||
Reflect.get(expression, "type") !== "CallExpression"
) {
return null;
}
const callee = Reflect.get(expression, "callee");
if (!callee || typeof callee !== "object" || Reflect.get(callee, "type") !== "MemberExpression") {
return null;
}
const object = Reflect.get(callee, "object");
const property = Reflect.get(callee, "property");
if (
!object ||
typeof object !== "object" ||
Reflect.get(object, "type") !== "Identifier" ||
Reflect.get(object, "name") !== contextName ||
!property ||
typeof property !== "object" ||
Reflect.get(property, "type") !== "Identifier"
) {
return null;
}
return String(Reflect.get(property, "name"));
}
function defaultPluginFunction(programBody: unknown[]): { body: object; contextName: string } {
const defaultExports = programBody.filter(
(statement) =>
statement !== null &&
typeof statement === "object" &&
Reflect.get(statement, "type") === "ExportDefaultDeclaration",
);
if (defaultExports.length !== 1) {
throw new Error("Plugin index.tsx must have exactly one default export function");
}
const declaration = Reflect.get(defaultExports[0] as object, "declaration");
const declarationType =
declaration !== null && typeof declaration === "object"
? Reflect.get(declaration, "type")
: null;
if (
declarationType !== "FunctionDeclaration" &&
declarationType !== "FunctionExpression" &&
declarationType !== "ArrowFunctionExpression"
) {
throw new Error("Plugin default export must be a function receiving its context");
}
const parameters = Reflect.get(declaration, "params");
const parameter = Array.isArray(parameters) && parameters.length === 1 ? parameters[0] : null;
if (
parameter === null ||
typeof parameter !== "object" ||
Reflect.get(parameter, "type") !== "Identifier"
) {
throw new Error("Plugin default export must receive one named context parameter");
}
const body = Reflect.get(declaration, "body");
if (body === null || typeof body !== "object" || Reflect.get(body, "type") !== "BlockStatement") {
throw new Error("Plugin default export must have a block body");
}
return { body, contextName: String(Reflect.get(parameter, "name")) };
}
function collectRemovedRegistrationRanges(
node: unknown,
contextName: string,
removedNames: ReadonlySet<string>,
ranges: Array<{ start: number; end: number }>,
): void {
if (node === null || typeof node !== "object") return;
if (Reflect.get(node, "type") === "ExpressionStatement") {
const name = registrationName(node, contextName);
if (name && removedNames.has(name)) {
const start = Reflect.get(node, "start");
const end = Reflect.get(node, "end");
if (typeof start !== "number" || typeof end !== "number") {
throw new Error(`Could not locate plugin context ${name} registration in index.tsx`);
}
ranges.push({ start, end });
return;
}
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const child of value)
collectRemovedRegistrationRanges(child, contextName, removedNames, ranges);
} else if (value !== null && typeof value === "object") {
collectRemovedRegistrationRanges(value, contextName, removedNames, ranges);
}
}
}
function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string {
const ast = parse(source, {
sourceType: "module",
plugins: ["typescript", "jsx"],
});
const pluginFunction = defaultPluginFunction(ast.program.body);
const ranges: Array<{ start: number; end: number }> = [];
collectRemovedRegistrationRanges(
pluginFunction.body,
pluginFunction.contextName,
REGISTRATIONS_REMOVED_BY_TARGET[target],
ranges,
);
let output = source;
for (const range of ranges.toReversed()) {
output = `${output.slice(0, range.start)}${output.slice(range.end)}`;
}
return output;
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
const filter =
target === "server"
? /^(@tanstack\/react-query|react|react\/jsx-runtime|react-native)$/
: /^node:/;
return {
name: `paseo-plugin-${target}-unused-platform-modules`,
setup(buildContext) {
buildContext.onResolve({ filter }, (args) => ({
path: args.path,
namespace: "paseo-unused-platform-module",
sideEffects: false,
}));
buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({
contents: "module.exports = {};",
loader: "js",
}));
},
};
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const source = await readFile(entryPath, "utf8");
const filteredSource = removeOppositeTargetRegistrations(source, target);
const result = await build({
stdin: {
contents: filteredSource,
loader: "tsx",
resolveDir: path.dirname(entryPath),
sourcefile: entryPath,
},
bundle: true,
format: "cjs",
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
external:
target === "client"
? [
"@paseo/plugin",
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: ["@paseo/plugin", "zod"],
plugins: [createUnusedPlatformModulePlugin(target)],
treeShaking: true,
write: false,
});
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
}
export async function compilePlugin(entryPath: string): Promise<{
clientBundle: string;
serverBundle: string;
}> {
const [clientBundle, serverBundle] = await Promise.all([
compileTarget(entryPath, "client"),
compileTarget(entryPath, "server"),
]);
return { clientBundle, serverBundle };
}
@@ -0,0 +1,370 @@
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js";
const roots: string[] = [];
async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-"));
roots.push(directory);
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }));
await writeFile(path.join(directory, "index.tsx"), source);
return directory;
}
function createStore(
home: string,
plugins: Record<string, { source: "directory"; path: string; enabled?: boolean }> = {},
): DaemonConfigStore {
return new DaemonConfigStore(home, {
mcp: { injectIntoAgents: true },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
pluginsEnabled: true,
plugins,
});
}
function createService(
home: string,
plugins: Record<string, { source: "directory"; path: string; enabled?: boolean }> = {},
runtime?: ConstructorParameters<typeof PluginService>[2],
): PluginService {
return new PluginService(pino({ level: "silent" }), createStore(home, plugins), runtime);
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
function createPausedRuntime() {
let releaseStart = () => undefined;
let markStarted = () => undefined;
const startGate = new Promise<void>((resolve) => {
releaseStart = resolve;
});
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const running = new Set<string>();
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[2]> = {
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
startPlugin: async (pluginId, _path, canPublish) => {
markStarted();
await startGate;
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
};
return { runtime, started, releaseStart };
}
function createPluginSelectivePausedRuntime(pausedPluginId: string) {
let releaseStart = () => undefined;
let markStarted = () => undefined;
const startGate = new Promise<void>((resolve) => {
releaseStart = resolve;
});
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const starts: string[] = [];
const running = new Set<string>();
const runtime: NonNullable<ConstructorParameters<typeof PluginService>[2]> = {
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
startPlugin: async (pluginId, _path, canPublish) => {
starts.push(pluginId);
if (pluginId === pausedPluginId) {
markStarted();
await startGate;
}
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
};
return { runtime, started, releaseStart, starts };
}
describe("PluginService", () => {
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = await createPlugin(
"manifest-default",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const store = createStore(home);
const service = new PluginService(pino({ level: "silent" }), store);
await service.start();
await expect(
service.installDirectory({ path: directory, id: "work-plugin" }),
).resolves.toMatchObject({
id: "work-plugin",
status: "running",
});
await expect(service.installDirectory({ path: directory, id: "work-plugin" })).rejects.toThrow(
"choose another ID with --id",
);
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
await expect(service.reloadPlugin("work-plugin")).rejects.toThrow();
expect(service.catalog()).toEqual([]);
expect(service.listPlugins()).toEqual([
expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }),
]);
await writeFile(
path.join(directory, "index.tsx"),
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
await expect(service.reloadPlugin("work-plugin")).resolves.toMatchObject({ status: "running" });
await service.stopAllPlugins();
});
it("disables and removes a plugin without touching its source directory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const cleanupFile = path.join(home, "cleanup.txt");
const directory = await createPlugin(
"cleanup-plugin",
`import { writeFileSync } from "node:fs";
export default function contribute(plugin: unknown) {
void plugin;
return () => writeFileSync(${JSON.stringify(cleanupFile)}, "cleaned");
}`,
);
const store = createStore(home);
const service = new PluginService(pino({ level: "silent" }), store);
await service.start();
await service.installDirectory({ path: directory });
await expect(service.disablePlugin("cleanup-plugin")).resolves.toMatchObject({
status: "disabled",
});
expect(await readFile(cleanupFile, "utf8")).toBe("cleaned");
await service.removePlugin("cleanup-plugin");
expect(service.listPlugins()).toEqual([]);
await expect(stat(directory)).resolves.toMatchObject({});
await service.stopAllPlugins();
});
it("detaches every plugin synchronously when the global switch turns off and recovers", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const first = await createPlugin(
"first",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const second = await createPlugin(
"second",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const store = createStore(home);
const service = new PluginService(pino({ level: "silent" }), store);
await service.start();
await service.installDirectory({ path: first });
await service.installDirectory({ path: second });
store.patch({ pluginsEnabled: false });
expect(service.catalog()).toEqual([]);
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
"Plugin is not available",
);
store.patch({ pluginsEnabled: true });
await service.reloadPlugin("first");
expect(service.listPlugins().map(({ id, status }) => ({ id, status }))).toEqual([
{ id: "first", status: "running" },
{ id: "second", status: "running" },
]);
await service.stopAllPlugins();
});
it("does not publish an in-flight start after a later global disable", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: true },
});
store.patch({ pluginsEnabled: false });
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, paused.runtime);
await service.start();
store.patch({ pluginsEnabled: true });
await paused.started;
store.patch({ pluginsEnabled: false });
paused.releaseStart();
await service.stopAllPlugins();
expect(service.catalog()).toEqual([]);
expect(service.listPlugins()).toEqual([
{ id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" },
]);
});
it("does not publish an in-flight enable after a later plugin disable", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPausedRuntime();
const service = new PluginService(pino({ level: "silent" }), store, paused.runtime);
await service.start();
const enabling = expect(service.enablePlugin("slow")).rejects.toThrow(
"Plugin start cancelled: slow",
);
await paused.started;
const disabling = service.disablePlugin("slow");
paused.releaseStart();
await enabling;
await expect(disabling).resolves.toMatchObject({ status: "disabled" });
expect(service.catalog()).toEqual([]);
});
it("keeps a later disable authoritative over an enable waiting behind another plugin", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const store = createStore(home, {
occupier: { source: "directory", path: "/plugins/occupier", enabled: false },
slow: { source: "directory", path: "/plugins/slow", enabled: false },
});
const paused = createPluginSelectivePausedRuntime("occupier");
const service = new PluginService(pino({ level: "silent" }), store, paused.runtime);
await service.start();
const occupying = service.enablePlugin("occupier");
await paused.started;
const enabling = service.enablePlugin("slow");
const disabling = service.disablePlugin("slow");
paused.releaseStart();
await occupying;
await expect(enabling).resolves.toMatchObject({
id: "slow",
enabled: false,
status: "disabled",
});
await expect(disabling).resolves.toMatchObject({
id: "slow",
enabled: false,
status: "disabled",
});
expect(paused.starts).toEqual(["occupier"]);
expect(service.catalog()).toEqual([{ id: "occupier", clientBundle: "bundle" }]);
});
it("notifies exactly once after successful and failed configured installs", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const successful = await createPlugin(
"successful-install",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const failed = await createPlugin(
"failed-install",
`export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`,
);
const service = createService(home);
const events: string[] = [];
service.subscribe((pluginId) => events.push(pluginId));
await service.start();
await service.installDirectory({ path: successful });
expect(events).toEqual(["successful-install"]);
events.length = 0;
await expect(service.installDirectory({ path: failed })).rejects.toThrow("startup exploded");
expect(events).toEqual(["failed-install"]);
await service.stopAllPlugins();
});
it("reports invalid manifests, missing entries, and startup failures", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const invalid = await createPlugin("valid-before-corruption", "export default () => () => {};");
await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({}));
const missingEntry = await createPlugin("missing-entry", "export default () => () => {};");
await rm(path.join(missingEntry, "index.tsx"));
const startupFailure = await createPlugin(
"startup-failure",
`export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`,
);
const service = createService(home);
await service.start();
await expect(service.installDirectory({ path: invalid })).rejects.toThrow();
await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow(
"Plugin entry point is missing",
);
await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow(
"startup exploded",
);
expect(service.listPlugins()).toEqual([
expect.objectContaining({
id: "missing-entry",
status: "failed",
error: expect.stringContaining("Plugin entry point is missing"),
}),
expect.objectContaining({
id: "startup-failure",
status: "failed",
error: "startup exploded",
}),
]);
await service.stopAllPlugins();
});
it("contains cleanup errors and invokes server cleanup once per stopped installation", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const cleanupFile = path.join(home, "cleanups.txt");
const directory = await createPlugin(
"cleanup-count",
`import { appendFileSync } from "node:fs";
export default function contribute(plugin: unknown) {
void plugin;
return () => {
appendFileSync(${JSON.stringify(cleanupFile)}, "cleanup\\n");
throw new Error("cleanup exploded");
};
}`,
);
const service = createService(home);
await service.start();
await service.installDirectory({ path: directory });
await service.reloadPlugin("cleanup-count");
await service.disablePlugin("cleanup-count");
await service.enablePlugin("cleanup-count");
await service.removePlugin("cleanup-count");
await service.installDirectory({ path: directory });
await service.stopAllPlugins();
expect((await readFile(cleanupFile, "utf8")).trim().split("\n")).toHaveLength(4);
});
});
+278
View File
@@ -0,0 +1,278 @@
import path from "node:path";
import type pino from "pino";
import {
PluginIdSchema,
type PluginListItem,
type PluginSource,
} from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import { readPluginManifest } from "./manifest.js";
import { PluginRuntime } from "./runtime.js";
interface PluginRuntimePort {
catalog(): Array<{ id: string; clientBundle: string }>;
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise<void>;
stopPluginById(pluginId: string): Promise<boolean>;
stopAll(): Promise<void>;
subscribe(listener: (pluginId: string, error?: string) => void): () => void;
}
export class PluginService {
private readonly runtime: PluginRuntimePort;
private readonly logger: pino.Logger;
private readonly errors = new Map<string, string>();
private readonly listeners = new Set<(pluginId: string) => void>();
private lifecycle = Promise.resolve();
private globalStartsBlocked = true;
private started = false;
constructor(
logger: pino.Logger,
private readonly configStore: DaemonConfigStore,
runtime: PluginRuntimePort = new PluginRuntime(logger),
) {
this.logger = logger.child({ module: "plugin-service" });
this.runtime = runtime;
this.runtime.subscribe((pluginId, error) => {
if (error) this.errors.set(pluginId, error);
this.notify(pluginId);
});
}
subscribe(listener: (pluginId: string) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
const config = this.configStore.get();
this.globalStartsBlocked = config.pluginsEnabled !== true;
if (config.pluginsEnabled === true) {
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
if (source.enabled === false) continue;
await this.startConfigured(pluginId);
}
}
this.configStore.onFieldChange("pluginsEnabled", (value) => {
this.handleGlobalSwitch(value === true);
});
}
listPlugins(): PluginListItem[] {
const config = this.configStore.get();
const running = new Set(this.runtime.catalog().map((plugin) => plugin.id));
return Object.entries(config.plugins ?? {})
.map(([id, source]) => {
const enabled = source.enabled !== false;
if (!enabled || config.pluginsEnabled !== true) {
return { id, path: source.path, enabled, status: "disabled" as const };
}
const error = this.errors.get(id);
const item: PluginListItem = {
id,
path: source.path,
enabled,
status: running.has(id) ? ("running" as const) : ("failed" as const),
};
if (error) item.error = error;
return item;
})
.sort((left, right) => left.id.localeCompare(right.id));
}
catalog(): Array<{ id: string; clientBundle: string }> {
return this.runtime.catalog();
}
async installDirectory(input: { path: string; id?: string }): Promise<PluginListItem> {
return this.enqueue(async () => {
const directory = path.resolve(input.path);
const manifest = await readPluginManifest(directory);
const pluginId = PluginIdSchema.parse(input.id ?? manifest.id);
if (this.configStore.get().plugins?.[pluginId]) {
throw new Error(
`Plugin ID "${pluginId}" is already configured; choose another ID with --id`,
);
}
const sources = {
...this.configStore.get().plugins,
[pluginId]: { source: "directory" as const, path: directory, enabled: true },
};
this.configStore.patch({ plugins: sources });
if (this.canPublish(pluginId)) await this.startConfigured(pluginId);
this.notify(pluginId);
const installed = this.requireItem(pluginId);
if (installed.status === "failed") {
throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`);
}
return installed;
});
}
async inspectDirectory(configuredPath: string): Promise<{ id: string }> {
return readPluginManifest(path.resolve(configuredPath));
}
async reloadPlugin(pluginId: string): Promise<PluginListItem> {
return this.enqueue(async () => {
const source = this.requireEnabledSource(pluginId);
if (this.configStore.get().pluginsEnabled !== true) {
throw new Error("Plugins are globally disabled");
}
this.errors.delete(pluginId);
await this.runtime.stopPluginById(pluginId);
await this.startExplicit(pluginId, source.path);
this.notify(pluginId);
return this.requireItem(pluginId);
});
}
async enablePlugin(pluginId: string): Promise<PluginListItem> {
const source = this.requireSource(pluginId);
this.patchSource(pluginId, { ...source, enabled: true });
this.errors.delete(pluginId);
return this.enqueue(async () => {
const current = this.requireSource(pluginId);
if (current.enabled !== false && this.configStore.get().pluginsEnabled === true) {
await this.startExplicit(pluginId, current.path);
}
this.notify(pluginId);
return this.requireItem(pluginId);
});
}
async disablePlugin(pluginId: string): Promise<PluginListItem> {
const source = this.requireSource(pluginId);
this.patchSource(pluginId, { ...source, enabled: false });
const stopping = this.runtime.stopPluginById(pluginId);
return this.enqueue(async () => {
await stopping;
this.errors.delete(pluginId);
this.notify(pluginId);
return this.requireItem(pluginId);
});
}
async removePlugin(pluginId: string): Promise<void> {
this.requireSource(pluginId);
const stopping = this.runtime.stopPluginById(pluginId);
const sources = { ...this.configStore.get().plugins };
delete sources[pluginId];
this.configStore.patch({ plugins: sources });
await this.enqueue(async () => {
await stopping;
this.errors.delete(pluginId);
this.notify(pluginId);
});
}
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown> {
return this.runtime.invoke(pluginId, method, input);
}
async stopAllPlugins(): Promise<void> {
this.globalStartsBlocked = true;
const stopping = this.runtime.stopAll();
await this.enqueue(async () => {
await stopping;
await this.runtime.stopAll();
});
}
private handleGlobalSwitch(enabled: boolean): void {
if (!enabled) {
this.globalStartsBlocked = true;
const stopping = this.runtime.stopAll();
for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id);
void this.enqueue(async () => {
await stopping;
}).catch((error) => this.logger.error({ err: error }, "Failed to disable plugins"));
return;
}
void this.enqueue(async () => {
this.globalStartsBlocked = false;
for (const [pluginId, source] of Object.entries(this.configStore.get().plugins ?? {})) {
if (source.enabled !== false) await this.startConfigured(pluginId);
this.notify(pluginId);
}
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
}
private async startConfigured(pluginId: string): Promise<void> {
const source = this.configStore.get().plugins?.[pluginId];
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
this.errors.delete(pluginId);
try {
await this.runtime.startPlugin(pluginId, source.path, () => this.canPublish(pluginId));
} catch (error) {
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
}
}
private async startExplicit(pluginId: string, sourcePath: string): Promise<void> {
try {
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
} catch (error) {
if (this.canPublish(pluginId)) {
this.recordFailure(pluginId, error);
this.notify(pluginId);
}
throw error;
}
}
private canPublish(pluginId: string): boolean {
const config = this.configStore.get();
return (
!this.globalStartsBlocked &&
config.pluginsEnabled === true &&
config.plugins?.[pluginId]?.enabled !== false &&
config.plugins?.[pluginId] !== undefined
);
}
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.lifecycle.then(operation, operation);
this.lifecycle = result.then(
() => undefined,
() => undefined,
);
return result;
}
private recordFailure(pluginId: string, error: unknown): void {
this.errors.set(pluginId, error instanceof Error ? error.message : String(error));
}
private requireSource(pluginId: string): PluginSource {
PluginIdSchema.parse(pluginId);
const source = this.configStore.get().plugins?.[pluginId];
if (!source) throw new Error(`Plugin is not configured: ${pluginId}`);
return source;
}
private requireEnabledSource(pluginId: string): PluginSource {
const source = this.requireSource(pluginId);
if (source.enabled === false) throw new Error(`Plugin is disabled: ${pluginId}`);
return source;
}
private patchSource(pluginId: string, source: PluginSource): void {
this.configStore.patch({
plugins: { ...this.configStore.get().plugins, [pluginId]: source },
});
}
private requireItem(pluginId: string): PluginListItem {
const item = this.listPlugins().find((plugin) => plugin.id === pluginId);
if (!item) throw new Error(`Plugin is not configured: ${pluginId}`);
return item;
}
private notify(pluginId: string): void {
for (const listener of this.listeners) listener(pluginId);
}
}
@@ -0,0 +1,16 @@
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { PluginIdSchema } from "@getpaseo/protocol/messages";
const MANIFEST_FILENAME = "paseo-plugin.json";
const PluginManifestSchema = z.object({ id: PluginIdSchema }).strict();
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
export async function readPluginManifest(directory: string): Promise<PluginManifest> {
const manifestPath = path.join(directory, MANIFEST_FILENAME);
const info = await stat(manifestPath).catch(() => null);
if (!info?.isFile()) throw new Error(`Plugin manifest is missing: ${manifestPath}`);
return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
}
@@ -0,0 +1,10 @@
export type PluginProcessRequest =
| { type: "initialize"; bundle: string }
| { type: "invoke"; requestId: string; method: string; input: unknown }
| { type: "shutdown" };
export type PluginProcessMessage =
| { type: "ready"; methods: string[] }
| { type: "result"; requestId: string; output: unknown }
| { type: "error"; requestId: string; error: string }
| { type: "fatal"; error: string };
@@ -0,0 +1,105 @@
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
import { createRequire } from "node:module";
import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@paseo/plugin";
type RpcHandler = (input: unknown) => unknown | Promise<unknown>;
interface RegisteredRpc {
contract: PluginRpcContract;
handler: RpcHandler;
}
const handlers = new Map<string, RegisteredRpc>();
let cleanup: (() => void) | null = null;
const nodeRequire = createRequire(import.meta.url);
function send(message: PluginProcessMessage): void {
process.send?.(message);
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function validateMethod(method: string): string {
const normalized = method.trim();
if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) {
throw new Error(`Invalid plugin RPC method: ${method}`);
}
if (handlers.has(normalized)) {
throw new Error(`Duplicate plugin RPC method: ${normalized}`);
}
return normalized;
}
function register(contract: PluginRpcContract, handler: RpcHandler): void {
if (typeof handler !== "function") {
throw new Error(`Plugin RPC ${contract.name} must provide a handler`);
}
const method = validateMethod(contract.name);
handlers.set(method, { contract: { ...contract, name: method }, handler });
}
function runtimeRequire(name: string): unknown {
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc };
return nodeRequire(name);
}
function evaluateBundle(bundle: string): void {
const evaluate: (source: string) => unknown = globalThis.eval;
const factory = evaluate(bundle);
if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable");
const exports = factory(runtimeRequire);
const setup =
exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined;
if (typeof setup !== "function") {
throw new Error("Plugin server bundle must default export a function");
}
const contributedCleanup = setup({ handle: register });
if (typeof contributedCleanup !== "function") {
throw new Error("Plugin contribution must return a cleanup function");
}
cleanup = contributedCleanup;
}
process.on("message", (message: PluginProcessRequest) => {
if (message.type === "initialize") {
try {
evaluateBundle(message.bundle);
send({ type: "ready", methods: [...handlers.keys()].sort() });
} catch (error) {
send({ type: "fatal", error: describeError(error) });
}
return;
}
if (message.type === "shutdown") {
const currentCleanup = cleanup;
cleanup = null;
if (currentCleanup) {
try {
currentCleanup();
} catch (error) {
console.error("Plugin cleanup failed", error);
}
}
process.disconnect();
return;
}
const registered = handlers.get(message.method);
if (!registered) {
send({
type: "error",
requestId: message.requestId,
error: `Unknown RPC method: ${message.method}`,
});
return;
}
void registered.contract.input
.parseAsync(message.input)
.then(registered.handler)
.then((output) => registered.contract.output.parseAsync(output))
.then(
(output) => send({ type: "result", requestId: message.requestId, output }),
(error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
});
@@ -0,0 +1,319 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { PluginRuntime } from "./runtime.js";
const temporaryDirectories: string[] = [];
async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }), "utf8");
await writeFile(path.join(directory, "index.tsx"), source, "utf8");
return directory;
}
function createReloadChild(name: string, events: string[], methods: string[] = []) {
const listeners = new Map<string, Array<(message: never) => void>>();
const emit = (event: string, message: unknown) => {
for (const listener of listeners.get(event) ?? []) listener(message as never);
};
return {
connected: true,
killed: false,
send(message: { type: string }, callback?: (error: Error | null) => void) {
callback?.(null);
if (message.type === "initialize") {
events.push(`start:${name}`);
queueMicrotask(() => emit("message", { type: "ready", methods }));
}
if (message.type === "shutdown") {
events.push(`shutdown:${name}`);
this.connected = false;
queueMicrotask(() => {
events.push(`exit:${name}`);
emit("close", null);
});
}
return true;
},
kill() {
this.killed = true;
this.connected = false;
queueMicrotask(() => emit("close", null));
return true;
},
disconnect() {
this.connected = false;
},
on(event: string, listener: (message: never) => void) {
const registered = listeners.get(event) ?? [];
registered.push(listener);
listeners.set(event, registered);
return this;
},
};
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("PluginRuntime", () => {
it("waits for the old subprocess to exit before starting its replacement", async () => {
const directory = await createPlugin(
"reloadable",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const events: string[] = [];
const children = [createReloadChild("old", events), createReloadChild("new", events)];
const runtime = new PluginRuntime(pino({ level: "silent" }), {
spawnChild: () => {
const child = children.shift();
if (!child) throw new Error("Unexpected extra child");
return child;
},
});
await runtime.startPlugin("configured-id", directory);
await runtime.stopPluginById("configured-id");
await runtime.startPlugin("configured-id", directory);
expect(events).toEqual(["start:old", "shutdown:old", "exit:old", "start:new"]);
await runtime.stopAll();
});
it("rejects pending RPCs when the plugin stops", async () => {
const directory = await createPlugin(
"pending",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const child = createReloadChild("pending", [], ["wait"]);
const runtime = new PluginRuntime(pino({ level: "silent" }), {
spawnChild: () => child,
});
await runtime.startPlugin("pending", directory);
const rejection = expect(runtime.invoke("pending", "wait", {})).rejects.toThrow(
"Plugin stopped: pending",
);
await runtime.stopPluginById("pending");
await rejection;
});
it("kills a plugin child that fails initialization", async () => {
const directory = await createPlugin(
"broken",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const listeners = new Map<string, Array<(message: never) => void>>();
const child = {
connected: true,
killed: false,
send(message: { type: string }, callback?: (error: Error | null) => void) {
callback?.(null);
if (message.type === "initialize") {
queueMicrotask(() => {
for (const listener of listeners.get("message") ?? []) {
listener({ type: "fatal", error: "broken plugin" } as never);
}
});
}
return true;
},
kill() {
this.killed = true;
this.connected = false;
return true;
},
disconnect() {
this.connected = false;
},
on(event: string, listener: (message: never) => void) {
const registered = listeners.get(event) ?? [];
registered.push(listener);
listeners.set(event, registered);
return this;
},
};
const runtime = new PluginRuntime(pino({ level: "silent" }), {
spawnChild: () => child,
});
await expect(runtime.startPlugin("broken", directory)).rejects.toThrow("broken plugin");
expect(child.killed).toBe(true);
await runtime.stopAll();
});
it("rejects a server contribution without cleanup", async () => {
const directory = await createPlugin(
"missing-cleanup",
`export default function contribute(plugin: unknown) { void plugin; }`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await expect(runtime.startPlugin("missing-cleanup", directory)).rejects.toThrow(
"must return a cleanup function",
);
expect(runtime.catalog()).toEqual([]);
await runtime.stopAll();
});
it("loads the official Linear attachment extension", async () => {
const directory = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../plugin-examples/linear",
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.startPlugin("linear", directory);
expect(runtime.catalog().map((plugin) => plugin.id)).toEqual(["linear"]);
expect(runtime.catalog()[0]?.clientBundle).toContain("Attach Linear issue");
expect(runtime.catalog()[0]?.clientBundle).not.toContain("LINEAR_API_KEY");
expect(runtime.catalog()[0]?.clientBundle).not.toContain("api.linear.app");
await runtime.stopAll();
});
it("loads one index.tsx, exposes its client bundle, and invokes its server RPC", async () => {
const directory = await createPlugin(
"hello",
`import React from "react";
import { platform } from "node:os";
import { Text } from "react-native";
import { z } from "zod";
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string(), platform: z.string() }),
});
const attachments = defineAttachmentSource({
id: "issues",
title: "Example issue",
icon: "CircleDot",
pickerTitle: "Attach example issue",
searchPlaceholder: "Search issues",
search: greetRpc,
});
function HelloSurface() {
return <Text>Hello from native UI</Text>;
}
export default function contribute(plugin: any) {
plugin.handle(greetRpc, async (input: { name: string }) => ({
message: "Hello, " + input.name,
platform: platform(),
}));
plugin.addSurface("main", HelloSurface);
plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" });
plugin.addAttachmentSource(attachments);
return () => undefined;
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.startPlugin("hello", directory);
const catalog = runtime.catalog();
expect(catalog).toHaveLength(1);
expect(catalog[0]?.id).toBe("hello");
expect(catalog[0]?.clientBundle).toContain("Hello from native UI");
expect(catalog[0]?.clientBundle).toContain("Attach example issue");
expect(catalog[0]?.clientBundle).not.toContain("node:os");
expect(catalog[0]?.clientBundle).not.toContain("get: () => from[key]");
await expect(runtime.invoke("hello", "greet", { name: "Paseo" })).resolves.toMatchObject({
message: "Hello, Paseo",
});
await expect(runtime.invoke("hello", "greet", { name: 7 })).rejects.toThrow();
await runtime.stopAll();
});
it("rejects a handler result that does not match its RPC output schema", async () => {
const directory = await createPlugin(
"invalid-output",
`import { z } from "zod";
import { defineRpc } from "@paseo/plugin";
const brokenRpc = defineRpc({
name: "broken",
input: z.object({}),
output: z.object({ value: z.number() }),
});
export default function contribute(plugin: any) {
plugin.handle(brokenRpc, async () => ({ value: "wrong" }));
return () => undefined;
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.startPlugin("invalid-output", directory);
await expect(runtime.invoke("invalid-output", "broken", {})).rejects.toThrow();
await runtime.stopAll();
});
it("uses the config key as runtime identity without comparing the manifest id", async () => {
const directory = await createPlugin(
"actual",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.startPlugin("configured", directory);
expect(runtime.catalog().map((plugin) => plugin.id)).toEqual(["configured"]);
await runtime.stopAll();
});
it("does not publish a plugin when lifecycle intent changes while it starts", async () => {
const directory = await createPlugin(
"blocked",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const events: string[] = [];
const child = createReloadChild("blocked", events);
const runtime = new PluginRuntime(pino({ level: "silent" }), { spawnChild: () => child });
await expect(runtime.startPlugin("blocked", directory, () => false)).rejects.toThrow(
"Plugin start cancelled: blocked",
);
expect(runtime.catalog()).toEqual([]);
expect(events).toEqual(["start:blocked", "shutdown:blocked", "exit:blocked"]);
});
it("reports an unexpected subprocess crash and removes its catalog entry", async () => {
const directory = await createPlugin(
"crashing",
`export default function contribute(plugin: unknown) {
void plugin;
setTimeout(() => process.exit(17), 20);
return () => undefined;
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
const crashed = new Promise<string>((resolve) => {
runtime.subscribe((pluginId, error) => {
if (pluginId === "crashing" && error) resolve(error);
});
});
await runtime.startPlugin("crashing", directory);
await expect(crashed).resolves.toBe("Plugin process exited: crashing");
expect(runtime.catalog()).toEqual([]);
await expect(runtime.invoke("crashing", "anything", {})).rejects.toThrow(
"Plugin is not available",
);
});
});
@@ -0,0 +1,269 @@
import { fork } from "node:child_process";
import { stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import type pino from "pino";
import { compilePlugin } from "./compiler.js";
import { readPluginManifest } from "./manifest.js";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
const ENTRY_FILENAME = "index.tsx";
const REQUEST_TIMEOUT_MS = 30_000;
interface PluginChild {
connected: boolean;
killed: boolean;
send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean;
kill(): boolean;
disconnect(): void;
on(event: "message", listener: (message: PluginProcessMessage) => void): this;
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
}
interface PendingInvocation {
resolve: (output: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
interface LoadedPlugin {
id: string;
clientBundle: string;
methods: ReadonlySet<string>;
child: PluginChild;
pending: Map<string, PendingInvocation>;
}
interface PluginRuntimeDependencies {
spawnChild?: () => PluginChild;
}
function resolveWorkerUrl(): URL {
return new URL(
import.meta.url.endsWith(".ts") ? "./plugin-process.ts" : "./plugin-process.js",
import.meta.url,
);
}
function resolveWorkerExecArgv(): string[] {
if (!import.meta.url.endsWith(".ts")) return [];
const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href;
const importSource = [
'import { register } from "node:module";',
'import { pathToFileURL } from "node:url";',
`register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`,
].join(" ");
return [
"--experimental-strip-types",
"--import",
`data:text/javascript,${encodeURIComponent(importSource)}`,
];
}
function spawnPluginChild(): PluginChild {
return fork(fileURLToPath(resolveWorkerUrl()), [], {
execArgv: resolveWorkerExecArgv(),
serialization: "advanced",
stdio: ["ignore", "ignore", "inherit", "ipc"],
}) as PluginChild;
}
function terminatePluginChild(child: PluginChild): void {
if (child.connected) child.disconnect();
if (!child.killed) child.kill();
}
function send(child: PluginChild, message: PluginProcessRequest): Promise<void> {
return new Promise((resolve, reject) => {
child.send(message, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function requireRegularFile(filePath: string, label: string): Promise<void> {
const info = await stat(filePath).catch(() => null);
if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`);
}
export class PluginRuntime {
private readonly plugins = new Map<string, LoadedPlugin>();
private readonly logger: pino.Logger;
private readonly spawnChild: () => PluginChild;
private readonly listeners = new Set<(pluginId: string, error?: string) => void>();
constructor(logger: pino.Logger, dependencies: PluginRuntimeDependencies = {}) {
this.logger = logger.child({ module: "plugins" });
this.spawnChild = dependencies.spawnChild ?? spawnPluginChild;
}
subscribe(listener: (pluginId: string, error?: string) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async startPlugin(
pluginId: string,
configuredPath: string,
canPublish: () => boolean = () => true,
): Promise<void> {
if (this.plugins.has(pluginId)) throw new Error(`Plugin is already running: ${pluginId}`);
const loaded = await this.loadDirectoryPlugin(pluginId, configuredPath);
if (!canPublish()) {
await this.stopPlugin(loaded);
throw new Error(`Plugin start cancelled: ${pluginId}`);
}
this.plugins.set(pluginId, loaded);
}
async stopPluginById(pluginId: string): Promise<boolean> {
const loaded = this.plugins.get(pluginId);
if (!loaded) return false;
this.plugins.delete(pluginId);
this.rejectPending(loaded, `Plugin stopped: ${pluginId}`);
await this.stopPlugin(loaded);
return true;
}
catalog(): Array<{ id: string; clientBundle: string }> {
return [...this.plugins.values()]
.map(({ id, clientBundle }) => ({ id, clientBundle }))
.sort((left, right) => left.id.localeCompare(right.id));
}
async invoke(pluginId: string, method: string, input: unknown): Promise<unknown> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
if (!loaded.methods.has(method))
throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`);
const requestId = randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
loaded.pending.delete(requestId);
reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`));
}, REQUEST_TIMEOUT_MS);
loaded.pending.set(requestId, { resolve, reject, timeout });
void send(loaded.child, { type: "invoke", requestId, method, input }).catch((error) => {
clearTimeout(timeout);
loaded.pending.delete(requestId);
reject(error);
});
});
}
async stopAll(): Promise<void> {
const loaded = [...this.plugins.values()];
this.plugins.clear();
for (const plugin of loaded) {
this.rejectPending(plugin, `Plugin stopped: ${plugin.id}`);
}
await Promise.all(loaded.map((plugin) => this.stopPlugin(plugin)));
}
private async loadDirectoryPlugin(
pluginId: string,
configuredPath: string,
): Promise<LoadedPlugin> {
const directory = path.resolve(configuredPath);
await readPluginManifest(directory);
const entryPath = path.join(directory, ENTRY_FILENAME);
await requireRegularFile(entryPath, "Plugin entry point");
const bundles = await compilePlugin(entryPath);
const child = this.spawnChild();
const pending = new Map<string, PendingInvocation>();
let methods: string[];
try {
methods = await new Promise<string[]>((resolve, reject) => {
let settled = false;
const timeout = setTimeout(
() => fail(new Error(`Plugin ${pluginId} did not initialize`)),
REQUEST_TIMEOUT_MS,
);
const fail = (error: Error): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(error);
};
child.on("message", (message) => {
if (message.type === "ready") {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(message.methods);
} else if (message.type === "fatal") {
fail(new Error(message.error));
}
});
child.on("close", () => fail(new Error(`Plugin ${pluginId} exited during initialization`)));
void send(child, { type: "initialize", bundle: bundles.serverBundle }).catch(fail);
});
} catch (error) {
terminatePluginChild(child);
throw error;
}
const loaded: LoadedPlugin = {
id: pluginId,
clientBundle: bundles.clientBundle,
methods: new Set(methods),
child,
pending,
};
child.on("message", (message) => this.handleChildMessage(loaded, message));
child.on("close", () => this.handleChildClose(loaded));
this.logger.info({ pluginId, methods }, "Loaded plugin");
return loaded;
}
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
if (message.type !== "result" && message.type !== "error") return;
const pending = loaded.pending.get(message.requestId);
if (!pending) return;
loaded.pending.delete(message.requestId);
clearTimeout(pending.timeout);
if (message.type === "result") pending.resolve(message.output);
else pending.reject(new Error(message.error));
}
private handleChildClose(loaded: LoadedPlugin): void {
if (this.plugins.get(loaded.id) === loaded) {
this.plugins.delete(loaded.id);
this.notify(loaded.id, `Plugin process exited: ${loaded.id}`);
}
this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`);
}
private async stopPlugin(loaded: LoadedPlugin): Promise<void> {
if (loaded.child.killed) return;
let didClose = false;
const closed = new Promise<void>((resolve) =>
loaded.child.on("close", () => {
didClose = true;
resolve();
}),
);
if (loaded.child.connected) {
await send(loaded.child, { type: "shutdown" }).catch(() => undefined);
}
await Promise.race([closed, new Promise<void>((resolve) => setTimeout(resolve, 1_000))]);
if (!didClose) terminatePluginChild(loaded.child);
await closed;
}
private rejectPending(loaded: LoadedPlugin, message: string): void {
for (const invocation of loaded.pending.values()) {
clearTimeout(invocation.timeout);
invocation.reject(new Error(message));
}
loaded.pending.clear();
}
private notify(pluginId: string, error?: string): void {
for (const listener of this.listeners) listener(pluginId, error);
}
}
@@ -320,6 +320,7 @@ interface SessionForTestOptions {
messages?: unknown[];
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
binaryMessages?: Uint8Array[];
pluginRuntime?: SessionOptions["pluginRuntime"];
}
function createSessionForTest(options: SessionForTestOptions = {}): Session {
@@ -405,6 +406,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
})),
onChange: vi.fn(() => () => {}),
}),
pluginRuntime: options.pluginRuntime,
stt: options.stt ?? null,
tts: null,
terminalManager: options.terminalManager ?? null,
@@ -424,6 +426,71 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
return new Session(sessionOptions);
}
test("routes plugin management requests and catalog notifications", async () => {
const messages: SessionOutboundMessage[] = [];
const listeners = new Set<(pluginId: string) => void>();
const plugin = {
id: "example",
path: "/plugins/example",
enabled: true,
status: "running" as const,
};
const pluginRuntime: NonNullable<SessionOptions["pluginRuntime"]> = {
listPlugins: () => [plugin],
installDirectory: async () => plugin,
inspectDirectory: async () => ({ id: "example" }),
reloadPlugin: async () => plugin,
enablePlugin: async () => plugin,
disablePlugin: async () => ({ ...plugin, enabled: false, status: "disabled" }),
removePlugin: async () => undefined,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
catalog: () => [{ id: "example", clientBundle: "bundle" }],
invokePluginRpc: async () => ({ ok: true }),
};
const session = createSessionForTest({ messages, pluginRuntime });
await session.handleMessage({ type: "plugin.list.request", requestId: "list" });
await session.handleMessage({
type: "plugin.directory.install.request",
requestId: "install",
path: "/plugins/example",
});
await session.handleMessage({
type: "plugin.reload.request",
requestId: "reload",
pluginId: "example",
});
await session.handleMessage({
type: "plugin.disable.request",
requestId: "disable",
pluginId: "example",
});
await session.handleMessage({
type: "plugin.remove.request",
requestId: "remove",
pluginId: "example",
});
for (const listener of listeners) listener("example");
expect(messages.map((message) => message.type)).toEqual([
"plugin.list.response",
"plugin.directory.install.response",
"plugin.reload.response",
"plugin.disable.response",
"plugin.remove.response",
"status",
]);
expect(messages.at(-1)).toEqual({
type: "status",
payload: { status: "plugin_catalog_changed", pluginId: "example" },
});
await session.cleanup();
expect(listeners.size).toBe(0);
});
describe("session authorization scopes", () => {
test("routes named-agent validation through the session source", async () => {
const messages: SessionOutboundMessage[] = [];
+127
View File
@@ -460,6 +460,21 @@ export interface SessionOptions {
workspaceGitService: WorkspaceGitService;
workspaceAutoName: WorkspaceAutoName;
daemonConfigStore: DaemonConfigStore;
pluginRuntime?: {
listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[];
installDirectory(input: {
path: string;
id?: string;
}): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
inspectDirectory(path: string): Promise<{ id: string }>;
reloadPlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
enablePlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
disablePlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
removePlugin(pluginId: string): Promise<void>;
subscribe(listener: (pluginId: string) => void): () => void;
catalog(): Array<{ id: string; clientBundle: string }>;
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
};
mcpBaseUrl?: string | null;
stt: Resolvable<SpeechToTextProvider | null>;
sttLanguage?: string;
@@ -637,8 +652,10 @@ export class Session {
private readonly workspaceRecovery: WorkspaceRecoveryService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushNotifications: PushNotifications;
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
private unsubscribeAgentEvents: (() => void) | null = null;
private unsubscribeProjectMutations: (() => void) | null = null;
private unsubscribePluginChanges: (() => void) | null = null;
private unsubscribeWorkspaceMutations: (() => void) | null = null;
private registryMutationQueue: Promise<void> = Promise.resolve();
private projectUpdateQueue: Promise<void> = Promise.resolve();
@@ -719,6 +736,7 @@ export class Session {
workspaceGitService,
workspaceAutoName,
daemonConfigStore,
pluginRuntime,
stt,
sttLanguage,
tts,
@@ -758,6 +776,8 @@ export class Session {
this.paseoHome = paseoHome;
this.projectIcons = new ProjectIconReader(paseoHome);
this.worktreesRoot = worktreesRoot;
this.pluginRuntime = pluginRuntime;
this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime);
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
@@ -1861,12 +1881,117 @@ export class Session {
this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchWorkspaceFileMessage(msg, source) ??
this.dispatchProviderMessage(msg) ??
this.dispatchPluginDirectoryMessage(msg) ??
this.dispatchPluginMessage(msg) ??
this.dispatchTerminalMessage(msg) ??
this.dispatchScheduleMessage(msg) ??
this.dispatchMiscMessage(msg);
if (promise) await promise;
}
private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "plugin.list.request") {
this.emit({
type: "plugin.list.response",
payload: { requestId: msg.requestId, plugins: this.pluginRuntime?.listPlugins() ?? [] },
});
return undefined;
}
if (msg.type === "plugin.reload.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.reloadPlugin(msg.pluginId).then((plugin) => {
this.emit({
type: "plugin.reload.response",
payload: { requestId: msg.requestId, plugin },
});
return undefined;
});
}
if (msg.type === "plugin.enable.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.enablePlugin(msg.pluginId).then((plugin) => {
this.emit({
type: "plugin.enable.response",
payload: { requestId: msg.requestId, plugin },
});
return undefined;
});
}
if (msg.type === "plugin.disable.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.disablePlugin(msg.pluginId).then((plugin) => {
this.emit({
type: "plugin.disable.response",
payload: { requestId: msg.requestId, plugin },
});
return undefined;
});
}
if (msg.type === "plugin.remove.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.removePlugin(msg.pluginId).then(() => {
this.emit({ type: "plugin.remove.response", payload: { requestId: msg.requestId } });
return undefined;
});
}
if (msg.type === "plugin.catalog.get.request") {
this.emit({
type: "plugin.catalog.get.response",
payload: {
requestId: msg.requestId,
plugins: this.pluginRuntime?.catalog() ?? [],
},
});
return undefined;
}
if (msg.type === "plugin.rpc.invoke.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime
.invokePluginRpc(msg.pluginId, msg.method, msg.input)
.then((output) => {
this.emit({
type: "plugin.rpc.invoke.response",
payload: { requestId: msg.requestId, output },
});
return undefined;
});
}
return undefined;
}
private dispatchPluginDirectoryMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "plugin.directory.install.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.installDirectory({ path: msg.path, id: msg.id }).then((plugin) => {
this.emit({
type: "plugin.directory.install.response",
payload: { requestId: msg.requestId, plugin },
});
return undefined;
});
}
if (msg.type === "plugin.directory.inspect.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
return this.pluginRuntime.inspectDirectory(msg.path).then(({ id }) => {
this.emit({
type: "plugin.directory.inspect.response",
payload: { requestId: msg.requestId, id },
});
return undefined;
});
}
return undefined;
}
private subscribeToPluginChanges(
pluginRuntime: SessionOptions["pluginRuntime"],
): (() => void) | null {
if (!pluginRuntime) return null;
return pluginRuntime.subscribe((pluginId) => {
this.emit({ type: "status", payload: { status: "plugin_catalog_changed", pluginId } });
});
}
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "voice_audio_chunk":
@@ -7025,6 +7150,8 @@ export class Session {
}
this.unsubscribeProjectMutations?.();
this.unsubscribeProjectMutations = null;
this.unsubscribePluginChanges?.();
this.unsubscribePluginChanges = null;
this.unsubscribeWorkspaceMutations?.();
this.unsubscribeWorkspaceMutations = null;
this.agentUpdates.dispose();
+13 -1
View File
@@ -30,7 +30,12 @@ import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frame
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import type { HostnamesConfig } from "./hostnames.js";
import { isHostnameAllowed } from "./hostnames.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
import {
Session,
type SessionLifecycleIntent,
type SessionOptions,
type SessionRuntimeMetrics,
} from "./session.js";
import type { HubRelationshipManagement } from "./hub/relationship-controller.js";
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
import type { HubExecutionAgents } from "./hub/daemon-executions.js";
@@ -579,6 +584,7 @@ export class VoiceAssistantWebSocketServer {
private readonly advertiseDaemonStatusRpc: boolean;
private readonly advertiseRelayConfig: boolean;
private readonly directorySync = new DirectorySyncService();
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
constructor(
server: HTTPServer,
@@ -623,6 +629,7 @@ export class VoiceAssistantWebSocketServer {
browserToolsBroker?: BrowserToolsBroker | null,
hubRelationships?: HubRelationshipManagement | null,
workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(),
pluginRuntime?: SessionOptions["pluginRuntime"],
) {
this.logger = logger.child({ module: "websocket-server" });
this.workspaceSetupRuntime = workspaceSetupRuntime;
@@ -636,6 +643,7 @@ export class VoiceAssistantWebSocketServer {
this.daemonRuntimeConfig = daemonRuntimeConfig;
this.browserToolsBroker = browserToolsBroker ?? null;
this.hubRelationships = hubRelationships ?? null;
this.pluginRuntime = pluginRuntime;
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
@@ -1357,6 +1365,7 @@ export class VoiceAssistantWebSocketServer {
workspaceGitService: this.workspaceGitService,
workspaceAutoName: this.workspaceAutoName,
daemonConfigStore: this.daemonConfigStore,
pluginRuntime: this.pluginRuntime,
mcpBaseUrl: this.mcpBaseUrl,
stt: () => this.speech?.resolveStt() ?? null,
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
@@ -1558,6 +1567,9 @@ export class VoiceAssistantWebSocketServer {
...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
// COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10.
pushTokenRevocation: true,
// COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07.
plugins: true,
pluginManagement: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.