Let plugins discover hosts and use host-targeted SDK clients (#4971)

* feat(plugins): expose hosts and host-targeted SDK clients

* test(plugins): verify exact cross-host observation cleanup

* fix(plugins): reacquire explicitly disposed host clients

* docs(plugins): clarify host client usage and lifecycle

* docs(plugins): use theme colors in host discovery example
This commit is contained in:
Mohamed Boudra
2026-09-16 22:45:18 +02:00
committed by GitHub
parent 72e3d6957a
commit 6ce6f4c961
16 changed files with 656 additions and 18 deletions
+6 -1
View File
@@ -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:<id>` 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
@@ -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();
}
});
@@ -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 <View>
{hosts.map(host => <View key={host.serverId}>
<Text>{host.label + ":" + host.status}</Text>
<Pressable accessibilityRole="button" onPress={() => disposeHost(host.serverId)}><Text>{"Dispose " + host.label}</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => read(host.serverId)}><Text>{"Read " + host.label}</Text></Pressable>
</View>)}
<Pressable accessibilityRole="button" onPress={() => read()}><Text>Read selected</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => read("missing")}><Text>Read missing</Text></Pressable>
<Text>{result}</Text>
<Text>{"Agents subscription:" + subscriptionId}</Text>
</View>;
}
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<string>();
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<unknown>) {
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 }),
};
}
@@ -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);
},
+36
View File
@@ -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();
});
+14 -2
View File
@@ -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<typeof createPluginHosts> };
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;
}
@@ -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<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
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");
});
+109
View File
@@ -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;
},
};
}
@@ -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 () => {
@@ -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");
@@ -104,19 +104,6 @@ vi.mock("expo-router", () => ({
router: { push },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) => {
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> = {}): 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");
+11
View File
@@ -45,3 +45,14 @@ import type { ZodType } from "zod";
export declare function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsState<Schema>;
/** 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;
+59
View File
@@ -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 (
<View style={styles.root}>
<Pressable accessibilityRole="button" onPress={listSelected}>
<Text style={styles.text}>List selected host agents</Text>
</Pressable>
{rows.map((host) => (
<Pressable key={host.serverId} accessibilityRole="button" onPress={host.onPress}>
<Text style={styles.text}>
{host.label}: {host.status}
</Text>
</Pressable>
))}
<Text style={styles.text}>{result}</Text>
</View>
);
}
+8
View File
@@ -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 () => {};
}
+1
View File
@@ -0,0 +1 @@
{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } }
+76
View File
@@ -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<PluginSurfaceProps, "theme">): ReactElement {
const hosts = useHosts();
const textStyle = useMemo(() => ({ color: theme.colors.foreground }), [theme]);
const [result, setResult] = useState("");
async function listAgents(serverId: string): Promise<void> {
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 (
<View>
{rows.map(({ host, onPress }) => (
<Pressable key={host.serverId} accessibilityRole="button" onPress={onPress}>
<Text style={textStyle}>
{host.label}: {host.status}
</Text>
</Pressable>
))}
<Text style={textStyle}>{result}</Text>
</View>
);
}
```
`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: <id>`; never falls through to another host. |
| Host is not online | Throws `Paseo host is disconnected: <id>`, 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.