Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 67439dc7fc fix(plugins): parse a subscription's input once in the plugin process
server.handle wrapped subscription handlers in a second input parse, so a
transforming input schema rejected its own parsed output before the
handler ran.
2026-11-02 06:34:49 +01:00
Mohamed Boudra 4b03f96f80 fix(plugins): apply subscription schema transforms once
Both ends parsed each subscription value, and the sender sent its parse
output, so the receiver parsed a schema's output as fresh input. An event
schema like z.string().transform((s) => s.length) closed the stream as
invalid. The sender now validates and sends the value as given; the
receiver's parse produces what the handler or callback sees.
2026-11-02 06:26:29 +01:00
Mohamed Boudra 11556aaf4c test(plugins): add the plain subscription unit test and the browser spec 2026-11-02 06:12:33 +01:00
Mohamed Boudra 1614903862 test(plugins): cover subscriptions in a real browser instead of JSDOM
The app subscription tests mounted hooks in JSDOM, which docs/testing.md
rules out. The adapter and contract behavior is now a plain unit test,
and a Playwright spec drives the diff-watch and media-stream examples:
JSON events following file changes, streamed bytes, and an upload.
2026-11-02 06:12:18 +01:00
Mohamed Boudra 509ca80e4e fix(plugins): withhold binary plugin results after plugin access is revoked
A binary RPC output or event skipped the outbound permission check its
JSON form passes, so an RPC in flight when daemon.manage was revoked still
delivered its bytes. Each frame now checks the permission of the JSON
message it stands in for.
2026-11-02 06:03:32 +01:00
Mohamed Boudra 9233e8cd38 fix(plugins): close subscription gaps found in review
- Losing daemon.manage now releases the session's plugin subscriptions;
  binary events bypass the outbound permission check, so streams would
  otherwise outlive the revocation.
- A subscribe that times out sends unsubscribe, since the plugin may
  still start the handler after the daemon gave up.
- The byte limit leaves room for the largest frame header and routing,
  and lives in assertPluginWireValue so events, outputs and inputs share it.
- A release right after the host accepted keeps `ready` resolved, so a
  handler that returns at once reports closed rather than an error.
- Session trace logging tolerates loggers without isLevelEnabled.
2026-11-02 05:51:55 +01:00
Mohamed Boudra c689b5626e feat(plugins): stream events and bytes between plugins and the app
Plugins can declare a subscription with defineSubscription, handle it with
server.handle(contract, (input, { push, signal })), and listen from a surface
with useSubscription or client.subscribe. A top-level Uint8Array in an event,
an RPC output, or an RPC input crosses the wire as a binary frame (opcodes
0x20-0x2f) without JSON or base64; a nested one fails validation on the sender.

Each subscriber runs its own handler, owned by the session that subscribed,
so release, disconnect, plugin stop and reload need no plugin code. The
runtime buffers pushes until the subscribe response has gone out, so a client
never sees an event for a subscription it does not know yet.

