diff --git a/docs/plugins.md b/docs/plugins.md index 67ca017cbb..cc97fa5d2e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -253,6 +253,11 @@ selected host's existing connection; switching the screen's host changes both `u installation. A server handler owns an IPC-backed daemon session for the life of its subprocess. Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation. +Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation +bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not +resolve ownership through a mutable current-plugin global. Keep observation ownership in this module +and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/v0.8/reference.md#discover-hosts-and-target-another-host). + Each subprocess gets an exclusively owned `plugin:` session. That identity is reserved from normal clients, never resumes another session, and is cleaned immediately on exit without reconnect grace. During daemon startup, plugin sessions may connect while application WebSockets remain @@ -261,7 +266,7 @@ catalog is complete. 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. +query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/v0.8/reference.md#discover-hosts-and-target-another-host). Workspace panels, Command Center items, and client slash commands are client contributions. The daemon transports their compiled bundle without interpreting placement or callbacks. Panel props diff --git a/packages/app/e2e/browser/plugin-hosts.spec.ts b/packages/app/e2e/browser/plugin-hosts.spec.ts new file mode 100644 index 0000000000..69364d2c42 --- /dev/null +++ b/packages/app/e2e/browser/plugin-hosts.spec.ts @@ -0,0 +1,31 @@ +import { test } from "../support/fixtures"; +import { installHostClientsScenario, readHost } from "../support/helpers/plugin-hosts"; + +test("plugin discovers offline hosts and borrows another host without installing there", async ({ + page, +}) => { + test.setTimeout(120_000); + const hosts = await installHostClientsScenario(page); + try { + await hosts.expectOnline(); + await readHost(page, "selected", "selected:plugins=true"); + await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`); + await hosts.disposeSecondaryAndExpectObservationReleased(); + await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`); + await readHost(page, "missing", "Unknown Paseo host: missing"); + await hosts.reloadAndExpectObservationReleased(); + await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`); + await page.screenshot({ path: test.info().outputPath("two-hosts.png") }); + await hosts.restartSecondary(); + await hosts.expectOnline(); + await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`); + await hosts.useCompactLayout(); + await readHost(page, "Secondary", `${hosts.serverId}:plugins=false`); + await page.screenshot({ path: test.info().outputPath("two-hosts-compact.png") }); + await hosts.disconnectSecondary(); + await readHost(page, "Secondary", `Paseo host is disconnected: ${hosts.serverId}`); + await readHost(page, "selected", "selected:plugins=true"); + } finally { + await hosts.close(); + } +}); diff --git a/packages/app/e2e/support/helpers/plugin-hosts.ts b/packages/app/e2e/support/helpers/plugin-hosts.ts new file mode 100644 index 0000000000..3072b92518 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-hosts.ts @@ -0,0 +1,154 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, type Page } from "../fixtures"; +import { gotoAppShell } from "./app"; +import { addConnectedHostAndReload } from "./hosts"; +import { startIsolatedHostDaemon } from "./isolated-host-daemon"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { pluginRequirements } from "./plugin-fixture"; + +const id = "host-clients"; +const source = `import { useHosts, getPaseoClient, usePaseo } from "@getpaseo/plugin/client"; +import { useState } from "react"; +import { View, Text, Pressable } from "react-native"; +export default function contribute(plugin) { + function Surface() { + const hosts = useHosts(); + const selected = usePaseo(); + const [result, setResult] = useState(""); + const [subscriptionId, setSubscriptionId] = useState(""); + async function read(serverId) { + setResult("Loading"); + try { + const client = serverId ? getPaseoClient(serverId) : selected; + const { subscriptionId } = await client.agents.list({ subscribe: {} }); + setSubscriptionId(subscriptionId); + const { config } = await client.config.get(); + setResult((serverId || "selected") + ":plugins=" + Boolean(config.pluginsEnabled)); + } catch (error) { setResult(error.message); } + } + async function disposeHost(serverId) { + await getPaseoClient(serverId).dispose(); + setResult("Disposed:" + serverId); + } + return + {hosts.map(host => + {host.label + ":" + host.status} + disposeHost(host.serverId)}>{"Dispose " + host.label} + read(host.serverId)}>{"Read " + host.label} + )} + read()}>Read selected + read("missing")}>Read missing + {result} + {"Agents subscription:" + subscriptionId} + ; + } + plugin.addSurface("main", Surface); + plugin.addSidebarItem({ id: "main", title: "Host clients", icon: "Server", surface: "main" }); + return () => {}; +}`; + +function observeSubscriptionReleases(page: Page, port: number) { + const released = new Set(); + page.on("websocket", (socket) => { + if (new URL(socket.url()).port !== String(port)) return; + socket.on("framesent", ({ payload }) => { + const frame = JSON.parse(String(payload)); + if (frame.type === "session" && frame.message?.type === "subscription.release.request") + released.add(frame.message.subscriptionId); + }); + }); + return { has: (subscriptionId: string) => released.has(subscriptionId) }; +} + +export async function openHostClients(page: Page) { + await page.getByRole("button", { name: "Host clients", exact: true }).click(); +} +export async function readHost(page: Page, label: string, result: string) { + await page.getByRole("button", { name: `Read ${label}`, exact: true }).click(); + await expect(page.getByText(result, { exact: true }).filter({ visible: true })).toBeVisible(); +} + +export async function installHostClientsScenario(page: Page) { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-hosts-")); + const primary = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const secondary = await startIsolatedHostDaemon("plugin-hosts-secondary"); + const remote = await connectNewWorkspaceDaemonClient({ + port: secondary.port, + ownProjects: false, + }); + const previous = await primary.getDaemonConfig(); + const releases = observeSubscriptionReleases(page, secondary.port); + async function close() { + await remote.close().catch(() => undefined); + await secondary.close(); + await primary.removePlugin(id).catch(() => undefined); + await primary.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false }); + await primary.close(); + await rm(directory, { recursive: true, force: true }); + } + try { + await remote.patchDaemonConfig({ pluginsEnabled: false }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id, requirements: pluginRequirements }), + ); + await writeFile(path.join(directory, "index.client.tsx"), source); + await primary.patchDaemonConfig({ pluginsEnabled: true }); + await primary.installDirectoryPlugin(directory); + await gotoAppShell(page); + await openHostClients(page); + await addConnectedHostAndReload(page, { + serverId: secondary.serverId, + label: "Secondary", + port: secondary.port, + primaryLabel: "Primary", + }); + await openHostClients(page); + } catch (error) { + await close(); + throw error; + } + async function expectObservationReleased(action: () => Promise) { + const text = await page + .getByText(/^Agents subscription:/) + .filter({ visible: true }) + .innerText(); + const subscriptionId = text.slice("Agents subscription:".length); + expect(subscriptionId).not.toBe(""); + expect(releases.has(subscriptionId)).toBe(false); + await action(); + await expect.poll(() => releases.has(subscriptionId)).toBe(true); + } + return { + serverId: secondary.serverId, + close, + async expectOnline() { + await expect( + page.getByText("Primary:online", { exact: true }).filter({ visible: true }), + ).toBeVisible(); + await expect( + page.getByText("Secondary:online", { exact: true }).filter({ visible: true }), + ).toBeVisible({ timeout: 30_000 }); + }, + reloadAndExpectObservationReleased: () => + expectObservationReleased(() => primary.reloadPlugin(id)), + async disposeSecondaryAndExpectObservationReleased() { + await expectObservationReleased(() => + page.getByRole("button", { name: "Dispose Secondary", exact: true }).click(), + ); + await expect( + page.getByText(`Disposed:${secondary.serverId}`, { exact: true }).filter({ visible: true }), + ).toBeVisible(); + }, + restartSecondary: () => secondary.restart(), + async disconnectSecondary() { + await secondary.close(); + await expect( + page.getByText(/^Secondary:(offline|error|connecting)$/).filter({ visible: true }), + ).toBeVisible(); + }, + useCompactLayout: () => page.setViewportSize({ width: 390, height: 844 }), + }; +} diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index 4424c240fa..6e7b13501b 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,3 +1,5 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { createPluginHosts } from "./hosts"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { @@ -26,6 +28,7 @@ export function createPluginClientRuntime( ); return { ...capabilities, + hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), addComposerPill(contribution) { return pluginButtonStore.addComposerPill(installation, contribution); }, diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index db72d0846a..859beb42b2 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -600,3 +600,39 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("setup exploded"); }); }); + +it("binds imported getters to each originating installation across delayed callbacks", async () => { + const calls: string[] = []; + const hostRuntime = (installation: string): PluginClientRuntime => ({ + ...runtime, + hosts: { + getSnapshot: () => [], + subscribe: () => () => {}, + getPaseoClient(serverId) { + calls.push(`${installation}/${serverId}`); + return runtime.paseo; + }, + }, + }); + const source = bundle(` + const { getPaseoClient } = require("@getpaseo/plugin/client"); + getPaseoClient("entry-host"); + plugin.addCommandCenterItem({ + id: "read", title: "Read", icon: "Server", context: "global", + onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); }, + }); + `); + const first = runPluginClientBundle("same-id", source, hostRuntime("first")); + const second = runPluginClientBundle("same-id", source, hostRuntime("second")); + // Both callbacks run after the second bundle has evaluated. + await first.commandCenterItems[0].onSelect({} as never); + await second.commandCenterItems[0].onSelect({} as never); + expect(calls).toEqual([ + "first/entry-host", + "second/entry-host", + "first/target-host", + "second/target-host", + ]); + await first.cleanup(); + await second.cleanup(); +}); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index d3cb17b150..02695dc5fb 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -1,3 +1,4 @@ +import type { createPluginHosts } from "./hosts"; import * as pluginUiRuntime from "./react-native/ui"; import { useSettings } from "./settings/use-settings"; import * as pluginSharedRuntime from "@getpaseo/plugin"; @@ -78,7 +79,7 @@ export type PluginClientRuntime = Pick< | "openPanel" | "addComposerPill" | "addHeaderButton" ->; +> & { hosts: ReturnType }; export function runPluginClientBundle( id: string, @@ -374,7 +375,18 @@ export function runPluginClientBundle( 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 }; + if (name === "@getpaseo/plugin/client") + return { + ...pluginClientRuntime, + useSettings, + getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId), + useHosts: () => + React.useSyncExternalStore( + runtime.hosts.subscribe, + runtime.hosts.getSnapshot, + runtime.hosts.getSnapshot, + ), + }; if (name === "@getpaseo/plugin/client/react-native") { return pluginReactNativeRuntime; } diff --git a/packages/app/src/plugins/hosts/index.test.ts b/packages/app/src/plugins/hosts/index.test.ts new file mode 100644 index 0000000000..287c1480eb --- /dev/null +++ b/packages/app/src/plugins/hosts/index.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { createPluginHosts, type PluginHostsSource } from "./index"; + +function registry() { + const hosts = [ + { serverId: "a", label: "Alpha", password: "secret" }, + { serverId: "b", label: "Beta", password: "secret" }, + ]; + const snapshots = new Map>>(); + const listeners = new Set<() => void>(); + const source: PluginHostsSource = { + getHosts: () => hosts, + getSnapshot: (id) => snapshots.get(id) ?? null, + subscribeAll(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + subscribeHostList(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + const lifetime = new AbortController(); + const runtime = createPluginHosts(source, lifetime.signal); + return { + hosts, + snapshots, + listeners, + lifetime, + runtime, + source, + publish() { + for (const listener of listeners) listener(); + }, + }; +} +function ignoreUpdate() {} + +function connection(id: string) { + return new DaemonClient({ url: `ws://${id}`, clientId: "test", reconnect: { enabled: false } }); +} + +describe("plugin host access", () => { + it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => { + const h = registry(); + expect(h.runtime.getSnapshot()).toEqual([ + { serverId: "a", label: "Alpha", status: "offline" }, + { serverId: "b", label: "Beta", status: "offline" }, + ]); + const before = h.runtime.getSnapshot(); + h.publish(); + expect(h.runtime.getSnapshot()).toBe(before); + let updates = 0; + h.runtime.subscribe(() => updates++); + h.snapshots.set("b", { connectionStatus: "connecting", client: null }); + h.publish(); + expect(updates).toBe(1); + expect(h.runtime.getSnapshot()[1].status).toBe("connecting"); + h.hosts[1].label = "Renamed"; + h.publish(); + expect(h.runtime.getSnapshot()[1].label).toBe("Renamed"); + h.lifetime.abort(); + expect(h.listeners.size).toBe(0); + }); + + it("rejects unknown and disconnected targets without falling through to another host", () => { + const h = registry(); + h.snapshots.set("a", { connectionStatus: "online", client: connection("a") }); + expect(() => h.runtime.getPaseoClient("missing")).toThrow("Unknown Paseo host: missing"); + expect(() => h.runtime.getPaseoClient("b")).toThrow("Paseo host is disconnected: b"); + h.lifetime.abort(); + }); + + it("isolates installation lifetimes and releases APIs when a connection is replaced", async () => { + const h = registry(); + const client = connection("b"); + h.snapshots.set("b", { connectionStatus: "online", client }); + h.publish(); + const otherLifetime = new AbortController(); + const other = createPluginHosts(h.source, otherLifetime.signal); + const api = h.runtime.getPaseoClient("b"); + expect(api).toBe(h.runtime.getPaseoClient("b")); + expect(api).not.toBe(other.getPaseoClient("b")); + expect(api).not.toHaveProperty("connect"); + expect(api).not.toHaveProperty("close"); + api.agents.subscribe(ignoreUpdate); + h.snapshots.set("b", { connectionStatus: "offline", client }); + h.publish(); + expect(() => h.runtime.getPaseoClient("b")).toThrow("disconnected"); + expect(() => api.config.get()).toThrow("Paseo host is disconnected: b"); + h.snapshots.set("b", { connectionStatus: "online", client }); + h.publish(); + expect(h.runtime.getPaseoClient("b")).toBe(api); + h.snapshots.set("b", { connectionStatus: "online", client: connection("new-b") }); + h.publish(); + expect(() => api.agents.subscribe(ignoreUpdate)).toThrow("disposed"); + expect(() => api.config.get()).toThrow("Paseo client is released: b"); + expect(h.runtime.getPaseoClient("b")).not.toBe(api); + h.lifetime.abort(); + expect(() => h.runtime.getPaseoClient("b")).toThrow("Plugin has stopped"); + expect(() => other.getPaseoClient("b").agents.subscribe(ignoreUpdate)).not.toThrow(); + otherLifetime.abort(); + }); +}); + +it("reacquires a fresh API after explicit disposal without affecting a later borrower", async () => { + const h = registry(); + h.snapshots.set("b", { connectionStatus: "online", client: connection("b") }); + const first = h.runtime.getPaseoClient("b"); + await first.dispose(); + const second = h.runtime.getPaseoClient("b"); + expect(second).not.toBe(first); + expect(() => second.agents.subscribe(ignoreUpdate)).not.toThrow(); + expect(() => first.config.get()).toThrow("Paseo client is released: b"); + await first.dispose(); + expect(h.runtime.getPaseoClient("b")).toBe(second); + h.lifetime.abort(); + expect(() => second.agents.subscribe(ignoreUpdate)).toThrow("disposed"); +}); diff --git a/packages/app/src/plugins/hosts/index.ts b/packages/app/src/plugins/hosts/index.ts new file mode 100644 index 0000000000..f3a6ace947 --- /dev/null +++ b/packages/app/src/plugins/hosts/index.ts @@ -0,0 +1,109 @@ +import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PluginHostSummary } from "@getpaseo/plugin/client"; + +export interface PluginHostsSource { + getHosts(): readonly { serverId: string; label: string }[]; + getSnapshot(serverId: string): { + connectionStatus: PluginHostSummary["status"]; + client: DaemonClient | null; + } | null; + subscribeAll(listener: () => void): () => void; + subscribeHostList(listener: () => void): () => void; +} + +/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */ +export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) { + const clients = new Map< + string, + { client: DaemonClient; api: PaseoApi; lifetime: AbortController } + >(); + const listeners = new Set<() => void>(); + let snapshot: readonly PluginHostSummary[] = []; + function readHosts(): readonly PluginHostSummary[] { + return source.getHosts().map(({ serverId, label }) => ({ + serverId, + label, + status: source.getSnapshot(serverId)?.connectionStatus ?? "offline", + })); + } + function resolve(serverId: string): DaemonClient { + if (signal.aborted) throw new Error("Plugin has stopped"); + if (!source.getHosts().some((host) => host.serverId === serverId)) { + throw new Error(`Unknown Paseo host: ${serverId}`); + } + const host = source.getSnapshot(serverId); + if (host?.connectionStatus !== "online" || !host.client) { + throw new Error(`Paseo host is disconnected: ${serverId}`); + } + return host.client; + } + function refresh() { + for (const [serverId, entry] of clients) { + if ( + !source.getHosts().some((host) => host.serverId === serverId) || + source.getSnapshot(serverId)?.client !== entry.client + ) { + entry.lifetime.abort(); + clients.delete(serverId); + } + } + const next = readHosts(); + if (JSON.stringify(snapshot) === JSON.stringify(next)) return; + snapshot = next; + for (const listener of listeners) listener(); + } + snapshot = readHosts(); + const unsubscribeRuntime = source.subscribeAll(refresh); + const unsubscribeHosts = source.subscribeHostList(refresh); + function stop() { + unsubscribeRuntime(); + unsubscribeHosts(); + listeners.clear(); + for (const entry of clients.values()) entry.lifetime.abort(); + clients.clear(); + } + if (signal.aborted) stop(); + else signal.addEventListener("abort", stop, { once: true }); + return { + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + if (signal.aborted) return () => {}; + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getPaseoClient(serverId: string): PaseoApi { + const client = resolve(serverId); + const existing = clients.get(serverId); + if (existing?.client === client) return existing.api; + existing?.lifetime.abort(); + const lifetime = new AbortController(); + // Guard even retained SDK handles against unload, offline calls, and replaced connections. + // Methods keep the real receiver because DaemonClient owns private state. + const borrowed = new Proxy(client, { + get(target, key) { + const value: unknown = Reflect.get(target, key, target); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + if (lifetime.signal.aborted) throw new Error(`Paseo client is released: ${serverId}`); + if (resolve(serverId) !== client) { + throw new Error(`Paseo connection changed; call getPaseoClient again: ${serverId}`); + } + return Reflect.apply(value, target, args); + }; + }, + }); + const api = createPaseoApi(borrowed, { signal: lifetime.signal }); + const dispose = api.dispose; + api.dispose = () => { + if (clients.get(serverId)?.api === api) clients.delete(serverId); + lifetime.abort(); + return dispose(); + }; + clients.set(serverId, { client, api, lifetime }); + return api; + }, + }; +} diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index 1a239a3d30..526ce1a809 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { createPluginHosts } from "./hosts"; import { expect, test } from "vitest"; import { createPaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -11,6 +12,15 @@ function registry() { createRuntime: (installation) => { const api = createPaseoApi(client); return { + hosts: createPluginHosts( + { + getHosts: () => [], + getSnapshot: () => null, + subscribeAll: () => () => {}, + subscribeHostList: () => () => {}, + }, + installation.lifetime.signal, + ), paseo: { ...api, dispose: async () => { diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 5bc3d7fb40..32e4bea8b9 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -1,3 +1,4 @@ +import { createPluginHosts } from "./hosts"; import { afterEach, expect, it } from "vitest"; import { createPaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -10,9 +11,18 @@ function registry(version: string) { let cleanups = 0; const result = new PluginRegistry({ version, - createRuntime() { + createRuntime(installation) { starts++; return { + hosts: createPluginHosts( + { + getHosts: () => [], + getSnapshot: () => null, + subscribeAll: () => () => {}, + subscribeHostList: () => () => {}, + }, + installation.lifetime.signal, + ), paseo: createPaseoApi(client), rpc: async () => { throw new Error("No RPC in this plugin"); diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx index 17c22e906f..0e3337e240 100644 --- a/packages/app/src/screens/projects-screen.test.tsx +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -104,19 +104,6 @@ vi.mock("expo-router", () => ({ router: { push }, })); -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string, values?: Record) => { - if (key === "sidebar.project.empty.title") return "No projects yet"; - if (key === "settings.projectList.hostLoadFailed") { - return `Couldn't load projects from host ${values?.hostName}: ${values?.message}`; - } - if (key === "settings.projectList.editProject") return `Edit ${values?.projectName}`; - return key; - }, - }), -})); - vi.mock("@/components/ui/loading-spinner", () => ({ LoadingSpinner: ({ size }: { size?: string | number }) => React.createElement("span", { @@ -184,6 +171,7 @@ vi.mock("@/projects/icons", () => ({ useProjectIcons: () => new Map(), })); +import { i18n } from "@/i18n/i18next"; import ProjectsScreen from "./projects-screen"; function workspaceSummary(overrides: Partial = {}): WorkspaceSummary { @@ -251,7 +239,8 @@ describe("ProjectsScreen", () => { let container: HTMLElement | null = null; let root: Root | null = null; - beforeEach(() => { + beforeEach(async () => { + await i18n.changeLanguage("en"); vi.stubGlobal("React", React); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 9e771cc9c0..a4d7c6e429 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -45,3 +45,14 @@ import type { ZodType } from "zod"; export declare function useSettings( definition: SettingsDefinition, ): SettingsState; + +/** Configured app host, including hosts that are currently disconnected. */ +export interface PluginHostSummary { + readonly serverId: string; + readonly label: string; + readonly status: "idle" | "connecting" | "online" | "offline" | "error"; +} +/** Live configured hosts. Supplied by the app's client bundle loader. */ +export declare function useHosts(): readonly PluginHostSummary[]; +/** Borrow an online host's API under this installation's lifetime. */ +export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi; diff --git a/plugin-examples/hosts/client/hosts.tsx b/plugin-examples/hosts/client/hosts.tsx new file mode 100644 index 0000000000..096843ed9a --- /dev/null +++ b/plugin-examples/hosts/client/hosts.tsx @@ -0,0 +1,59 @@ +import { + getPaseoClient, + useHosts, + usePaseo, + type PluginSurfaceProps, +} from "@getpaseo/plugin/client"; +import { useCallback, useMemo, useState } from "react"; +import { Pressable, Text, View } from "react-native"; + +export function Hosts({ theme }: PluginSurfaceProps) { + const hosts = useHosts(); + const selected = usePaseo(); + const [result, setResult] = useState(""); + const list = useCallback( + async (serverId?: string) => { + try { + const client = serverId ? getPaseoClient(serverId) : selected; + const { entries } = await client.agents.list(); + setResult(`${serverId ?? "Selected host"}: ${entries.length} agents`); + } catch (error) { + setResult(error instanceof Error ? error.message : String(error)); + } + }, + [selected], + ); + const styles = useMemo( + () => ({ + root: { padding: 16, gap: 12, backgroundColor: theme.colors.surface0 }, + text: { color: theme.colors.foreground }, + }), + [theme], + ); + const listSelected = useCallback(() => { + void list(); + }, [list]); + const rows = hosts.map((host) => ({ + serverId: host.serverId, + label: host.label, + status: host.status, + onPress() { + void list(host.serverId); + }, + })); + return ( + + + List selected host agents + + {rows.map((host) => ( + + + {host.label}: {host.status} + + + ))} + {result} + + ); +} diff --git a/plugin-examples/hosts/index.client.tsx b/plugin-examples/hosts/index.client.tsx new file mode 100644 index 0000000000..f6b50a0a08 --- /dev/null +++ b/plugin-examples/hosts/index.client.tsx @@ -0,0 +1,8 @@ +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { Hosts } from "./client/hosts"; + +export default function contribute(client: PluginClientContext) { + client.addSurface("main", Hosts); + client.addSidebarItem({ id: "hosts", title: "Host agents", icon: "Server", surface: "main" }); + return () => {}; +} diff --git a/plugin-examples/hosts/paseo-plugin.json b/plugin-examples/hosts/paseo-plugin.json new file mode 100644 index 0000000000..b642ae50ae --- /dev/null +++ b/plugin-examples/hosts/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } } diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index b1a5ec44ab..5e5615cc54 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -1527,6 +1527,82 @@ function PullRequestAction({ theme }: PluginSurfaceProps) { The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection. +### Discover hosts and target another host + +Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback +to run SDK operations on one of them: + +```tsx +import { getPaseoClient, useHosts, type PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { useMemo, useState, type ReactElement } from "react"; +import { Pressable, Text, View } from "react-native"; + +export function HostAgents({ theme }: Pick): ReactElement { + const hosts = useHosts(); + const textStyle = useMemo(() => ({ color: theme.colors.foreground }), [theme]); + const [result, setResult] = useState(""); + + async function listAgents(serverId: string): Promise { + try { + const { entries } = await getPaseoClient(serverId).agents.list(); + setResult(`${entries.length} agents`); + } catch (error) { + setResult(error instanceof Error ? error.message : String(error)); + } + } + + const rows = hosts.map((host) => ({ + host, + onPress() { + void listAgents(host.serverId); + }, + })); + + return ( + + {rows.map(({ host, onPress }) => ( + + + {host.label}: {host.status} + + + ))} + {result} + + ); +} +``` + +`useHosts(): readonly PluginHostSummary[]` includes offline hosts and updates when hosts, +labels, or statuses change. + +| Summary field | Type or values | Meaning | +| ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| `serverId` | `string` | ID to pass to `getPaseoClient`. | +| `label` | `string` | Host's display name. | +| `status` | `"idle"`, `"connecting"`, `"online"`, `"offline"`, `"error"` | Current app connection status. SDK calls require `"online"`. | + +`getPaseoClient(serverId: string): PaseoApi` borrows the host's authenticated app connection. +Call it in client entry code or callbacks; it opens no socket and does not require the plugin +on the target daemon. Acquire the API when performing an action to use the current connection. + +| Event or condition | Result and caller action | +| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unknown host ID | Throws `Unknown Paseo host: `; never falls through to another host. | +| Host is not online | Throws `Paseo host is disconnected: `, including calls through a retained API. Retry when online. | +| Same connection reconnects | Retained APIs remain usable after reconnection; observations resume automatically. | +| Connection settings change or the app switches connections, including automatic failover | The old API is released. Call `getPaseoClient(serverId)` again and recreate subscriptions. | +| Host is removed | Its API is released; the removed ID is unknown. | +| `client.dispose()` | Releases that API and its observations. A later getter call returns a fresh API over the app connection. Disposing the old API again leaves the new API usable. | +| Originating plugin unloads | All its borrowed APIs and observations are released, including those targeting other hosts. Retained handles cannot outlive the installation. | +| Surface host selection changes | `usePaseo()` follows the selected host. An explicitly acquired API keeps its original target. | + +You can also release individual subscriptions through the normal SDK API. + +Plugins are trusted app code; cross-host access is intentional. Summaries contain no connection +URLs or credentials, and borrowed APIs provide no connection lifecycle controls. See the +[host agents example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts). + ## Add plugin-specific backend behavior Use plugin RPC only for work that is not a normal Paseo operation: reading a vendor API, accessing daemon-local resources, or keeping credentials off the client.