mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3751f1bf | ||
|
|
cb4976289b | ||
|
|
2668f31e07 | ||
|
|
be523e09e9 | ||
|
|
1c41ba0bee |
@@ -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
@@ -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.
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "local-example"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "linear"
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-RCp5Ogd8AETLmJCZaUebcgSPRk+is26nkUY7+blDb/g=
|
||||
sha256-BfRbfVUFaHQEMtDOsWAo49txz+cueRpe/7Qxltg+HSg=
|
||||
|
||||
Generated
+531
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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'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" &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user