Gated on server_info.features.pluginSubscriptions; binary outputs reach only
clients that advertise plugin_binary_payloads.
2026-11-02 05:30:01 +01:00
80 changed files with 3237 additions and 87 deletions
+21 -3
View File
@@ -357,9 +357,27 @@ keys are stored under a `param.` prefix; plugins never see it and may use any ke
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
RPC and subscription contracts validate inputs, outputs, and events in both the app and plugin
subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query`
for request state and caching; Paseo gives each plugin installation its own query client.
A subscription is a session-owned observation (`plugin-rpc` delivery family): the session that
subscribed owns its lifetime, so release, disconnect, plugin stop, and reload need no plugin code.
Each subscriber gets its own handler call in the subprocess; there is no fan-out or replay. The
runtime buffers pushes until the session has sent `plugin.rpc.subscribe.response`, so a client
never sees an event for an unknown subscription. The app installation's lifetime aborts every
subscription its surfaces left open, and a reload replaces the installation, which resubscribes
mounted hooks.
The byte rule (a top-level `Uint8Array` is binary, a nested one is a validation error) is checked
once per hop by `assertPluginWireValue`. Bytes ride plugin binary frames, opcodes `0x20`-`0x2f`
(`packages/protocol/src/binary-frames/plugin.ts`); nothing on the path copies or re-encodes them,
and `plugin-subscriptions.perf.e2e.test.ts` locks that. Keep it that way: never stringify a plugin
payload for a log, and the client's bootstrap buffer holds bytes only until the first observer
attaches. Binary outputs reach only clients that send `pluginBinaryPayloads`. A binary input
frame (`0x22`) becomes a synthetic `plugin.rpc.invoke.request` and runs through the normal message
path, so it is authorized like any other invoke. The author contract, including the native upload
limit, is in [stream events from the daemon](../public-docs/plugins/reference.md#subscriptions).
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
@@ -0,0 +1,79 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */
async function withExample(
page: Page,
example: { name: string; id: string },
exercise: (workspace: SeededWorkspace) => Promise<void>,
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const copy = await copyPluginExample(example.name);
const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` });
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(copy.directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await exercise(workspace);
} finally {
try {
await client.removePlugin(example.id);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await copy.cleanup();
}
}
}
test.describe("plugin subscriptions", () => {
test.describe.configure({ timeout: 180_000 });
test("a panel follows events the daemon pushes as files change", async ({ page }) => {
await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => {
await runCommand(page, "Open live changes");
await expect(page.getByText("No changes", { exact: true })).toBeVisible();
await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n");
await expect(page.getByText("1 file changed", { exact: true })).toBeVisible();
await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible();
});
});
test("a panel plays streamed bytes and sends bytes back", async ({ page }) => {
await withExample(page, { name: "media-stream", id: "media-stream" }, async () => {
await runCommand(page, "Open media stream");
await expect(page.getByText("live", { exact: true })).toBeVisible();
await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible();
await page.getByRole("button", { name: "Send this frame back", exact: true }).click();
await expect(
page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/),
).toBeVisible();
});
});
});
@@ -69,6 +69,9 @@ function installProbe(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
}
@@ -17,6 +17,9 @@ function installation(): InstalledPlugin {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
@@ -153,6 +153,9 @@ function harness() {
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
@@ -2,6 +2,7 @@ import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
createPluginAgentActionContext,
createPluginCapabilities,
@@ -36,6 +37,8 @@ export function createPluginClientRuntime(
openPanel(panelId, options) {
openClientPanel({ installation, state, panelId, options });
},
subscribe: (contract, input, onEvent, options) =>
subscribePluginRpc(contract, installation.subscribe, input, onEvent, options),
};
}
@@ -71,6 +71,9 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
clientType: "cli",
}),
),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
invoke: async (method: string, input: unknown) => {
expect(method).toBe("review.inspect");
return { value: inspect.input.parse(input).value + 1 };
+1
View File
@@ -111,6 +111,7 @@ export type PluginClientRuntime = Pick<
| "openScreen"
| "openSurface"
| "openPanel"
| "subscribe"
| "addComposerPill"
| "addHeaderButton"
> & { hosts: ReturnType<typeof createPluginHosts> };
@@ -29,6 +29,9 @@ function installation(invoked: string[]): InstalledPlugin {
invoked.push(method);
return { state: "green" };
},
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
@@ -18,7 +18,7 @@ export function PluginInstallationProvider({
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PluginRpcProvider invoke={plugin.invoke}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
@@ -62,6 +62,9 @@ function registry(
openPanel: () => {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
@@ -39,6 +39,9 @@ function registry(version: string) {
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
+8 -1
View File
@@ -8,6 +8,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { resolveAppVersion } from "@/utils/app-version";
import { createPluginClientRuntime } from "./client-runtime";
import { createPluginSubscribe } from "./subscriptions";
import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
@@ -89,12 +90,18 @@ export class PluginRegistry {
return [existing];
}
const client = options.client;
const lifetime = new AbortController();
installation = {
lifetime: new AbortController(),
lifetime,
locale: this.dependencies.localeSource.getLocale(),
i18n: new PluginTranslationRuntime(this.dependencies.localeSource),
paseo: createPaseoApi(client),
invoke: (method, input) => client.invokePluginRpc(entry.id, method, input),
subscribe: createPluginSubscribe({
client,
pluginId: entry.id,
lifetime: lifetime.signal,
}),
id: entry.id,
serverId,
clientBundle: entry.clientBundle,
@@ -26,6 +26,9 @@ function installed(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [],
surfaces: [{ id: "surface", title: "Surface", Component: () => null }],
sidebarItems: {
@@ -0,0 +1,189 @@
import { defineSubscription } from "@getpaseo/plugin";
import type { PluginSubscriptionClosed } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SubscriptionObserver } from "@getpaseo/client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { expect, it } from "vitest";
import { z } from "zod";
import { createPluginSubscribe } from "./subscriptions";
const changes = defineSubscription({
name: "diff.watch",
input: z.object({ ref: z.string() }),
event: z.union([z.instanceof(Uint8Array), z.object({ path: z.string() })]),
});
type Observed = Parameters<DaemonClient["observePluginRpc"]>;
/** One host-side subscription as the daemon client exposes it, driven by the test. */
class HostSubscription {
readonly released: Promise<void>;
isReleased = false;
private observer: SubscriptionObserver<never> | null = null;
private accept!: () => void;
private refuse!: (error: Error) => void;
private markReleased!: () => void;
readonly ready = new Promise<never>((resolve, reject) => {
this.accept = () => resolve({ requestId: "r", subscriptionId: "s" } as never);
this.refuse = reject;
});
constructor(
readonly method: string,
readonly input: unknown,
readonly signal: AbortSignal | undefined,
) {
void this.ready.catch(() => undefined);
this.released = new Promise((resolve) => {
this.markReleased = resolve;
});
signal?.addEventListener("abort", () => void this.release(), { once: true });
}
handle() {
return {
subscriptionId: "s",
ready: this.ready,
subscribe: (observer: SubscriptionObserver<never>) => {
this.observer = observer;
return () => undefined;
},
release: () => this.release(),
};
}
acknowledge() {
this.accept();
}
refuseWith(error: Error) {
this.refuse(error);
this.observer?.error?.(error);
}
event(event: unknown) {
this.observer?.update({
type: "plugin.rpc.event",
payload: { subscriptionId: "s", event },
} as SessionOutboundMessage);
}
bytes(payload: Uint8Array) {
this.observer?.binary?.(payload);
}
closed(error?: string) {
this.observer?.update({
type: "plugin.rpc.closed",
payload: { subscriptionId: "s", ...(error ? { error } : {}) },
} as SessionOutboundMessage);
}
async release() {
this.isReleased = true;
this.markReleased();
}
}
/** An installation's subscribe on a host the test drives, and what one subscriber observed. */
function installation() {
const subscriptions: HostSubscription[] = [];
const client = {
observePluginRpc: (...[pluginId, method, input, options]: Observed) => {
expect(pluginId).toBe("diffs");
const subscription = new HostSubscription(method, input, options?.signal);
subscriptions.push(subscription);
return subscription.handle() as unknown as ReturnType<DaemonClient["observePluginRpc"]>;
},
};
const lifetime = new AbortController();
const subscribe = createPluginSubscribe({ client, pluginId: "diffs", lifetime: lifetime.signal });
function open(ref: string, signal?: AbortSignal) {
const received: string[] = [];
const closed: PluginSubscriptionClosed[] = [];
const handle = subscribePluginRpc(
changes,
subscribe,
{ ref },
(event) =>
received.push(event instanceof Uint8Array ? `bytes:${[...event]}` : `path:${event.path}`),
{ signal, onClosed: (result) => closed.push(result) },
);
return { handle, received, closed, host: subscriptions[subscriptions.length - 1]! };
}
return { open, lifetime };
}
it("delivers JSON and byte events in order and releases the host subscription", async () => {
const { open } = installation();
const { handle, received, closed, host } = open("main");
expect(host.method).toBe("diff.watch");
expect(host.input).toEqual({ ref: "main" });
host.acknowledge();
await handle.ready;
host.event({ path: "a.ts" });
host.bytes(new Uint8Array([1, 2]));
expect(received).toEqual(["path:a.ts", "bytes:1,2"]);
handle();
await host.released;
expect(closed).toEqual([]);
});
it("reports a refusal, a failure after going live, and a clean close", async () => {
const { open } = installation();
const refused = open("a");
refused.host.refuseWith(new Error("no such ref"));
await expect(refused.handle.ready).rejects.toThrow("no such ref");
expect(refused.closed).toEqual([{ error: new Error("no such ref") }]);
const failed = open("b");
failed.host.acknowledge();
await failed.handle.ready;
failed.host.closed("Plugin stopped: diffs");
expect(failed.closed).toEqual([{ error: new Error("Plugin stopped: diffs") }]);
await failed.host.released;
// A handler that returns at once closes in the same tick the host accepts it.
const finished = open("c");
finished.host.acknowledge();
finished.host.closed();
await expect(finished.handle.ready).resolves.toBeUndefined();
expect(finished.closed).toEqual([{}]);
});
it("closes a subscription whose event fails the contract and drops what follows", async () => {
const { open } = installation();
const { handle, received, closed, host } = open("a");
host.acknowledge();
await handle.ready;
host.event({ path: 3 });
host.event({ path: "late.ts" });
expect(received).toEqual([]);
expect(closed).toEqual([
{ error: new Error("Plugin subscription diff.watch sent an invalid event") },
]);
await host.released;
});
it("releases on its signal, on its remover, and when the installation stops", async () => {
const { open, lifetime } = installation();
const controller = new AbortController();
const bySignal = open("a", controller.signal);
bySignal.host.acknowledge();
await bySignal.handle.ready;
controller.abort();
await bySignal.host.released;
const byRemover = open("b");
byRemover.handle();
await byRemover.host.released;
const byLifetime = open("c");
byLifetime.host.event({ path: "a.ts" });
expect(byLifetime.received).toEqual(["path:a.ts"]);
lifetime.abort();
await byLifetime.host.released;
});
+52
View File
@@ -0,0 +1,52 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginSubscribe } from "@getpaseo/plugin/client/host";
/**
* Plugin subscriptions on the installation's client. The client re-subscribes after a reconnect;
* the installation's lifetime releases whatever its surfaces left open.
*/
export function createPluginSubscribe(input: {
client: Pick<DaemonClient, "observePluginRpc">;
pluginId: string;
lifetime: AbortSignal;
}): PluginSubscribe {
const { client, pluginId, lifetime } = input;
return (method, subscriptionInput, observer) => {
const controller = new AbortController();
const abort = () => controller.abort();
lifetime.addEventListener("abort", abort, { once: true });
controller.signal.addEventListener(
"abort",
() => lifetime.removeEventListener("abort", abort),
{ once: true },
);
if (lifetime.aborted) controller.abort();
const subscription = client.observePluginRpc(pluginId, method, subscriptionInput, {
signal: controller.signal,
});
subscription.subscribe({
snapshot: () => undefined,
update: (message) => {
if (message.type === "plugin.rpc.event") observer.event(message.payload.event);
else if (message.type === "plugin.rpc.closed") {
controller.abort();
const error = message.payload.error;
observer.closed(error === undefined ? undefined : new Error(error));
}
},
binary: (payload) => observer.event(payload),
error: (error) => {
controller.abort();
observer.closed(error instanceof Error ? error : new Error(String(error)));
},
});
const release = () => {
controller.abort();
return subscription.release();
};
return Object.assign(() => void release().catch(() => undefined), {
ready: subscription.ready.then(() => undefined),
release,
});
};
}
@@ -26,6 +26,9 @@ function installation(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => undefined,
settingsScreens: [],
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
+3
View File
@@ -56,6 +56,9 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [],
surfaces: [],
sidebarItems: { header: [], footer: [] },
@@ -18,6 +18,9 @@ function plugin(input: {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => {},
settingsScreens: [],
surfaces: [],
+2
View File
@@ -71,6 +71,8 @@ export interface InstalledPlugin extends EvaluatedPlugin {
paseo: PaseoApi;
/** Calls one of the plugin's server RPC methods on its host. */
invoke(method: string, input: unknown): Promise<unknown>;
/** Subscribes to one of the plugin's server subscriptions; released with the lifetime. */
subscribe: import("@getpaseo/plugin/client/host").PluginSubscribe;
serverId: string;
requirements?: PluginRequirements;
clientBundle: string;
@@ -15,6 +15,9 @@ function installed(): InstalledPlugin {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => {},
settingsScreens: [],
surfaces: [],
+1
View File
@@ -144,6 +144,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.pluginTimelineItems]: true,
[CLIENT_CAPS.workspaceSetupBlocked]: true,
[CLIENT_CAPS.workspaceSetupObservation]: true,
[CLIENT_CAPS.pluginBinaryPayloads]: true,
[CLIENT_CAPS.explicitEventSubscriptions]: true,
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
@@ -83,6 +83,26 @@ test("cancellation before the ID waits for bootstrap and releases exactly that I
expect(c.released).toEqual(["cancelled"]);
});
test("a release right after the host accepts still reports the subscription as accepted", async () => {
const c = connection();
const handle = c.observe();
c.requests[0]!("closing");
const released = handle.release();
await expect(handle.ready).resolves.toMatchObject({ subscriptionId: "closing" });
await released;
expect(c.released).toEqual(["closing"]);
});
test("a release before the host accepts rejects ready", async () => {
const c = connection();
const handle = c.observe();
const released = handle.release();
await expect(handle.ready).rejects.toThrow("Subscription released");
c.requests[0]!("late");
await released;
expect(c.released).toEqual(["late"]);
});
test("surviving handles use new remote IDs after reconnect and released handles stay gone", async () => {
const c = connection();
const a = c.observe();
+32 -14
View File
@@ -5,6 +5,8 @@ export type SubscriptionSnapshot<T> = T & { subscriptionId: string };
export interface SubscriptionObserver<T> {
snapshot(snapshot: T): void;
update(message: SessionOutboundMessage): void;
/** A binary frame the host addressed to this subscription; the payload is a view. */
binary?(payload: Uint8Array): void;
error?(error: unknown): void;
}
@@ -20,9 +22,12 @@ interface Registration {
start(): void;
disconnected(): void;
receive(message: SessionOutboundMessage): void;
receiveBinary(payload: Uint8Array): void;
release(): Promise<void>;
}
type Update = { message: SessionOutboundMessage } | { binary: Uint8Array };
export interface OwnedSubscriptionsHost {
release(subscriptionId: string): Promise<void>;
failed(error: unknown): void;
@@ -50,7 +55,7 @@ export class OwnedSubscriptions {
let teardown: Promise<void> | null = null;
let disposed = false;
let overflow = false;
const buffered: SessionOutboundMessage[] = [];
const buffered: Update[] = [];
const observers = new Set<SubscriptionObserver<SubscriptionSnapshot<T>>>();
let resolve!: (snapshot: SubscriptionSnapshot<T>) => void;
let reject!: (error: unknown) => void;
@@ -60,12 +65,10 @@ export class OwnedSubscriptions {
});
void ready.catch(() => {});
const deliver = (
observer: SubscriptionObserver<SubscriptionSnapshot<T>>,
message?: SessionOutboundMessage,
) => {
const deliver = (observer: SubscriptionObserver<SubscriptionSnapshot<T>>, update?: Update) => {
try {
if (message) observer.update(message);
if (update && "binary" in update) observer.binary?.(update.binary);
else if (update) observer.update(update.message);
else if (snapshot) observer.snapshot(snapshot);
} catch (error) {
this.host.failed(error);
@@ -78,6 +81,13 @@ export class OwnedSubscriptions {
this.routes.delete(released);
await this.host.release(released);
};
const buffer = (update: Update) => {
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(update);
else {
buffered.length = 0;
overflow = true;
}
};
const registration: Registration = {
start: () => {
if (disposed || pending || id || !this.connected) return;
@@ -128,12 +138,14 @@ export class OwnedSubscriptions {
},
receive: (message) => {
if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return;
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message);
else {
buffered.length = 0;
overflow = true;
}
for (const observer of observers) deliver(observer, message);
buffer({ message });
for (const observer of observers) deliver(observer, { message });
},
receiveBinary: (payload) => {
if (disposed) return;
// Bytes wait only for the first observer; retaining them after would hold every frame.
if (observers.size === 0) buffer({ binary: payload });
for (const observer of observers) deliver(observer, { binary: payload });
},
release: () => {
if (teardown) return teardown;
@@ -142,7 +154,9 @@ export class OwnedSubscriptions {
observers.clear();
buffered.length = 0;
if (id) this.routes.delete(id);
reject(new Error("Subscription released"));
// A host that already accepted keeps `ready` resolved, even if released in the same tick.
if (snapshot) resolve(snapshot);
else reject(new Error("Subscription released"));
teardown = (async () => {
await pending;
await releaseId();
@@ -174,7 +188,7 @@ export class OwnedSubscriptions {
.catch(this.host.failed);
} else {
deliver(observer);
for (const message of buffered) deliver(observer, message);
for (const update of buffered) deliver(observer, update);
}
return () => {
observers.delete(observer);
@@ -205,6 +219,10 @@ export class OwnedSubscriptions {
this.routes.get(payload.subscriptionId)?.receive(message);
}
receiveBinary(subscriptionId: string, payload: Uint8Array): void {
this.routes.get(subscriptionId)?.receiveBinary(payload);
}
restore(): void {
this.connected = true;
for (const registration of this.registrations) registration.start();
@@ -1106,6 +1106,7 @@ test("advertises client capabilities in hello", async () => {
terminal_chunked_replay: true,
timeline_notifications: true,
plugin_timeline_items: true,
plugin_binary_payloads: true,
workspace_setup_blocked: true,
workspace_setup_observation: true,
hello_rejection: true,
+79 -9
View File
@@ -164,6 +164,11 @@ import {
FileTransferOpcode,
TerminalStreamOpcode,
type FileTransferFrame,
assertPluginWireValue,
decodePluginFrame,
encodePluginInputFrame,
isPluginFrameOpcode,
PluginFrameOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import {
createRelayE2eeTransportFactory,
@@ -2260,6 +2265,8 @@ export class DaemonClient {
timeout?: number;
select: (msg: SessionOutboundMessage) => T | null;
options?: { skipQueue?: boolean };
/** Sends the request in another form, such as a binary frame; defaults to `message`. */
transmit?: () => Promise<void>;
}): Promise<T> {
const wire = this.owned.prepareRequest(params.message);
const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS;
@@ -2290,7 +2297,9 @@ export class DaemonClient {
// The response deadline still applies while the transport waits for drain.
void promise.catch(() => undefined);
try {
const sent = this.sendSessionMessageOrThrow(wire.message);
const sent = params.transmit
? params.transmit()
: this.sendSessionMessageOrThrow(wire.message);
const result = await Promise.race([promise, sent.then(() => promise)]);
if (result.kind === "error") throw result.error;
return result.value;
@@ -2313,12 +2322,14 @@ export class DaemonClient {
responseType: TResponseType;
options?: { skipQueue?: boolean };
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
transmit?: () => Promise<void>;
}): Promise<TResult> {
return this.sendRequest({
requestId: params.requestId,
message: params.message,
timeout: params.timeout,
options: params.options,
...(params.transmit ? { transmit: params.transmit } : {}),
select: (msg) => {
const correlated = msg as CorrelatedResponseMessage;
if (correlated.type !== params.responseType) {
@@ -2650,6 +2661,7 @@ export class DaemonClient {
resetSource = false;
if (query.type === "workspace.setup.subscribe.request")
this.requireWorkspaceSetupObservation();
if (query.type === "plugin.rpc.subscribe.request") this.requirePluginSubscriptions();
const requestId = this.createRequestId(options?.requestId);
const request = SessionInboundMessageSchema.parse({ ...query, requestId });
try {
@@ -6197,22 +6209,59 @@ export class DaemonClient {
return payload.plugin;
}
/**
* A `Uint8Array` input or output crosses the wire as a binary frame. Any other value is JSON and
* must not contain a `Uint8Array`.
*/
async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown> {
assertPluginWireValue(input, "input");
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.rpc.invoke.request",
if (!(input instanceof Uint8Array)) {
const payload = await this.sendCorrelatedSessionRequest({
requestId,
pluginId,
method,
input,
},
message: {
type: "plugin.rpc.invoke.request",
requestId,
pluginId,
method,
input,
},
responseType: "plugin.rpc.invoke.response",
});
return payload.output;
}
this.requirePluginSubscriptions();
const frame = encodePluginInputFrame({ requestId, pluginId, method, payload: input });
const payload = await this.sendCorrelatedRequest({
requestId,
message: { type: "plugin.rpc.invoke.request", requestId, pluginId, method, input: null },
responseType: "plugin.rpc.invoke.response",
options: { skipQueue: true },
transmit: () => this.sendBinaryFrame(frame),
});
return payload.output;
}
/** One subscription to a plugin subscription handler; events arrive as updates and binary. */
observePluginRpc(
pluginId: string,
method: string,
input: unknown,
options?: { signal?: AbortSignal },
): OwnedSubscription<CorrelatedResponsePayload<"plugin.rpc.subscribe.response">> {
assertPluginWireValue(input, "input");
return this.observe(
"plugin.rpc.subscribe.response",
{ type: "plugin.rpc.subscribe.request", pluginId, method, input },
options,
);
}
private requirePluginSubscriptions(): void {
if (this.lastServerInfoMessage?.features?.pluginSubscriptions !== true)
throw new Error("Update the host to use plugin subscriptions and binary plugin RPC");
}
async respondToPermissionAndWait(
agentId: string,
requestId: string,
@@ -6975,6 +7024,23 @@ export class DaemonClient {
}
}
private handlePluginFrame(rawBytes: Uint8Array): void {
const frame = decodePluginFrame(rawBytes);
this.consecutiveLivenessFailures = 0;
this.runtimeMetrics?.recordBinaryFrame("plugin", rawBytes.byteLength, 0);
if (frame?.opcode === PluginFrameOpcode.Event) {
this.owned.receiveBinary(frame.subscriptionId, frame.payload);
return;
}
if (frame?.opcode === PluginFrameOpcode.Output) {
// The binary form of plugin.rpc.invoke.response.
this.resolveWaiters({
type: "plugin.rpc.invoke.response",
payload: { requestId: frame.requestId, output: frame.payload },
});
}
}
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
const hostFrame = decodeHostRouteFrame(rawBytes);
if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) {
@@ -6986,6 +7052,10 @@ export class DaemonClient {
for (const handler of this.hostFrameListeners) handler(hostFrame);
return true;
}
if (isPluginFrameOpcode(rawBytes[0])) {
this.handlePluginFrame(rawBytes);
return true;
}
const fileFrame = decodeFileTransferFrame(rawBytes);
if (fileFrame) {
this.traceInstant("paseo.ws.message.inbound", {
+65
View File
@@ -323,3 +323,68 @@ describe("agent session reload SDK boundary", () => {
).toEqual([]);
});
});
describe("plugin subscription SDK boundary", () => {
it("types contracts, overloaded handlers, the client hook, and the client entry handle", () => {
expect(
typeErrors(`
import { defineRpc, defineSubscription, type SubscriptionEvent, type SubscriptionInput } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { useRpc, useSubscription } from "@getpaseo/plugin/client";
import type { PluginServerContext, PluginSubscriptionContext } from "@getpaseo/plugin/server";
import { z } from "zod";
const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ a: z.string(), b: z.string() }),
event: z.object({ path: z.string(), status: z.enum(["added", "modified", "deleted"]) }),
});
const frames = defineSubscription({
name: "screen.capture",
input: z.object({ display: z.number() }),
event: z.union([z.instanceof(Uint8Array), z.object({ type: z.literal("resized"), width: z.number() })]),
});
const thumbnail = defineRpc({ name: "thumbnail.render", input: z.object({ path: z.string() }), output: z.instanceof(Uint8Array) });
const input: SubscriptionInput<typeof diffWatch> = { a: "main", b: "HEAD" };
const event: SubscriptionEvent<typeof diffWatch> = { path: "a.ts", status: "added" };
function server(server: PluginServerContext) {
server.handle(diffWatch, ({ a, b }, { push, signal, paseo }: PluginSubscriptionContext<SubscriptionEvent<typeof diffWatch>>) => {
const pushed: Promise<void> = push({ path: a + b, status: "modified" });
// @ts-expect-error events follow the contract
void push({ path: 1, status: "modified" });
signal.addEventListener("abort", () => undefined, { once: true });
});
server.handle(frames, async (_input, { push }) => {
await push(new Uint8Array(4));
await push({ type: "resized", width: 2 });
});
server.handle(thumbnail, ({ path }) => new Uint8Array(path.length));
// @ts-expect-error subscription handlers receive push, not an output
server.handle(diffWatch, () => ({ path: "a", status: "added" as const }) as unknown as number);
}
function Panel() {
const state = useSubscription(diffWatch, { a: "main", b: "HEAD" }, (change) => {
const status: "added" | "modified" | "deleted" = change.status;
});
if (state.status === "error") state.error.message;
// @ts-expect-error the hook has no ready state, only live
if (state.status === "ready") return null;
useSubscription(frames, { display: 1 }, (event) => {
if (event instanceof Uint8Array) event.byteLength;
else event.width;
});
const render = useRpc(thumbnail);
void render({ path: "x" }).then((png: Uint8Array) => png.byteLength);
return null;
}
function client(client: PluginClientContext) {
const controller = new AbortController();
const remover = client.subscribe(diffWatch, { a: "main", b: "HEAD" }, (change) => change.path, { signal: controller.signal });
const ready: Promise<void> = remover.ready;
const released: Promise<void> = remover.release();
remover();
return () => {};
}
`),
).toEqual([]);
});
});
+18 -2
View File
@@ -4,7 +4,13 @@ import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginRpcContract,
PluginSubscribeOptions,
PluginSubscriptionContract,
PluginSubscriptionHandle,
PluginWireInput,
} from "../rpc.js";
import type {
PluginButtonRegistration,
PluginHeaderButtonContribution,
@@ -203,6 +209,16 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
/**
* Subscribes to a plugin subscription on this client's host. Paseo re-subscribes after a
* reconnect and releases what is left when the plugin stops.
*/
subscribe<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
options?: PluginSubscribeOptions,
): PluginSubscriptionHandle;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
@@ -295,7 +311,7 @@ export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: ZodInput<InputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
): Promise<ZodOutput<OutputSchema>>;
openScreen(input: PluginOpenScreenInput): void;
/** @deprecated Use `openScreen`. */
+4 -2
View File
@@ -8,7 +8,7 @@ export {
import type { PluginAttachmentSourceContribution } from "../contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-context.js";
import { callPluginRpc } from "../rpc.js";
import { callPluginRpc, subscribePluginRpc, type PluginSubscriptionObserver } from "../rpc.js";
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution<PluginText>,
@@ -19,7 +19,9 @@ export async function searchPluginAttachments(
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
}
export { callPluginRpc, PaseoApiProvider, PluginRpcProvider };
export { callPluginRpc, subscribePluginRpc, PaseoApiProvider, PluginRpcProvider };
export type { PluginSubscriptionObserver };
export type { PluginSubscribe } from "./rpc-context.js";
export {
PluginTranslationRuntime,
+6 -1
View File
@@ -71,7 +71,12 @@ export {
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js";
export type {
PluginSubscribeOptions,
PluginSubscriptionClosed,
PluginSubscriptionHandle,
} from "../rpc.js";
import type { SettingsDefinition } from "../settings.js";
import type { SettingsState } from "./contracts.js";
import type { ZodType } from "zod";
+108 -5
View File
@@ -1,9 +1,34 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import { callPluginRpc, type PluginRpcContract } from "../rpc.js";
import {
callPluginRpc,
subscribePluginRpc,
type PluginRpcContract,
type PluginSubscriptionContract,
type PluginSubscriptionHandle,
type PluginSubscriptionObserver,
type PluginWireInput,
} from "../rpc.js";
import { shallowEqual } from "./shallow.js";
export type PluginSubscribe = (
method: string,
input: unknown,
observer: PluginSubscriptionObserver,
) => PluginSubscriptionHandle;
export interface PluginRpcContextValue {
invoke(method: string, input: unknown): Promise<unknown>;
subscribe: PluginSubscribe;
}
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
@@ -15,22 +40,100 @@ export function usePluginRpcContextValue(): PluginRpcContextValue | null {
export function PluginRpcProvider({
children,
invoke,
subscribe,
}: {
children: ReactNode;
invoke(method: string, input: unknown): Promise<unknown>;
subscribe: PluginSubscribe;
}) {
const value = useMemo(() => ({ invoke }), [invoke]);
const value = useMemo(() => ({ invoke, subscribe }), [invoke, subscribe]);
return <PluginRpcContext.Provider value={value}>{children}</PluginRpcContext.Provider>;
}
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
): (input: PluginWireInput<ZodInput<InputSchema>>) => Promise<ZodOutput<OutputSchema>> {
const context = usePluginRpcContextValue();
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
return useCallback(
(input: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
(input: PluginWireInput<ZodInput<InputSchema>>) =>
callPluginRpc(contract, context.invoke, input),
[context, contract],
);
}
export type PluginSubscriptionState =
| { status: "connecting" }
| { status: "live" }
/** The plugin's handler finished. The hook subscribes again when `input` changes. */
| { status: "closed" }
| { status: "error"; error: Error };
/**
* Subscribes while the component is mounted. Re-subscribes when `input` changes by shallow
* comparison; `onEvent` always runs the latest callback.
*/
export function useSubscription<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
): PluginSubscriptionState {
const context = usePluginRpcContextValue();
if (!context) throw new Error("useSubscription must run inside a contributed plugin surface");
const subscribe = context.subscribe;
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const stableInput = useShallowStable(input);
const [state, setState] = useState<PluginSubscriptionState>({ status: "connecting" });
useEffect(() => {
let active = true;
setState({ status: "connecting" });
let handle: PluginSubscriptionHandle;
try {
handle = subscribePluginRpc(
contract,
subscribe,
stableInput,
(event) => onEventRef.current(event),
{
onClosed: ({ error }) => {
if (active) setState(error ? { status: "error", error } : CLOSED);
},
},
);
} catch (error) {
setState({ status: "error", error: toError(error) });
return;
}
handle.ready.then(
() => {
if (active) setState((current) => (current.status === "connecting" ? LIVE : current));
return undefined;
},
(error: unknown) => {
if (active) setState({ status: "error", error: toError(error) });
},
);
return () => {
active = false;
handle();
};
}, [contract, subscribe, stableInput]);
return state;
}
const LIVE: PluginSubscriptionState = { status: "live" };
const CLOSED: PluginSubscriptionState = { status: "closed" };
function useShallowStable<Value>(value: Value): Value {
const ref = useRef(value);
if (!shallowEqual(ref.current, value)) ref.current = value;
return ref.current;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
@@ -26,7 +26,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
);
return (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke}>
<PluginRpcProvider invoke={rpc.invoke} subscribe={rpc.subscribe}>
<PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
+10 -1
View File
@@ -19,7 +19,16 @@ export {
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
export {
defineRpc,
defineSubscription,
type PluginRpcContract,
type PluginSubscriptionContract,
type RpcInput,
type RpcOutput,
type SubscriptionEvent,
type SubscriptionInput,
} from "./rpc.js";
export type { PluginText, PluginMessage } from "./text.js";
export {
+97 -1
View File
@@ -9,6 +9,12 @@ export interface PluginRpcContract<
output: OutputSchema;
}
/**
* `z.instanceof(Uint8Array)` infers `Uint8Array<ArrayBuffer>`; values crossing the wire accept any
* `Uint8Array`, including a `Buffer` or a view of a shared buffer.
*/
export type PluginWireInput<Value> = Value extends Uint8Array ? Uint8Array : Value;
export type RpcInput<Contract extends PluginRpcContract> = ZodOutput<Contract["input"]>;
export type RpcOutput<Contract extends PluginRpcContract> = ZodInput<Contract["output"]>;
@@ -31,9 +37,99 @@ export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodT
export async function callPluginRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
invoke: (method: string, input: unknown) => Promise<unknown>,
input: ZodInput<InputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
): Promise<ZodOutput<OutputSchema>> {
const parsedInput = await contract.input.parseAsync(input);
const output = await invoke(contract.name, parsedInput);
return contract.output.parseAsync(output);
}
export interface PluginSubscriptionContract<
InputSchema extends ZodType = ZodType,
EventSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
event: EventSchema;
}
export type SubscriptionInput<Contract extends PluginSubscriptionContract> = ZodOutput<
Contract["input"]
>;
export type SubscriptionEvent<Contract extends PluginSubscriptionContract> = ZodOutput<
Contract["event"]
>;
interface SubscriptionDefinition<InputSchema extends ZodType, EventSchema extends ZodType> {
name: string;
input: InputSchema;
event: EventSchema;
}
export function defineSubscription<InputSchema extends ZodType, EventSchema extends ZodType>(
definition: SubscriptionDefinition<InputSchema, EventSchema>,
): PluginSubscriptionContract<InputSchema, EventSchema> {
const name = definition.name.trim();
if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin subscription: ${definition.name}`);
return { ...definition, name };
}
/** One subscription from a client surface. Calling it or `release()` ends it. */
export type PluginSubscriptionHandle = (() => void) & {
/** Resolves once the plugin acknowledged the subscription; rejects if it refused. */
readonly ready: Promise<void>;
release(): Promise<void>;
};
export interface PluginSubscriptionObserver {
event(event: unknown): void;
closed(error?: Error): void;
}
export interface PluginSubscriptionClosed {
/** Set when the plugin's handler failed, the plugin stopped, or an event failed validation. */
error?: Error;
}
export interface PluginSubscribeOptions {
/** Aborting releases the subscription. */
signal?: AbortSignal;
/** Runs once when the daemon closes the subscription. Not called after a release. */
onClosed?: (closed: PluginSubscriptionClosed) => void;
}
export function subscribePluginRpc<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
subscribe: (
method: string,
input: unknown,
observer: PluginSubscriptionObserver,
) => PluginSubscriptionHandle,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
options: PluginSubscribeOptions = {},
): PluginSubscriptionHandle {
// The plugin parses the input into the schema's output, so it receives the input as given.
contract.input.parse(input);
let closed = false;
function close(error?: Error) {
if (closed) return;
closed = true;
options.onClosed?.(error ? { error } : {});
}
const handle = subscribe(contract.name, input, {
event(event) {
if (closed) return;
const parsed = contract.event.safeParse(event);
if (parsed.success) return onEvent(parsed.data);
handle();
close(new Error(`Plugin subscription ${contract.name} sent an invalid event`));
},
closed: close,
});
if (options.signal) {
if (options.signal.aborted) handle();
else options.signal.addEventListener("abort", () => handle(), { once: true });
}
return handle;
}
+26 -2
View File
@@ -1,6 +1,6 @@
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js";
import type { PluginRpcContract, PluginSubscriptionContract, PluginWireInput } from "../rpc.js";
import type { PluginCleanup } from "../contracts.js";
import type { ProviderRegistration } from "./provider.js";
import type { UsageSourceRegistration } from "./usage.js";
@@ -10,6 +10,18 @@ export interface PluginHandlerContext {
paseo: PaseoApi;
}
/** What a subscription handler receives beside its input. */
export interface PluginSubscriptionContext<Event = unknown> extends PluginHandlerContext {
/**
* Sends one event to this subscriber. A `Uint8Array` event travels as a binary frame; any other
* event is JSON and must not contain a `Uint8Array`. Resolves once the event left the plugin
* process. Events pushed after `signal` aborts are dropped.
*/
push(event: Event): Promise<void>;
/** Aborts when the subscriber releases, disconnects, or the plugin stops. */
signal: AbortSignal;
}
export type PluginSettingsState<Schema extends ZodType> =
| {
status: "ready";
@@ -31,12 +43,24 @@ export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>(
definition: import("../settings.js").SettingsDefinition<Schema>,
): PluginSettings<Schema>;
/**
* Serves a subscription. The handler starts the work and returns; the subscription stays open
* until `signal` aborts. A handler that throws refuses the subscription. When it returns a
* promise, settling the promise closes the subscription, with the rejection as its error.
*/
handle<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginSubscriptionContext<PluginWireInput<ZodInput<EventSchema>>>,
) => void | Promise<void>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginHandlerContext,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
) => PluginWireInput<ZodInput<OutputSchema>> | Promise<PluginWireInput<ZodInput<OutputSchema>>>,
): void;
registerProvider(provider: ProviderRegistration): void;
registerUsageSource(source: UsageSourceRegistration): void;
+1
View File
@@ -11,6 +11,7 @@ export type {
PluginServerContribution,
PluginSettings,
PluginSettingsState,
PluginSubscriptionContext,
} from "./contracts.js";
export type {
PluginHooks,
+9 -1
View File
@@ -4,6 +4,7 @@ import {
FileTransferOpcode,
type FileTransferFrame,
} from "./file-transfer.js";
import { decodePluginFrame, PluginFrameOpcode, type PluginFrame } from "./plugin.js";
import {
decodeTerminalStreamFrame,
TerminalStreamOpcode,
@@ -13,7 +14,8 @@ import {
export type BinaryFrame =
| { kind: "host_route"; frame: HostRouteFrame }
| { kind: "terminal"; frame: TerminalStreamFrame }
| { kind: "file_transfer"; frame: FileTransferFrame };
| { kind: "file_transfer"; frame: FileTransferFrame }
| { kind: "plugin"; frame: PluginFrame };
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
switch (bytes[0]) {
@@ -38,6 +40,12 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
const frame = decodeFileTransferFrame(bytes);
return frame ? { kind: "file_transfer", frame } : null;
}
case PluginFrameOpcode.Event:
case PluginFrameOpcode.Output:
case PluginFrameOpcode.Input: {
const frame = decodePluginFrame(bytes);
return frame ? { kind: "plugin", frame } : null;
}
default:
return null;
}
@@ -2,3 +2,4 @@ export * from "./demux.js";
export * from "./file-transfer.js";
export * from "./terminal.js";
export * from "./host-route.js";
export * from "./plugin.js";
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import {
assertPluginWireValue,
decodeBinaryFrame,
decodePluginFrame,
encodePluginEventFrame,
encodePluginInputFrame,
encodePluginOutputFrame,
FileTransferOpcode,
MAX_PLUGIN_PAYLOAD_BYTES,
MAX_ROUTED_PAYLOAD_BYTES,
PluginFrameOpcode,
TerminalStreamOpcode,
} from "./index.js";
describe("plugin binary frames", () => {
it("uses the reserved plugin opcode range", () => {
for (const opcode of Object.values(PluginFrameOpcode)) {
expect(opcode).toBeGreaterThanOrEqual(0x20);
expect(opcode).toBeLessThanOrEqual(0x2f);
expect(Object.values(TerminalStreamOpcode)).not.toContain(opcode);
expect(Object.values(FileTransferOpcode)).not.toContain(opcode);
}
});
it("frames a subscription event as opcode, id length, id, and the untouched payload", () => {
const payload = new Uint8Array([9, 8, 7]);
const frame = encodePluginEventFrame("sub-1", payload);
expect([...frame]).toEqual([0x20, 5, ...new TextEncoder().encode("sub-1"), 9, 8, 7]);
expect(decodeBinaryFrame(frame)).toEqual({
kind: "plugin",
frame: { opcode: PluginFrameOpcode.Event, subscriptionId: "sub-1", payload },
});
});
it("decodes payloads as views into the received buffer", () => {
const frame = encodePluginOutputFrame("req-1", new Uint8Array([1, 2, 3, 4]));
const decoded = decodePluginFrame(frame);
if (decoded?.opcode !== PluginFrameOpcode.Output) throw new Error("Expected an output frame");
expect(decoded.requestId).toBe("req-1");
expect(decoded.payload.buffer).toBe(frame.buffer);
expect([...decoded.payload]).toEqual([1, 2, 3, 4]);
});
it("frames an RPC input with the plugin and method in its header", () => {
const frame = encodePluginInputFrame({
requestId: "req-2",
pluginId: "uploads",
method: "upload.store",
payload: new Uint8Array([5, 6]),
});
expect(decodePluginFrame(frame)).toEqual({
opcode: PluginFrameOpcode.Input,
requestId: "req-2",
pluginId: "uploads",
method: "upload.store",
payload: new Uint8Array([5, 6]),
});
});
it("rejects truncated frames", () => {
expect(decodePluginFrame(new Uint8Array([0x20]))).toBeNull();
expect(decodePluginFrame(new Uint8Array([0x20, 9, 1]))).toBeNull();
expect(decodePluginFrame(new Uint8Array([0x22, 1, 65, 0, 40]))).toBeNull();
});
});
describe("the plugin byte rule", () => {
it("accepts JSON values and a top-level Uint8Array", () => {
expect(() => assertPluginWireValue({ a: [1, "b", null] }, "event")).not.toThrow();
expect(() => assertPluginWireValue(new Uint8Array(4), "event")).not.toThrow();
});
it("rejects bytes that would not fit in one frame with the largest header", () => {
const bytes = new Uint8Array(MAX_PLUGIN_PAYLOAD_BYTES + 1);
expect(() => assertPluginWireValue(bytes.subarray(1), "event")).not.toThrow();
expect(() => assertPluginWireValue(bytes, "event")).toThrow(
`event is ${MAX_PLUGIN_PAYLOAD_BYTES + 1} bytes; one value carries at most ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
);
const frame = encodePluginInputFrame({
requestId: "r".repeat(255),
pluginId: "p",
method: "m".repeat(0xffff - 30),
payload: bytes.subarray(1),
});
expect(frame.byteLength).toBeLessThanOrEqual(MAX_ROUTED_PAYLOAD_BYTES);
});
it("rejects bytes nested in a value and names the rule and the path", () => {
expect(() => assertPluginWireValue({ frames: [{ data: new Uint8Array(1) }] }, "event")).toThrow(
"event.frames[0].data is a Uint8Array. Bytes cross the wire only as the whole value: send the Uint8Array itself, or encode it into the value.",
);
});
});
@@ -0,0 +1,152 @@
import { MAX_ROUTED_PAYLOAD_BYTES } from "./host-route.js";
/**
* Plugin values that are a `Uint8Array` travel as binary frames. Opcodes 0x20-0x2f are
* reserved for plugin traffic. Every frame is `[opcode][id length u8][id]...` and the payload
* is the rest of the frame, decoded as a view so no hop copies it.
*/
export const PluginFrameOpcode = {
/** `[0x20][id length][subscriptionId][payload]`: a subscription event. */
Event: 0x20,
/** `[0x21][id length][requestId][payload]`: an RPC output; no JSON response follows. */
Output: 0x21,
/** `[0x22][id length][requestId][header length u16][header JSON][payload]`: an RPC input. */
Input: 0x22,
} as const;
export type PluginFrameOpcode = (typeof PluginFrameOpcode)[keyof typeof PluginFrameOpcode];
/** The largest frame header: an input's opcode, ID and header JSON, each at its maximum length. */
const MAX_PLUGIN_FRAME_HEADER_BYTES = 2 + 0xff + 2 + 0xffff;
/** The largest `Uint8Array` a plugin value carries, so its frame fits in one routed message. */
export const MAX_PLUGIN_PAYLOAD_BYTES = MAX_ROUTED_PAYLOAD_BYTES - MAX_PLUGIN_FRAME_HEADER_BYTES;
export type PluginFrame =
| { opcode: typeof PluginFrameOpcode.Event; subscriptionId: string; payload: Uint8Array }
| { opcode: typeof PluginFrameOpcode.Output; requestId: string; payload: Uint8Array }
| {
opcode: typeof PluginFrameOpcode.Input;
requestId: string;
pluginId: string;
method: string;
payload: Uint8Array;
};
export function isPluginFrameOpcode(value: number | undefined): value is PluginFrameOpcode {
return (
value === PluginFrameOpcode.Event ||
value === PluginFrameOpcode.Output ||
value === PluginFrameOpcode.Input
);
}
export function encodePluginEventFrame(subscriptionId: string, payload: Uint8Array): Uint8Array {
return encodeFrame(PluginFrameOpcode.Event, subscriptionId, new Uint8Array(0), payload);
}
export function encodePluginOutputFrame(requestId: string, payload: Uint8Array): Uint8Array {
return encodeFrame(PluginFrameOpcode.Output, requestId, new Uint8Array(0), payload);
}
export function encodePluginInputFrame(input: {
requestId: string;
pluginId: string;
method: string;
payload: Uint8Array;
}): Uint8Array {
const header = new TextEncoder().encode(
JSON.stringify({ pluginId: input.pluginId, method: input.method }),
);
if (header.byteLength > 0xffff) throw new RangeError("Plugin input header is too long");
const prefix = new Uint8Array(2 + header.byteLength);
new DataView(prefix.buffer).setUint16(0, header.byteLength);
prefix.set(header, 2);
return encodeFrame(PluginFrameOpcode.Input, input.requestId, prefix, input.payload);
}
export function decodePluginFrame(bytes: Uint8Array): PluginFrame | null {
const opcode = bytes[0];
if (bytes.byteLength < 2 || !isPluginFrameOpcode(opcode)) return null;
const idLength = bytes[1];
if (idLength === 0 || idLength > bytes.byteLength - 2) return null;
const id = new TextDecoder().decode(bytes.subarray(2, 2 + idLength));
const body = bytes.subarray(2 + idLength);
if (opcode === PluginFrameOpcode.Event) return { opcode, subscriptionId: id, payload: body };
if (opcode === PluginFrameOpcode.Output) return { opcode, requestId: id, payload: body };
if (body.byteLength < 2) return null;
const headerLength = new DataView(body.buffer, body.byteOffset, body.byteLength).getUint16(0);
if (headerLength > body.byteLength - 2) return null;
const header = decodeInputHeader(body.subarray(2, 2 + headerLength));
if (!header) return null;
return { opcode, requestId: id, ...header, payload: body.subarray(2 + headerLength) };
}
function encodeFrame(
opcode: PluginFrameOpcode,
id: string,
prefix: Uint8Array,
payload: Uint8Array,
): Uint8Array {
const idBytes = new TextEncoder().encode(id);
if (idBytes.byteLength === 0) throw new RangeError("Plugin frame ID is required");
if (idBytes.byteLength > 0xff) throw new RangeError("Plugin frame ID is too long");
const headerLength = 2 + idBytes.byteLength + prefix.byteLength;
const frame = new Uint8Array(headerLength + payload.byteLength);
frame[0] = opcode;
frame[1] = idBytes.byteLength;
frame.set(idBytes, 2);
frame.set(prefix, 2 + idBytes.byteLength);
frame.set(payload, headerLength);
return frame;
}
function decodeInputHeader(bytes: Uint8Array): { pluginId: string; method: string } | null {
try {
const header: unknown = JSON.parse(new TextDecoder().decode(bytes));
if (typeof header !== "object" || header === null) return null;
const pluginId = Reflect.get(header, "pluginId");
const method = Reflect.get(header, "method");
if (typeof pluginId !== "string" || typeof method !== "string" || !pluginId || !method)
return null;
return { pluginId, method };
} catch {
return null;
}
}
/**
* The byte rule: a plugin value is either a `Uint8Array`, sent as a binary frame, or a JSON value
* with no `Uint8Array` inside it. Checks object structure only; never reads the bytes.
*/
export function assertPluginWireValue(value: unknown, label: string): void {
if (value instanceof Uint8Array) {
if (value.byteLength > MAX_PLUGIN_PAYLOAD_BYTES)
throw new RangeError(
`${label} is ${value.byteLength} bytes; one value carries at most ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
);
return;
}
const nested = findNestedBytes(value, label);
if (nested)
throw new TypeError(
`${nested} is a Uint8Array. Bytes cross the wire only as the whole value: send the Uint8Array itself, or encode it into the value.`,
);
}
function findNestedBytes(value: unknown, path: string): string | null {
if (value instanceof Uint8Array) return path;
if (typeof value !== "object" || value === null) return null;
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const found = findNestedBytes(value[index], `${path}[${index}]`);
if (found) return found;
}
return null;
}
for (const [key, entry] of Object.entries(value)) {
const found = findNestedBytes(entry, `${path}.${key}`);
if (found) return found;
}
return null;
}
@@ -46,6 +46,9 @@ export const CLIENT_CAPS = {
// COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
pluginTimelineItems: "plugin_timeline_items",
workspaceSetupObservation: "workspace_setup_observation",
// COMPAT(pluginBinaryPayloads): added in v0.15.0, remove after 2027-05-02 once client floor >= v0.15.0.
// The daemon sends plugin RPC outputs as binary opcode 0x21 only to clients that decode it.
pluginBinaryPayloads: "plugin_binary_payloads",
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
workspaceSetupBlocked: "workspace_setup_blocked",
browserHost: "browser_host",
@@ -372,3 +372,43 @@ it("npm metadata leaves legacy plugin rows parseable without extending their clo
payload: { requestId: "npm", plugins: [plugin] },
});
});
describe("plugin subscription protocol", () => {
it("parses a subscribe request and its response", () => {
const request = {
type: "plugin.rpc.subscribe.request",
requestId: "r1",
pluginId: "diff-watch",
method: "diff.watch",
input: { a: "main", b: "HEAD" },
};
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
const response = {
type: "plugin.rpc.subscribe.response",
payload: { requestId: "r1", subscriptionId: "s1" },
};
expect(SessionOutboundMessageSchema.parse(response)).toEqual(response);
});
it("parses JSON events and closes with and without an error", () => {
for (const message of [
{ type: "plugin.rpc.event", payload: { subscriptionId: "s1", event: { path: "a.ts" } } },
{ type: "plugin.rpc.closed", payload: { subscriptionId: "s1" } },
{ type: "plugin.rpc.closed", payload: { subscriptionId: "s1", error: "boom" } },
]) {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
}
});
it("advertises the feature as an optional flag", () => {
const status = {
type: "status",
payload: {
status: "server_info",
serverId: "srv",
features: { pluginSubscriptions: true },
},
};
expect(StatusMessageSchema.parse(status)).toMatchObject(status);
});
});
+30
View File
@@ -1606,6 +1606,14 @@ export const PluginRpcInvokeRequestSchema = z.object({
input: z.unknown(),
});
export const PluginRpcSubscribeRequestSchema = z.object({
type: z.literal("plugin.rpc.subscribe.request"),
requestId: z.string(),
pluginId: PluginIdSchema,
method: z.string().min(1),
input: z.unknown(),
});
export const AgentTimelineAppendRequestSchema = z.object({
type: z.literal("agent.timeline.append.request"),
requestId: z.string(),
@@ -3339,6 +3347,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PluginDisableRequestSchema,
PluginRemoveRequestSchema,
PluginRpcInvokeRequestSchema,
PluginRpcSubscribeRequestSchema,
AgentTimelineAppendRequestSchema,
AgentSkillsGetStatusRequestSchema,
AgentSkillsReconcileRequestSchema,
@@ -3739,6 +3748,8 @@ export const ServerInfoStatusPayloadSchema = z
pluginThemes: z.boolean().optional(),
pluginSettings: z.boolean().optional(),
pluginTimelineItems: z.boolean().optional(),
// COMPAT(pluginSubscriptions): added in v0.15.0, remove gate after 2027-05-02.
pluginSubscriptions: z.boolean().optional(),
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: z.boolean().optional(),
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
@@ -6962,6 +6973,22 @@ export const PluginRpcInvokeResponseSchema = z.object({
}),
});
export const PluginRpcSubscribeResponseSchema = z.object({
type: z.literal("plugin.rpc.subscribe.response"),
payload: z.object({ requestId: z.string(), subscriptionId: z.string() }),
});
/** A JSON subscription event. A `Uint8Array` event rides as binary opcode 0x20 instead. */
export const PluginRpcEventSchema = z.object({
type: z.literal("plugin.rpc.event"),
payload: z.object({ subscriptionId: z.string(), event: z.unknown() }),
});
export const PluginRpcClosedSchema = z.object({
type: z.literal("plugin.rpc.closed"),
payload: z.object({ subscriptionId: z.string(), error: z.string().optional() }),
});
export const AgentTimelineAppendResponseSchema = z.object({
type: z.literal("agent.timeline.append.response"),
payload: z.object({
@@ -7035,6 +7062,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PluginDisableResponseSchema,
PluginRemoveResponseSchema,
PluginRpcInvokeResponseSchema,
PluginRpcSubscribeResponseSchema,
PluginRpcEventSchema,
PluginRpcClosedSchema,
AgentTimelineAppendResponseSchema,
AgentSkillsGetStatusResponseSchema,
AgentSkillsReconcileResponseSchema,
@@ -142,6 +142,7 @@ const INBOUND_PERMISSION = {
"plugin.reload.request": "daemon.manage",
"plugin.remove.request": "daemon.manage",
"plugin.rpc.invoke.request": "daemon.manage",
"plugin.rpc.subscribe.request": "daemon.manage",
"plugin.source.install.request": "daemon.manage",
"plugin.source.status.request": "daemon.manage",
"plugin.source.update.request": "daemon.manage",
@@ -378,6 +379,9 @@ const OUTBOUND_PERMISSION = {
"plugin.reload.response": "daemon.manage",
"plugin.remove.response": "daemon.manage",
"plugin.rpc.invoke.response": "daemon.manage",
"plugin.rpc.subscribe.response": "daemon.manage",
"plugin.rpc.event": "daemon.manage",
"plugin.rpc.closed": "daemon.manage",
"plugin.source.install.response": "daemon.manage",
"plugin.source.status.response": "daemon.manage",
"plugin.source.update.response": "daemon.manage",
@@ -34,7 +34,13 @@ const emptyUsageRuntime = {
getUsageSourceRegistrations: () => [],
fetchUsage: async () => undefined,
discoverUsage: async () => [],
} satisfies Pick<TestPluginRuntime, "getUsageSourceRegistrations" | "fetchUsage" | "discoverUsage">;
startSubscription: () => {
throw new Error("No subscriptions in this test");
},
} satisfies Pick<
TestPluginRuntime,
"getUsageSourceRegistrations" | "fetchUsage" | "discoverUsage" | "startSubscription"
>;
async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-"));
@@ -25,6 +25,7 @@ import { readPluginManifest } from "./manifest.js";
import { expandTilde } from "../../utils/path.js";
import { runPluginBuild } from "./preparation.js";
import { PluginRuntime } from "./runtime.js";
import type { PluginRuntimeSubscription } from "./runtime-subscription.js";
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
import { readPluginProviderIcon } from "./provider-icon.js";
@@ -48,6 +49,7 @@ interface PluginRuntimePort {
wait?: PluginLifecycle["wait"];
catalog: PluginRuntime["catalog"];
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
startSubscription: PluginRuntime["startSubscription"];
getLogs(pluginId: string): PluginLogEntry[];
clearLogs(pluginId: string): void;
getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[];
@@ -539,6 +541,10 @@ export class PluginService {
return this.runtime.invoke(pluginId, method, input);
}
subscribePluginRpc(pluginId: string, method: string, input: unknown): PluginRuntimeSubscription {
return this.runtime.startSubscription(pluginId, method, input);
}
async stopAllPlugins(): Promise<void> {
this.globalStartsBlocked = true;
const stopping = this.stopAll();
@@ -60,6 +60,14 @@ export type PluginProcessRequest =
| { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown }
| { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope }
| { type: "invoke"; requestId: string; method: string; input: unknown }
| {
type: "subscribe";
requestId: string;
subscriptionId: string;
method: string;
input: unknown;
}
| { type: "unsubscribe"; subscriptionId: string }
| {
type: "provider.connect";
providerId: string;
@@ -83,6 +91,7 @@ export type PluginProcessMessage =
| {
type: "ready";
methods: string[];
subscriptions?: string[];
providers: PluginProviderMetadata[];
usageSources?: PluginUsageSourceMetadata[];
hooks?: string[];
@@ -90,6 +99,8 @@ export type PluginProcessMessage =
| { type: "result"; requestId: string; output: unknown }
| { type: "error"; requestId: string; error: string }
| { type: "fatal"; error: string }
| { type: "subscription.event"; subscriptionId: string; event: unknown }
| { type: "subscription.closed"; subscriptionId: string; error?: string }
| {
type: "provider.connected";
connectionId: string;
@@ -240,6 +251,16 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
input: z.unknown(),
})
.strict(),
z
.object({
type: z.literal("subscribe"),
requestId: z.string().min(1),
subscriptionId: z.string().min(1),
method: z.string().min(1),
input: z.unknown(),
})
.strict(),
z.object({ type: z.literal("unsubscribe"), subscriptionId: z.string().min(1) }).strict(),
z
.object({
type: z.literal("provider.connect"),
@@ -272,6 +293,7 @@ export const PluginProcessMessageSchema: z.ZodType<PluginProcessMessage> = z.dis
.object({
type: z.literal("ready"),
methods: z.array(z.string()),
subscriptions: z.array(z.string()).optional(),
providers: z.array(providerMetadataSchema),
usageSources: z.array(usageSourceMetadataSchema).optional(),
hooks: hooksSchema.optional(),
@@ -284,6 +306,20 @@ export const PluginProcessMessageSchema: z.ZodType<PluginProcessMessage> = z.dis
.object({ type: z.literal("error"), requestId: z.string().min(1), error: z.string() })
.strict(),
z.object({ type: z.literal("fatal"), error: z.string() }).strict(),
z
.object({
type: z.literal("subscription.event"),
subscriptionId: z.string().min(1),
event: z.unknown(),
})
.strict(),
z
.object({
type: z.literal("subscription.closed"),
subscriptionId: z.string().min(1),
error: z.string().optional(),
})
.strict(),
z
.object({
type: z.literal("provider.connected"),
@@ -7,8 +7,21 @@ import {
type PluginProcessRequest,
} from "./plugin-process-protocol.js";
import type { UsageSourceRegistration } from "@getpaseo/plugin/server/usage";
import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin";
import type { PluginHandlerContext, PluginServerContribution } from "@getpaseo/plugin/server";
import type {
SettingsDefinition,
PluginRpcContract,
PluginSubscriptionContract,
} from "@getpaseo/plugin";
import type {
PluginHandlerContext,
PluginServerContext,
PluginServerContribution,
} from "@getpaseo/plugin/server";
import { assertPluginWireValue } from "@getpaseo/protocol/binary-frames/index";
import {
PluginSubscriptionHandlers,
type PluginSubscriptionHandler,
} from "./subscription-handlers.js";
import { fileURLToPath } from "node:url";
import type { ZodType } from "zod";
import {
@@ -66,6 +79,7 @@ export function createPluginWorker(options: {
send({ type: "hooks.changed", hooks: hooks.catalog() });
}, agentTools);
const handlers = new Map<string, RegisteredRpc>();
const subscriptions = new PluginSubscriptionHandlers({ send, sendAndWait });
const providers = new Map<string, ProviderRegistration>();
const usageSources = new Map<string, UsageSourceRegistration>();
const providerConnections = new Map<
@@ -98,12 +112,36 @@ export function createPluginWorker(options: {
if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) {
throw new Error(`Invalid plugin RPC method: ${method}`);
}
if (handlers.has(normalized)) {
if (handlers.has(normalized) || subscriptions.has(normalized)) {
throw new Error(`Duplicate plugin RPC method: ${normalized}`);
}
return normalized;
}
function registerSubscription(
contract: PluginSubscriptionContract,
handler: PluginSubscriptionHandler,
): void {
if (typeof handler !== "function") {
throw new Error(`Plugin subscription ${contract.name} must provide a handler`);
}
const method = validateMethod(contract.name);
subscriptions.register({ ...contract, name: method }, handler);
}
// One name serves either an RPC or a subscription; the contract says which.
const handle = ((
contract: PluginRpcContract | PluginSubscriptionContract,
handler: (input: unknown, context: never) => unknown,
) => {
if ("event" in contract) {
// The subscription handlers parse the input once and pass the result here.
registerSubscription(contract, handler as PluginSubscriptionHandler);
return;
}
register(contract, (input, context) => handler(contract.input.parse(input), context as never));
}) as PluginServerContext["handle"];
function register(contract: PluginRpcContract, handler: RpcHandler): void {
if (typeof handler !== "function") {
throw new Error(`Plugin RPC ${contract.name} must provide a handler`);
@@ -284,8 +322,7 @@ export function createPluginWorker(options: {
)
: null;
const contributedCleanup = contribute({
handle: (contract, handler) =>
register(contract, (input, context) => handler(contract.input.parse(input), context)),
handle,
registerProvider,
registerUsageSource,
registerSettings,
@@ -311,6 +348,7 @@ export function createPluginWorker(options: {
send({
type: "ready",
methods: [...handlers.keys()].sort(),
subscriptions: subscriptions.catalog(),
hooks: hooks.catalog(),
providers: [...providers.values()]
.sort((left, right) => left.id.localeCompare(right.id))
@@ -327,6 +365,7 @@ export function createPluginWorker(options: {
.catch((error) => console.error("Plugin API cleanup failed", error));
hooks.close();
agentTools.close();
subscriptions.releaseAll();
for (const pending of pendingProviderConnections.values()) pending.tombstoned = true;
const currentCleanup = cleanup;
cleanup = null;
@@ -365,7 +404,8 @@ export function createPluginWorker(options: {
message.type === "provider.status" ||
message.type === "provider.catalog_key" ||
message.type === "usage.fetch" ||
message.type === "usage.discover"
message.type === "usage.discover" ||
message.type === "subscribe"
) {
send({ type: "error", requestId: message.requestId, error: "Plugin is stopping" });
} else if (message.type === "provider.connect") {
@@ -422,6 +462,14 @@ export function createPluginWorker(options: {
return false;
}
function handleSubscriptionMessage(
message: Extract<PluginProcessRequest, { type: "subscribe" | "unsubscribe" }>,
): void {
if (message.type === "unsubscribe") subscriptions.release(message.subscriptionId);
else if (paseo) subscriptions.start(message, paseo);
else send({ type: "error", requestId: message.requestId, error: "Plugin API unavailable" });
}
function handleMessage(rawMessage: unknown): void {
const parsed = PluginProcessRequestSchema.safeParse(rawMessage);
if (!parsed.success) {
@@ -525,6 +573,11 @@ export function createPluginWorker(options: {
handleHookMessage(message);
return;
}
if (message.type === "invoke") invoke(message);
else handleSubscriptionMessage(message);
}
function invoke(message: Extract<PluginProcessRequest, { type: "invoke" }>): void {
const registered = handlers.get(message.method);
if (!registered) {
send({
@@ -541,6 +594,10 @@ export function createPluginWorker(options: {
return registered.handler(input, { paseo });
})
.then((output) => registered.contract.output.parseAsync(output))
.then((output: unknown) => {
assertPluginWireValue(output, "output");
return output;
})
.then(
(output) => send({ type: "result", requestId: message.requestId, output }),
(error) =>
@@ -0,0 +1,354 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { WebSocket } from "ws";
import {
DaemonClient,
type OwnedSubscription,
type WebSocketLike,
} from "@getpaseo/client/internal/daemon-client";
import { afterEach, expect, test } from "vitest";
import { resolveDaemonVersion } from "../daemon-version.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
const PLUGIN_ID = "subscriptions";
// Written as an author writes a plugin: one contract per name, handlers that read like English.
const SERVER_ENTRY = `
import { defineRpc, defineSubscription } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { z } from "zod";
const ticks = defineSubscription({
name: "ticks",
input: z.object({ count: z.number().int(), bytes: z.boolean() }),
event: z.union([z.instanceof(Uint8Array), z.object({ n: z.number() })]),
});
const failing = defineSubscription({
name: "failing",
input: z.object({ when: z.enum(["before-ack", "after-ack"]) }),
event: z.object({}),
});
const nestedPush = defineSubscription({
name: "nested-push",
input: z.object({}),
event: z.object({ data: z.instanceof(Uint8Array) }),
});
const repeated = defineSubscription({
name: "repeated",
input: z.object({ times: z.string().transform(Number) }),
event: z.string().transform((text) => text.length),
});
const watchers = defineRpc({
name: "watchers",
input: z.object({}),
output: z.object({ starts: z.number(), active: z.number() }),
});
const thumbnail = defineRpc({
name: "thumbnail",
input: z.object({ size: z.number().int() }),
output: z.instanceof(Uint8Array),
});
const nestedOutput = defineRpc({
name: "nested-output",
input: z.object({}),
output: z.object({ data: z.instanceof(Uint8Array) }),
});
const reverse = defineRpc({
name: "reverse",
input: z.instanceof(Uint8Array),
output: z.instanceof(Uint8Array),
});
export default function contribute(server: PluginServerContext) {
let starts = 0;
let active = 0;
server.handle(ticks, ({ count, bytes }, { push, signal }) => {
starts += 1;
active += 1;
signal.addEventListener("abort", () => { active -= 1; }, { once: true });
for (let n = 0; n < count; n += 1) void push(bytes ? new Uint8Array([n, n + 1]) : { n });
});
server.handle(failing, ({ when }) => {
if (when === "before-ack") throw new Error("refused before acknowledging");
return Promise.reject(new Error("failed after acknowledging"));
});
server.handle(nestedPush, (_input, { push }) => {
void push({ data: new Uint8Array(1) }).catch((error) => {
console.error("nested push", error.message);
});
});
server.handle(repeated, async ({ times }, { push }) => {
await push(typeof times === "number" ? "x".repeat(times) : "input arrived untransformed");
});
server.handle(watchers, () => ({ starts, active }));
server.handle(thumbnail, ({ size }) => Buffer.alloc(size, 7));
server.handle(nestedOutput, () => ({ data: new Uint8Array(1) }));
server.handle(reverse, (bytes) => Uint8Array.from(bytes).reverse());
return () => undefined;
}
`;
type Received =
| { type: "event"; event: unknown }
| { type: "bytes"; bytes: number[] }
| { type: "closed"; error?: string };
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
interface Harness {
daemon: TestPaseoDaemon;
client: DaemonClient;
sockets: WebSocket[];
close(): Promise<void>;
}
async function writePlugin(id: string, serverEntry: string): Promise<string> {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-subscriptions-"));
roots.push(pluginDirectory);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({ id, requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` } }),
);
await writeFile(path.join(pluginDirectory, "index.server.ts"), serverEntry);
return pluginDirectory;
}
async function startHarness(): Promise<Harness> {
const pluginDirectory = await writePlugin(PLUGIN_ID, SERVER_ENTRY);
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const sockets: WebSocket[] = [];
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
clientId: `clid_plugin_subscriptions_${Date.now()}`,
appVersion: resolveDaemonVersion(import.meta.url),
reconnect: { enabled: true, baseDelayMs: 10, maxDelayMs: 50 },
webSocketFactory: (url) => {
const socket = new WebSocket(url);
sockets.push(socket);
return socket as unknown as WebSocketLike;
},
});
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await expect(client.installDirectoryPlugin(pluginDirectory)).resolves.toMatchObject({
id: PLUGIN_ID,
status: "running",
});
return {
daemon,
client,
sockets,
async close() {
await client.close().catch(() => undefined);
await daemon.close();
},
};
}
function collect(subscription: OwnedSubscription<unknown>): Received[] {
const received: Received[] = [];
subscription.subscribe({
snapshot: () => undefined,
update: (message) => {
if (message.type === "plugin.rpc.event")
received.push({ type: "event", event: message.payload.event });
if (message.type === "plugin.rpc.closed")
received.push({
type: "closed",
...(message.payload.error ? { error: message.payload.error } : {}),
});
},
binary: (payload) => received.push({ type: "bytes", bytes: [...payload] }),
});
return received;
}
function watcherCount(client: DaemonClient) {
return client.invokePluginRpc(PLUGIN_ID, "watchers", {});
}
test("a subscription delivers JSON and binary events in order and release aborts its handler", async () => {
const harness = await startHarness();
const { client } = harness;
try {
const json = client.observePluginRpc(PLUGIN_ID, "ticks", { count: 3, bytes: false });
const jsonEvents = collect(json);
await json.ready;
const binary = client.observePluginRpc(PLUGIN_ID, "ticks", { count: 2, bytes: true });
const binaryEvents = collect(binary);
await binary.ready;
await expect
.poll(() => jsonEvents)
.toEqual([
{ type: "event", event: { n: 0 } },
{ type: "event", event: { n: 1 } },
{ type: "event", event: { n: 2 } },
]);
await expect
.poll(() => binaryEvents)
.toEqual([
{ type: "bytes", bytes: [0, 1] },
{ type: "bytes", bytes: [1, 2] },
]);
expect(json.subscriptionId).not.toBe(binary.subscriptionId);
await expect(watcherCount(client)).resolves.toEqual({ starts: 2, active: 2 });
await json.release();
await expect(watcherCount(client)).resolves.toEqual({ starts: 2, active: 1 });
await binary.release();
await expect(watcherCount(client)).resolves.toEqual({ starts: 2, active: 0 });
} finally {
await harness.close();
}
}, 60_000);
test("a schema transform runs once: the handler gets the parsed input and the wire carries the pushed event", async () => {
const harness = await startHarness();
try {
const subscription = harness.client.observePluginRpc(PLUGIN_ID, "repeated", { times: "2" });
const received = collect(subscription);
await subscription.ready;
// The client parses "xx" into 2; parsing it on the plugin side too would reject it.
await expect.poll(() => received).toEqual([{ type: "event", event: "xx" }, { type: "closed" }]);
} finally {
await harness.close();
}
}, 60_000);
test("dropping the connection releases subscriptions and reconnecting runs the handler again", async () => {
const harness = await startHarness();
const { client, sockets } = harness;
try {
const subscription = client.observePluginRpc(PLUGIN_ID, "ticks", { count: 1, bytes: false });
const events = collect(subscription);
await subscription.ready;
await expect.poll(() => events).toEqual([{ type: "event", event: { n: 0 } }]);
const firstId = subscription.subscriptionId;
sockets.at(-1)!.terminate();
await expect
.poll(() => subscription.subscriptionId, { timeout: 10_000 })
.toSatisfy((id: unknown) => typeof id === "string" && id !== firstId);
await expect
.poll(() => events)
.toEqual([
{ type: "event", event: { n: 0 } },
{ type: "event", event: { n: 0 } },
]);
// The first handler's signal aborted when its connection dropped; one handler runs now.
await expect(watcherCount(client)).resolves.toEqual({ starts: 2, active: 1 });
await subscription.release();
} finally {
await harness.close();
}
}, 60_000);
test("a handler that throws before acknowledging rejects; one that fails later closes", async () => {
const harness = await startHarness();
const { client } = harness;
try {
const refused = client.observePluginRpc(PLUGIN_ID, "failing", { when: "before-ack" });
await expect(refused.ready).rejects.toThrow("refused before acknowledging");
const later = client.observePluginRpc(PLUGIN_ID, "failing", { when: "after-ack" });
const events = collect(later);
await later.ready;
await expect
.poll(() => events)
.toEqual([{ type: "closed", error: "failed after acknowledging" }]);
await later.release();
const invalid = client.observePluginRpc(PLUGIN_ID, "ticks", { count: "many" });
await expect(invalid.ready).rejects.toThrow();
const unknown = client.observePluginRpc(PLUGIN_ID, "watchers", {});
await expect(unknown.ready).rejects.toThrow("does not contribute subscription watchers");
} finally {
await harness.close();
}
}, 60_000);
test("reloading the plugin closes its subscriptions with an error", async () => {
const harness = await startHarness();
const { client } = harness;
try {
const subscription = client.observePluginRpc(PLUGIN_ID, "ticks", { count: 0, bytes: false });
const events = collect(subscription);
await subscription.ready;
await client.reloadPlugin(PLUGIN_ID);
await expect
.poll(() => events)
.toEqual([{ type: "closed", error: `Plugin stopped: ${PLUGIN_ID}` }]);
await subscription.release();
// The reloaded plugin starts fresh and serves new subscriptions.
const again = client.observePluginRpc(PLUGIN_ID, "ticks", { count: 1, bytes: false });
const againEvents = collect(again);
await again.ready;
await expect.poll(() => againEvents).toEqual([{ type: "event", event: { n: 0 } }]);
await again.release();
} finally {
await harness.close();
}
}, 60_000);
test("RPC bytes ride as binary frames in both directions and nested bytes fail validation", async () => {
const harness = await startHarness();
const { client } = harness;
try {
const output = await client.invokePluginRpc(PLUGIN_ID, "thumbnail", { size: 4 });
expect(output).toBeInstanceOf(Uint8Array);
expect([...(output as Uint8Array)]).toEqual([7, 7, 7, 7]);
const reversed = await client.invokePluginRpc(PLUGIN_ID, "reverse", new Uint8Array([1, 2, 3]));
expect([...(reversed as Uint8Array)]).toEqual([3, 2, 1]);
await expect(client.invokePluginRpc(PLUGIN_ID, "nested-output", {})).rejects.toThrow(
"output.data is a Uint8Array. Bytes cross the wire only as the whole value",
);
await expect(
client.invokePluginRpc(PLUGIN_ID, "reverse", { data: new Uint8Array(1) }),
).rejects.toThrow("input.data is a Uint8Array");
const nested = client.observePluginRpc(PLUGIN_ID, "nested-push", {});
await nested.ready;
await expect
.poll(async () => JSON.stringify(await client.getPluginLogs(PLUGIN_ID)))
.toContain("event.data is a Uint8Array");
await nested.release();
} finally {
await harness.close();
}
}, 60_000);
test("one name cannot serve both an RPC and a subscription", async () => {
const harness = await startHarness();
try {
const directory = await writePlugin(
"collision",
`
import { defineRpc, defineSubscription } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { z } from "zod";
export default function contribute(server: PluginServerContext) {
server.handle(defineRpc({ name: "watch", input: z.object({}), output: z.null() }), () => null);
server.handle(
defineSubscription({ name: "watch", input: z.object({}), event: z.object({}) }),
() => undefined,
);
return () => undefined;
}
`,
);
await expect(harness.client.installDirectoryPlugin(directory)).rejects.toThrow(
"Duplicate plugin RPC method: watch",
);
} finally {
await harness.close();
}
}, 60_000);
@@ -0,0 +1,108 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, expect, test } from "vitest";
import { resolveDaemonVersion } from "../daemon-version.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
const PUSH_BYTES = 256 * 1024;
const TOTAL_BYTES = 100 * 1024 * 1024;
const PUSHES = TOTAL_BYTES / PUSH_BYTES;
const MIN_MEGABYTES_PER_SECOND = 200;
// One buffer, pushed until the total is out: what a capture loop looks like.
const SERVER_ENTRY = `
import { defineSubscription } from "@getpaseo/plugin";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { z } from "zod";
const stream = defineSubscription({
name: "stream",
input: z.object({ pushes: z.number().int(), bytes: z.number().int() }),
event: z.instanceof(Uint8Array),
});
export default function contribute(server: PluginServerContext) {
server.handle(stream, async ({ pushes, bytes }, { push, signal }) => {
const chunk = new Uint8Array(bytes).fill(7);
for (let sent = 0; sent < pushes && !signal.aborted; sent += 1) await push(chunk);
await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
});
return () => undefined;
}
`;
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test(`a plugin streams ${TOTAL_BYTES / 1024 / 1024} MiB in ${PUSH_BYTES / 1024} KiB pushes at ${MIN_MEGABYTES_PER_SECOND} MB/s without copying per frame`, async () => {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-stream-"));
roots.push(pluginDirectory);
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "stream",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(path.join(pluginDirectory, "index.server.ts"), SERVER_ENTRY);
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
clientId: `clid_plugin_stream_${Date.now()}`,
appVersion: resolveDaemonVersion(import.meta.url),
});
try {
await client.connect();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pluginDirectory);
let received = 0;
let frames = 0;
let copied = 0;
let finished!: () => void;
const done = new Promise<void>((resolve) => {
finished = resolve;
});
const baseline = process.memoryUsage().arrayBuffers;
let peak = baseline;
const started = performance.now();
const subscription = client.observePluginRpc("stream", "stream", {
pushes: PUSHES,
bytes: PUSH_BYTES,
});
subscription.subscribe({
snapshot: () => undefined,
update: () => undefined,
binary: (payload) => {
received += payload.byteLength;
frames += 1;
// The payload is a view into the frame the socket received, not a copy of it.
if (payload.buffer.byteLength - payload.byteLength > 1024) copied += 1;
if (frames % 40 === 0) peak = Math.max(peak, process.memoryUsage().arrayBuffers);
if (received >= TOTAL_BYTES) finished();
},
});
await done;
const seconds = (performance.now() - started) / 1000;
const megabytesPerSecond = TOTAL_BYTES / 1e6 / seconds;
process.stderr.write(
`[plugin stream] ${frames} frames, ${(TOTAL_BYTES / 1e6).toFixed(1)} MB in ${(seconds * 1000).toFixed(0)} ms: ${megabytesPerSecond.toFixed(0)} MB/s, peak external buffers +${((peak - baseline) / 1024 / 1024).toFixed(1)} MiB\n`,
);
expect(frames).toBe(PUSHES);
expect(received).toBe(TOTAL_BYTES);
expect(copied).toBe(0);
expect(megabytesPerSecond).toBeGreaterThanOrEqual(MIN_MEGABYTES_PER_SECOND);
// Memory held while streaming stays bounded by in-flight frames, not by the payload total.
expect(peak - baseline).toBeLessThan(TOTAL_BYTES / 2);
await subscription.release();
} finally {
await client.close().catch(() => undefined);
await daemon.close();
}
}, 120_000);
@@ -0,0 +1,56 @@
/** Receives a plugin subscription's events once its owner attached. */
export interface PluginSubscriptionSink {
event(event: unknown): void;
/** The plugin ended the subscription, or stopped. Not called after `release()`. */
closed(error?: string): void;
}
/** A started plugin subscription, from the daemon's side of the subprocess. */
export interface PluginRuntimeSubscription {
/** Resolves when the plugin acknowledged; rejects when it refused. */
readonly ready: Promise<void>;
/** Delivers what arrived so far, then everything that follows. */
attach(sink: PluginSubscriptionSink): void;
release(): void;
}
type Pending = { type: "event"; event: unknown } | { type: "closed"; error?: string };
/**
* Holds events until the owner attaches, so the owner can answer the subscribe request before
* the first event goes out.
*/
export class BufferedPluginSubscription {
private sink: PluginSubscriptionSink | null = null;
private pending: Pending[] = [];
private ended = false;
attach(sink: PluginSubscriptionSink): void {
if (this.sink) throw new Error("Plugin subscription is already attached");
this.sink = sink;
const pending = this.pending;
this.pending = [];
for (const entry of pending) {
if (entry.type === "event") sink.event(entry.event);
else sink.closed(entry.error);
}
}
event(event: unknown): void {
if (this.ended) return;
if (this.sink) this.sink.event(event);
else this.pending.push({ type: "event", event });
}
closed(error?: string): void {
if (this.ended) return;
this.ended = true;
if (this.sink) this.sink.closed(error);
else this.pending.push({ type: "closed", ...(error === undefined ? {} : { error }) });
}
released(): void {
this.ended = true;
this.pending = [];
}
}
@@ -1399,6 +1399,44 @@ export default function contribute(server: { registerProvider(provider: Provider
}
});
it("releases a subscription in the plugin when its subscribe times out", async () => {
vi.useFakeTimers();
try {
const directory = await createPlugin(
"slow-subscribe",
`export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`,
);
const child = createReloadChild("slow-subscribe", []);
const sent: Array<{ type: string; subscriptionId?: string }> = [];
const originalSend = child.send.bind(child);
child.send = (message, callback) => {
sent.push(message);
if (message.type !== "initialize") return originalSend(message, callback);
callback?.(null);
queueMicrotask(() =>
child.emitMessage({ type: "ready", methods: [], subscriptions: ["feed"], providers: [] }),
);
return true;
};
const runtime = createTestRuntime({ spawnChild: () => child });
await runtime.startPlugin("slow-subscribe", directory);
const subscription = runtime.startSubscription("slow-subscribe", "feed", {});
const rejection = subscription.ready.catch((error: Error) => error);
await vi.advanceTimersByTimeAsync(30_000);
await expect(rejection).resolves.toMatchObject({
message: expect.stringContaining("timed out"),
});
const subscribe = sent.find((message) => message.type === "subscribe");
expect(sent).toContainEqual({
type: "unsubscribe",
subscriptionId: subscribe?.subscriptionId,
});
} finally {
vi.useRealTimers();
}
});
it("escalates a plugin that ignores graceful shutdown from TERM to KILL", async () => {
vi.useFakeTimers();
try {
@@ -34,6 +34,10 @@ import type {
PluginUsageSourceMetadata,
} from "./plugin-process-protocol.js";
import { PluginProcessMessageSchema } from "./plugin-process-protocol.js";
import {
BufferedPluginSubscription,
type PluginRuntimeSubscription,
} from "./runtime-subscription.js";
import { PluginSessionSocket } from "./session-socket.js";
import { InternalPluginChild } from "./internal-child.js";
import { evaluateBundle } from "./bundle-evaluator.js";
@@ -75,6 +79,8 @@ interface LoadedPlugin {
/** Lets a client that already holds this bundle skip downloading it again. */
clientBundleHash: string;
methods: ReadonlySet<string>;
subscriptionMethods: ReadonlySet<string>;
subscriptions: Map<string, BufferedPluginSubscription>;
hooks: string[];
providers: readonly PluginProviderMetadata[];
usageSources: readonly PluginUsageSourceMetadata[];
@@ -514,6 +520,40 @@ export class PluginRuntime {
return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input });
}
/** Starts one subscriber's handler in the plugin process. */
startSubscription(pluginId: string, method: string, input: unknown): PluginRuntimeSubscription {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
if (!loaded.subscriptionMethods.has(method))
throw new Error(`Plugin ${pluginId} does not contribute subscription ${method}`);
const subscriptionId = randomUUID();
const subscription = new BufferedPluginSubscription();
loaded.subscriptions.set(subscriptionId, subscription);
const ready = this.request(loaded, {
type: "subscribe",
requestId: randomUUID(),
subscriptionId,
method,
input,
}).then(
() => undefined,
(error: unknown) => {
loaded.subscriptions.delete(subscriptionId);
throw error;
},
);
return {
ready,
attach: (sink) => subscription.attach(sink),
release: () => {
subscription.released();
if (!loaded.subscriptions.delete(subscriptionId)) return;
if (loaded.child?.connected)
void send(loaded.child, { type: "unsubscribe", subscriptionId }).catch(() => undefined);
},
};
}
emit<Name extends keyof PluginHookEvents>(name: Name, event: PluginHookEvents[Name]): void {
for (const loaded of this.plugins.values()) {
if (!loaded.hooks.includes(name)) {
@@ -727,6 +767,11 @@ export class PluginRuntime {
if (message.type === "hook") {
void send(child, { type: "hook.cancel", requestId }).catch(() => {});
}
// The handler may still start after the daemon gave up on it.
if (message.type === "subscribe") {
const { subscriptionId } = message;
void send(child, { type: "unsubscribe", subscriptionId }).catch(() => {});
}
reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`));
},
message.type === "tool.call" ? TOOL_CALL_TIMEOUT_MS : REQUEST_TIMEOUT_MS,
@@ -781,6 +826,8 @@ export class PluginRuntime {
clientBundleHash: hashClientBundle(clientBundle),
requirements: manifest.requirements,
methods: new Set(),
subscriptionMethods: new Set(),
subscriptions: new Map(),
hooks: [],
providers: [],
usageSources: [],
@@ -911,6 +958,8 @@ export class PluginRuntime {
clientBundleHash: hashClientBundle(clientBundle),
requirements,
methods: new Set(ready.methods),
subscriptionMethods: new Set(ready.subscriptions ?? []),
subscriptions: new Map(),
hooks: ready.hooks ?? [],
providers: ready.providers ?? [],
usageSources: ready.usageSources ?? [],
@@ -1009,6 +1058,16 @@ export class PluginRuntime {
this.handleProviderMessage(loaded, message);
return;
}
if (message.type === "subscription.event") {
loaded.subscriptions.get(message.subscriptionId)?.event(message.event);
return;
}
if (message.type === "subscription.closed") {
const subscription = loaded.subscriptions.get(message.subscriptionId);
loaded.subscriptions.delete(message.subscriptionId);
subscription?.closed(message.error);
return;
}
if (message.type !== "result" && message.type !== "error") return;
const pending = loaded.pending.get(message.requestId);
if (!pending) return;
@@ -1367,12 +1426,16 @@ export class PluginRuntime {
this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped");
}
/** Rejects in-flight requests and closes open subscriptions with the reason. */
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();
const subscriptions = [...loaded.subscriptions.values()];
loaded.subscriptions.clear();
for (const subscription of subscriptions) subscription.closed(message);
}
private appendLog(pluginId: string, stream: PluginLogEntry["stream"], message: string): void {
@@ -0,0 +1,55 @@
import { defineSubscription } from "@getpaseo/plugin";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import type { PluginSubscriptionContext } from "@getpaseo/plugin/server";
import type { PaseoApi } from "@getpaseo/client";
import { expect, test } from "vitest";
import { z } from "zod";
import type { PluginProcessMessage } from "./plugin-process-protocol.js";
import { PluginSubscriptionHandlers } from "./subscription-handlers.js";
const lengths = defineSubscription({
name: "lengths",
input: z.object({ times: z.string().transform(Number) }),
event: z.string().transform((text) => text.length),
});
/** The plugin process's handlers wired to the client helper through the channel between them. */
function connect(handler: (input: unknown, context: PluginSubscriptionContext) => unknown) {
const handlers = new PluginSubscriptionHandlers({
send: (message) => deliver(message),
sendAndWait: async (message) => deliver(message),
});
handlers.register(lengths, handler);
let observer: Parameters<Parameters<typeof subscribePluginRpc>[1]>[2] | null = null;
function deliver(message: PluginProcessMessage) {
if (message.type === "subscription.event") observer?.event(message.event);
if (message.type === "subscription.closed") observer?.closed();
}
return (method: string, input: unknown, next: NonNullable<typeof observer>) => {
observer = next;
handlers.start(
{ type: "subscribe", requestId: "r", subscriptionId: "s", method, input },
{} as PaseoApi,
);
return Object.assign(() => handlers.release("s"), {
ready: Promise.resolve(),
release: async () => handlers.release("s"),
});
};
}
test("an event schema that transforms is applied once, on the receiving side", async () => {
const handled: unknown[] = [];
const received: number[] = [];
const closed: unknown[] = [];
const subscribe = connect(async (input, { push }) => {
handled.push(input);
await push("abc");
});
subscribePluginRpc(lengths, subscribe, { times: "2" }, (length) => received.push(length), {
onClosed: (result) => closed.push(result),
});
await expect.poll(() => received).toEqual([3]);
expect(handled).toEqual([{ times: 2 }]);
expect(closed).toEqual([{}]);
});
@@ -0,0 +1,132 @@
import type { PluginSubscriptionContract } from "@getpaseo/plugin";
import type { PluginSubscriptionContext } from "@getpaseo/plugin/server";
import type { PaseoApi } from "@getpaseo/client";
import { assertPluginWireValue } from "@getpaseo/protocol/binary-frames/index";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
export type PluginSubscriptionHandler = (
input: unknown,
context: PluginSubscriptionContext,
) => unknown;
interface ActiveSubscription {
controller: AbortController;
}
interface SubscriptionChannel {
send(message: PluginProcessMessage): void;
sendAndWait(message: PluginProcessMessage): Promise<void>;
}
type SubscribeRequest = Extract<PluginProcessRequest, { type: "subscribe" }>;
/**
* The plugin subprocess side of subscriptions: one handler call per subscriber, an abort signal
* that fires on release, and `push` that validates each event and resolves when it left the
* process.
*/
export class PluginSubscriptionHandlers {
private readonly registered = new Map<
string,
{ contract: PluginSubscriptionContract; handler: PluginSubscriptionHandler }
>();
private readonly active = new Map<string, ActiveSubscription>();
private readonly channel: SubscriptionChannel;
constructor(channel: SubscriptionChannel) {
this.channel = channel;
}
has(name: string): boolean {
return this.registered.has(name);
}
register(contract: PluginSubscriptionContract, handler: PluginSubscriptionHandler): void {
this.registered.set(contract.name, { contract, handler });
}
catalog(): string[] {
return [...this.registered.keys()].sort();
}
start(message: SubscribeRequest, paseo: PaseoApi): void {
const { requestId, subscriptionId } = message;
const registered = this.registered.get(message.method);
if (!registered) {
this.channel.send({
type: "error",
requestId,
error: `Unknown subscription: ${message.method}`,
});
return;
}
const parsed = registered.contract.input.safeParse(message.input);
if (!parsed.success) {
this.channel.send({
type: "error",
requestId,
error: `Invalid input for ${message.method}: ${parsed.error.message}`,
});
return;
}
const controller = new AbortController();
const { signal } = controller;
let acknowledge = () => {};
const acknowledged = new Promise<void>((resolve) => {
acknowledge = resolve;
});
const push = async (event: unknown): Promise<void> => {
if (signal.aborted) return;
// The receiver parses into the schema's output; sending that output would parse it twice.
registered.contract.event.parse(event);
assertPluginWireValue(event, "event");
// Events pushed while the handler runs follow the acknowledgement.
await acknowledged;
if (signal.aborted) return;
await this.channel.sendAndWait({ type: "subscription.event", subscriptionId, event });
};
this.active.set(subscriptionId, { controller });
let result: unknown;
try {
result = registered.handler(parsed.data, { paseo, push, signal });
} catch (error) {
this.active.delete(subscriptionId);
controller.abort();
this.channel.send({ type: "error", requestId, error: describeError(error) });
return;
}
this.channel.send({ type: "result", requestId, output: null });
acknowledge();
if (result instanceof Promise)
result.then(
() => this.close(subscriptionId),
(error: unknown) => this.close(subscriptionId, describeError(error)),
);
}
/** The daemon released the subscription; it needs no closed message back. */
release(subscriptionId: string): void {
const active = this.active.get(subscriptionId);
if (!active) return;
this.active.delete(subscriptionId);
active.controller.abort();
}
releaseAll(): void {
for (const subscriptionId of this.active.keys()) this.release(subscriptionId);
}
private close(subscriptionId: string, error?: string): void {
if (!this.active.has(subscriptionId)) return;
this.release(subscriptionId);
this.channel.send({
type: "subscription.closed",
subscriptionId,
...(error === undefined ? {} : { error }),
});
}
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -348,6 +348,7 @@ interface SessionForTestOptions {
messages?: unknown[];
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
binaryMessages?: Uint8Array[];
clientCapabilities?: SessionOptions["clientCapabilities"];
pluginRuntime?: SessionOptions["pluginRuntime"];
orchestrationSkills?: SessionOptions["orchestrationSkills"];
workspaceLabelService?: WorkspaceLabelService;
@@ -402,6 +403,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
messages.push(message),
}),
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
clientCapabilities: options.clientCapabilities,
logger,
downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(),
pushNotifications: options.pushNotifications ?? asPushNotifications(),
@@ -601,6 +603,64 @@ test("routes plugin requests and releases its owned catalog subscription on clea
expect(releasePluginSubscription).toHaveBeenCalledOnce();
});
test("losing daemon.manage releases the session's plugin subscriptions", async () => {
const messages: SessionOutboundMessage[] = [];
const release = vi.fn();
const pluginRuntime = {
subscribe: () => () => {},
subscribePluginRpc: () => ({ ready: Promise.resolve(), attach: () => {}, release }),
} as unknown as NonNullable<SessionOptions["pluginRuntime"]>;
const session = createSessionForTest({ messages, pluginRuntime });
await session.handleMessage({
type: "plugin.rpc.subscribe.request",
requestId: "watch",
pluginId: "example",
method: "screen.capture",
input: {},
});
expect(messages.map((message) => message.type)).toContain("plugin.rpc.subscribe.response");
session.setPermissions(["daemon.read"]);
// Bytes have no outbound permission check, so the stream itself has to end.
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce());
});
test("losing daemon.manage during a plugin RPC withholds its JSON and binary results", async () => {
const messages: SessionOutboundMessage[] = [];
const binaryMessages: Uint8Array[] = [];
const outputs = new Map<string, (output: unknown) => void>();
const pluginRuntime = {
subscribe: () => () => {},
invokePluginRpc: (_pluginId: string, method: string) =>
new Promise((resolve) => outputs.set(method, resolve)),
} as unknown as NonNullable<SessionOptions["pluginRuntime"]>;
const session = createSessionForTest({
messages,
binaryMessages,
pluginRuntime,
clientCapabilities: { [CLIENT_CAPS.pluginBinaryPayloads]: true },
});
const invoke = (method: string) =>
session.handleMessage({
type: "plugin.rpc.invoke.request",
requestId: method,
pluginId: "example",
method,
input: {},
});
const pending = [invoke("capture.json"), invoke("capture.bytes")];
await vi.waitFor(() => expect(outputs.size).toBe(2));
session.setPermissions(["daemon.read"]);
outputs.get("capture.json")?.({ width: 1 });
outputs.get("capture.bytes")?.(new Uint8Array([1, 2, 3]));
await Promise.all(pending);
expect(messages.filter((message) => message.type === "plugin.rpc.invoke.response")).toEqual([]);
expect(binaryMessages).toEqual([]);
});
test("leaves out the client bundles a catalog request already holds", async () => {
const messages: SessionOutboundMessage[] = [];
const pluginRuntime: NonNullable<SessionOptions["pluginRuntime"]> = {
+134 -17
View File
@@ -50,6 +50,11 @@ import type {
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import type { BinaryFrame } from "@getpaseo/protocol/binary-frames/index";
import {
encodePluginEventFrame,
encodePluginOutputFrame,
PluginFrameOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import { CursorError } from "./pagination/cursor.js";
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
import { matchesAgentHistoryQuery } from "./agent-history-search.js";
@@ -521,6 +526,11 @@ export interface SessionOptions {
subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void;
catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>;
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
subscribePluginRpc(
pluginId: string,
method: string,
input: unknown,
): import("./plugins/runtime-subscription.js").PluginRuntimeSubscription;
listUsageReports(options?: {
forceRefresh?: boolean;
reportIds?: string[];
@@ -926,7 +936,7 @@ export class Session {
this.workspaceFilesSession = new WorkspaceFilesSession({
host: {
emit: (msg, source) => this.emitForSource(msg, source),
emitBinary: (frame, source) => this.emitBinaryForFileTransfer(frame, source),
emitBinary: (frame, source) => this.emitBinaryReply(frame, source),
hasBinaryChannel: () => this.onBinaryMessage !== null,
},
downloadTokenStore,
@@ -2221,13 +2231,15 @@ export class Session {
this.peakInflightRequests = this.inflightRequests;
}
try {
this.sessionLogger.trace(
{
messageType: msg.type,
payloadBytes: JSON.stringify(msg).length,
},
"agent.session.inbound",
);
// Stringifying a binary plugin input costs its size; test logger stubs lack isLevelEnabled.
if (this.sessionLogger.isLevelEnabled?.("trace"))
this.sessionLogger.trace(
{
messageType: msg.type,
payloadBytes: JSON.stringify(msg).length,
},
"agent.session.inbound",
);
if (!this.authorization.allowsInbound(msg)) {
const requestId = sessionRequestId(msg);
if (requestId) {
@@ -2292,6 +2304,17 @@ export class Session {
this.sessionLogger.error({ err: error }, "Failed to release revoked browser hosting"),
);
}
// Binary plugin events skip the outbound permission check, so revocation ends the streams.
if (!this.authorization.allowsPermission("daemon.manage")) {
void this.delivery
.releaseFamily("plugin-rpc")
.catch((error) =>
this.sessionLogger.error(
{ err: error },
"Failed to release revoked plugin subscriptions",
),
);
}
}
public getPermissions(): DaemonPermission[] {
@@ -2454,6 +2477,7 @@ export class Session {
this.dispatchOrchestrationSkillsMessage(msg) ??
this.dispatchPluginDirectoryMessage(msg) ??
this.dispatchPluginMessage(msg) ??
this.dispatchPluginRpcMessage(msg) ??
this.dispatchHostServiceMessage(msg) ??
this.dispatchMiscMessage(msg);
if (promise) await promise;
@@ -2621,21 +2645,98 @@ export class Session {
});
return undefined;
}
return undefined;
}
private dispatchPluginRpcMessage(msg: SessionInboundMessage): Promise<void> | 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;
});
.then((output) => this.replyPluginRpc(msg, output));
}
if (msg.type === "plugin.rpc.subscribe.request") return this.subscribePluginRpc(msg);
return undefined;
}
/** A `Uint8Array` output is the binary form of `plugin.rpc.invoke.response`. */
private async replyPluginRpc(
request: Extract<SessionInboundMessage, { type: "plugin.rpc.invoke.request" }>,
output: unknown,
): Promise<void> {
const response: SessionOutboundMessage = {
type: "plugin.rpc.invoke.response",
payload: { requestId: request.requestId, output },
};
if (!(output instanceof Uint8Array)) {
this.emit(response);
return;
}
// The frame is the binary form of the response, so it needs the response's permission.
if (!this.authorization.allowsOutbound(response)) return;
if (!this.supports(CLIENT_CAPS.pluginBinaryPayloads) || this.onBinaryMessage === null)
throw new Error(
`Update Paseo to receive the binary output of plugin RPC ${request.pluginId}.${request.method}`,
);
await this.emitBinaryReply(
encodePluginOutputFrame(request.requestId, output),
this.delivery.currentSource,
);
}
private async subscribePluginRpc(
request: Extract<SessionInboundMessage, { type: "plugin.rpc.subscribe.request" }>,
): Promise<void> {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
const subscription = this.pluginRuntime.subscribePluginRpc(
request.pluginId,
request.method,
request.input,
);
const owner = this.delivery.begin("plugin-rpc", undefined, () => subscription.release());
try {
await subscription.ready;
} catch (error) {
await owner.release();
throw error;
}
if (owner.signal.aborted) return;
this.emit({
type: "plugin.rpc.subscribe.response",
payload: { requestId: request.requestId, subscriptionId: owner.responseId },
});
const close = (reason?: string) => {
owner.emit({
type: "plugin.rpc.closed",
payload: {
subscriptionId: owner.responseId,
...(reason === undefined ? {} : { error: reason }),
},
});
void owner.release().catch(() => undefined);
};
subscription.attach({
event: (event) => {
const message: SessionOutboundMessage = {
type: "plugin.rpc.event",
payload: { subscriptionId: owner.responseId, event },
};
if (!(event instanceof Uint8Array)) {
owner.emit(message);
return;
}
if (!this.authorization.allowsOutbound(message)) return;
if (this.onBinaryMessage === null) {
subscription.release();
close("This connection cannot carry binary plugin events");
return;
}
owner.emitBinary(encodePluginEventFrame(owner.responseId, event));
},
closed: close,
});
}
private dispatchPluginDirectoryMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "plugin.source.install.request") {
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
@@ -3272,6 +3373,22 @@ export class Session {
this.connectedHostSession?.binary(binaryFrame.frame, source);
return;
}
if (binaryFrame.kind === "plugin") {
const frame = binaryFrame.frame;
// The binary form of plugin.rpc.invoke.request; it authorizes and replies like the JSON form.
if (frame.opcode !== PluginFrameOpcode.Input) return;
await this.handleMessage(
{
type: "plugin.rpc.invoke.request",
requestId: frame.requestId,
pluginId: frame.pluginId,
method: frame.method,
input: frame.payload,
},
source,
);
return;
}
if (!this.authorization.allowsPermission("workspace.write")) {
return;
}
@@ -8690,9 +8807,9 @@ export class Session {
}
}
private async emitBinaryForFileTransfer(frame: Uint8Array, source?: object): Promise<void> {
private async emitBinaryReply(frame: Uint8Array, source?: object): Promise<void> {
if (source && this.onBinaryMessageToSource) {
this.delivery.authorizeFileReply(frame, source);
this.delivery.authorizeBinaryReply(frame, source);
await this.onBinaryMessageToSource(source, frame);
return;
}
@@ -56,6 +56,11 @@ export interface OwnedSubscription {
release(): Promise<void>;
}
const BINARY_REPLY_REQUESTS: ReadonlySet<string> = new Set([
"file_explorer_request",
"plugin.rpc.invoke.request",
]);
// One registration per context kind; per-session registrations outlive closed sessions in Node.
const requests = new AsyncLocalStorage<RequestOwner>();
const projectionSource = new AsyncLocalStorage<ProjectionOwner>();
@@ -394,12 +399,13 @@ export class SessionDelivery {
if (owner) await this.releaseOwner(owner);
}
authorizeFileReply(frame: Uint8Array, socket: object): void {
/** Lets a binary frame answer the current request, for requests whose reply can be bytes. */
authorizeBinaryReply(frame: Uint8Array, socket: object): void {
const owner = this.currentRequest;
if (
owner?.active &&
owner.source.socket === socket &&
owner.request.type === "file_explorer_request"
BINARY_REPLY_REQUESTS.has(owner.request.type)
)
this.proofs.set(frame, owner);
}
@@ -2066,6 +2066,8 @@ export class VoiceAssistantWebSocketServer {
pluginThemes: true,
pluginSettings: true,
pluginTimelineItems: true,
// COMPAT(pluginSubscriptions): added in v0.15.0, remove gate after 2027-05-02.
pluginSubscriptions: true,
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: true,
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
+7
View File
@@ -0,0 +1,7 @@
# Live changes
See which files differ from `HEAD` in a workspace panel that updates as you edit. The daemon watches the workspace and pushes the changed-file list to the panel; the panel never polls.
Open **Open live changes** in the Command Center, or pick **Live changes** from the workspace tab menu. Each open panel runs its own watcher, which stops when you close the panel.
The watcher reads the working tree recursively, so a checkout with very large ignored directories makes it slower to start.
+14
View File
@@ -0,0 +1,14 @@
# Diff watch plugin example
A workspace panel subscribes to a daemon-side watcher and lists the files that differ from `HEAD`,
updating as files change.
```bash
paseo plugin add /absolute/path/to/paseo/plugin-examples/diff-watch
```
- `shared/diff.ts` defines the `diff.watch` subscription contract.
- `index.server.ts` handles it: an async handler that loops until the subscriber leaves, pushing
the file list whenever it changes. A git failure rejects the handler and closes the subscription
with that error.
- `client/diff-panel.tsx` calls `useSubscription` and renders the latest list.
@@ -0,0 +1,65 @@
import {
useSubscription,
useWorkspace,
type PluginWorkspacePanelProps,
} from "@getpaseo/plugin/client";
import { useMemo, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { diffWatch, type ChangedFile } from "../shared/diff";
const BASE = "HEAD";
export function DiffPanel({ workspaceId, theme, layout }: PluginWorkspacePanelProps) {
const cwd = useWorkspace(workspaceId, (workspace) => workspace.directory);
const styles = useStyles(theme, layout.compact);
if (!cwd) return <Text style={styles.muted}>Workspace unavailable</Text>;
return <ChangedFiles cwd={cwd} styles={styles} />;
}
function ChangedFiles({ cwd, styles }: { cwd: string; styles: ReturnType<typeof useStyles> }) {
const [files, setFiles] = useState<ChangedFile[] | null>(null);
const subscription = useSubscription(diffWatch, { cwd, from: BASE }, (event) => {
setFiles(event.files);
});
return (
<ScrollView style={styles.screen} contentContainerStyle={styles.content}>
<Text style={styles.title}>Changes against {BASE}</Text>
<Text style={styles.muted}>{describe(subscription, files)}</Text>
{files?.map((file) => (
<View key={file.path} style={styles.row}>
<Text style={styles.status}>{file.status}</Text>
<Text style={styles.path} numberOfLines={1}>
{file.path}
</Text>
</View>
))}
</ScrollView>
);
}
function describe(
subscription: ReturnType<typeof useSubscription>,
files: ChangedFile[] | null,
): string {
if (subscription.status === "error") return subscription.error.message;
if (subscription.status === "closed") return "Stopped watching";
if (!files) return "Reading changes…";
if (files.length === 0) return "No changes";
return files.length === 1 ? "1 file changed" : `${files.length} files changed`;
}
function useStyles(theme: PluginWorkspacePanelProps["theme"], compact: boolean) {
return useMemo(
() =>
StyleSheet.create({
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
content: { padding: compact ? 16 : 24, gap: 8 },
title: { color: theme.colors.foreground, fontSize: 18, fontWeight: "600" },
muted: { color: theme.colors.foregroundMuted },
row: { flexDirection: "row", gap: 12, alignItems: "center" },
status: { color: theme.colors.foregroundMuted, width: 72 },
path: { color: theme.colors.foreground, flex: 1 },
}),
[theme, compact],
);
}
@@ -0,0 +1,22 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { DiffPanel } from "./client/diff-panel";
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({
id: "changes",
title: "Live changes",
icon: "GitCompare",
context: "workspace",
Component: DiffPanel,
});
client.addCommandCenterItem({
id: "open-changes",
title: "Open live changes",
icon: "GitCompare",
context: "workspace",
onSelect({ openPanel }) {
openPanel("changes");
},
});
return () => {};
}
@@ -0,0 +1,19 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { readChangedFiles, watchDirectory } from "./server/diff";
import { diffWatch } from "./shared/diff";
export default function contribute(server: PluginServerContext) {
// The handler runs for as long as the panel is subscribed; a git failure closes it with the error.
server.handle(diffWatch, async (input, { push, signal }) => {
const directory = watchDirectory(input.cwd, signal);
let previous = "";
while (await directory.settled()) {
const files = await readChangedFiles(input);
const current = JSON.stringify(files);
if (current === previous) continue;
previous = current;
await push({ type: "changes", files });
}
});
return () => {};
}
@@ -0,0 +1,6 @@
{
"id": "diff-watch",
"requirements": {
"paseo": ">=0.15.0-beta.2"
}
}
+79
View File
@@ -0,0 +1,79 @@
import { watch } from "node:fs";
import type { SubscriptionInput } from "@getpaseo/plugin";
import { execCommand } from "@getpaseo/plugin/server";
import type { ChangedFile, diffWatch } from "../shared/diff";
const SETTLE_MS = 150;
const STATUSES: Record<string, ChangedFile["status"]> = {
A: "added",
M: "modified",
D: "deleted",
R: "renamed",
};
/** Lists changed files the way `git diff --name-status` does, plus untracked files. */
export async function readChangedFiles({
cwd,
from,
to,
}: SubscriptionInput<typeof diffWatch>): Promise<ChangedFile[]> {
const range = to ? [from, to] : [from];
const { stdout } = await execCommand("git", ["diff", "--name-status", "-z", ...range], { cwd });
const files = parseNameStatus(stdout);
if (!to) {
const untracked = await execCommand(
"git",
["ls-files", "--others", "--exclude-standard", "-z"],
{
cwd,
},
);
for (const path of untracked.stdout.split("\0").filter(Boolean))
files.push({ path, status: "added" });
}
return files.sort((left, right) => left.path.localeCompare(right.path));
}
function parseNameStatus(output: string): ChangedFile[] {
const fields = output.split("\0").filter(Boolean);
const files: ChangedFile[] = [];
for (let index = 0; index < fields.length; index += 1) {
const code = fields[index][0];
// Renames carry the old path before the new one.
if (code === "R") index += 1;
const path = fields[index + 1];
index += 1;
if (path) files.push({ path, status: STATUSES[code] ?? "modified" });
}
return files;
}
/** Resolves each time the directory settles after a change, and with false once `signal` aborts. */
export function watchDirectory(cwd: string, signal: AbortSignal) {
let changed = true;
let wake: (() => void) | null = null;
const watcher = watch(cwd, { recursive: true }, () => {
changed = true;
wake?.();
});
signal.addEventListener(
"abort",
() => {
watcher.close();
wake?.();
},
{ once: true },
);
return {
async settled(): Promise<boolean> {
// The watcher and the abort both wake a wait.
if (!changed && !signal.aborted) await new Promise<void>((resolve) => (wake = resolve));
wake = null;
if (signal.aborted) return false;
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
changed = false;
return !signal.aborted;
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import { defineSubscription } from "@getpaseo/plugin";
import { z } from "zod";
export const ChangedFileSchema = z.object({
path: z.string(),
status: z.enum(["added", "modified", "deleted", "renamed"]),
});
export type ChangedFile = z.infer<typeof ChangedFileSchema>;
/** Files that differ between `from` and `to`; without `to`, the working tree. */
export const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ cwd: z.string(), from: z.string(), to: z.string().optional() }),
event: z.object({ type: z.literal("changes"), files: z.array(ChangedFileSchema) }),
});
+7
View File
@@ -0,0 +1,7 @@
# Media stream
Watch a moving test pattern and an audio level meter, streamed live from your machine into a workspace panel. Video and audio arrive together on one connection, and the counters show how many frames of each have played.
Open **Open media stream** in the Command Center, or pick **Media stream** from the workspace tab menu. Closing the panel stops the stream.
Press **Send this frame back** to upload the picture on screen to your machine. The panel shows the size and checksum your machine received. A 16 by 9 picture is 432 bytes.
+17
View File
@@ -0,0 +1,17 @@
# Media stream plugin example
One subscription carries two binary streams. Each pushed `Uint8Array` is a binary frame; its first
byte says whether it is video, a keyframe, or audio.
```bash
paseo plugin add /absolute/path/to/paseo/plugin-examples/media-stream
```
- `shared/media-frames.ts` owns the byte layout: `encodeMediaFrame`, `decodeMediaFrame`, and the
`media.stream` contract. `decodeMediaFrame` returns a view, so the panel never copies a payload.
- `server/test-pattern.ts` is a synthetic source; replace it with a real capture.
- `index.server.ts` pushes each source callback as a frame and stops the source when the
subscription's `signal` aborts.
- `client/media-panel.tsx` decodes each frame and routes it by opcode.
- `shared/frame-check.ts` is an RPC whose input is a `Uint8Array`: the panel uploads the picture
on screen and `server/frame-check.ts` reports its size and checksum.
@@ -0,0 +1,196 @@
import { useRpc, useSubscription, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
import type { RpcOutput } from "@getpaseo/plugin";
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { checkFrame } from "../shared/frame-check";
import {
decodeMediaFrame,
MediaOpcode,
mediaStream,
VIDEO,
type MediaFrame,
} from "../shared/media-frames";
interface Playback {
picture: Uint8Array | null;
level: number;
videoFrames: number;
keyframes: number;
audioChunks: number;
bytes: number;
}
const EMPTY: Playback = {
picture: null,
level: 0,
videoFrames: 0,
keyframes: 0,
audioChunks: 0,
bytes: 0,
};
export function MediaPanel({ theme, layout }: PluginWorkspacePanelProps) {
const styles = useStyles(theme, layout.compact);
const [playback, setPlayback] = useState(EMPTY);
const subscription = useSubscription(mediaStream, { source: "test-pattern" }, (bytes) => {
const frame = decodeMediaFrame(bytes);
if (frame) setPlayback((current) => play(current, frame, bytes.byteLength));
});
return (
<View style={styles.screen}>
<Text style={styles.title}>Test pattern</Text>
<Text style={styles.muted}>
{subscription.status === "error" ? subscription.error.message : subscription.status}
</Text>
<Picture pixels={playback.picture} cellSize={layout.compact ? 14 : 20} />
<LevelMeter level={playback.level} styles={styles} />
<Text style={styles.muted}>
{playback.videoFrames} video frames ({playback.keyframes} keyframes), {playback.audioChunks}{" "}
audio chunks, {(playback.bytes / 1024).toFixed(0)} KiB
</Text>
<SendFrameBack picture={playback.picture} styles={styles} />
</View>
);
}
/** Uploads the picture on screen and shows what the daemon received. */
function SendFrameBack({
picture,
styles,
}: {
picture: Uint8Array | null;
styles: ReturnType<typeof useStyles>;
}) {
const sendFrame = useRpc(checkFrame);
const check = useMutation({ mutationFn: sendFrame });
const { mutate } = check;
const send = useCallback(() => {
if (picture) mutate(picture);
}, [mutate, picture]);
return (
<View style={styles.upload}>
<Pressable
accessibilityRole="button"
disabled={!picture || check.isPending}
onPress={send}
style={styles.button}
>
<Text style={styles.buttonText}>Send this frame back</Text>
</Pressable>
<Text style={styles.muted}>{describeCheck(check)}</Text>
</View>
);
}
function describeCheck(
check: UseMutationResult<RpcOutput<typeof checkFrame>, Error, Uint8Array>,
): string {
if (check.status === "error") return check.error.message;
if (check.status === "success")
return `The daemon received ${check.data.bytes} bytes, sha256 ${check.data.sha256.slice(0, 12)}`;
return check.status === "pending" ? "Sending…" : "";
}
function play(current: Playback, frame: MediaFrame, bytes: number): Playback {
if (frame.opcode === MediaOpcode.Audio)
return {
...current,
level: audioLevel(frame.payload),
audioChunks: current.audioChunks + 1,
bytes: current.bytes + bytes,
};
return {
...current,
picture: frame.payload,
videoFrames: current.videoFrames + 1,
keyframes: current.keyframes + (frame.opcode === MediaOpcode.Keyframe ? 1 : 0),
bytes: current.bytes + bytes,
};
}
function audioLevel(samples: Uint8Array): number {
const view = new DataView(samples.buffer, samples.byteOffset, samples.byteLength);
let sum = 0;
const count = samples.byteLength / 2;
for (let index = 0; index < count; index += 1) sum += view.getInt16(index * 2, true) ** 2;
return Math.min(1, Math.sqrt(sum / Math.max(1, count)) / 16384);
}
function LevelMeter({ level, styles }: { level: number; styles: ReturnType<typeof useStyles> }) {
const fill = useMemo(
() => [styles.meter, { width: `${Math.round(level * 100)}%` as const }],
[level, styles],
);
return (
<View style={styles.meterTrack}>
<View style={fill} />
</View>
);
}
function Picture({ pixels, cellSize }: { pixels: Uint8Array | null; cellSize: number }) {
const rows = useMemo(() => pictureRows(pixels, cellSize), [pixels, cellSize]);
return (
<View accessibilityLabel="Test pattern video">
{rows.map((row) => (
<View key={row.key} style={pictureStyles.row}>
{row.cells.map((cell) => (
<View key={cell.key} style={cell.style} />
))}
</View>
))}
</View>
);
}
/** One style per pixel, row by row. */
function pictureRows(pixels: Uint8Array | null, cellSize: number) {
const rows = [];
for (let y = 0; y < VIDEO.height; y += 1) {
const cells = [];
for (let x = 0; x < VIDEO.width; x += 1) {
const offset = (y * VIDEO.width + x) * 3;
const backgroundColor = pixels
? `rgb(${pixels[offset]}, ${pixels[offset + 1]}, ${pixels[offset + 2]})`
: "transparent";
cells.push({ key: `${x}`, style: { width: cellSize, height: cellSize, backgroundColor } });
}
rows.push({ key: `${y}`, cells });
}
return rows;
}
const pictureStyles = StyleSheet.create({ row: { flexDirection: "row" } });
function useStyles(theme: PluginWorkspacePanelProps["theme"], compact: boolean) {
return useMemo(
() =>
StyleSheet.create({
screen: {
flex: 1,
padding: compact ? 16 : 24,
gap: 12,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: 18, fontWeight: "600" },
muted: { color: theme.colors.foregroundMuted },
meterTrack: {
height: 8,
borderRadius: 4,
overflow: "hidden",
backgroundColor: theme.colors.surface2,
},
meter: { height: 8, backgroundColor: theme.colors.accent },
upload: { gap: 8, alignItems: "flex-start" },
button: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 8,
backgroundColor: theme.colors.accent,
},
buttonText: { color: theme.colors.accentForeground },
}),
[theme, compact],
);
}
@@ -0,0 +1,22 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { MediaPanel } from "./client/media-panel";
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({
id: "media",
title: "Media stream",
icon: "MonitorPlay",
context: "workspace",
Component: MediaPanel,
});
client.addCommandCenterItem({
id: "open-media",
title: "Open media stream",
icon: "MonitorPlay",
context: "workspace",
onSelect({ openPanel }) {
openPanel("media");
},
});
return () => {};
}
@@ -0,0 +1,27 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { describeFrame } from "./server/frame-check";
import { openTestPattern } from "./server/test-pattern";
import { checkFrame } from "./shared/frame-check";
import { encodeMediaFrame, MediaOpcode, mediaStream } from "./shared/media-frames";
export default function contribute(server: PluginServerContext) {
// Two streams share one subscription; each frame's opcode says which one it belongs to.
server.handle(mediaStream, (_input, { push, signal }) => {
const source = openTestPattern({
video: (pixels, timestampMs, keyframe) =>
void push(
encodeMediaFrame({
opcode: keyframe ? MediaOpcode.Keyframe : MediaOpcode.Video,
timestampMs,
payload: pixels,
}),
),
audio: (samples, timestampMs) =>
void push(encodeMediaFrame({ opcode: MediaOpcode.Audio, timestampMs, payload: samples })),
});
signal.addEventListener("abort", () => source.close(), { once: true });
});
// A Uint8Array input arrives as the bytes the panel sent.
server.handle(checkFrame, describeFrame);
return () => {};
}
@@ -0,0 +1,6 @@
{
"id": "media-stream",
"requirements": {
"paseo": ">=0.15.0-beta.2"
}
}
@@ -0,0 +1,7 @@
import { createHash } from "node:crypto";
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
import type { checkFrame } from "../shared/frame-check";
export function describeFrame(frame: RpcInput<typeof checkFrame>): RpcOutput<typeof checkFrame> {
return { bytes: frame.byteLength, sha256: createHash("sha256").update(frame).digest("hex") };
}
@@ -0,0 +1,44 @@
import { AUDIO, VIDEO } from "../shared/media-frames";
export interface MediaSourceHandlers {
video(pixels: Uint8Array, timestampMs: number, keyframe: boolean): void;
audio(samples: Uint8Array, timestampMs: number): void;
}
/** A synthetic source: a moving gradient and a tone that rises and falls. */
export function openTestPattern(handlers: MediaSourceHandlers): { close(): void } {
const started = Date.now();
let frame = 0;
let sample = 0;
const video = setInterval(() => {
const pixels = new Uint8Array(VIDEO.width * VIDEO.height * 3);
for (let y = 0; y < VIDEO.height; y += 1)
for (let x = 0; x < VIDEO.width; x += 1) {
const offset = (y * VIDEO.width + x) * 3;
pixels[offset] = ((x + frame) * 16) % 256;
pixels[offset + 1] = (y * 28) % 256;
pixels[offset + 2] = ((x + y + frame) * 8) % 256;
}
handlers.video(pixels, Date.now() - started, frame % VIDEO.keyframeInterval === 0);
frame += 1;
}, 1000 / VIDEO.framesPerSecond);
const audio = setInterval(() => {
const count = (AUDIO.sampleRate * AUDIO.chunkMs) / 1000;
const samples = new Uint8Array(count * 2);
const view = new DataView(samples.buffer);
const amplitude = 8000 + 7000 * Math.sin((Date.now() - started) / 700);
for (let index = 0; index < count; index += 1, sample += 1)
view.setInt16(
index * 2,
amplitude * Math.sin((2 * Math.PI * 440 * sample) / AUDIO.sampleRate),
true,
);
handlers.audio(samples, Date.now() - started);
}, AUDIO.chunkMs);
return {
close() {
clearInterval(video);
clearInterval(audio);
},
};
}
@@ -0,0 +1,9 @@
import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
/** Sends one frame back to the daemon, which reports what arrived. */
export const checkFrame = defineRpc({
name: "media.frame.check",
input: z.instanceof(Uint8Array),
output: z.object({ bytes: z.number(), sha256: z.string() }),
});
@@ -0,0 +1,47 @@
import { defineSubscription } from "@getpaseo/plugin";
import { z } from "zod";
/** The first byte of every frame says which stream it belongs to. */
export const MediaOpcode = { Video: 0x01, Audio: 0x02, Keyframe: 0x03 } as const;
export type MediaFrame =
| {
opcode: typeof MediaOpcode.Video | typeof MediaOpcode.Keyframe;
timestampMs: number;
payload: Uint8Array;
}
| { opcode: typeof MediaOpcode.Audio; timestampMs: number; payload: Uint8Array };
const HEADER_BYTES = 1 + 8;
/** `[opcode u8][timestampMs f64][payload]` */
export function encodeMediaFrame(frame: MediaFrame): Uint8Array {
const bytes = new Uint8Array(HEADER_BYTES + frame.payload.byteLength);
bytes[0] = frame.opcode;
new DataView(bytes.buffer).setFloat64(1, frame.timestampMs);
bytes.set(frame.payload, HEADER_BYTES);
return bytes;
}
/** Returns a view into `bytes`; the payload is not copied. */
export function decodeMediaFrame(bytes: Uint8Array): MediaFrame | null {
if (bytes.byteLength < HEADER_BYTES) return null;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const opcode = bytes[0];
const timestampMs = view.getFloat64(1);
const payload = bytes.subarray(HEADER_BYTES);
if (opcode === MediaOpcode.Audio) return { opcode, timestampMs, payload };
if (opcode === MediaOpcode.Video || opcode === MediaOpcode.Keyframe)
return { opcode, timestampMs, payload };
return null;
}
/** A video frame is `width * height` RGB pixels; audio is signed 16-bit mono PCM. */
export const VIDEO = { width: 16, height: 9, framesPerSecond: 15, keyframeInterval: 30 } as const;
export const AUDIO = { sampleRate: 8000, chunkMs: 20 } as const;
export const mediaStream = defineSubscription({
name: "media.stream",
input: z.object({ source: z.enum(["test-pattern"]) }),
event: z.instanceof(Uint8Array),
});
+158
View File
@@ -2226,6 +2226,164 @@ Inputs and outputs are validated on both sides. RPC names start with a lowercase
Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. It does not subscribe to timelines or catalog events until plugin code subscribes. Follow the [SDK event contract](/docs/sdk/events) for cleanup and timeline replacements. Backend code can use Node APIs and dependencies installed in the plugin directory.
## Stream events from the daemon {#subscriptions}
A subscription runs a daemon handler for as long as one client listens and sends that client typed
events. Use it for watchers, tails, and live media; use plugin RPC for a single answer.
Define the contract in `shared/`:
```ts
// shared/diff.ts
import { defineSubscription } from "@getpaseo/plugin";
import { z } from "zod";
export const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ cwd: z.string(), from: z.string() }),
event: z.object({ type: z.literal("changes"), files: z.array(z.string()) }),
});
```
Handle it in `index.server.ts`. The handler receives `push` and an abort `signal` next to `paseo`:
```ts
server.handle(diffWatch, ({ cwd, from }, { push, signal }) => {
const watcher = watchChanges(cwd, from, (files) => void push({ type: "changes", files }));
signal.addEventListener("abort", () => watcher.close(), { once: true });
});
```
Subscribe from a surface with `useSubscription(contract, input, onEvent)`:
```tsx
const [files, setFiles] = useState<string[]>([]);
const subscription = useSubscription(diffWatch, { cwd, from: "HEAD" }, (event) => {
setFiles(event.files);
});
if (subscription.status === "error") return <Text>{subscription.error.message}</Text>;
```
Outside React, `client.subscribe` takes the same arguments plus options and returns a remover:
```ts
const stop = client.subscribe(diffWatch, { cwd, from: "HEAD" }, onChanges, { signal });
await stop.ready; // the daemon accepted the subscription
stop(); // or await stop.release()
```
RPCs and subscriptions share one name space; registering the same name for both fails the plugin
load.
### Handler lifetime
| The handler... | Result |
| --------------------------------------------- | ------------------------------------------------------------ |
| Throws synchronously, or the input is invalid | The subscribe call fails with the error. No events are sent. |
| Returns nothing | The subscription stays open until the client releases it. |
| Returns a promise that resolves | The subscription closes. |
| Returns a promise that rejects | The subscription closes with the error. |
`signal` aborts when the client releases the subscription, unmounts the hook, changes its input,
disconnects, or the plugin stops. Pushes after the abort are dropped. `push` validates the event
against the contract and resolves once the event has left the plugin process; await it in a loop
to pace a producer.
Each subscriber runs its own handler. Paseo does not share one handler between clients and does
not replay missed events: after a reconnect or a plugin reload, mounted surfaces subscribe again and
the handler starts from the beginning. Share an
expensive watcher inside your plugin when several subscribers need it.
`useSubscription` returns a union on `status`:
| Status | Meaning |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `connecting` | Sent; the daemon has not accepted it yet. |
| `live` | The handler is running. Events arrive through `onEvent`. |
| `closed` | The handler finished. |
| `error` | The subscribe failed, the handler failed, an event failed the contract, or the plugin stopped. |
The hook subscribes again when `input` changes by shallow equality. `onEvent` is always the latest
render's callback, so an inline function does not resubscribe. `client.subscribe` takes
`{ signal, onClosed }`; `onClosed` receives `{ error? }`.
### Bytes
A top-level `Uint8Array` crosses the wire as binary, without JSON or base64. Everything else is
JSON. The rule applies to subscription events, RPC outputs, and RPC inputs. A Node `Buffer` is a
`Uint8Array`.
```ts
export const thumbnail = defineRpc({
name: "thumbnail.render",
input: z.object({ path: z.string(), width: z.number() }),
output: z.instanceof(Uint8Array),
});
// index.server.ts
server.handle(thumbnail, ({ path, width }) => renderPng(path, width));
// client
const renderThumbnail = useRpc(thumbnail);
const png = await renderThumbnail({ path, width }); // Uint8Array
```
Declare bytes with `z.instanceof(Uint8Array)`. An event can be bytes or JSON per push: declare a
union and branch with `event instanceof Uint8Array`. A `Uint8Array` nested in an object fails
validation on the sending side with an error that names the rule; send the bytes as their own
event or RPC value and the metadata as a separate JSON event.
One event or RPC value carries at most 100 MiB less 65 KiB of framing. Push large payloads in
chunks of a few hundred KiB; each `push` waits for the previous one to leave the plugin process.
Uploads from a phone have a lower limit: Android closes the connection when one send exceeds
16 MiB, so split a larger upload into several RPCs.
### Several streams on one subscription
Put a header byte in front of each payload to multiplex streams. Keep the layout in `shared/`, so
the handler and the surface call named functions:
```ts
// shared/media-frames.ts
export const MediaOpcode = { Video: 0x01, Audio: 0x02 } as const;
export function encodeMediaFrame(opcode: number, payload: Uint8Array): Uint8Array {
const bytes = new Uint8Array(1 + payload.byteLength);
bytes[0] = opcode;
bytes.set(payload, 1);
return bytes;
}
export function decodeMediaFrame(bytes: Uint8Array) {
return { opcode: bytes[0], payload: bytes.subarray(1) }; // a view, not a copy
}
export const mediaStream = defineSubscription({
name: "media.stream",
input: z.object({ source: z.string() }),
event: z.instanceof(Uint8Array),
});
```
```ts
useSubscription(mediaStream, { source }, (bytes) => {
const frame = decodeMediaFrame(bytes);
if (frame.opcode === MediaOpcode.Audio) audio.enqueue(frame.payload);
else video.draw(frame.payload);
});
```
Events from one subscription arrive in push order, JSON and binary alike.
`plugin-examples/media-stream` streams video and audio this way and uploads a frame back through
an RPC with a `Uint8Array` input; `plugin-examples/diff-watch` watches a working tree.
### Older hosts
Subscriptions and binary RPC values need a host that advertises them. Against an older host,
`useSubscription` reports `error` asking to update the host, `client.subscribe` rejects `ready`
with the same message, and an RPC with a `Uint8Array` input or output fails. Raise your plugin's
`requirements.paseo` to the release that ships subscriptions.
## Debug backend output
Backend contributions can write to stdout and stderr with normal Node logging:
+134
View File
@@ -1924,6 +1924,140 @@ export default function contribute(server: PluginServerContext) {
后端处理函数以 `{ paseo }` 的形式收到同样的 `PaseoApi`。它们的连接属于子进程,并在插件停止时关闭。在插件代码订阅之前,它不会订阅时间线或目录事件。清理和时间线替换请遵循 [SDK 事件契约](/docs/sdk/events)。后端代码可以使用 Node API 以及安装在插件目录中的依赖。
## 从 daemon 推送事件 {#subscriptions}
订阅会在一个客户端监听期间运行 daemon 处理函数,并向该客户端发送带类型的事件。监听文件、追踪日志和实时媒体使用订阅;只需要一次应答时使用插件 RPC。
在 `shared/` 中定义契约:
```ts
// shared/diff.ts
import { defineSubscription } from "@getpaseo/plugin";
import { z } from "zod";
export const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ cwd: z.string(), from: z.string() }),
event: z.object({ type: z.literal("changes"), files: z.array(z.string()) }),
});
```
在 `index.server.ts` 中处理它。处理函数除了 `paseo`,还会收到 `push` 和一个中止 `signal`:
```ts
server.handle(diffWatch, ({ cwd, from }, { push, signal }) => {
const watcher = watchChanges(cwd, from, (files) => void push({ type: "changes", files }));
signal.addEventListener("abort", () => watcher.close(), { once: true });
});
```
在界面中用 `useSubscription(contract, input, onEvent)` 订阅:
```tsx
const [files, setFiles] = useState<string[]>([]);
const subscription = useSubscription(diffWatch, { cwd, from: "HEAD" }, (event) => {
setFiles(event.files);
});
if (subscription.status === "error") return <Text>{subscription.error.message}</Text>;
```
在 React 之外,`client.subscribe` 接受同样的参数和一个选项对象,并返回一个移除函数:
```ts
const stop = client.subscribe(diffWatch, { cwd, from: "HEAD" }, onChanges, { signal });
await stop.ready; // the daemon accepted the subscription
stop(); // or await stop.release()
```
RPC 和订阅共用一个名称空间;同一个名称同时注册为 RPC 和订阅会导致插件加载失败。
### 处理函数的生命周期 {#handler-lifetime}
| 处理函数…… | 结果 |
| ------------------------ | -------------------------------------------- |
| 同步抛出错误,或输入无效 | 订阅调用失败并返回该错误,不会发送任何事件。 |
| 不返回任何值 | 订阅保持打开,直到客户端释放它。 |
| 返回一个兑现的 Promise | 订阅关闭。 |
| 返回一个拒绝的 Promise | 订阅关闭并带上该错误。 |
当客户端释放订阅、卸载 hook、更改输入、断开连接,或者插件停止时,`signal` 会中止。中止之后的推送会被丢弃。`push` 会按契约校验事件,并在事件离开插件进程后兑现;在循环中 await 它可以控制生产者的速度。
每个订阅者运行各自的处理函数。Paseo 不会在多个客户端之间共享同一个处理函数,也不会重放错过的事件:重新连接或重新加载插件之后,已挂载的界面会重新订阅,处理函数从头开始运行。多个订阅者需要同一个昂贵的监听器时,请在插件内部共享它。
`useSubscription` 返回一个以 `status` 区分的联合类型:
| 状态 | 含义 |
| ------------ | -------------------------------------------------------- |
| `connecting` | 已发送,daemon 尚未接受。 |
| `live` | 处理函数正在运行,事件通过 `onEvent` 送达。 |
| `closed` | 处理函数已结束。 |
| `error` | 订阅失败、处理函数失败、事件不符合契约,或者插件已停止。 |
`input` 按浅比较发生变化时,hook 会重新订阅。`onEvent` 始终是最近一次渲染的回调,所以内联函数不会导致重新订阅。`client.subscribe` 接受 `{ signal, onClosed }`;`onClosed` 会收到 `{ error? }`。
### 字节 {#bytes}
顶层的 `Uint8Array` 以二进制形式传输,不经过 JSON 或 base64,其他值都是 JSON。这条规则适用于订阅事件、RPC 输出和 RPC 输入。Node 的 `Buffer` 也是 `Uint8Array`。
```ts
export const thumbnail = defineRpc({
name: "thumbnail.render",
input: z.object({ path: z.string(), width: z.number() }),
output: z.instanceof(Uint8Array),
});
// index.server.ts
server.handle(thumbnail, ({ path, width }) => renderPng(path, width));
// client
const renderThumbnail = useRpc(thumbnail);
const png = await renderThumbnail({ path, width }); // Uint8Array
```
用 `z.instanceof(Uint8Array)` 声明字节。事件可以在每次推送时选择字节或 JSON:声明一个联合类型,并用 `event instanceof Uint8Array` 分支。嵌套在对象中的 `Uint8Array` 会在发送端校验失败,错误信息会说明这条规则;把字节作为单独的事件或 RPC 值发送,元数据作为另一个 JSON 事件发送。
一个事件或 RPC 值最多 100 MiB(减去 65 KiB 的帧头)。大载荷请按几百 KiB 分块推送;每次 `push` 都会等待上一次离开插件进程。手机上传的上限更低:一次发送超过 16 MiB 时 Android 会关闭连接,所以较大的上传请拆成多个 RPC。
### 在一个订阅中传输多条流 {#several-streams-on-one-subscription}
在每个载荷前加一个头字节即可复用多条流。把布局放在 `shared/` 中,让处理函数和界面调用有名字的函数:
```ts
// shared/media-frames.ts
export const MediaOpcode = { Video: 0x01, Audio: 0x02 } as const;
export function encodeMediaFrame(opcode: number, payload: Uint8Array): Uint8Array {
const bytes = new Uint8Array(1 + payload.byteLength);
bytes[0] = opcode;
bytes.set(payload, 1);
return bytes;
}
export function decodeMediaFrame(bytes: Uint8Array) {
return { opcode: bytes[0], payload: bytes.subarray(1) }; // a view, not a copy
}
export const mediaStream = defineSubscription({
name: "media.stream",
input: z.object({ source: z.string() }),
event: z.instanceof(Uint8Array),
});
```
```ts
useSubscription(mediaStream, { source }, (bytes) => {
const frame = decodeMediaFrame(bytes);
if (frame.opcode === MediaOpcode.Audio) audio.enqueue(frame.payload);
else video.draw(frame.payload);
});
```
同一个订阅的事件按推送顺序到达,JSON 和二进制都一样。`plugin-examples/media-stream` 用这种方式传输视频和音频,并通过一个输入为 `Uint8Array` 的 RPC 把一帧上传回去;`plugin-examples/diff-watch` 监听工作树。
### 旧版主机 {#older-hosts}
订阅和二进制 RPC 值需要主机声明支持。连接旧版主机时,`useSubscription` 会报告 `error` 并提示更新主机,`client.subscribe` 的 `ready` 会以同样的信息拒绝,带有 `Uint8Array` 输入或输出的 RPC 会失败。请把插件的 `requirements.paseo` 提高到包含订阅功能的版本。
## 调试后端输出 {#debug-backend-output}
后端贡献可以用普通的 Node 日志写入 stdout 和 stderr:
+18 -17
View File
@@ -27,22 +27,23 @@ public docs; this skill indexes the references and examples.
Pick the contribution that matches the request. Each row names the registration, when it fits, and where the full contract lives. Most plugins combine several: a slash command that calls an RPC, which appends a timeline row, which a renderer draws.
| Contribution | Registration | Use it when | Reference |
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) |
| Creation and launch hooks | `server.before` | `agent.create` edits saved config; `agent.session_open` contributes launch-only `env`, `tools`, `mcpServers` on every open; `workspace.create` chooses isolation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Contribution | Registration | Use it when | Reference |
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| File panel | `addFilePanel` | View or edit a matching file inside the file tab through its file handle | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |
| Timeline transformer | `addTimelineTransformer` + `addTimelineRenderer` | Replace, explode, or hide a built-in timeline item, including while it streams | reference.md → Timeline items; `plugin-examples/timeline-items`, `plugin-examples/inline-thinking` |
| Timeline row | `paseo.agents.ref(id).timeline.append(...)` | Push a plugin-owned row into an agent timeline from a server handler and update it later | reference.md → Append a timeline row from the daemon |
| Attachment source | `client.addAttachmentSource` + `server.handle` | Let the user attach a searchable external resource, such as an issue, to a prompt | reference.md → Add a composer attachment source; `plugin-examples/linear` |
| Theme | `addTheme` | A light or dark palette under Settings → Appearance | reference.md → Contribute a theme; `plugin-examples/catppuccin` |
| Plugin RPC | `defineRpc` + `server.handle` + `useRpc` | Daemon-side work that is not a normal Paseo operation: vendor APIs, credentials, local files | reference.md → Add plugin-specific backend behavior |
| Plugin subscription | `defineSubscription` + `server.handle` + `useSubscription` | A daemon handler that pushes events while a surface listens: watchers, tails, live media; a top-level `Uint8Array` rides as binary | reference.md → Stream events from the daemon; `plugin-examples/diff-watch`, `plugin-examples/media-stream` |
| Lifecycle events | `server.on` | Observe agent/workspace lifecycle, inspect ended turns, and answer permission requests | [Lifecycle hooks](https://paseo.sh/docs/plugins/reference.md#lifecycle-hooks) |
| Creation and launch hooks | `server.before` | `agent.create` edits saved config; `agent.session_open` contributes launch-only `env`, `tools`, `mcpServers` on every open; `workspace.create` chooses isolation | [Before hooks](https://paseo.sh/docs/plugins/reference.md#before-hooks) |
| Paseo SDK | `usePaseo()` / handler `{ paseo }` | Normal Paseo operations: workspaces, agents, providers, config | reference.md → Use the Paseo SDK |
| Lifecycle task | Example |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
@@ -374,7 +375,7 @@ function Greeting() {
}
```
Inputs and outputs are validated on both sides. Backend handlers receive the same `PaseoApi` as `{ paseo }`; their IPC-backed daemon session lives exactly as long as the subprocess. Backend code can use Node APIs and installed dependencies. Keep credentials, filesystem access, shell commands, and vendor API calls in the handler rather than the client surface.
Inputs and outputs are validated on both sides. A top-level `Uint8Array` input or output (declared `z.instanceof(Uint8Array)`) crosses as binary; a nested one fails validation. For a stream of events, use `defineSubscription` and `useSubscription`; see [subscriptions](https://paseo.sh/docs/plugins/reference.md#subscriptions). Backend handlers receive the same `PaseoApi` as `{ paseo }`; their IPC-backed daemon session lives exactly as long as the subprocess. Backend code can use Node APIs and installed dependencies. Keep credentials, filesystem access, shell commands, and vendor API calls in the handler rather than the client surface.
Use TanStack Query for async request state, caching, and mutations.