mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
* 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.
* 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.
* 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.
* 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.
* test(plugins): add the plain subscription unit test and the browser spec
* 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.
* 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.
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
import type { PluginText } from "../text.js";
|
|
import type { ComponentType } from "react";
|
|
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,
|
|
PluginSubscribeOptions,
|
|
PluginSubscriptionContract,
|
|
PluginSubscriptionHandle,
|
|
PluginWireInput,
|
|
} from "../rpc.js";
|
|
import type {
|
|
PluginButtonRegistration,
|
|
PluginHeaderButtonContribution,
|
|
PluginComposerPillContribution,
|
|
} from "./buttons.js";
|
|
import type {
|
|
PluginTheme,
|
|
PluginWorkspaceSnapshot,
|
|
PluginAgentSnapshot,
|
|
PluginThemeContribution,
|
|
PluginAttachmentSourceContribution,
|
|
PluginTimelineTransformResult,
|
|
PluginCleanup,
|
|
} from "../contracts.js";
|
|
import type { PluginIconProps, PluginSerializableIcon } from "../icons.js";
|
|
|
|
export interface PluginHostProps {
|
|
theme: PluginTheme;
|
|
host: {
|
|
id: string;
|
|
label: string;
|
|
};
|
|
layout: {
|
|
compact: boolean;
|
|
platform: "ios" | "android" | "web";
|
|
};
|
|
}
|
|
|
|
interface PluginNavigableHostProps extends PluginHostProps {
|
|
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
|
|
readonly navigation?: {
|
|
/** Present only on Electron. The browser runs locally; serverId selects workspace ownership. */
|
|
readonly openBrowser?: (input: {
|
|
readonly url: string;
|
|
readonly workspaceId: string;
|
|
readonly serverId?: string;
|
|
}) => void;
|
|
readonly openAgent: (input: { readonly agentId: string; readonly serverId?: string }) => void;
|
|
readonly openWorkspace: (input: {
|
|
readonly workspaceId: string;
|
|
readonly serverId?: string;
|
|
}) => void;
|
|
};
|
|
}
|
|
|
|
/** String keys and values: params travel in the screen's URL. */
|
|
export type PluginScreenParams = Record<string, string>;
|
|
|
|
export interface PluginOpenScreenInput {
|
|
screenId: string;
|
|
params?: PluginScreenParams;
|
|
}
|
|
|
|
export interface PluginScreenLocation {
|
|
screenId: string;
|
|
params: PluginScreenParams;
|
|
}
|
|
|
|
export interface PluginSurfaceProps extends PluginNavigableHostProps {}
|
|
|
|
export interface PluginScreenProps extends PluginSurfaceProps {
|
|
/** The params the screen was opened with; `{}` when none. */
|
|
params: PluginScreenParams;
|
|
}
|
|
|
|
export interface PluginPopoverProps extends PluginHostProps {
|
|
close(): void;
|
|
openScreen(input: PluginOpenScreenInput): void;
|
|
}
|
|
|
|
export interface PluginSidebarItemProps extends PluginHostProps {
|
|
/** This plugin's screen open on this item's host, with its params, else null. */
|
|
currentScreen: PluginScreenLocation | null;
|
|
openScreen(input: PluginOpenScreenInput): void;
|
|
/** Anchored to the item on wide layouts; a bottom sheet on compact layouts. */
|
|
openPopover(Content: ComponentType<PluginPopoverProps>): void;
|
|
}
|
|
|
|
export interface PluginSidebarItemContribution<Text extends PluginText = string> {
|
|
id: string;
|
|
/** Settings row label, accessibility label, and default label for SidebarRow. */
|
|
title: Text;
|
|
Component: ComponentType<PluginSidebarItemProps>;
|
|
}
|
|
|
|
/**
|
|
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
|
|
* and color; buttons widen the props with their host and target context.
|
|
*/
|
|
export type PluginIcon<Props extends PluginIconProps = PluginIconProps> =
|
|
| PluginSerializableIcon
|
|
| ComponentType<Props>;
|
|
|
|
export type PluginPanelLocation = "workspace" | "explorer";
|
|
|
|
export interface PluginOpenPanelOptions {
|
|
location?: PluginPanelLocation;
|
|
}
|
|
|
|
interface PluginWorkspacePanelBase<Text extends PluginText = string> {
|
|
id: string;
|
|
title: Text;
|
|
icon: PluginIcon;
|
|
locations?: readonly PluginPanelLocation[];
|
|
}
|
|
|
|
export interface PluginTab {
|
|
/** Shows the tab's unsaved dot and asks before closing. */
|
|
setModified(modified: boolean): void;
|
|
/** Opaque persisted JSON. Undefined until written; the panel validates it. */
|
|
readonly state: unknown;
|
|
setState(state: JsonValue): void;
|
|
}
|
|
|
|
export interface PluginPanelProps<Params> extends PluginNavigableHostProps {
|
|
workspaceId: string;
|
|
readonly params: Readonly<Params>;
|
|
revision: number;
|
|
tab: PluginTab;
|
|
}
|
|
export interface PluginWorkspacePanelParams {}
|
|
export interface PluginAgentPanelParams {
|
|
readonly agentId: string;
|
|
}
|
|
export interface PluginFilePanelParams {
|
|
readonly path: string;
|
|
readonly lineStart?: number;
|
|
readonly lineEnd?: number;
|
|
}
|
|
export interface PluginWorkspacePanelProps extends PluginPanelProps<PluginWorkspacePanelParams> {
|
|
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
|
|
/** @deprecated Use the panel registration to select the context. */
|
|
context: "workspace";
|
|
}
|
|
export interface PluginAgentPanelProps extends PluginPanelProps<PluginAgentPanelParams> {
|
|
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
|
|
/** @deprecated Use the panel registration to select the context. */
|
|
context: "agent";
|
|
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
|
|
/** @deprecated Read `params.agentId`. */
|
|
agentId: string;
|
|
}
|
|
export interface PluginFilePanelProps extends PluginPanelProps<PluginFilePanelParams> {
|
|
file: PluginFile;
|
|
}
|
|
|
|
export interface PluginFilePanelContribution<Text extends PluginText = string> {
|
|
id: string;
|
|
title: Text;
|
|
icon: PluginIcon;
|
|
/** Extensions without the dot, case-insensitive, and exact filenames. */
|
|
files: { extensions: readonly string[]; filenames?: readonly string[] };
|
|
Component: ComponentType<PluginFilePanelProps>;
|
|
}
|
|
|
|
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
|
|
workspaceId: string;
|
|
agentId?: string;
|
|
}
|
|
|
|
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
|
|
interface PluginClientContextAliases {
|
|
/** @deprecated Use `addScreen`. */
|
|
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
|
|
/** @deprecated Use `addSidebarHeaderItem`. */
|
|
addSidebarItem(contribution: PluginSidebarContribution<PluginText>): PluginCleanup;
|
|
}
|
|
|
|
export interface PluginClientContext extends PluginCommandCapabilities, PluginClientContextAliases {
|
|
readonly i18n: import("./translations.js").PluginI18n;
|
|
/** Play a base64-encoded audio file on this client; resolves when playback ends. */
|
|
playAudio(source: { base64: string; mimeType: string }): Promise<void>;
|
|
addSettingsScreen(contribution: PluginSettingsScreenContribution<PluginText>): PluginCleanup;
|
|
addScreen(contribution: PluginScreenContribution<PluginText>): PluginCleanup;
|
|
addSidebarHeaderItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
|
addSidebarFooterItem(contribution: PluginSidebarItemContribution<PluginText>): PluginCleanup;
|
|
addAgentPanel(contribution: PluginAgentPanelContribution<PluginText>): PluginCleanup;
|
|
addWorkspacePanel(contribution: PluginWorkspacePanelContribution<PluginText>): PluginCleanup;
|
|
addCommandCenterItem(
|
|
contribution: PluginCommandCenterItemContribution<PluginText>,
|
|
): PluginCleanup;
|
|
addSlashCommand(contribution: PluginClientSlashCommandContribution<PluginText>): PluginCleanup;
|
|
addHeaderButton(
|
|
contribution: PluginHeaderButtonContribution<PluginText>,
|
|
): PluginButtonRegistration<PluginText>;
|
|
addComposerPill(
|
|
contribution: PluginComposerPillContribution<PluginText>,
|
|
): PluginButtonRegistration<PluginText>;
|
|
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
|
|
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
|
|
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
|
|
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
|
contribution: PluginTimelineTransformerContribution<ItemType>,
|
|
): PluginCleanup;
|
|
addTimelineRenderer<Schema extends ZodType>(
|
|
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;
|
|
|
|
export type PluginWorkspacePanelContribution<Text extends PluginText = string> =
|
|
| (PluginWorkspacePanelBase<Text> & {
|
|
// COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23
|
|
/** @deprecated Omit context. */
|
|
context?: "workspace";
|
|
Component: ComponentType<PluginWorkspacePanelProps>;
|
|
})
|
|
| (PluginWorkspacePanelBase<Text> & {
|
|
// COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23
|
|
/** @deprecated Register with `addAgentPanel`. */
|
|
context: "agent";
|
|
Component: ComponentType<PluginAgentPanelProps>;
|
|
});
|
|
|
|
export interface PluginAgentPanelContribution<
|
|
Text extends PluginText = string,
|
|
> extends PluginWorkspacePanelBase<Text> {
|
|
Component: ComponentType<PluginAgentPanelProps>;
|
|
}
|
|
|
|
export interface PluginSettingsScreenContribution<Text extends PluginText = string> {
|
|
id: string;
|
|
title: Text;
|
|
icon: PluginIcon;
|
|
Component: ComponentType<PluginSurfaceProps>;
|
|
}
|
|
|
|
/** The screen header's title: fixed, or derived from the params the screen was opened with. */
|
|
export type PluginScreenTitle<Text extends PluginText = string> =
|
|
| Text
|
|
| ((params: PluginScreenParams) => Text);
|
|
|
|
export interface PluginScreenContribution<Text extends PluginText = string> {
|
|
id: string;
|
|
title: PluginScreenTitle<Text>;
|
|
Component: ComponentType<PluginScreenProps>;
|
|
}
|
|
|
|
export interface PluginSurfaceContribution {
|
|
id: string;
|
|
Component: ComponentType<PluginSurfaceProps>;
|
|
}
|
|
|
|
/** @deprecated Use `PluginSidebarItemContribution` with `addSidebarHeaderItem`. */
|
|
export interface PluginSidebarContribution<Text extends PluginText = string> {
|
|
id: string;
|
|
title: Text;
|
|
icon: PluginIcon;
|
|
surface: string;
|
|
}
|
|
|
|
export type PluginTimelineTransformerContribution<
|
|
ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"],
|
|
> = ItemType extends AgentTimelineItem["type"]
|
|
? {
|
|
id: string;
|
|
query: {
|
|
itemType: ItemType;
|
|
};
|
|
transform(input: {
|
|
item: Extract<AgentTimelineItem, { type: ItemType }>;
|
|
phase: "streaming" | "complete";
|
|
}): PluginTimelineTransformResult | undefined;
|
|
}
|
|
: never;
|
|
|
|
export interface PluginTimelineItemProps<Data = unknown> extends PluginHostProps {
|
|
agentId: string;
|
|
item: {
|
|
type: "plugin";
|
|
kind: string;
|
|
version: number;
|
|
data: Data;
|
|
};
|
|
timestamp: Date;
|
|
}
|
|
|
|
export interface PluginTimelineRendererContribution<Schema extends ZodType = ZodType> {
|
|
kind: string;
|
|
version: number;
|
|
schema: Schema;
|
|
Component: ComponentType<PluginTimelineItemProps<ZodOutput<Schema>>>;
|
|
}
|
|
|
|
export interface PluginCommandCapabilities {
|
|
paseo: PaseoApi;
|
|
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
|
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
|
input: PluginWireInput<ZodInput<InputSchema>>,
|
|
): Promise<ZodOutput<OutputSchema>>;
|
|
openScreen(input: PluginOpenScreenInput): void;
|
|
/** @deprecated Use `openScreen`. */
|
|
openSurface(id: string): void;
|
|
openSettings(id: string): void;
|
|
}
|
|
|
|
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
|
|
context: "global";
|
|
}
|
|
|
|
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
|
|
context: "workspace";
|
|
workspace: PluginWorkspaceSnapshot;
|
|
openPanel(id: string, options?: PluginOpenPanelOptions): void;
|
|
}
|
|
|
|
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
|
|
context: "agent";
|
|
workspace: PluginWorkspaceSnapshot;
|
|
agent: PluginAgentSnapshot;
|
|
openPanel(id: string, options?: PluginOpenPanelOptions): void;
|
|
}
|
|
|
|
interface PluginCommandCenterItemBase<Text extends PluginText = string> {
|
|
id: string;
|
|
title: Text;
|
|
icon: PluginIcon;
|
|
keywords?: readonly string[];
|
|
}
|
|
|
|
export type PluginCommandCenterItemContribution<Text extends PluginText = string> =
|
|
| (PluginCommandCenterItemBase<Text> & {
|
|
context: "global";
|
|
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
|
|
})
|
|
| (PluginCommandCenterItemBase<Text> & {
|
|
context: "workspace";
|
|
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
|
|
})
|
|
| (PluginCommandCenterItemBase<Text> & {
|
|
context: "agent";
|
|
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
|
|
});
|
|
|
|
interface PluginClientSlashCommandBase<Text extends PluginText = string> {
|
|
name: string;
|
|
description: Text;
|
|
argumentHint: Text;
|
|
}
|
|
|
|
export type PluginClientSlashCommandContribution<Text extends PluginText = string> =
|
|
| (PluginClientSlashCommandBase<Text> & {
|
|
context: "workspace";
|
|
onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise<void>;
|
|
})
|
|
| (PluginClientSlashCommandBase<Text> & {
|
|
context: "agent";
|
|
onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise<void>;
|
|
});
|
|
|
|
export type SettingsState<Schema extends ZodType> = (
|
|
| { status: "loading" }
|
|
| { status: "error"; error: string }
|
|
| { status: "invalid"; error: string; revision: string }
|
|
| { status: "ready"; values: ZodOutput<Schema>; revision: string }
|
|
) & {
|
|
saving: boolean;
|
|
saveError: string | null;
|
|
/** Save an entire document against the revision currently displayed. Never throws. */
|
|
save(values: ZodOutput<Schema>, revision: string): Promise<boolean>;
|
|
reset(): Promise<boolean>;
|
|
reload(): Promise<void>;
|
|
};
|