Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 3a3751f1bf test(server): distinguish Hub execution sessions 2026-08-09 02:39:21 +02:00
Mohamed Boudra cb4976289b test(server): exclude internal sessions from Hub counts 2026-08-09 02:26:55 +02:00
Mohamed Boudra 2668f31e07 fix(nix): refresh npm dependency hash 2026-08-09 02:06:43 +02:00
Mohamed Boudra be523e09e9 feat(plugins): add Linear attachment source 2026-08-09 01:53:55 +02:00
Mohamed Boudra 1c41ba0bee feat(plugins): add local plugin surfaces and RPCs
Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.
2026-08-09 01:53:55 +02:00
64 changed files with 4128 additions and 11 deletions
+1
View File
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
+135
View File
@@ -0,0 +1,135 @@
# Local plugins
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
## Configure a directory source
Add the plugin to the root `plugins` object in `$PASEO_HOME/config.json`:
```json
{
"plugins": {
"my-plugin": {
"source": "directory",
"path": "/absolute/path/to/my-plugin"
}
}
}
```
The directory contains an identity-only manifest and one entry point:
```text
my-plugin/
paseo-plugin.json
index.tsx
```
```json
{
"id": "my-plugin"
}
```
The manifest ID must match the config key. Restart the development daemon after changing plugin
source or configuration; directory watching is outside the first slice.
## Contribute behavior and UI
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
removes UI registrations from the server bundle and RPC registrations from the client bundle before
resolving dependencies.
```tsx
import { Text } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
function Greeting() {
const greet = useRpc(greetRpc);
const query = useQuery({
queryKey: ["greeting", "Paseo"],
queryFn: () => greet({ name: "Paseo" }),
});
return <Text>{query.data?.message}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({
id: "main",
title: "Greeting",
icon: "MessageCircle",
surface: "main",
});
}
```
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
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.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
credentials and vendor API calls stay in the daemon handler.
```tsx
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
const issues = defineAttachmentSource({
id: "issues",
title: "Acme issue",
icon: "CircleDot",
pickerTitle: "Attach Acme issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
}
```
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
external-resource presentation, so it remains readable if the plugin is removed or an older peer
drops the optional presentation fields.
See `examples/local-plugin` for a native surface and `extensions/linear` for a complete official
attachment-source extension.
+74
View File
@@ -0,0 +1,74 @@
import React, { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
const incrementRpc = defineRpc({
name: "increment",
input: z.object({ value: z.number() }),
output: z.object({ value: z.number(), handledBy: z.string() }),
});
interface ExampleTheme {
colors: {
surface0: string;
foreground: string;
foregroundMuted: string;
accent: string;
accentForeground: string;
statusDanger: string;
};
}
function ExampleSurface({ theme }: { theme: ExampleTheme }) {
const callIncrement = useRpc(incrementRpc);
const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement });
const value = data?.value ?? 0;
const styles = useMemo(
() => ({
screen: { flex: 1, padding: 24, gap: 16, backgroundColor: theme.colors.surface0 },
title: { color: theme.colors.foreground, fontSize: 24 },
detail: { color: theme.colors.foregroundMuted },
button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent },
buttonText: { color: theme.colors.accentForeground, textAlign: "center" },
error: { color: theme.colors.statusDanger },
}),
[theme.colors],
);
const handleIncrement = useCallback(() => {
mutate({ value });
}, [mutate, value]);
return (
<View style={styles.screen}>
<Text style={styles.title}>Native plugin surface</Text>
<Text style={styles.detail}>{data?.handledBy ?? "The RPC has not run yet."}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Increment plugin counter, currently ${value}`}
onPress={handleIncrement}
style={styles.button}
>
<Text style={styles.buttonText}>
{isPending ? "Calling daemon…" : `RPC counter: ${value}`}
</Text>
</Pressable>
{error ? <Text style={styles.error}>{error.message}</Text> : null}
</View>
);
}
export default function contribute(plugin: PluginContext) {
plugin.handle(incrementRpc, async (input) => ({
value: input.value + 1,
handledBy: "plugin subprocess",
}));
plugin.addSurface("main", ExampleSurface);
plugin.addSidebarItem({
id: "main",
title: "Local plugin",
icon: "Blocks",
surface: "main",
});
}
+3
View File
@@ -0,0 +1,3 @@
{
"id": "local-example"
}
+32
View File
@@ -0,0 +1,32 @@
# Linear
The official Linear extension adds `Attach Linear issue` to the composer. Search by issue identifier
or title, then send a stable issue snapshot to the agent.
Set a personal API key in the daemon environment:
```bash
export LINEAR_API_KEY="lin_api_..."
```
Register the extension in `$PASEO_HOME/config.json`:
```json
{
"plugins": {
"linear": {
"source": "directory",
"path": "/absolute/path/to/paseo/extensions/linear"
}
}
}
```
Restart the development daemon after changing its environment or plugin configuration.
The entry point demonstrates the complete attachment-source pattern:
- define one validated search RPC;
- handle it in the daemon subprocess;
- register a declarative attachment source for the client;
- keep credentials and vendor API calls out of the client bundle.
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod";
import {
defineAttachmentSource,
defineRpc,
type PluginAttachmentSearchPayload,
type PluginContext,
} from "@paseo/plugin";
import { createLinearIssueSearch } from "./linear";
const SearchPayloadSchema = z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
});
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: SearchPayloadSchema,
});
const issueAttachments = defineAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, async ({ query }): Promise<PluginAttachmentSearchPayload> => {
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
return linear.search(query);
});
plugin.addAttachmentSource(issueAttachments);
}
+135
View File
@@ -0,0 +1,135 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, it } from "vitest";
import { createLinearIssueSearch } from "./linear";
const issue = {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
description: "Let extensions attach external context.",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
priorityLabel: "High",
state: { name: "In Progress" },
assignee: { name: "Mohamed" },
project: { name: "Paseo" },
labels: { nodes: [{ name: "Feature" }] },
};
interface CapturedRequest {
authorization: string | undefined;
body: { query: string; variables: Record<string, unknown> };
}
async function readRequest(request: IncomingMessage): Promise<CapturedRequest> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
return {
authorization: request.headers.authorization,
body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as CapturedRequest["body"],
};
}
async function withLinearServer<T>(
respond: (request: CapturedRequest, response: ServerResponse) => void,
run: (endpoint: string) => Promise<T>,
): Promise<T> {
const server = createServer((request, response) => {
void readRequest(request).then((captured) => respond(captured, response));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
try {
return await run(`http://127.0.0.1:${address.port}/graphql`);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
function sendJson(response: ServerResponse, body: unknown): void {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
describe("Linear issue search", () => {
it("resolves an exact issue identifier and formats its agent snapshot", async () => {
const result = await withLinearServer(
(request, response) => {
expect(request.authorization).toBe("lin_api_test");
expect(request.body.variables).toEqual({ id: "ENG-123" });
sendJson(response, { data: { issue } });
},
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search("eng-123");
},
);
expect(result).toEqual({
items: [
{
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In Progress · Mohamed",
url: issue.url,
resourceType: "issue",
text: [
"Linear issue ENG-123: Plugin attachments",
`URL: ${issue.url}`,
"Status: In Progress",
"Priority: High",
"Assignee: Mohamed",
"Project: Paseo",
"Labels: Feature",
"",
"Let extensions attach external context.",
].join("\n"),
},
],
});
});
it("searches issue titles through a case-insensitive filter", async () => {
const result = await withLinearServer(
(request, response) => {
expect(request.body.variables).toEqual({
filter: { title: { containsIgnoreCase: "plugin" } },
});
sendJson(response, { data: { issues: { nodes: [issue] } } });
},
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search(" plugin ");
},
);
expect(result.items.map((item) => item.identifier)).toEqual(["ENG-123"]);
});
it("surfaces GraphQL errors even when HTTP succeeds", async () => {
await expect(
withLinearServer(
(_request, response) => sendJson(response, { errors: [{ message: "Token expired" }] }),
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search("ENG-123");
},
),
).rejects.toThrow("Token expired");
});
it("requires a daemon-side API key", () => {
expect(() => createLinearIssueSearch({ apiKey: "" })).toThrow(
"Set LINEAR_API_KEY in the daemon environment",
);
});
});
+183
View File
@@ -0,0 +1,183 @@
import { z } from "zod";
const LinearIssueSchema = z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
description: z.string().nullable(),
url: z.string(),
priorityLabel: z.string(),
state: z.object({ name: z.string() }),
assignee: z.object({ name: z.string() }).nullable(),
project: z.object({ name: z.string() }).nullable(),
labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }),
});
const LinearGraphqlErrorSchema = z.object({ message: z.string() }).passthrough();
const ExactIssueResponseSchema = z.object({
data: z.object({ issue: LinearIssueSchema.nullable() }).nullable().optional(),
errors: z.array(LinearGraphqlErrorSchema).optional(),
});
const IssueSearchResponseSchema = z.object({
data: z
.object({ issues: z.object({ nodes: z.array(LinearIssueSchema) }) })
.nullable()
.optional(),
errors: z.array(LinearGraphqlErrorSchema).optional(),
});
const ISSUE_FIELDS = `
id
identifier
title
description
url
priorityLabel
state { name }
assignee { name }
project { name }
labels { nodes { name } }
`;
const EXACT_ISSUE_QUERY = `
query PaseoLinearIssue($id: String!) {
issue(id: $id) { ${ISSUE_FIELDS} }
}
`;
const SEARCH_ISSUES_QUERY = `
query PaseoLinearIssues($filter: IssueFilter) {
issues(first: 20, filter: $filter, orderBy: updatedAt) {
nodes { ${ISSUE_FIELDS} }
}
}
`;
const LINEAR_IDENTIFIER = /^[A-Z][A-Z0-9]+-\d+$/i;
export interface LinearAttachmentItem {
id: string;
identifier: string;
title: string;
subtitle?: string;
url: string;
text: string;
resourceType: string;
}
export interface LinearIssueSearch {
search(query: string): Promise<{ items: LinearAttachmentItem[] }>;
}
interface LinearIssueSearchOptions {
apiKey: string;
endpoint?: string;
request?: typeof fetch;
}
interface GraphqlRequest {
query: string;
variables: Record<string, unknown>;
}
class LinearApiError extends Error {
constructor(message: string) {
super(message);
this.name = "LinearApiError";
}
}
function issueSubtitle(issue: z.infer<typeof LinearIssueSchema>): string | undefined {
const parts = [issue.state.name, issue.assignee?.name].filter(
(part): part is string => typeof part === "string" && part.length > 0,
);
return parts.length > 0 ? parts.join(" · ") : undefined;
}
function issueText(issue: z.infer<typeof LinearIssueSchema>): string {
const labels = issue.labels.nodes.map((label) => label.name).join(", ");
const lines = [
`Linear issue ${issue.identifier}: ${issue.title}`,
`URL: ${issue.url}`,
`Status: ${issue.state.name}`,
`Priority: ${issue.priorityLabel}`,
];
if (issue.assignee) lines.push(`Assignee: ${issue.assignee.name}`);
if (issue.project) lines.push(`Project: ${issue.project.name}`);
if (labels) lines.push(`Labels: ${labels}`);
lines.push("", issue.description ?? "No description.");
return lines.join("\n");
}
function toAttachmentItem(issue: z.infer<typeof LinearIssueSchema>): LinearAttachmentItem {
const subtitle = issueSubtitle(issue);
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
...(subtitle ? { subtitle } : {}),
url: issue.url,
text: issueText(issue),
resourceType: "issue",
};
}
function describeHttpFailure(status: number): string {
if (status === 401 || status === 403) return "Linear rejected LINEAR_API_KEY";
if (status === 429) return "Linear rate limit reached. Try again shortly";
return `Linear API request failed with HTTP ${status}`;
}
function throwGraphqlErrors(errors: Array<{ message: string }> | undefined): void {
if (!errors || errors.length === 0) return;
throw new LinearApiError(errors.map((error) => error.message).join("; "));
}
export function createLinearIssueSearch(options: LinearIssueSearchOptions): LinearIssueSearch {
const apiKey = options.apiKey.trim();
if (!apiKey) throw new LinearApiError("Set LINEAR_API_KEY in the daemon environment");
const endpoint = options.endpoint ?? "https://api.linear.app/graphql";
const request = options.request ?? fetch;
async function graphql(body: GraphqlRequest): Promise<unknown> {
const response = await request(endpoint, {
method: "POST",
headers: {
Authorization: apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status));
return response.json();
}
async function findExact(identifier: string): Promise<LinearAttachmentItem[]> {
const response = ExactIssueResponseSchema.parse(
await graphql({ query: EXACT_ISSUE_QUERY, variables: { id: identifier } }),
);
throwGraphqlErrors(response.errors);
return response.data?.issue ? [toAttachmentItem(response.data.issue)] : [];
}
async function searchTitles(query: string): Promise<LinearAttachmentItem[]> {
const filter = query ? { title: { containsIgnoreCase: query } } : null;
const response = IssueSearchResponseSchema.parse(
await graphql({ query: SEARCH_ISSUES_QUERY, variables: { filter } }),
);
throwGraphqlErrors(response.errors);
if (!response.data) throw new LinearApiError("Linear returned no issue data");
return response.data.issues.nodes.map(toAttachmentItem);
}
return {
async search(query: string) {
const normalized = query.trim();
if (LINEAR_IDENTIFIER.test(normalized)) {
return { items: await findExact(normalized.toUpperCase()) };
}
const items = await searchTitles(normalized);
return { items };
},
};
}
+3
View File
@@ -0,0 +1,3 @@
{
"id": "linear"
}
+1 -1
View File
@@ -1 +1 @@
sha256-RCp5Ogd8AETLmJCZaUebcgSPRk+is26nkUY7+blDb/g=
sha256-BfRbfVUFaHQEMtDOsWAo49txz+cueRpe/7Qxltg+HSg=
+531
View File
@@ -2291,6 +2291,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/standalone": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.1.tgz",
"integrity": "sha512-z42abD0C6fiHfgLyCWw8PYv6FCJ0IGVtSCxXk/NPykWO5LNIEGfdLDJ3HdYqlPcAhwtQ3oKH1PvNj2JGpTxQKg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -12503,6 +12512,21 @@
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__standalone": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/@types/babel__standalone/-/babel__standalone-7.1.9.tgz",
"integrity": "sha512-IcCNPLqpevUD7UpV8QB0uwQPOyoOKACFf0YtYWRHcmxcakaje4Q7dbG2+jMqxw/I8Zk0NHvEps66WwS7z/UaaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.6",
"@babel/types": "^7.25.6",
"@types/babel__core": "^7.20.5",
"@types/babel__generator": "^7.6.8",
"@types/babel__template": "^7.4.4",
"@types/babel__traverse": "^7.20.6"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
@@ -35301,6 +35325,7 @@
"name": "@getpaseo/app",
"version": "0.3.0",
"dependencies": {
"@babel/standalone": "^7.29.1",
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
"@codemirror/search": "6.7.1",
@@ -35393,6 +35418,7 @@
"@playwright/test": "^1.56.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/babel__standalone": "^7.1.9",
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
@@ -38226,6 +38252,7 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.3.0",
"@getpaseo/highlight": "0.3.0",
"@getpaseo/protocol": "0.3.0",
@@ -38239,6 +38266,7 @@
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
@@ -38402,6 +38430,468 @@
"win32"
]
},
"packages/server/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"packages/server/node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
@@ -38556,6 +39046,47 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"packages/server/node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+2
View File
@@ -32,6 +32,7 @@
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
},
"dependencies": {
"@babel/standalone": "^7.29.1",
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
"@codemirror/search": "6.7.1",
@@ -124,6 +125,7 @@
"@playwright/test": "^1.56.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/babel__standalone": "^7.1.9",
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
+3 -1
View File
@@ -124,6 +124,7 @@ import {
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins/catalog-sync";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
@@ -266,7 +267,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
return (
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
{null}
<PluginCatalogSync serverId={daemon.serverId} client={client} />
</SessionProvider>
);
}
@@ -892,6 +893,7 @@ function RootStack() {
return (
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
<Stack.Screen name="index" />
<Stack.Screen name="runtime-component" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings/index" />
@@ -41,6 +41,7 @@ function KnownHostRoute() {
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
</ThemedStack>
);
@@ -0,0 +1,5 @@
import { PluginSurfaceScreen } from "@/plugins/surface-screen";
export default function PluginSurfaceRoute() {
return <PluginSurfaceScreen />;
}
@@ -0,0 +1,5 @@
import { RuntimeComponentPrototypeScreen } from "@/runtime-component/prototype-screen";
export default function RuntimeComponentRoute() {
return <RuntimeComponentPrototypeScreen />;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getAgentAttachmentPillContent } from "./attachment-pill-content";
describe("agent attachment pill content", () => {
it("presents external resources with their provider identity", () => {
const content = getAgentAttachmentPillContent(
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
i18n.t,
);
expect(content.title).toBe("Plugin attachments");
expect(content.subtitle).toBe("Linear issue ENG-123");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import React from "react";
import {
CircleDot,
FileText,
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
subtitle: `Issue #${attachment.number}`,
};
case "text":
if (attachment.externalResource) {
return {
icon: attachmentGithubIssueIcon,
title: attachment.externalResource.title,
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
};
}
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
+2
View File
@@ -3,6 +3,7 @@ import type {
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
import type { PluginResourceComposerAttachment } from "@/plugins/attachments/model";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| PluginResourceComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -74,6 +74,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { PluginSidebarItems } from "@/plugins/sidebar-items";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -678,6 +679,7 @@ function MobileSidebar({
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
</View>
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable
@@ -883,6 +885,7 @@ function DesktopSidebar({
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems />
</View>
</View>
+29
View File
@@ -863,6 +863,35 @@ describe("openComposerAttachment", () => {
});
expect(externalUrlCalls).toEqual([issueItem.url]);
});
it("opens plugin resource URLs through the external url opener", () => {
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: {
kind: "plugin_resource",
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
item: {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
},
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]);
});
});
describe("toggleGithubAttachment", () => {
@@ -10,6 +10,7 @@ import {
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments/model";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "plugin_resource") {
agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
+34 -1
View File
@@ -125,6 +125,8 @@ import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { readClipboardImage } from "./clipboard-image";
import { usePluginAttachmentPicker } from "@/plugins/attachments/picker";
import { PluginResourceAttachmentPill } from "@/plugins/attachments/pill";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
@@ -440,6 +442,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
onRemove,
});
}
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -1158,6 +1174,14 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const pluginAttachments = usePluginAttachmentPicker({
serverId,
client,
connected: isConnected,
attachments,
onChangeAttachments: setSelectedAttachments,
anchorRef: attachButtonRef,
});
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
@@ -1922,6 +1946,7 @@ export function Composer({
setIsGithubPickerOpen(true);
},
},
...pluginAttachments.menuItems,
{
id: "file",
label: t("composer.attachments.addFile"),
@@ -1932,7 +1957,14 @@ export function Composer({
},
);
return items;
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
}, [
forgePresentation,
handlePasteImage,
handlePickFile,
handlePickImage,
pluginAttachments.menuItems,
t,
]);
const handleToggleGithubItem = useCallback(
(item: ForgeSearchItem) => {
@@ -2189,6 +2221,7 @@ export function Composer({
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
{pluginAttachments.picker}
</View>
</View>
</View>
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
createPluginResourceAttachment,
PluginResourceComposerAttachmentSchema,
pluginResourceAttachmentToAgentAttachment,
togglePluginResourceAttachment,
} from "./model";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
const source = {
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
};
const item = {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In progress · Mohamed",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments\nStatus: In progress",
resourceType: "issue",
};
describe("plugin resource attachments", () => {
it("creates a draft-safe composer attachment", () => {
const attachment = createPluginResourceAttachment(source, item);
expect(PluginResourceComposerAttachmentSchema.parse(attachment)).toEqual({
kind: "plugin_resource",
...source,
item,
});
});
it("toggles one resource by plugin, source, and stable item id", () => {
const attachment = createPluginResourceAttachment(source, item);
expect(togglePluginResourceAttachment([], attachment)).toEqual([attachment]);
expect(togglePluginResourceAttachment([attachment], attachment)).toEqual([]);
});
it("submits through the backward-compatible text attachment", () => {
const attachment = createPluginResourceAttachment(source, item);
const agentAttachment = pluginResourceAttachmentToAgentAttachment(attachment);
expect(agentAttachment).toEqual({
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: item.text,
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: item.url,
},
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [agentAttachment],
});
});
});
@@ -0,0 +1,70 @@
import { z } from "zod";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { UserComposerAttachment } from "@/attachments/types";
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "./schema";
export const PluginResourceComposerAttachmentSchema = z.object({
kind: z.literal("plugin_resource"),
pluginId: z.string().min(1),
sourceId: z.string().min(1),
sourceTitle: z.string().min(1),
sourceIcon: z.string().min(1),
item: PluginAttachmentItemSchema,
});
export type PluginResourceComposerAttachment = z.infer<
typeof PluginResourceComposerAttachmentSchema
>;
export interface PluginResourceSourceIdentity {
pluginId: string;
sourceId: string;
sourceTitle: string;
sourceIcon: string;
}
export function createPluginResourceAttachment(
source: PluginResourceSourceIdentity,
item: PluginAttachmentItem,
): PluginResourceComposerAttachment {
return {
kind: "plugin_resource",
...source,
item,
};
}
export function togglePluginResourceAttachment(
current: UserComposerAttachment[],
attachment: PluginResourceComposerAttachment,
): UserComposerAttachment[] {
const matches = (candidate: UserComposerAttachment) =>
candidate.kind === "plugin_resource" &&
candidate.pluginId === attachment.pluginId &&
candidate.sourceId === attachment.sourceId &&
candidate.item.id === attachment.item.id;
if (current.some(matches)) {
return current.filter((candidate) => !matches(candidate));
}
return [...current, attachment];
}
export function pluginResourceAttachmentToAgentAttachment(
attachment: PluginResourceComposerAttachment,
): AgentAttachment {
return {
type: "text",
mimeType: "text/plain",
title: `${attachment.item.identifier} ${attachment.item.title}`,
text: attachment.item.text,
externalResource: {
provider: attachment.pluginId,
providerLabel: attachment.sourceTitle,
resourceType: attachment.item.resourceType,
id: attachment.item.id,
identifier: attachment.item.identifier,
title: attachment.item.title,
url: attachment.item.url,
},
};
}
@@ -0,0 +1,176 @@
import { useCallback, useMemo, useState, type ReactElement, type RefObject } from "react";
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { LucideIcon } from "lucide-react-native";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useFetchQuery } from "@/data/query";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import { useInstalledPlugins } from "../registry";
import { searchPluginAttachments } from "../sdk";
import type { InstalledPlugin, PluginAttachmentSourceContribution } from "../types";
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
import type { PluginAttachmentItem } from "./schema";
const SEARCH_STALE_TIME_MS = 30_000;
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
}
const ThemedSourceIcon = withUnistyles(SourceIcon);
interface InstalledAttachmentSource {
plugin: InstalledPlugin;
source: PluginAttachmentSourceContribution;
key: string;
}
interface PluginAttachmentPickerInput {
serverId: string;
client: DaemonClient | null;
connected: boolean;
attachments: UserComposerAttachment[];
onChangeAttachments: (attachments: UserComposerAttachment[]) => void;
anchorRef: RefObject<View | null>;
}
interface PluginAttachmentPickerBinding {
menuItems: AttachmentMenuItem[];
picker: ReactElement | null;
}
function searchEmptyText(error: unknown, isFetching: boolean): string {
if (error instanceof Error) return error.message;
if (error) return String(error);
return isFetching ? "Searching..." : "No results";
}
function installedAttachmentSources(
plugins: InstalledPlugin[],
serverId: string,
): InstalledAttachmentSource[] {
return plugins
.filter((plugin) => plugin.serverId === serverId)
.flatMap((plugin) =>
plugin.attachmentSources.map((source) => ({
plugin,
source,
key: `${plugin.id}/${source.id}`,
})),
);
}
function attachmentOptions(items: PluginAttachmentItem[]): ComboboxOption[] {
return items.map((item) => ({
id: item.id,
label: `${item.identifier} ${item.title}`,
description: item.subtitle,
}));
}
export function usePluginAttachmentPicker(
input: PluginAttachmentPickerInput,
): PluginAttachmentPickerBinding {
const plugins = useInstalledPlugins();
const sources = useMemo(
() => installedAttachmentSources(plugins, input.serverId),
[input.serverId, plugins],
);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const active = sources.find((candidate) => candidate.key === activeKey) ?? null;
const trimmedQuery = query.trim();
const search = useFetchQuery({
queryKey: [
"plugin-attachment-search",
input.serverId,
active?.plugin.id ?? "",
active?.source.id ?? "",
trimmedQuery,
],
queryFn: async () => {
if (!input.client || !active) throw new Error("Plugin host is offline");
const client = input.client;
return searchPluginAttachments(
active.source,
(method, rpcInput) => client.invokePluginRpc(active.plugin.id, method, rpcInput),
trimmedQuery,
);
},
enabled: input.connected && active !== null,
dataShape: "list",
staleTimeMs: SEARCH_STALE_TIME_MS,
});
const items = search.error
? EMPTY_ATTACHMENT_ITEMS
: (search.data?.items ?? EMPTY_ATTACHMENT_ITEMS);
const options = useMemo(() => attachmentOptions(items), [items]);
const close = useCallback(() => {
setActiveKey(null);
setQuery("");
}, []);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) close();
},
[close],
);
const handleSelect = useCallback(
(itemId: string) => {
if (!active) return;
const item = items.find((candidate) => candidate.id === itemId);
if (!item) return;
const attachment = createPluginResourceAttachment(
{
pluginId: active.plugin.id,
sourceId: active.source.id,
sourceTitle: active.source.title,
sourceIcon: active.source.icon,
},
item,
);
input.onChangeAttachments(togglePluginResourceAttachment(input.attachments, attachment));
close();
},
[active, close, input, items],
);
const menuItems = useMemo(
() =>
sources.map(({ key, source }) => {
const Icon = resolvePluginIcon(source.icon);
return {
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
};
}),
[sources],
);
if (!active) return { menuItems, picker: null };
return {
menuItems,
picker: (
<Combobox
options={options}
value=""
onSelect={handleSelect}
searchable
searchPlaceholder={active.source.searchPlaceholder}
title={active.source.pickerTitle}
open
onOpenChange={handleOpenChange}
onSearchQueryChange={setQuery}
desktopPlacement="top-start"
anchorRef={input.anchorRef}
emptyText={searchEmptyText(search.error, search.isFetching)}
/>
),
};
}
@@ -0,0 +1,65 @@
import { useCallback, useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import type { ComposerAttachment } from "@/attachments/types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import type { PluginResourceComposerAttachment } from "./model";
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ResourceIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={ICON_SIZE.sm} color={color} />;
}
const ThemedResourceIcon = withUnistyles(ResourceIcon);
interface PluginResourceAttachmentPillProps {
attachment: PluginResourceComposerAttachment;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, identifier: string) => string;
removeLabel: (kind: string, identifier: string) => string;
}
export function PluginResourceAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: PluginResourceAttachmentPillProps) {
const Icon = resolvePluginIcon(attachment.sourceIcon);
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
const icon = useMemo(
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
[Icon],
);
return (
<AttachmentPill
testID="composer-plugin-resource-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel(attachment.sourceTitle, attachment.item.identifier)}
removeAccessibilityLabel={removeLabel(attachment.sourceTitle, attachment.item.identifier)}
disabled={disabled}
>
<AttachmentLabel
icon={icon}
title={attachment.item.title}
subtitle={`${attachment.sourceTitle} ${attachment.item.identifier}`}
/>
</AttachmentPill>
);
}
@@ -0,0 +1,18 @@
import { z } from "zod";
export const PluginAttachmentItemSchema = z.object({
id: z.string().min(1),
identifier: z.string().min(1),
title: z.string().min(1),
subtitle: z.string().min(1).optional(),
url: z.string().url(),
text: z.string().min(1),
resourceType: z.string().min(1),
});
export const PluginAttachmentSearchPayloadSchema = z.object({
items: z.array(PluginAttachmentItemSchema),
});
export type PluginAttachmentItem = z.infer<typeof PluginAttachmentItemSchema>;
export type PluginAttachmentSearchPayload = z.infer<typeof PluginAttachmentSearchPayloadSchema>;
+43
View File
@@ -0,0 +1,43 @@
import { useEffect } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { pluginRegistry } from "./registry";
export function PluginCatalogSync({
serverId,
client,
}: {
serverId: string;
client: DaemonClient;
}) {
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "plugins");
useEffect(() => {
let cancelled = false;
if (!supported) {
pluginRegistry.removeHost(serverId);
return;
}
if (!connected) return;
void client
.getPluginCatalog()
.then((catalog) => {
if (!cancelled) pluginRegistry.installCatalog(serverId, catalog);
return undefined;
})
.catch((error) => {
if (!cancelled) {
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
}
return undefined;
});
return () => {
cancelled = true;
};
}, [client, connected, serverId, supported]);
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
return null;
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { evaluatePluginClientBundle } from "./evaluate";
function bundle(body: string): string {
return `(function() {
const module = { exports: {} };
module.exports.default = function(plugin) { ${body} };
return module.exports;
})`;
}
describe("evaluatePluginClientBundle", () => {
it("collects a surface and its sidebar placement", () => {
const plugin = evaluatePluginClientBundle(
"example",
bundle(`
function Surface() { return null; }
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
`),
);
expect(plugin.id).toBe("example");
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
expect(plugin.sidebarItems).toEqual([
{ id: "main", title: "Example", icon: "Blocks", surface: "main" },
]);
});
it("collects a declarative attachment source", () => {
const plugin = evaluatePluginClientBundle(
"linear",
bundle(`
plugin.addAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search by identifier or title",
search: { name: "issues.search", input: {}, output: {} },
});
`),
);
expect(plugin.attachmentSources).toEqual([
{
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search by identifier or title",
search: { name: "issues.search", input: {}, output: {} },
},
]);
});
it("rejects duplicate attachment source ids", () => {
expect(() =>
evaluatePluginClientBundle(
"linear",
bundle(`
const source = {
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search",
search: { name: "issues.search", input: {}, output: {} },
};
plugin.addAttachmentSource(source);
plugin.addAttachmentSource(source);
`),
),
).toThrow("Duplicate attachment source: issues");
});
it("rejects a sidebar placement whose surface does not exist", () => {
expect(() =>
evaluatePluginClientBundle(
"example",
bundle(`
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "missing" });
`),
),
).toThrow("references missing surface missing");
});
it("rejects a bundle without a default contribution function", () => {
expect(() => evaluatePluginClientBundle("example", `(function() { return {}; })`)).toThrow(
"must default export a function",
);
});
});
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react";
import * as ReactJsxRuntime from "react/jsx-runtime";
import * as ReactNative from "react-native";
// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers.
import * as ReactQuery from "@tanstack/react-query";
import * as Zod from "zod";
import {
createPluginContext,
defineAttachmentSource,
defineRpc,
type PluginRegistrationCollector,
useRpc,
} from "./sdk";
import type {
EvaluatedPlugin,
PluginAttachmentSourceContribution,
PluginSidebarContribution,
PluginSurfaceProps,
} from "./types";
import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
function requireId(value: string, label: string): string {
const id = value.trim();
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`);
return id;
}
export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin {
const collector: PluginRegistrationCollector = {
surfaces: [],
sidebarItems: [],
attachmentSources: [],
};
const surfaceIds = new Set<string>();
const sidebarItemIds = new Set<string>();
const attachmentSourceIds = new Set<string>();
const pluginContext = createPluginContext({
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
const normalizedId = requireId(surfaceId, "surface id");
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
if (typeof Component !== "function")
throw new Error(`Surface ${normalizedId} is not a component`);
surfaceIds.add(normalizedId);
collector.surfaces.push({ id: normalizedId, Component });
},
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
if (sidebarItemIds.has(normalizedId))
throw new Error(`Duplicate sidebar item: ${normalizedId}`);
if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`);
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
sidebarItemIds.add(normalizedId);
collector.sidebarItems.push({
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
surface: requireId(contribution.surface, "sidebar surface id"),
});
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
if (attachmentSourceIds.has(normalizedId)) {
throw new Error(`Duplicate attachment source: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
const pickerTitle = contribution.pickerTitle.trim();
const searchPlaceholder = contribution.searchPlaceholder.trim();
const method = contribution.search.name.trim();
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
if (!searchPlaceholder) {
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
}
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
collector.attachmentSources.push({
id: normalizedId,
title,
icon,
pickerTitle,
searchPlaceholder,
search: { ...contribution.search, name: method },
});
},
});
const runtimeRequire = (name: string): unknown => {
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
if (name === "react-native") return ReactNative;
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc, useRpc };
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`);
};
const evaluate: (source: string) => unknown = globalThis.eval;
const factory = evaluate(bundle);
if (typeof factory !== "function")
throw new Error(`Plugin ${id} client bundle is not executable`);
const exports = factory(runtimeRequire);
const setup =
exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined;
if (typeof setup !== "function") {
throw new Error(`Plugin ${id} must default export a function`);
}
setup(pluginContext);
for (const item of collector.sidebarItems) {
if (!surfaceIds.has(item.surface)) {
throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`);
}
}
return {
id,
surfaces: collector.surfaces,
sidebarItems: collector.sidebarItems,
attachmentSources: collector.attachmentSources,
};
}
+11
View File
@@ -0,0 +1,11 @@
import * as LucideIcons from "lucide-react-native";
import type { LucideIcon } from "lucide-react-native";
export function resolvePluginIcon(name: string): LucideIcon {
const candidate = Reflect.get(LucideIcons, name);
const isComponent =
typeof candidate === "function" ||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
if (!isComponent) throw new Error(`Unknown Lucide icon: ${name}`);
return candidate as LucideIcon;
}
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import { pluginRegistry } from "./registry";
function bundle(marker: string): string {
return `(function() {
const module = { exports: {} };
module.exports.default = function(plugin) {
function Surface() { return ${JSON.stringify(marker)}; }
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
};
return module.exports;
})`;
}
afterEach(() => {
pluginRegistry.removeHost("host-a");
pluginRegistry.removeHost("host-b");
});
describe("PluginRegistry", () => {
it("preserves a plugin query cache when the same bundle reconnects", () => {
const clientBundle = bundle("one");
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
const first = pluginRegistry.getSnapshot()[0];
first?.queryClient.setQueryData(["counter"], 3);
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
const second = pluginRegistry.getSnapshot()[0];
expect(second?.queryClient).toBe(first?.queryClient);
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
});
it("replaces the query cache when the plugin bundle changes", () => {
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
const first = pluginRegistry.getSnapshot()[0];
first?.queryClient.setQueryData(["counter"], 3);
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("two") }]);
const second = pluginRegistry.getSnapshot()[0];
expect(second?.queryClient).not.toBe(first?.queryClient);
expect(second?.queryClient.getQueryData(["counter"])).toBeUndefined();
expect(first?.queryClient.getQueryData(["counter"])).toBeUndefined();
});
it("isolates query caches between hosts", () => {
const clientBundle = bundle("same");
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
pluginRegistry.installCatalog("host-b", [{ id: "example", clientBundle }]);
const [hostA, hostB] = pluginRegistry.getSnapshot();
hostA?.queryClient.setQueryData(["counter"], 3);
expect(hostB?.queryClient.getQueryData(["counter"])).toBeUndefined();
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
import { evaluatePluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
interface CatalogPlugin {
id: string;
clientBundle: string;
}
class PluginRegistry {
private readonly byHost = new Map<string, InstalledPlugin[]>();
private readonly listeners = new Set<() => void>();
private snapshot: InstalledPlugin[] = [];
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): InstalledPlugin[] => this.snapshot;
installCatalog(serverId: string, catalog: CatalogPlugin[]): void {
const previous = this.byHost.get(serverId) ?? [];
const installed = catalog.flatMap((entry) => {
try {
const existing = previous.find(
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
);
const queryClient = existing?.queryClient ?? new QueryClient();
return [
{
...evaluatePluginClientBundle(entry.id, entry.clientBundle),
serverId,
clientBundle: entry.clientBundle,
queryClient,
},
];
} catch (error) {
console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error);
return [];
}
});
for (const plugin of previous) {
if (!installed.some((candidate) => candidate.queryClient === plugin.queryClient)) {
plugin.queryClient.clear();
}
}
this.byHost.set(serverId, installed);
this.publish();
}
removeHost(serverId: string): void {
const installed = this.byHost.get(serverId);
if (!installed) return;
for (const plugin of installed) plugin.queryClient.clear();
this.byHost.delete(serverId);
this.publish();
}
private publish(): void {
this.snapshot = [...this.byHost.values()]
.flat()
.sort((left, right) =>
`${left.serverId}/${left.id}`.localeCompare(`${right.serverId}/${right.id}`),
);
for (const listener of this.listeners) listener();
}
}
export const pluginRegistry = new PluginRegistry();
export function useInstalledPlugins(): InstalledPlugin[] {
return useSyncExternalStore(
pluginRegistry.subscribe,
pluginRegistry.getSnapshot,
pluginRegistry.getSnapshot,
);
}
export function useInstalledPlugin(serverId: string, pluginId: string): InstalledPlugin | null {
return (
useInstalledPlugins().find(
(plugin) => plugin.serverId === serverId && plugin.id === pluginId,
) ?? null
);
}
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
return useInstalledPlugins().filter((plugin) => plugin.id === pluginId);
}
+17
View File
@@ -0,0 +1,17 @@
export function buildPluginSurfaceRoute(
serverId: string,
pluginId: string,
contributionId: string,
): `/h/${string}/plugin/${string}/${string}` {
return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`;
}
export function hostIdFromPathname(pathname: string): string | null {
const encoded = /^\/h\/([^/]+)/.exec(pathname)?.[1];
if (!encoded) return null;
try {
return decodeURIComponent(encoded);
} catch {
return null;
}
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { callPluginRpc, defineAttachmentSource, defineRpc, searchPluginAttachments } from "./sdk";
const contract = defineRpc({
name: "increment",
input: z.unknown().pipe(z.object({ value: z.number() })),
output: z.object({ value: z.number() }),
});
describe("plugin RPC contracts", () => {
it("validates input before invoking the daemon", async () => {
let invocations = 0;
const invoke = async () => {
invocations += 1;
return { value: 2 };
};
await expect(callPluginRpc(contract, invoke, { value: "wrong" })).rejects.toBeInstanceOf(
z.ZodError,
);
expect(invocations).toBe(0);
});
it("validates output before resolving", async () => {
const invoke = async () => ({ value: "wrong" });
await expect(callPluginRpc(contract, invoke, { value: 1 })).rejects.toBeInstanceOf(z.ZodError);
});
it("returns parsed output for a valid invocation", async () => {
const invoke = async (method: string, input: unknown) => ({
value: method === "increment" && typeof input === "object" ? 2 : 0,
});
await expect(callPluginRpc(contract, invoke, { value: 1 })).resolves.toEqual({ value: 2 });
});
});
describe("plugin attachment sources", () => {
const search = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
it("searches through the declared RPC and validates the standard result", async () => {
const source = defineAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search",
search,
});
const payload = await searchPluginAttachments(
source,
async (method, input) => ({
items: [
{
id: "issue-1",
identifier: `${method}:${String(Reflect.get(input as object, "query"))}`,
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
],
}),
"ENG-123",
);
expect(payload.items[0]).toEqual({
id: "issue-1",
identifier: "issues.search:ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
});
});
it("rejects a malformed standard search result", async () => {
const source = defineAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search",
search,
});
await expect(
searchPluginAttachments(source, async () => ({ items: [{ id: "issue-1" }] }), "issue"),
).rejects.toBeInstanceOf(z.ZodError);
});
});
+138
View File
@@ -0,0 +1,138 @@
import {
createContext,
useCallback,
useContext,
useMemo,
type ComponentType,
type ReactNode,
} from "react";
import type { ZodType, output as ZodOutput, input as ZodInput } from "zod";
import {
PluginAttachmentSearchPayloadSchema,
type PluginAttachmentSearchPayload,
} from "./attachments/schema";
import type {
PluginAttachmentSourceContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
} from "./types";
interface PluginCollector {
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
interface PluginRpcContextValue {
invoke(method: string, input: unknown): Promise<unknown>;
}
export interface PluginRpcContract<
InputSchema extends ZodType = ZodType,
OutputSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
output: OutputSchema;
}
export interface PluginContext {
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
input: ZodOutput<InputSchema>,
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
interface RpcDefinition<InputSchema extends ZodType, OutputSchema extends ZodType> {
name: string;
input: InputSchema;
output: OutputSchema;
}
const RPC_NAME = /^[a-z][a-z0-9._-]*$/;
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
definition: RpcDefinition<InputSchema, OutputSchema>,
): PluginRpcContract<InputSchema, OutputSchema> {
const name = definition.name.trim();
if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin RPC method: ${definition.name}`);
return { ...definition, name };
}
export type { PluginAttachmentItem, PluginAttachmentSearchPayload } from "./attachments/schema";
export function defineAttachmentSource(
definition: PluginAttachmentSourceContribution,
): PluginAttachmentSourceContribution {
return definition;
}
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
export function PluginRpcProvider({
children,
invoke,
}: {
children: ReactNode;
invoke(method: string, input: unknown): Promise<unknown>;
}) {
const value = useMemo(() => ({ invoke }), [invoke]);
return <PluginRpcContext.Provider value={value}>{children}</PluginRpcContext.Provider>;
}
export function createPluginContext(collector: PluginCollector) {
return {
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>) {
collector.addSurface(id, Component);
},
addSidebarItem(contribution: PluginSidebarContribution) {
collector.addSidebarItem(contribution);
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
collector.addAttachmentSource(contribution);
},
};
}
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution,
invoke: (method: string, input: unknown) => Promise<unknown>,
query: string,
): Promise<PluginAttachmentSearchPayload> {
const output = await callPluginRpc(source.search, invoke, { query });
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
}
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
const context = useContext(PluginRpcContext);
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
return useCallback(
(input: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
[context, contract],
);
}
export async function callPluginRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
invoke: (method: string, input: unknown) => Promise<unknown>,
input: 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 PluginRegistrationCollector {
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
@@ -0,0 +1,44 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { InstalledPlugin } from "./types";
import { groupPluginSidebarContributions } from "./sidebar-groups";
function installed(serverId: string, contributionId = "main"): InstalledPlugin {
return {
id: "example",
serverId,
clientBundle: serverId,
queryClient: new QueryClient(),
surfaces: [{ id: "surface", Component: () => null }],
sidebarItems: [
{
id: contributionId,
title: "Example",
icon: "Blocks",
surface: "surface",
},
],
attachmentSources: [],
};
}
describe("groupPluginSidebarContributions", () => {
it("coalesces the same plugin contribution across hosts", () => {
const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]);
expect(groups).toHaveLength(1);
expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([
"host-a",
"host-b",
]);
});
it("keeps different contribution ids separate", () => {
const groups = groupPluginSidebarContributions([
installed("host-a", "main"),
installed("host-b", "settings"),
]);
expect(groups.map((group) => group.key)).toEqual(["example/main", "example/settings"]);
});
});
@@ -0,0 +1,48 @@
import type { InstalledPlugin, PluginSidebarContribution } from "./types";
const preferredHosts = new Map<string, string>();
export interface PluginSidebarTarget {
plugin: InstalledPlugin;
item: PluginSidebarContribution;
}
export interface PluginSidebarGroup {
key: string;
pluginId: string;
contributionId: string;
title: string;
icon: string;
targets: PluginSidebarTarget[];
}
export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] {
const groups = new Map<string, PluginSidebarGroup>();
for (const plugin of plugins) {
for (const item of plugin.sidebarItems) {
const key = `${plugin.id}/${item.id}`;
const existing = groups.get(key);
if (existing) {
existing.targets.push({ plugin, item });
} else {
groups.set(key, {
key,
pluginId: plugin.id,
contributionId: item.id,
title: item.title,
icon: item.icon,
targets: [{ plugin, item }],
});
}
}
}
return [...groups.values()];
}
export function getPreferredPluginContributionHost(groupKey: string): string | null {
return preferredHosts.get(groupKey) ?? null;
}
export function rememberPluginContributionHost(groupKey: string, serverId: string): void {
preferredHosts.set(groupKey, serverId);
}
@@ -0,0 +1,80 @@
import { router, usePathname } from "expo-router";
import { useCallback, useMemo } from "react";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { resolvePluginIcon } from "./icons";
import { useInstalledPlugins } from "./registry";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
import {
groupPluginSidebarContributions,
getPreferredPluginContributionHost,
rememberPluginContributionHost,
type PluginSidebarGroup,
type PluginSidebarTarget,
} from "./sidebar-groups";
function selectTarget(
group: PluginSidebarGroup,
currentHostId: string | null,
): PluginSidebarTarget {
const current = group.targets.find((target) => target.plugin.serverId === currentHostId);
if (current) return current;
const rememberedHostId = getPreferredPluginContributionHost(group.key);
const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId);
return remembered ?? group.targets[0];
}
export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const plugins = useInstalledPlugins();
const pathname = usePathname();
const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
const currentHostId = hostIdFromPathname(pathname);
return groups.map((group) => (
<PluginSidebarItemRow
key={group.key}
group={group}
currentHostId={currentHostId}
pathname={pathname}
onBeforeNavigate={onBeforeNavigate}
/>
));
}
function PluginSidebarItemRow({
group,
currentHostId,
pathname,
onBeforeNavigate,
}: {
group: PluginSidebarGroup;
currentHostId: string | null;
pathname: string;
onBeforeNavigate?: () => void;
}) {
const target = selectTarget(group, currentHostId);
const route = buildPluginSurfaceRoute(
target.plugin.serverId,
group.pluginId,
group.contributionId,
);
const isActive = group.targets.some(
(candidate) =>
pathname ===
buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId),
);
const navigate = useCallback(() => {
rememberPluginContributionHost(group.key, target.plugin.serverId);
onBeforeNavigate?.();
router.push(route);
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
return (
<SidebarHeaderRow
icon={resolvePluginIcon(group.icon)}
label={group.title}
onPress={navigate}
isActive={isActive}
testID={`plugin-sidebar-${group.pluginId}-${group.contributionId}`}
variant="compact"
/>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { router, useLocalSearchParams } from "expo-router";
import { QueryClientProvider } from "@tanstack/react-query";
import { ChevronDown, X } from "lucide-react-native";
import {
Component,
useCallback,
useMemo,
useRef,
useState,
type ComponentType,
type ErrorInfo,
type ReactNode,
} from "react";
import { Platform, Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostPicker } from "@/components/hosts/host-picker";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { resolvePluginIcon } from "./icons";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute } from "./routes";
import { rememberPluginContributionHost } from "./sidebar-groups";
import { PluginRpcProvider } from "./sdk";
import type { PluginSurfaceProps } from "./types";
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
const EMPTY_THEME_DTO: Record<string, unknown> = {};
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const pluginThemeMapping = (theme: Theme) => ({
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
});
const ThemedX = withUnistyles(X);
const ThemedChevronDown = withUnistyles(ChevronDown);
function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
}
function PluginHeaderIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={16} color={color} />;
}
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
function SurfaceRenderer({
Surface,
invoke,
plugin,
layout,
host,
themeDto = EMPTY_THEME_DTO,
}: {
Surface: ComponentType<PluginSurfaceProps>;
invoke(method: string, input: unknown): Promise<unknown>;
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
layout: PluginSurfaceProps["layout"];
host: PluginSurfaceProps["host"];
themeDto?: Record<string, unknown>;
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PluginRpcProvider invoke={invoke}>
<Surface theme={themeDto} host={host} layout={layout} />
</PluginRpcProvider>
</QueryClientProvider>
);
}
const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer);
interface SurfaceErrorBoundaryState {
error: string | null;
}
class SurfaceErrorBoundary extends Component<{ children: ReactNode }, SurfaceErrorBoundaryState> {
state: SurfaceErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: unknown): SurfaceErrorBoundaryState {
return { error: error instanceof Error ? error.message : String(error) };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.warn("[Plugins] Surface render failed", error, info.componentStack);
}
render() {
if (this.state.error) {
return <Text style={styles.errorText}>Plugin failed: {this.state.error}</Text>;
}
return this.props.children;
}
}
function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] {
if (Platform.OS === "ios") return "ios";
if (Platform.OS === "android") return "android";
return "web";
}
function PluginHostSwitcher({
serverId,
pluginId,
contributionId,
serverIds,
}: {
serverId: string;
pluginId: string;
contributionId: string;
serverIds: string[];
}) {
const allHosts = useHosts();
const hosts = useMemo(
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
[allHosts, serverIds],
);
const [open, setOpen] = useState(false);
const anchorRef = useRef<View | null>(null);
const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const selectHost = useCallback(
(nextServerId: string) => {
rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId);
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId));
},
[contributionId, pluginId],
);
const openPicker = useCallback(() => setOpen(true), []);
const show = serverIds.length > 1 && hosts.length > 1;
if (!show) return null;
return (
<HostPicker
hosts={hosts}
value={serverId}
onSelect={selectHost}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
title="Choose plugin host"
desktopPlacement="bottom-start"
>
<View ref={anchorRef} collapsable={false}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Plugin host: ${selectedLabel}`}
testID="plugin-host-switcher"
onPress={openPicker}
style={styles.hostSwitcher}
>
<Text numberOfLines={1} style={styles.hostSwitcherText}>
{selectedLabel}
</Text>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</Pressable>
</View>
</HostPicker>
);
}
export function PluginSurfaceScreen() {
const params = useLocalSearchParams<{
serverId?: string | string[];
pluginId?: string | string[];
surfaceId?: string | string[];
}>();
const serverId = routeParam(params.serverId);
const pluginId = routeParam(params.pluginId);
const contributionId = routeParam(params.surfaceId);
const plugin = useInstalledPlugin(serverId, pluginId);
const installations = usePluginInstallations(pluginId);
const hosts = useHosts();
const client = useHostRuntimeClient(serverId);
const compact = useIsCompactFormFactor();
const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null;
const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null;
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const contributionServerIds = useMemo(
() =>
installations
.filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId))
.map((entry) => entry.serverId),
[contributionId, installations],
);
const title = sidebarItem?.title ?? (pluginId || "Plugin");
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
const invoke = useCallback(
async (method: string, input: unknown) => {
if (!client) throw new Error("Plugin host is offline");
return client.invokePluginRpc(pluginId, method, input);
},
[client, pluginId],
);
const close = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace(`/h/${encodeURIComponent(serverId)}`);
}, [serverId]);
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
const headerLeft = useMemo(
() => (
<>
{Icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
</HeaderIconBadge>
) : null}
<ScreenTitle>{title}</ScreenTitle>
</>
),
[Icon, title],
);
const headerRight = useMemo(
() => (
<>
<PluginHostSwitcher
serverId={serverId}
pluginId={pluginId}
contributionId={contributionId}
serverIds={contributionServerIds}
/>
<HeaderToggleButton
accessibilityLabel="Close plugin"
onPress={close}
testID="plugin-surface-close"
tooltipKeys={EMPTY_SHORTCUT_KEYS}
tooltipLabel="Close"
tooltipSide="bottom"
>
<ThemedX size={18} uniProps={mutedColorMapping} />
</HeaderToggleButton>
</>
),
[close, contributionId, contributionServerIds, pluginId, serverId],
);
return (
<View style={styles.screen}>
<ScreenHeader left={headerLeft} right={headerRight} />
<View style={styles.body}>
{plugin && surface ? (
<SurfaceErrorBoundary key={`${serverId}/${pluginId}/${contributionId}`}>
<ThemedSurfaceRenderer
Surface={surface.Component}
invoke={invoke}
plugin={plugin}
host={host}
layout={layout}
uniProps={pluginThemeMapping}
/>
</SurfaceErrorBoundary>
) : (
<Text style={styles.errorText}>This plugin surface is unavailable.</Text>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
screen: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
body: {
flex: 1,
},
errorText: {
color: theme.colors.statusDanger,
padding: theme.spacing[4],
},
hostSwitcher: {
maxWidth: 180,
minHeight: 32,
paddingHorizontal: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
hostSwitcherText: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
+49
View File
@@ -0,0 +1,49 @@
import type { ComponentType } from "react";
import type { QueryClient } from "@tanstack/react-query";
import type { PluginRpcContract } from "./sdk";
export interface PluginSurfaceProps {
theme: Record<string, unknown>;
host: {
id: string;
label: string;
};
layout: {
compact: boolean;
platform: "ios" | "android" | "web";
};
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
surface: string;
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
}
export interface EvaluatedPlugin {
id: string;
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
export interface InstalledPlugin extends EvaluatedPlugin {
serverId: string;
clientBundle: string;
queryClient: QueryClient;
}
@@ -0,0 +1,73 @@
import { transform } from "@babel/standalone";
import * as React from "react";
import type { ComponentType, ReactElement } from "react";
import * as ReactNative from "react-native";
interface RuntimeModule {
exports: unknown;
}
const RUNTIME_MODULES = new Map<string, unknown>([
["react", React],
["react-native", ReactNative],
]);
function requireRuntimeModule(name: string): unknown {
if (!RUNTIME_MODULES.has(name)) {
throw new Error(`Module "${name}" is not available in the runtime component.`);
}
return RUNTIME_MODULES.get(name);
}
function readDefaultExport(moduleExports: unknown): unknown {
if (typeof moduleExports !== "object" || moduleExports === null) {
return moduleExports;
}
if ("default" in moduleExports) {
return moduleExports.default;
}
return moduleExports;
}
export function evaluateRuntimeComponent(source: string): ComponentType {
const compiled = transform(source, {
filename: "user-component.tsx",
plugins: ["transform-modules-commonjs"],
presets: [
["typescript", { allExtensions: true, isTSX: true }],
["react", { runtime: "classic" }],
],
sourceType: "module",
}).code;
if (compiled === undefined) {
throw new Error("Babel produced no JavaScript for the component.");
}
const evaluateScript: (script: string) => unknown = globalThis.eval;
const moduleFactory = evaluateScript(
`(function(require, module, exports) { "use strict";\n${compiled}\n})`,
);
if (typeof moduleFactory !== "function") {
throw new Error("The compiled component did not produce a module factory.");
}
const runtimeModule: RuntimeModule = { exports: {} };
moduleFactory(requireRuntimeModule, runtimeModule, runtimeModule.exports);
const authoredComponent = readDefaultExport(runtimeModule.exports);
if (typeof authoredComponent !== "function") {
throw new Error("The source must default-export a function component.");
}
const renderAuthoredComponent = authoredComponent;
function RuntimeComponent(): ReactElement {
const rendered: unknown = renderAuthoredComponent();
if (!React.isValidElement(rendered)) {
throw new Error("The authored component must return one React element.");
}
return rendered;
}
RuntimeComponent.displayName = "RuntimeComponent";
return RuntimeComponent;
}
@@ -0,0 +1,284 @@
import { Component, type ComponentType, type ReactNode, useCallback, useReducer } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { evaluateRuntimeComponent } from "@/runtime-component/evaluate";
const INITIAL_SOURCE = `import React, { useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
interface Todo {
id: number;
label: string;
}
export default function UserAuthoredCard() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Ada");
const todos: Todo[] = [
{ id: 1, label: "Compiled TSX on-device" },
{ id: 2, label: "Rendered native views" },
{ id: 3, label: "Hooks and events survived" },
];
return (
<View style={{ gap: 14, padding: 18, borderRadius: 18, backgroundColor: "#172554" }}>
<Text style={{ color: "#93c5fd", fontSize: 12, fontWeight: "700" }}>
USER-AUTHORED TSX STRING
</Text>
<Text style={{ color: "white", fontSize: 24, fontWeight: "800" }}>
Hello {name || "stranger"}
</Text>
<TextInput
accessibilityLabel="Authored name"
value={name}
onChangeText={setName}
style={{ backgroundColor: "white", borderRadius: 10, padding: 12, color: "#111827" }}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={\`Native presses: \${count}\`}
onPress={() => setCount((value) => value + 1)}
style={{ backgroundColor: "#22c55e", borderRadius: 10, padding: 14 }}
>
<Text style={{ color: "#052e16", fontWeight: "800", textAlign: "center" }}>
Native presses: {count}
</Text>
</Pressable>
<View style={{ gap: 6 }}>
{todos.map((todo) => (
<Text key={todo.id} style={{ color: "#dbeafe" }}>✓ {todo.label}</Text>
))}
</View>
</View>
);
}`;
const SECOND_SOURCE = `import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
export default function SecondProgram() {
const [isOn, setIsOn] = useState(false);
return (
<View style={{ gap: 16, padding: 24, borderRadius: 20, backgroundColor: "#4c1d95" }}>
<Text style={{ color: "white", fontSize: 26, fontWeight: "900" }}>SECOND PROGRAM</Text>
<Text style={{ color: "#ddd6fe" }}>This TSX replaced the original string at runtime.</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={isOn ? "Dynamic state on" : "Dynamic state off"}
onPress={() => setIsOn((value) => !value)}
style={{ padding: 16, borderRadius: 12, backgroundColor: isOn ? "#4ade80" : "#f472b6" }}
>
<Text style={{ textAlign: "center", fontWeight: "900" }}>{isOn ? "ON" : "OFF"}</Text>
</Pressable>
</View>
);
}`;
interface SuccessfulOutput {
kind: "success";
Component: ComponentType;
}
interface FailedOutput {
kind: "failure";
message: string;
}
type PrototypeOutput = SuccessfulOutput | FailedOutput;
interface PrototypeState {
source: string;
revision: number;
output: PrototypeOutput;
}
type PrototypeAction = { type: "edit"; source: string } | { type: "run"; output: PrototypeOutput };
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function compile(source: string): PrototypeOutput {
try {
return { kind: "success", Component: evaluateRuntimeComponent(source) };
} catch (error) {
return { kind: "failure", message: describeError(error) };
}
}
function createInitialState(): PrototypeState {
return {
source: INITIAL_SOURCE,
revision: 0,
output: compile(INITIAL_SOURCE),
};
}
function prototypeReducer(state: PrototypeState, action: PrototypeAction): PrototypeState {
if (action.type === "edit") {
return { ...state, source: action.source };
}
return {
...state,
revision: state.revision + 1,
output: action.output,
};
}
interface RenderBoundaryProps {
children: ReactNode;
}
interface RenderBoundaryState {
message: string | null;
}
class RenderBoundary extends Component<RenderBoundaryProps, RenderBoundaryState> {
state: RenderBoundaryState = { message: null };
static getDerivedStateFromError(error: unknown): RenderBoundaryState {
return { message: describeError(error) };
}
render() {
if (this.state.message !== null) {
return <Text style={styles.errorText}>Render failed: {this.state.message}</Text>;
}
return this.props.children;
}
}
export function RuntimeComponentPrototypeScreen() {
const [state, dispatch] = useReducer(prototypeReducer, undefined, createInitialState);
const runSource = useCallback(() => {
dispatch({ type: "run", output: compile(state.source) });
}, [state.source]);
const editSource = useCallback((source: string) => {
dispatch({ type: "edit", source });
}, []);
const loadSecondSource = useCallback(() => {
dispatch({ type: "edit", source: SECOND_SOURCE });
}, []);
const output = state.output;
return (
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.keyboardAvoidingView}
>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<Text style={styles.title}>Native runtime component</Text>
<Text style={styles.subtitle}>
Babel compiles this string locally. Hermes executes it in Paseo&apos;s JavaScript realm.
</Text>
<View style={styles.preview}>
{output.kind === "success" ? (
<RenderBoundary key={state.revision}>
<output.Component />
</RenderBoundary>
) : (
<Text style={styles.errorText}>Compile failed: {output.message}</Text>
)}
</View>
<View style={styles.editorHeader}>
<Text style={styles.editorLabel}>user-component.tsx</Text>
<View style={styles.editorActions}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Load second authored component"
onPress={loadSecondSource}
style={styles.loadButton}
>
<Text style={styles.loadButtonText}>Load second</Text>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel="Run authored component"
onPress={runSource}
style={styles.runButton}
>
<Text style={styles.runButtonText}>Run string</Text>
</Pressable>
</View>
</View>
<TextInput
accessibilityLabel="Authored component source"
autoCapitalize="none"
autoCorrect={false}
multiline
onChangeText={editSource}
style={styles.editor}
textAlignVertical="top"
value={state.source}
/>
<Text style={styles.warning}>
This is code execution, not a sandbox. The string has the same authority as the app.
</Text>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1, backgroundColor: "#020617" },
keyboardAvoidingView: { flex: 1 },
content: { gap: 14, padding: 18, paddingBottom: 50 },
title: { color: "white", fontSize: 28, fontWeight: "800" },
subtitle: { color: "#94a3b8", fontSize: 14, lineHeight: 20 },
preview: {
minHeight: 260,
borderColor: "#334155",
borderRadius: 20,
borderWidth: 1,
justifyContent: "center",
padding: 12,
},
editorHeader: { alignItems: "center", flexDirection: "row", justifyContent: "space-between" },
editorActions: { flexDirection: "row", gap: 8 },
editorLabel: { color: "#cbd5e1", fontFamily: "Menlo", fontSize: 13 },
loadButton: {
backgroundColor: "#334155",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
loadButtonText: { color: "#f8fafc", fontWeight: "800" },
runButton: {
backgroundColor: "#f8fafc",
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 10,
},
runButtonText: { color: "#0f172a", fontWeight: "800" },
editor: {
minHeight: 520,
backgroundColor: "#0f172a",
borderColor: "#334155",
borderRadius: 12,
borderWidth: 1,
color: "#e2e8f0",
fontFamily: "Menlo",
fontSize: 11,
lineHeight: 16,
padding: 12,
},
errorText: { color: "#fca5a5", fontSize: 14, lineHeight: 20 },
warning: { color: "#fbbf24", fontSize: 12, lineHeight: 18 },
});
@@ -71,6 +71,34 @@ describe("draft-store lifecycle", () => {
});
describe("draft-store normalization", () => {
it("preserves plugin resources when hydrating a draft", () => {
const attachment = {
kind: "plugin_resource" as const,
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
item: {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In progress",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
};
expect(
toDraftInputIfReady({
input: { text: "Implement this", attachments: [attachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({ text: "Implement this", attachments: [attachment] });
});
it("preserves uploaded file attachments when hydrating a draft", () => {
const attachment = {
kind: "file" as const,
@@ -9,6 +9,7 @@ import {
GitHubSearchItemSchema,
UploadedFileAttachmentSchema,
} from "@getpaseo/protocol/messages";
import { PluginResourceComposerAttachmentSchema } from "@/plugins/attachments/model";
export const DRAFT_STORE_VERSION = 5;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
@@ -94,6 +95,9 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
if (record.kind === "file") {
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
}
if (record.kind === "plugin_resource") {
return PluginResourceComposerAttachmentSchema.safeParse(record).success;
}
if (
record.kind !== "forge_issue" &&
record.kind !== "forge_change_request" &&
+26
View File
@@ -4702,6 +4702,32 @@ export class DaemonClient {
});
}
async getPluginCatalog(): Promise<Array<{ id: string; clientBundle: string }>> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "plugin.catalog.get.request", requestId },
responseType: "plugin.catalog.get.response",
});
return payload.plugins;
}
async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown> {
const requestId = this.createRequestId();
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.rpc.invoke.request",
requestId,
pluginId,
method,
input,
},
responseType: "plugin.rpc.invoke.response",
});
return payload.output;
}
async respondToPermissionAndWait(
agentId: string,
requestId: string,
@@ -310,6 +310,50 @@ describe("shared messages attachments", () => {
]);
});
it("preserves neutral external-resource presentation on text attachments", () => {
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "req-external-resource",
agentId: "agent-1",
text: "Implement this",
attachments: [
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
],
});
expect(parsed.attachments).toEqual([
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
]);
});
it("keeps known firstAgentContext attachments and drops unknown ones", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request",
+67
View File
@@ -144,6 +144,20 @@ const MutableRelayConfigSchema = z
enabled: z.boolean(),
})
.passthrough();
export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/);
export const DirectoryPluginSourceSchema = z
.object({
source: z.literal("directory"),
path: z.string().min(1),
})
.strict();
export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPluginSourceSchema]);
export type PluginSource = z.infer<typeof PluginSourceSchema>;
export const MutableDaemonConfigSchema = z
.object({
// COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported.
@@ -160,6 +174,7 @@ export const MutableDaemonConfigSchema = z
enableTerminalAgentHooks: z.boolean().default(false),
appendSystemPrompt: z.string().default(""),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
})
.passthrough();
@@ -177,6 +192,7 @@ export const MutableDaemonConfigPatchSchema = z
enableTerminalAgentHooks: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
})
.partial()
.passthrough();
@@ -969,6 +985,16 @@ export const ForgeIssueAttachmentSchema = z.object({
projectPath: z.string().optional(),
});
export const ExternalResourceAttachmentMetadataSchema = z.object({
provider: z.string(),
providerLabel: z.string(),
resourceType: z.string(),
id: z.string(),
identifier: z.string(),
title: z.string(),
url: z.string(),
});
export const TextAttachmentSchema = z
.object({
type: z.literal("text"),
@@ -976,6 +1002,7 @@ export const TextAttachmentSchema = z
contextKind: z.string().optional(),
title: z.string().nullable().optional(),
text: z.string(),
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
})
.transform(({ contextKind, ...attachment }) => ({
...attachment,
@@ -1231,6 +1258,19 @@ export const DiagnosticsRequestSchema = z.object({
requestId: z.string(),
});
export const PluginCatalogGetRequestSchema = z.object({
type: z.literal("plugin.catalog.get.request"),
requestId: z.string(),
});
export const PluginRpcInvokeRequestSchema = z.object({
type: z.literal("plugin.rpc.invoke.request"),
requestId: z.string(),
pluginId: PluginIdSchema,
method: z.string().min(1),
input: z.unknown(),
});
export const GetDaemonConfigRequestMessageSchema = z.object({
type: z.literal("get_daemon_config_request"),
requestId: z.string(),
@@ -2600,6 +2640,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HubManagementDaemonGetStatusRequestSchema,
HubManagementDaemonDisconnectRequestSchema,
DiagnosticsRequestSchema,
PluginCatalogGetRequestSchema,
PluginRpcInvokeRequestSchema,
GetDaemonConfigRequestMessageSchema,
SetDaemonConfigRequestMessageSchema,
ReadProjectConfigRequestMessageSchema,
@@ -2909,6 +2951,8 @@ export const ServerInfoStatusPayloadSchema = z
daemonStatusRpc: z.boolean().optional(),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
relayConfig: z.boolean().optional(),
// COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07.
plugins: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.
@@ -5407,12 +5451,35 @@ export function parseHubExecutionOutboundMessage(value: unknown): HubExecutionOu
export type DaemonUpdateProgressMessage = z.infer<typeof DaemonUpdateProgressMessageSchema>;
export const PluginCatalogGetResponseSchema = z.object({
type: z.literal("plugin.catalog.get.response"),
payload: z.object({
requestId: z.string(),
plugins: z.array(
z.object({
id: PluginIdSchema,
clientBundle: z.string(),
}),
),
}),
});
export const PluginRpcInvokeResponseSchema = z.object({
type: z.literal("plugin.rpc.invoke.response"),
payload: z.object({
requestId: z.string(),
output: z.unknown(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentCreateResponseSchema,
HubExecutionControlResponseSchema,
HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema,
BrowserAutomationExecuteRequestSchema,
PluginCatalogGetResponseSchema,
PluginRpcInvokeResponseSchema,
ActivityLogMessageSchema,
AssistantChunkMessageSchema,
AudioOutputMessageSchema,
+2
View File
@@ -69,6 +69,7 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.3.0",
"@getpaseo/highlight": "0.3.0",
"@getpaseo/protocol": "0.3.0",
@@ -82,6 +83,7 @@
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
+8 -1
View File
@@ -170,7 +170,7 @@ import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages";
import type { FirstAgentContext, PluginSource, TerminalProfile } from "@getpaseo/protocol/messages";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -218,6 +218,7 @@ import {
type HubRelationshipRemote,
} from "./hub/relationship-remote.js";
import { DaemonExecutions } from "./hub/daemon-executions.js";
import { PluginRuntime } from "./plugins/runtime.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
@@ -399,6 +400,7 @@ export interface PaseoDaemonConfig {
enableTerminalAgentHooks?: boolean;
appendSystemPrompt?: string;
terminalProfiles?: TerminalProfile[];
plugins?: Record<string, PluginSource>;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -532,6 +534,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
appendSystemPrompt: config.appendSystemPrompt ?? "",
plugins: config.plugins ?? {},
};
if (config.terminalProfiles !== undefined) {
@@ -559,6 +562,7 @@ export async function createPaseoDaemon(
);
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({});
const pluginRuntime = new PluginRuntime(logger);
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
@@ -1439,6 +1443,7 @@ export async function createPaseoDaemon(
const start = async () => {
let mainStarted = false;
try {
await pluginRuntime.start(daemonConfigStore.get().plugins ?? {});
if (serviceProxyListenTarget) {
const boundServiceProxyTarget = await serviceProxy.startStandalone({
listenTarget: serviceProxyListenTarget,
@@ -1570,6 +1575,7 @@ export async function createPaseoDaemon(
browserToolsBroker,
hubRelationships,
workspaceSetupRuntime,
pluginRuntime,
);
relayRuntime = createRelayRuntime({
config: {
@@ -1623,6 +1629,7 @@ export async function createPaseoDaemon(
};
const stop = async () => {
await pluginRuntime.stop();
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
+1
View File
@@ -520,6 +520,7 @@ export function loadConfig(
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
appendSystemPrompt,
terminalProfiles,
plugins: persisted.plugins,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -335,6 +335,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
return {
...persisted,
plugins: mutable.plugins,
daemon: {
...persisted.daemon,
...(persistRelayEnabled
@@ -187,7 +187,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution"
type: "hub.execution.agent.create.response",
payload: { success: true, executionId: "replayed-execution" },
});
const providerCreations = hub.providerCreations();
const executionProviderCreations = hub.executionProviderCreations();
hub.beginOwnedCreate("replay-create", "replayed-execution", {
mcpServers: {
@@ -204,7 +204,7 @@ test("reserved Paseo MCP input does not invalidate replay of an owned execution"
agentId: original.payload.agentId,
},
});
expect(hub.providerCreations()).toBe(providerCreations);
expect(hub.executionProviderCreations()).toBe(executionProviderCreations);
expect(await hub.durableOwnedAgentIds()).toEqual([original.payload.agentId]);
});
@@ -434,7 +434,7 @@ describe("Hub relationship", () => {
});
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
expect(durableAgentIds).toEqual([created.payload.agentId]);
expect(relationship.providerCreations()).toBe(1);
expect(relationship.executionProviderCreations()).toBe(1);
expect(relationship.providerResumes()).toBe(0);
expect(relationship.providerPromptTexts()).toEqual([prompt]);
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
@@ -599,7 +599,7 @@ describe("Hub relationship", () => {
agentId: durableAgentIds[0],
},
});
expect(relationship.providerCreations()).toBe(1);
expect(relationship.executionProviderCreations()).toBe(1);
expect(durableAgentIds).toHaveLength(1);
});
@@ -624,7 +624,7 @@ describe("Hub relationship", () => {
agent: { id: expect.any(String) },
},
});
expect(relationship.providerCreations()).toBe(1);
expect(relationship.executionProviderCreations()).toBe(1);
expect(await relationship.durableOwnedAgentIds()).toHaveLength(1);
});
@@ -824,6 +824,10 @@ export class HubRelationshipHarness {
return this.codex.creations;
}
executionProviderCreations(): number {
return this.codex.createdConfigs.filter((config) => config.internal !== true).length;
}
providerResumes(): number {
return this.codex.resumes;
}
@@ -9,7 +9,11 @@ import {
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
import { TerminalProfileSchema } from "@getpaseo/protocol/messages";
import {
PluginIdSchema,
PluginSourceSchema,
TerminalProfileSchema,
} from "@getpaseo/protocol/messages";
import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema";
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
@@ -303,6 +307,7 @@ export const PersistedConfigSchema = z
.optional(),
providers: ProvidersSchema.optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
worktrees: WorktreesConfigSchema.optional(),
agents: z
.object({
@@ -0,0 +1,206 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { parse } from "@babel/parser";
import { build, type Plugin } from "esbuild";
type PluginBuildTarget = "client" | "server";
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
client: new Set(["handle"]),
server: new Set(["addSurface", "addSidebarItem", "addAttachmentSource"]),
};
function registrationName(statement: unknown, contextName: string): string | null {
if (!statement || typeof statement !== "object") return null;
const expression = Reflect.get(statement, "expression");
if (
!expression ||
typeof expression !== "object" ||
Reflect.get(expression, "type") !== "CallExpression"
) {
return null;
}
const callee = Reflect.get(expression, "callee");
if (!callee || typeof callee !== "object" || Reflect.get(callee, "type") !== "MemberExpression") {
return null;
}
const object = Reflect.get(callee, "object");
const property = Reflect.get(callee, "property");
if (
!object ||
typeof object !== "object" ||
Reflect.get(object, "type") !== "Identifier" ||
Reflect.get(object, "name") !== contextName ||
!property ||
typeof property !== "object" ||
Reflect.get(property, "type") !== "Identifier"
) {
return null;
}
return String(Reflect.get(property, "name"));
}
function defaultPluginFunction(programBody: unknown[]): { body: object; contextName: string } {
const defaultExports = programBody.filter(
(statement) =>
statement !== null &&
typeof statement === "object" &&
Reflect.get(statement, "type") === "ExportDefaultDeclaration",
);
if (defaultExports.length !== 1) {
throw new Error("Plugin index.tsx must have exactly one default export function");
}
const declaration = Reflect.get(defaultExports[0] as object, "declaration");
const declarationType =
declaration !== null && typeof declaration === "object"
? Reflect.get(declaration, "type")
: null;
if (
declarationType !== "FunctionDeclaration" &&
declarationType !== "FunctionExpression" &&
declarationType !== "ArrowFunctionExpression"
) {
throw new Error("Plugin default export must be a function receiving its context");
}
const parameters = Reflect.get(declaration, "params");
const parameter = Array.isArray(parameters) && parameters.length === 1 ? parameters[0] : null;
if (
parameter === null ||
typeof parameter !== "object" ||
Reflect.get(parameter, "type") !== "Identifier"
) {
throw new Error("Plugin default export must receive one named context parameter");
}
const body = Reflect.get(declaration, "body");
if (body === null || typeof body !== "object" || Reflect.get(body, "type") !== "BlockStatement") {
throw new Error("Plugin default export must have a block body");
}
return { body, contextName: String(Reflect.get(parameter, "name")) };
}
function collectRemovedRegistrationRanges(
node: unknown,
contextName: string,
removedNames: ReadonlySet<string>,
ranges: Array<{ start: number; end: number }>,
): void {
if (node === null || typeof node !== "object") return;
if (Reflect.get(node, "type") === "ExpressionStatement") {
const name = registrationName(node, contextName);
if (name && removedNames.has(name)) {
const start = Reflect.get(node, "start");
const end = Reflect.get(node, "end");
if (typeof start !== "number" || typeof end !== "number") {
throw new Error(`Could not locate plugin context ${name} registration in index.tsx`);
}
ranges.push({ start, end });
return;
}
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const child of value)
collectRemovedRegistrationRanges(child, contextName, removedNames, ranges);
} else if (value !== null && typeof value === "object") {
collectRemovedRegistrationRanges(value, contextName, removedNames, ranges);
}
}
}
function removeOppositeTargetRegistrations(source: string, target: PluginBuildTarget): string {
const ast = parse(source, {
sourceType: "module",
plugins: ["typescript", "jsx"],
});
const pluginFunction = defaultPluginFunction(ast.program.body);
const ranges: Array<{ start: number; end: number }> = [];
collectRemovedRegistrationRanges(
pluginFunction.body,
pluginFunction.contextName,
REGISTRATIONS_REMOVED_BY_TARGET[target],
ranges,
);
let output = source;
for (const range of ranges.toReversed()) {
output = `${output.slice(0, range.start)}${output.slice(range.end)}`;
}
return output;
}
function wrapCommonJsBundle(code: string): string {
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
}
function makeHermesInteropEager(code: string): string {
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
// the final loop binding, so every named import can resolve to the last export.
// Plugin bundles execute once and do not need live bindings from host modules.
return code.replaceAll("get: () => from[key]", "value: from[key]");
}
function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
const filter =
target === "server"
? /^(@tanstack\/react-query|react|react\/jsx-runtime|react-native)$/
: /^node:/;
return {
name: `paseo-plugin-${target}-unused-platform-modules`,
setup(buildContext) {
buildContext.onResolve({ filter }, (args) => ({
path: args.path,
namespace: "paseo-unused-platform-module",
sideEffects: false,
}));
buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({
contents: "module.exports = {};",
loader: "js",
}));
},
};
}
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const source = await readFile(entryPath, "utf8");
const filteredSource = removeOppositeTargetRegistrations(source, target);
const result = await build({
stdin: {
contents: filteredSource,
loader: "tsx",
resolveDir: path.dirname(entryPath),
sourcefile: entryPath,
},
bundle: true,
format: "cjs",
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
external:
target === "client"
? [
"@paseo/plugin",
"@tanstack/react-query",
"react",
"react/jsx-runtime",
"react-native",
"zod",
]
: ["@paseo/plugin", "zod"],
plugins: [createUnusedPlatformModulePlugin(target)],
treeShaking: true,
write: false,
});
const output = result.outputFiles[0]?.text;
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
return wrapCommonJsBundle(makeHermesInteropEager(output));
}
export async function compilePlugin(entryPath: string): Promise<{
clientBundle: string;
serverBundle: string;
}> {
const [clientBundle, serverBundle] = await Promise.all([
compileTarget(entryPath, "client"),
compileTarget(entryPath, "server"),
]);
return { clientBundle, serverBundle };
}
@@ -0,0 +1,10 @@
export type PluginProcessRequest =
| { type: "initialize"; bundle: string }
| { type: "invoke"; requestId: string; method: string; input: unknown }
| { type: "shutdown" };
export type PluginProcessMessage =
| { type: "ready"; methods: string[] }
| { type: "result"; requestId: string; output: unknown }
| { type: "error"; requestId: string; error: string }
| { type: "fatal"; error: string };
@@ -0,0 +1,105 @@
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
import { createRequire } from "node:module";
import type { ZodType } from "zod";
type RpcHandler = (input: unknown) => unknown | Promise<unknown>;
interface RpcContract {
name: string;
input: ZodType;
output: ZodType;
}
interface RegisteredRpc {
contract: RpcContract;
handler: RpcHandler;
}
const handlers = new Map<string, RegisteredRpc>();
const nodeRequire = createRequire(import.meta.url);
function send(message: PluginProcessMessage): void {
process.send?.(message);
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function validateMethod(method: string): string {
const normalized = method.trim();
if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) {
throw new Error(`Invalid plugin RPC method: ${method}`);
}
if (handlers.has(normalized)) {
throw new Error(`Duplicate plugin RPC method: ${normalized}`);
}
return normalized;
}
function defineRpc(contract: RpcContract): RpcContract {
return { ...contract, name: contract.name.trim() };
}
function defineAttachmentSource<T>(definition: T): T {
return definition;
}
function register(contract: RpcContract, handler: RpcHandler): void {
if (typeof handler !== "function") {
throw new Error(`Plugin RPC ${contract.name} must provide a handler`);
}
const method = validateMethod(contract.name);
handlers.set(method, { contract: { ...contract, name: method }, handler });
}
function runtimeRequire(name: string): unknown {
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc };
return nodeRequire(name);
}
function evaluateBundle(bundle: string): void {
const evaluate: (source: string) => unknown = globalThis.eval;
const factory = evaluate(bundle);
if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable");
const exports = factory(runtimeRequire);
const setup =
exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined;
if (typeof setup !== "function") {
throw new Error("Plugin server bundle must default export a function");
}
setup({ handle: register });
}
process.on("message", (message: PluginProcessRequest) => {
if (message.type === "initialize") {
try {
evaluateBundle(message.bundle);
send({ type: "ready", methods: [...handlers.keys()].sort() });
} catch (error) {
send({ type: "fatal", error: describeError(error) });
}
return;
}
if (message.type === "shutdown") {
process.disconnect();
return;
}
const registered = handlers.get(message.method);
if (!registered) {
send({
type: "error",
requestId: message.requestId,
error: `Unknown RPC method: ${message.method}`,
});
return;
}
void registered.contract.input
.parseAsync(message.input)
.then(registered.handler)
.then((output) => registered.contract.output.parseAsync(output))
.then(
(output) => send({ type: "result", requestId: message.requestId, output }),
(error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }),
);
});
@@ -0,0 +1,130 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { PluginRuntime } from "./runtime.js";
const temporaryDirectories: string[] = [];
async function createPlugin(id: string, source: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-"));
temporaryDirectories.push(directory);
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id }), "utf8");
await writeFile(path.join(directory, "index.tsx"), source, "utf8");
return directory;
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("PluginRuntime", () => {
it("loads the official Linear attachment extension", async () => {
const directory = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../extensions/linear",
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.start({ linear: { source: "directory", path: directory } });
expect(runtime.catalog().map((plugin) => plugin.id)).toEqual(["linear"]);
expect(runtime.catalog()[0]?.clientBundle).toContain("Attach Linear issue");
expect(runtime.catalog()[0]?.clientBundle).not.toContain("LINEAR_API_KEY");
expect(runtime.catalog()[0]?.clientBundle).not.toContain("api.linear.app");
await runtime.stop();
});
it("loads one index.tsx, exposes its client bundle, and invokes its server RPC", async () => {
const directory = await createPlugin(
"hello",
`import React from "react";
import { platform } from "node:os";
import { Text } from "react-native";
import { z } from "zod";
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string(), platform: z.string() }),
});
const attachments = defineAttachmentSource({
id: "issues",
title: "Example issue",
icon: "CircleDot",
pickerTitle: "Attach example issue",
searchPlaceholder: "Search issues",
search: greetRpc,
});
function HelloSurface() {
return <Text>Hello from native UI</Text>;
}
export default function contribute(plugin: any) {
plugin.handle(greetRpc, async (input: { name: string }) => ({
message: "Hello, " + input.name,
platform: platform(),
}));
plugin.addSurface("main", HelloSurface);
plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" });
plugin.addAttachmentSource(attachments);
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.start({ hello: { source: "directory", path: directory } });
const catalog = runtime.catalog();
expect(catalog).toHaveLength(1);
expect(catalog[0]?.id).toBe("hello");
expect(catalog[0]?.clientBundle).toContain("Hello from native UI");
expect(catalog[0]?.clientBundle).toContain("Attach example issue");
expect(catalog[0]?.clientBundle).not.toContain("node:os");
expect(catalog[0]?.clientBundle).not.toContain("get: () => from[key]");
await expect(runtime.invoke("hello", "greet", { name: "Paseo" })).resolves.toMatchObject({
message: "Hello, Paseo",
});
await expect(runtime.invoke("hello", "greet", { name: 7 })).rejects.toThrow();
await runtime.stop();
});
it("rejects a handler result that does not match its RPC output schema", async () => {
const directory = await createPlugin(
"invalid-output",
`import { z } from "zod";
import { defineRpc } from "@paseo/plugin";
const brokenRpc = defineRpc({
name: "broken",
input: z.object({}),
output: z.object({ value: z.number() }),
});
export default function contribute(plugin: any) {
plugin.handle(brokenRpc, async () => ({ value: "wrong" }));
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.start({ "invalid-output": { source: "directory", path: directory } });
await expect(runtime.invoke("invalid-output", "broken", {})).rejects.toThrow();
await runtime.stop();
});
it("rejects a configured id that does not match the manifest", async () => {
const directory = await createPlugin("actual", `export default function contribute() {}`);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.start({ configured: { source: "directory", path: directory } });
expect(runtime.catalog()).toEqual([]);
await runtime.stop();
});
});
@@ -0,0 +1,209 @@
import { fork } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import type pino from "pino";
import { z } from "zod";
import { PluginIdSchema, type PluginSource } from "@getpaseo/protocol/messages";
import { compilePlugin } from "./compiler.js";
import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js";
const MANIFEST_FILENAME = "paseo-plugin.json";
const ENTRY_FILENAME = "index.tsx";
const REQUEST_TIMEOUT_MS = 30_000;
const PluginManifestSchema = z.object({ id: PluginIdSchema }).strict();
interface PluginChild {
connected: boolean;
killed: boolean;
send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean;
kill(): boolean;
disconnect(): void;
on(event: "message", listener: (message: PluginProcessMessage) => void): this;
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
}
interface PendingInvocation {
resolve: (output: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
interface LoadedPlugin {
id: string;
clientBundle: string;
methods: ReadonlySet<string>;
child: PluginChild;
pending: Map<string, PendingInvocation>;
}
function resolveWorkerUrl(): URL {
return new URL(
import.meta.url.endsWith(".ts") ? "./plugin-process.ts" : "./plugin-process.js",
import.meta.url,
);
}
function resolveWorkerExecArgv(): string[] {
if (!import.meta.url.endsWith(".ts")) return [];
const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href;
const importSource = [
'import { register } from "node:module";',
'import { pathToFileURL } from "node:url";',
`register(${JSON.stringify(loaderUrl)}, pathToFileURL("./"));`,
].join(" ");
return [
"--experimental-strip-types",
"--import",
`data:text/javascript,${encodeURIComponent(importSource)}`,
];
}
function spawnPluginChild(): PluginChild {
return fork(fileURLToPath(resolveWorkerUrl()), [], {
execArgv: resolveWorkerExecArgv(),
serialization: "advanced",
stdio: ["ignore", "ignore", "inherit", "ipc"],
}) as PluginChild;
}
function send(child: PluginChild, message: PluginProcessRequest): Promise<void> {
return new Promise((resolve, reject) => {
child.send(message, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function requireRegularFile(filePath: string, label: string): Promise<void> {
const info = await stat(filePath).catch(() => null);
if (!info?.isFile()) throw new Error(`${label} is missing: ${filePath}`);
}
async function readManifest(directory: string): Promise<{ id: string }> {
const manifestPath = path.join(directory, MANIFEST_FILENAME);
await requireRegularFile(manifestPath, "Plugin manifest");
return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
}
export class PluginRuntime {
private readonly plugins = new Map<string, LoadedPlugin>();
private readonly logger: pino.Logger;
constructor(logger: pino.Logger) {
this.logger = logger.child({ module: "plugins" });
}
async start(config: Record<string, PluginSource>): Promise<void> {
for (const [pluginId, source] of Object.entries(config)) {
try {
await this.loadDirectoryPlugin(pluginId, source.path);
} catch (error) {
this.logger.error({ err: error, pluginId, source }, "Failed to load plugin");
}
}
}
catalog(): Array<{ id: string; clientBundle: string }> {
return [...this.plugins.values()]
.map(({ id, clientBundle }) => ({ id, clientBundle }))
.sort((left, right) => left.id.localeCompare(right.id));
}
async invoke(pluginId: string, method: string, input: unknown): Promise<unknown> {
const loaded = this.plugins.get(pluginId);
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
if (!loaded.methods.has(method))
throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`);
const requestId = randomUUID();
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
loaded.pending.delete(requestId);
reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`));
}, REQUEST_TIMEOUT_MS);
loaded.pending.set(requestId, { resolve, reject, timeout });
void send(loaded.child, { type: "invoke", requestId, method, input }).catch((error) => {
clearTimeout(timeout);
loaded.pending.delete(requestId);
reject(error);
});
});
}
async stop(): Promise<void> {
await Promise.all([...this.plugins.values()].map((loaded) => this.stopPlugin(loaded)));
this.plugins.clear();
}
private async loadDirectoryPlugin(pluginId: string, configuredPath: string): Promise<void> {
const directory = path.resolve(configuredPath);
const manifest = await readManifest(directory);
if (manifest.id !== pluginId) {
throw new Error(`Configured plugin "${pluginId}" points to manifest "${manifest.id}"`);
}
const entryPath = path.join(directory, ENTRY_FILENAME);
await requireRegularFile(entryPath, "Plugin entry point");
const bundles = await compilePlugin(entryPath);
const child = spawnPluginChild();
const pending = new Map<string, PendingInvocation>();
const methods = await new Promise<string[]>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Plugin ${pluginId} did not initialize`)),
REQUEST_TIMEOUT_MS,
);
child.on("message", (message) => {
if (message.type === "ready") {
clearTimeout(timeout);
resolve(message.methods);
} else if (message.type === "fatal") {
clearTimeout(timeout);
reject(new Error(message.error));
}
});
void send(child, { type: "initialize", bundle: bundles.serverBundle }).catch(reject);
});
const loaded: LoadedPlugin = {
id: pluginId,
clientBundle: bundles.clientBundle,
methods: new Set(methods),
child,
pending,
};
child.on("message", (message) => this.handleChildMessage(loaded, message));
child.on("close", () => this.handleChildClose(loaded));
this.plugins.set(pluginId, loaded);
this.logger.info({ pluginId, methods }, "Loaded plugin");
}
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
if (message.type !== "result" && message.type !== "error") return;
const pending = loaded.pending.get(message.requestId);
if (!pending) return;
loaded.pending.delete(message.requestId);
clearTimeout(pending.timeout);
if (message.type === "result") pending.resolve(message.output);
else pending.reject(new Error(message.error));
}
private handleChildClose(loaded: LoadedPlugin): void {
if (this.plugins.get(loaded.id) === loaded) this.plugins.delete(loaded.id);
for (const invocation of loaded.pending.values()) {
clearTimeout(invocation.timeout);
invocation.reject(new Error(`Plugin process exited: ${loaded.id}`));
}
loaded.pending.clear();
}
private async stopPlugin(loaded: LoadedPlugin): Promise<void> {
if (!loaded.child.connected) return;
await send(loaded.child, { type: "shutdown" }).catch(() => undefined);
loaded.child.disconnect();
if (!loaded.child.killed) loaded.child.kill();
}
}
+32
View File
@@ -462,6 +462,10 @@ export interface SessionOptions {
workspaceGitService: WorkspaceGitService;
workspaceAutoName: WorkspaceAutoName;
daemonConfigStore: DaemonConfigStore;
pluginRuntime?: {
catalog(): Array<{ id: string; clientBundle: string }>;
invoke(pluginId: string, method: string, input: unknown): Promise<unknown>;
};
mcpBaseUrl?: string | null;
stt: Resolvable<SpeechToTextProvider | null>;
sttLanguage?: string;
@@ -632,6 +636,7 @@ export class Session {
private readonly workspaceProvisioning: WorkspaceProvisioningService;
private readonly workspaceRecovery: WorkspaceRecoveryService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
private readonly pushTokenStore: PushTokenStore;
private unsubscribeAgentEvents: (() => void) | null = null;
private unsubscribeProjectMutations: (() => void) | null = null;
@@ -713,6 +718,7 @@ export class Session {
workspaceGitService,
workspaceAutoName,
daemonConfigStore,
pluginRuntime,
stt,
sttLanguage,
tts,
@@ -751,6 +757,7 @@ export class Session {
this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome;
this.worktreesRoot = worktreesRoot;
this.pluginRuntime = pluginRuntime;
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
@@ -1853,12 +1860,37 @@ export class Session {
this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchWorkspaceFileMessage(msg, source) ??
this.dispatchProviderMessage(msg) ??
this.dispatchPluginMessage(msg) ??
this.dispatchTerminalMessage(msg) ??
this.dispatchChatScheduleLoopMessage(msg) ??
this.dispatchMiscMessage(msg);
if (promise) await promise;
}
private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "plugin.catalog.get.request") {
this.emit({
type: "plugin.catalog.get.response",
payload: {
requestId: msg.requestId,
plugins: this.pluginRuntime?.catalog() ?? [],
},
});
return undefined;
}
if (msg.type === "plugin.rpc.invoke.request") {
if (!this.pluginRuntime) throw new Error("Plugin runtime is unavailable");
return this.pluginRuntime.invoke(msg.pluginId, msg.method, msg.input).then((output) => {
this.emit({
type: "plugin.rpc.invoke.response",
payload: { requestId: msg.requestId, output },
});
return undefined;
});
}
return undefined;
}
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "voice_audio_chunk":
+12 -1
View File
@@ -32,7 +32,12 @@ import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frame
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import type { HostnamesConfig } from "./hostnames.js";
import { isHostnameAllowed } from "./hostnames.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
import {
Session,
type SessionLifecycleIntent,
type SessionOptions,
type SessionRuntimeMetrics,
} from "./session.js";
import type { HubRelationshipManagement } from "./hub/relationship-controller.js";
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
import type { HubExecutionAgents } from "./hub/daemon-executions.js";
@@ -561,6 +566,7 @@ export class VoiceAssistantWebSocketServer {
private acceptingConnections = true;
private readonly advertiseDaemonStatusRpc: boolean;
private readonly advertiseRelayConfig: boolean;
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
constructor(
server: HTTPServer,
@@ -607,6 +613,7 @@ export class VoiceAssistantWebSocketServer {
browserToolsBroker?: BrowserToolsBroker | null,
hubRelationships?: HubRelationshipManagement | null,
workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(),
pluginRuntime?: SessionOptions["pluginRuntime"],
) {
this.logger = logger.child({ module: "websocket-server" });
this.workspaceSetupRuntime = workspaceSetupRuntime;
@@ -620,6 +627,7 @@ export class VoiceAssistantWebSocketServer {
this.daemonRuntimeConfig = daemonRuntimeConfig;
this.browserToolsBroker = browserToolsBroker ?? null;
this.hubRelationships = hubRelationships ?? null;
this.pluginRuntime = pluginRuntime;
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
@@ -1340,6 +1348,7 @@ export class VoiceAssistantWebSocketServer {
workspaceGitService: this.workspaceGitService,
workspaceAutoName: this.workspaceAutoName,
daemonConfigStore: this.daemonConfigStore,
pluginRuntime: this.pluginRuntime,
mcpBaseUrl: this.mcpBaseUrl,
stt: () => this.speech?.resolveStt() ?? null,
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
@@ -1535,6 +1544,8 @@ export class VoiceAssistantWebSocketServer {
...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}),
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
// COMPAT(plugins): added in v0.3.0, remove gate after 2027-08-07.
plugins: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
// COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02.