mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
+6
-1
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } }
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user