import { requirePluginText } from "@getpaseo/plugin/client/host"; import type { createPluginHosts } from "./hosts"; import { openExternalUrl } from "@/utils/open-external-url"; import * as pluginUiRuntime from "./react-native/ui"; import { useRevealedText } from "@/hooks/use-revealed-text"; import { useSettings } from "./settings/use-settings"; import * as pluginSharedRuntime from "@getpaseo/plugin"; import * as pluginClientRuntime from "@getpaseo/plugin/client"; import * as React from "react"; import * as ReactJsxRuntime from "react/jsx-runtime"; // eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative. 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 { type PluginCleanup } from "@getpaseo/plugin"; import { type PluginClientContext, type PluginAgentPanelContribution, type PluginWorkspacePanelContribution as WorkspacePanelInput, type PluginScreenProps, type PluginScreenTitle, type PluginText, type PluginSurfaceProps, type PluginTimelineRendererContribution, type PluginTimelineTransformerContribution, type PluginButtonRegistration, } from "@getpaseo/plugin/client"; import type { EvaluatedPlugin, PluginSidebarSection, PluginAttachmentSourceContribution, PluginThemeContribution, PluginCommandCenterItemContribution, PluginClientSlashCommandContribution, PluginSidebarContribution, PluginScreenContribution, PluginSidebarItemContribution, PluginWorkspacePanelContribution, } from "./types"; import type { ComponentType } from "react"; import { parsePluginIcon, parseSerializablePluginIcon } from "./icons"; import { parsePluginThemeContribution } from "./themes"; const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/; const PANEL_LOCATIONS = ["workspace", "explorer"] as const; const TIMELINE_ITEM_TYPES = new Set([ "user_message", "assistant_message", "reasoning", "tool_call", "todo", "notification", "error", "compaction", ]); function normalizePanelLocations( panelId: string, locations: PluginWorkspacePanelContribution["locations"], ): readonly (typeof PANEL_LOCATIONS)[number][] { if (locations === undefined) return ["workspace"]; if (!Array.isArray(locations) || locations.length === 0) { throw new Error(`Workspace panel ${panelId} must support at least one location`); } const normalized = locations.map((location) => { if (!PANEL_LOCATIONS.includes(location as never)) { throw new Error(`Workspace panel ${panelId} has invalid location: ${String(location)}`); } return location; }); if (new Set(normalized).size !== normalized.length) { throw new Error(`Workspace panel ${panelId} has duplicate locations`); } return normalized; } function isComponentType(value: unknown): boolean { return ( typeof value === "function" || (typeof value === "object" && value !== null && "$$typeof" in value) ); } function normalizePluginText(text: import("@getpaseo/plugin/client").PluginText) { return typeof text === "string" ? text.trim() : requirePluginText(text); } function requireId(value: string, label: string): string { const id = value.trim(); if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`); return id; } /** A non-empty string, or a function of the screen's params resolved when the header renders. */ function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle { if (typeof title === "function") return title as PluginScreenTitle; try { return requirePluginText(title); } catch {} throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`); } export type PluginClientRuntime = Pick< PluginClientContext, | "i18n" | "playAudio" | "paseo" | "rpc" | "openSettings" | "openScreen" | "openSurface" | "openPanel" | "addComposerPill" | "addHeaderButton" > & { hosts: ReturnType }; // COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 // The 0.12 entry exposed the reveal hook as well as UI components. const pluginReactNativeCompatRuntime = { ...pluginUiRuntime, useRevealedText }; export function runPluginClientBundle( id: string, bundle: string, runtime: PluginClientRuntime, onChange: () => void = () => undefined, ): EvaluatedPlugin { const collector: Omit = { surfaces: [], settingsScreens: [], sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], attachmentSources: [], themes: [], timelineTransformers: [], timelineRenderers: [], }; const surfaceIds = new Set(); const settingsScreenIds = new Set(); const sidebarItemIds: Record> = { header: new Set(), footer: new Set(), }; const filePanelIds = new Set(); const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); const clientSlashCommandNames = new Set(); const attachmentSourceIds = new Set(); const themeIds = new Set(); const timelineTransformerIds = new Set(); const timelineRendererIds = new Set(); const removals = new Set(); let setupComplete = false; let stopped = false; function trackButton( registration: PluginButtonRegistration, ): PluginButtonRegistration { let active = true; const remove = () => { if (!active) return; active = false; registration.remove(); removals.delete(remove); }; removals.add(remove); return { update: (patch) => { if (active && !stopped) registration.update(patch); }, remove, }; } const notifyChange = () => { if (setupComplete) onChange(); }; function register(items: T[], item: T, release: () => void): PluginCleanup { items.push(item); notifyChange(); let active = true; const remove = () => { if (!active) return; active = false; const index = items.indexOf(item); if (index !== -1) items.splice(index, 1); release(); removals.delete(remove); notifyChange(); }; removals.add(remove); return remove; } function addSidebarItem( section: PluginSidebarSection, contribution: PluginSidebarItemContribution, ): PluginCleanup { const ids = sidebarItemIds[section]; const normalizedId = requireId(contribution.id, `sidebar ${section} item id`); if (ids.has(normalizedId)) throw new Error(`Duplicate sidebar ${section} item: ${normalizedId}`); const title = normalizePluginText(contribution.title); if (!title) throw new Error(`Sidebar item ${normalizedId} has no title`); if (!isComponentType(contribution.Component)) { throw new Error(`Sidebar item ${normalizedId} is not a component`); } ids.add(normalizedId); return register( collector.sidebarItems[section], { id: normalizedId, title, Component: contribution.Component }, () => ids.delete(normalizedId), ); } function registerPanel(contribution: PluginWorkspacePanelContribution): PluginCleanup { const normalizedId = requireId(contribution.id, "workspace panel id"); if (workspacePanelIds.has(normalizedId)) { throw new Error(`Duplicate workspace panel: ${normalizedId}`); } const title = normalizePluginText(contribution.title); if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon); if (typeof contribution.Component !== "function") { throw new Error(`Workspace panel ${normalizedId} is not a component`); } const locations = normalizePanelLocations(normalizedId, contribution.locations); workspacePanelIds.add(normalizedId); return register( collector.workspacePanels, { ...contribution, id: normalizedId, title, icon, locations, }, () => workspacePanelIds.delete(normalizedId), ); } const pluginContext: PluginClientContext = { ...runtime, addSettingsScreen(contribution) { const screenId = requireId(contribution.id, "settings screen id"); if (settingsScreenIds.has(screenId)) throw new Error(`Duplicate settings screen: ${screenId}`); if (!normalizePluginText(contribution.title)) throw new Error(`Invalid settings screen: ${screenId}`); const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon); settingsScreenIds.add(screenId); return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () => settingsScreenIds.delete(screenId), ); }, addScreen(contribution: PluginScreenContribution) { if (typeof contribution !== "object" || contribution === null) { throw new Error("addScreen takes { id, title, Component }"); } const normalizedId = requireId(contribution.id, "screen id"); if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate screen: ${normalizedId}`); const title = requireScreenTitle(normalizedId, contribution.title); if (!isComponentType(contribution.Component)) { throw new Error(`Screen ${normalizedId} is not a component`); } surfaceIds.add(normalizedId); return register( collector.surfaces, { id: normalizedId, title, Component: contribution.Component }, () => surfaceIds.delete(normalizedId), ); }, addSidebarHeaderItem(contribution: PluginSidebarItemContribution) { return addSidebarItem("header", contribution); }, addSidebarFooterItem(contribution: PluginSidebarItemContribution) { return addSidebarItem("footer", contribution); }, // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 // Titled by its id; a legacy item pointing at it lends the header its own title instead. addSurface(surfaceId: string, Component: ComponentType) { if (!isComponentType(Component)) throw new Error(`Screen ${surfaceId} is not a component`); return pluginContext.addScreen({ id: surfaceId, title: surfaceId, Component: (props: PluginScreenProps) => React.createElement(Component, props), }); }, // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 addSidebarItem(contribution: PluginSidebarContribution) { const normalizedId = requireId(contribution.id, "sidebar item id"); const legacy = { id: normalizedId, title: normalizePluginText(contribution.title), icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon), surface: requireId(contribution.surface, "sidebar surface id"), }; // Shares the header's ids: the sidebar orders both kinds of item under one key. const ids = sidebarItemIds.header; if (ids.has(normalizedId)) throw new Error(`Duplicate sidebar header item: ${normalizedId}`); if (!legacy.title) throw new Error(`Sidebar item ${normalizedId} has no title`); ids.add(normalizedId); return register(collector.legacySidebarItems, legacy, () => ids.delete(normalizedId)); }, addFilePanel(contribution) { const panelId = requireId(contribution.id, "file panel id"); if (filePanelIds.has(panelId)) throw new Error(`Duplicate file panel: ${panelId}`); const title = normalizePluginText(contribution.title); if (!title) throw new Error(`File panel ${panelId} has no title`); const icon = parsePluginIcon(`File panel ${panelId}`, contribution.icon); const files = contribution.files; const validEntries = (entries: unknown, extension: boolean): entries is string[] => Array.isArray(entries) && entries.every( (entry) => typeof entry === "string" && entry.trim() === entry && entry.length > 0 && !/[\\/*?]/.test(entry) && (!extension || !entry.includes(".")), ); if ( !files || Object.keys(files).some((key) => key !== "extensions" && key !== "filenames") || !validEntries(files.extensions, true) || (files.filenames !== undefined && !validEntries(files.filenames, false)) || (files.extensions.length === 0 && !files.filenames?.length) ) { throw new Error(`File panel ${panelId} has invalid selector`); } if (typeof contribution.Component !== "function") throw new Error(`File panel ${panelId} is not a component`); filePanelIds.add(panelId); return register( collector.filePanels, { ...contribution, id: panelId, title, icon, files: { extensions: [...files.extensions], ...(files.filenames ? { filenames: [...files.filenames] } : {}), }, }, () => filePanelIds.delete(panelId), ); }, addAgentPanel(contribution: PluginAgentPanelContribution) { return registerPanel({ ...contribution, context: "agent" }); }, addWorkspacePanel(input: WorkspacePanelInput) { // COMPAT(pluginAgentPanelAlias): added in v0.13.0, remove after 2027-04-23 if (input.context === "agent") return pluginContext.addAgentPanel(input); // COMPAT(pluginPanelFlatProps): added in v0.13.0, remove after 2027-04-23 if (input.context !== undefined && input.context !== "workspace") { throw new Error(`Workspace panel ${input.id} has invalid context`); } return registerPanel({ ...input, context: "workspace" }); }, addCommandCenterItem(contribution: PluginCommandCenterItemContribution) { const normalizedId = requireId(contribution.id, "Command Center item id"); if (commandCenterItemIds.has(normalizedId)) { throw new Error(`Duplicate Command Center item: ${normalizedId}`); } const title = normalizePluginText(contribution.title); if (!title) throw new Error(`Command Center item ${normalizedId} has no title`); const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon); if ( contribution.context !== "global" && contribution.context !== "workspace" && contribution.context !== "agent" ) { throw new Error(`Command Center item ${normalizedId} has invalid context`); } if (typeof contribution.onSelect !== "function") { throw new Error(`Command Center item ${normalizedId} has no callback`); } commandCenterItemIds.add(normalizedId); return register( collector.commandCenterItems, { ...contribution, id: normalizedId, title, icon, keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean), }, () => commandCenterItemIds.delete(normalizedId), ); }, addSlashCommand(contribution: PluginClientSlashCommandContribution) { const name = requireId(contribution.name, "client slash command name"); if (clientSlashCommandNames.has(name)) { throw new Error(`Duplicate client slash command: ${name}`); } const description = normalizePluginText(contribution.description); if (!description) throw new Error(`Client slash command ${name} has no description`); if (contribution.context !== "workspace" && contribution.context !== "agent") { throw new Error(`Client slash command ${name} has invalid context`); } if (typeof contribution.onSubmit !== "function") { throw new Error(`Client slash command ${name} has no callback`); } clientSlashCommandNames.add(name); return register( collector.clientSlashCommands, { ...contribution, name, description, argumentHint: normalizePluginText(contribution.argumentHint), }, () => clientSlashCommandNames.delete(name), ); }, addAttachmentSource(contribution: PluginAttachmentSourceContribution) { const normalizedId = requireId(contribution.id, "attachment source id"); if (attachmentSourceIds.has(normalizedId)) { throw new Error(`Duplicate attachment source: ${normalizedId}`); } const title = normalizePluginText(contribution.title); const pickerTitle = normalizePluginText(contribution.pickerTitle); const searchPlaceholder = normalizePluginText(contribution.searchPlaceholder); const method = contribution.search.name.trim(); if (!title) throw new Error(`Attachment source ${normalizedId} has no title`); const icon = parseSerializablePluginIcon( `Attachment source ${normalizedId}`, contribution.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`); attachmentSourceIds.add(normalizedId); return register( collector.attachmentSources, { id: normalizedId, title, icon, pickerTitle, searchPlaceholder, search: { ...contribution.search, name: method }, }, () => attachmentSourceIds.delete(normalizedId), ); }, addTheme(contribution: PluginThemeContribution) { const normalizedId = requireId(contribution.id, "theme id"); if (themeIds.has(normalizedId)) throw new Error(`Duplicate theme: ${normalizedId}`); const theme = parsePluginThemeContribution({ ...contribution, id: normalizedId }); themeIds.add(normalizedId); return register(collector.themes, theme, () => themeIds.delete(normalizedId)); }, addTimelineTransformer(contribution: PluginTimelineTransformerContribution) { const normalizedId = requireId(contribution.id, "timeline transformer id"); if (timelineTransformerIds.has(normalizedId)) { throw new Error(`Duplicate timeline transformer: ${normalizedId}`); } if (!contribution.query || typeof contribution.query.itemType !== "string") { throw new Error(`Timeline transformer ${normalizedId} has no item type`); } if (!TIMELINE_ITEM_TYPES.has(contribution.query.itemType)) { throw new Error( `Timeline transformer ${normalizedId} has invalid item type: ${contribution.query.itemType}`, ); } if (typeof contribution.transform !== "function") { throw new Error(`Timeline transformer ${normalizedId} has no transform`); } if (contribution.id !== normalizedId) { throw new Error(`Invalid timeline transformer id: ${contribution.id}`); } timelineTransformerIds.add(normalizedId); return register(collector.timelineTransformers, contribution, () => timelineTransformerIds.delete(normalizedId), ); }, addTimelineRenderer(contribution: PluginTimelineRendererContribution) { const kind = requireId(contribution.kind, "timeline renderer kind"); if (!Number.isInteger(contribution.version) || contribution.version < 1) { throw new Error(`Timeline renderer ${kind} has invalid version`); } const rendererId = `${kind}/${contribution.version}`; if (timelineRendererIds.has(rendererId)) { throw new Error(`Duplicate timeline renderer: ${rendererId}`); } if (!contribution.schema || typeof contribution.schema.safeParse !== "function") { throw new Error(`Timeline renderer ${rendererId} has no schema`); } if (typeof contribution.Component !== "function") { throw new Error(`Timeline renderer ${rendererId} is not a component`); } timelineRendererIds.add(rendererId); return register(collector.timelineRenderers, { ...contribution, kind }, () => timelineRendererIds.delete(rendererId), ); }, addComposerPill(contribution) { if (stopped) throw new Error("Plugin has stopped"); return trackButton(runtime.addComposerPill(contribution)); }, addHeaderButton(contribution) { if (stopped) throw new Error("Plugin has stopped"); return trackButton(runtime.addHeaderButton(contribution)); }, }; const runtimeRequire = (name: string): unknown => { // COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20 if (name === "@getpaseo/plugin/client/react-native") return pluginReactNativeCompatRuntime; if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; if (name === "react-native") return ReactNative; if (name === "@getpaseo/plugin") return pluginSharedRuntime; if (name === "@getpaseo/plugin/client") return { ...pluginClientRuntime, useSettings, openExternalUrl, getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId), useHosts: () => React.useSyncExternalStore( runtime.hosts.subscribe, runtime.hosts.getSnapshot, runtime.hosts.getSnapshot, ), }; 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`); } let entryCleanup: PluginCleanup | undefined; try { entryCleanup = setup(pluginContext); if (typeof entryCleanup !== "function") throw new Error(`Plugin ${id} contribution must return a cleanup function`); for (const item of collector.legacySidebarItems) { if (!surfaceIds.has(item.surface)) { throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); } } } catch (error) { stopped = true; try { void Promise.resolve( typeof entryCleanup === "function" ? entryCleanup() : entryCleanup, ).catch((cleanupError) => { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); }); } catch (cleanupError) { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); } finally { for (const remove of removals) remove(); } throw error; } setupComplete = true; const cleanupEntry = entryCleanup; const cleanup = async () => { if (stopped) return; stopped = true; try { await cleanupEntry(); } finally { for (const remove of removals) remove(); } }; return { id, cleanup, surfaces: collector.surfaces, settingsScreens: collector.settingsScreens, sidebarItems: collector.sidebarItems, legacySidebarItems: collector.legacySidebarItems, filePanels: collector.filePanels, workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, clientSlashCommands: collector.clientSlashCommands, attachmentSources: collector.attachmentSources, themes: collector.themes, timelineTransformers: collector.timelineTransformers, timelineRenderers: collector.timelineRenderers, }; }