feat(plugins): manage saved hosts through client handles

This commit is contained in:
Mohamed Boudra
2026-11-02 05:01:46 +01:00
parent 4992b8d3da
commit 8029f8d5ea
17 changed files with 577 additions and 53 deletions
+3 -3
View File
@@ -374,10 +374,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline
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
Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. 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/reference.md#discover-hosts-and-target-another-host).
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts).
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
@@ -396,7 +396,7 @@ in-memory bundles usable, and a later catalog retries persistence.
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. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#hosts).
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
@@ -136,8 +136,14 @@ function harness() {
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
+9 -1
View File
@@ -1,5 +1,6 @@
import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import {
@@ -26,7 +27,14 @@ export function createPluginClientRuntime(
...capabilities,
i18n: installation.i18n,
playAudio: createPlayAudio(audio, installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, {
openSettings(serverId) {
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
},
async isManagedLocalHost(serverId) {
return shouldUseDesktopDaemon() && (await getDesktopDaemonStatus()).serverId === serverId;
},
}),
addComposerPill(contribution) {
return pluginButtonStore.addComposerPill(installation, contribution);
},
@@ -819,6 +819,7 @@ it("binds imported getters to each originating installation across delayed callb
hosts: {
getSnapshot: () => [],
subscribe: () => () => {},
observeHosts: () => () => {},
getPaseoClient(serverId) {
calls.push(`${installation}/${serverId}`);
return runtime.paseo;
+2
View File
@@ -520,6 +520,8 @@ export function runPluginClientBundle(
useSettings,
openExternalUrl,
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
observeHosts: (listener: Parameters<typeof runtime.hosts.observeHosts>[0]) =>
runtime.hosts.observeHosts(listener),
useHosts: () =>
React.useSyncExternalStore(
runtime.hosts.subscribe,
+118 -7
View File
@@ -4,14 +4,61 @@ import { createPluginHosts, type PluginHostsSource } from "./index";
function registry() {
const hosts = [
{ serverId: "a", label: "Alpha", password: "secret" },
{ serverId: "b", label: "Beta", password: "secret" },
{
serverId: "a",
label: "Alpha",
password: "secret",
appearance: { color: "none" as const, badgeDisplay: null },
connections: [
{ id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false },
{ id: "two", type: "directSocket" as const, path: "/tmp/test" },
],
lifecycle: {},
preferredConnectionId: null,
createdAt: "today",
updatedAt: "today",
},
{
serverId: "b",
label: "Beta",
password: "secret",
appearance: { color: "none" as const, badgeDisplay: null },
connections: [],
lifecycle: {},
preferredConnectionId: null,
createdAt: "today",
updatedAt: "today",
},
];
const snapshots = new Map<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
const snapshots = new Map<
string,
{ connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null }
>();
const listeners = new Set<() => void>();
const source: PluginHostsSource = {
getHosts: () => hosts,
getSnapshot: (id) => snapshots.get(id) ?? null,
getSnapshot: (id) => {
const value = snapshots.get(id);
return value
? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null }
: null;
},
renameHost: async (id, label) => {
hosts.find((candidate) => candidate.serverId === id)!.label = label;
},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async (id) => {
const index = hosts.findIndex((host) => host.serverId === id);
if (index >= 0) hosts.splice(index, 1);
for (const l of listeners) l();
},
removeConnection: async (id, connectionId) => {
const host = hosts.find((candidate) => candidate.serverId === id)!;
host.connections = host.connections.filter((c) => c.id !== connectionId);
if (!host.connections.length) await source.removeHost(id);
else for (const l of listeners) l();
},
subscribeAll(listener) {
listeners.add(listener);
return () => {
@@ -26,7 +73,10 @@ function registry() {
},
};
const lifetime = new AbortController();
const runtime = createPluginHosts(source, lifetime.signal);
const runtime = createPluginHosts(source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
return {
hosts,
snapshots,
@@ -48,7 +98,7 @@ function connection(id: string) {
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([
expect(h.runtime.getSnapshot()).toMatchObject([
{ serverId: "a", label: "Alpha", status: "offline" },
{ serverId: "b", label: "Beta", status: "offline" },
]);
@@ -68,6 +118,64 @@ describe("plugin host access", () => {
expect(h.listeners.size).toBe(0);
});
it("keeps handles stable, protects credentials, and stops observations on unload", async () => {
const h = registry();
const seen: unknown[] = [];
const stop = h.runtime.observeHosts((hosts) => seen.push(hosts));
const first = h.runtime.getSnapshot()[0];
function walk(value: unknown): void {
if (typeof value === "string") expect(value).not.toContain("secret");
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
expect(key.toLowerCase()).not.toBe("password");
walk(child);
}
}
walk(h.runtime.getSnapshot());
h.hosts[0].label = "New name";
h.publish();
expect(h.runtime.getSnapshot()[0]).toBe(first);
expect(first.label).toBe("New name");
expect(seen).toHaveLength(2);
h.lifetime.abort();
h.publish();
expect(seen).toHaveLength(2);
await expect(first.remove()).rejects.toThrow("Plugin has stopped");
stop();
});
it("redacts saved passwords from mutation errors", async () => {
const h = registry();
h.source.renameHost = async () => {
throw new Error("Failed for secret");
};
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret");
h.lifetime.abort();
});
it("removes a connection and then the last host connection", async () => {
const h = registry();
const host = h.runtime.getSnapshot()[0];
await host.removeConnection("one");
expect(host.connections.map((item) => item.id)).toEqual(["two"]);
await host.removeConnection("two");
expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]);
h.lifetime.abort();
});
it("rejects removal of the managed desktop daemon", async () => {
const h = registry();
const lifetime = new AbortController();
const runtime = createPluginHosts(h.source, lifetime.signal, {
openSettings() {},
isManagedLocalHost: async (id) => id === "a",
});
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
expect(runtime.getSnapshot()).toHaveLength(2);
lifetime.abort();
h.lifetime.abort();
});
it("rejects unknown and disconnected targets without falling through to another host", () => {
const h = registry();
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
@@ -82,7 +190,10 @@ describe("plugin host access", () => {
h.snapshots.set("b", { connectionStatus: "online", client });
h.publish();
const otherLifetime = new AbortController();
const other = createPluginHosts(h.source, otherLifetime.signal);
const other = createPluginHosts(h.source, otherLifetime.signal, {
openSettings() {},
isManagedLocalHost: async () => false,
});
const api = h.runtime.getPaseoClient("b");
expect(api).toBe(h.runtime.getPaseoClient("b"));
expect(api).not.toBe(other.getPaseoClient("b"));
+251 -17
View File
@@ -1,31 +1,251 @@
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginHostSummary } from "@getpaseo/plugin/client";
import type { PluginHost, PluginHostConnection, PluginHostPatch } from "@getpaseo/plugin/client";
import type { HostProfile } from "@/types/host-connection";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
import {
parseHostPort,
serializeConnectionUri,
serializeRelayConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "@getpaseo/protocol/daemon-endpoints";
import { serializeSocketHostTarget } from "@getpaseo/protocol/host-target";
import {
DEFAULT_SSH_DAEMON_PORT,
serializeSshTransportUri,
} from "@getpaseo/protocol/ssh-transport";
type HostSnapshot = Pick<
HostRuntimeSnapshot,
| "connectionStatus"
| "client"
| "activeConnectionId"
| "lastError"
| "authFailureReason"
| "lastOnlineAt"
>;
export interface PluginHostsSource {
getHosts(): readonly { serverId: string; label: string }[];
getSnapshot(serverId: string): {
connectionStatus: PluginHostSummary["status"];
client: DaemonClient | null;
} | null;
getHosts(): readonly HostProfile[];
getSnapshot(serverId: string): HostSnapshot | null;
subscribeAll(listener: () => void): () => void;
subscribeHostList(listener: () => void): () => void;
renameHost(serverId: string, label: string): Promise<void>;
setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise<void>;
setHostBadgeDisplay(
serverId: string,
badge: PluginHost["appearance"]["badgeDisplay"],
): Promise<void>;
removeHost(serverId: string): Promise<void>;
removeConnection(serverId: string, connectionId: string): Promise<void>;
}
type ProjectedHost = Omit<PluginHost, "update" | "remove" | "removeConnection" | "openSettings">;
function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost {
const connections: PluginHostConnection[] = profile.connections.map((connection) => {
switch (connection.type) {
case "directTcp":
return {
id: connection.id,
type: connection.type,
endpoint: connection.endpoint,
useTls: connection.useTls ?? false,
uri: serializeConnectionUri({
...parseHostPort(connection.endpoint),
useTls: connection.useTls ?? false,
}),
};
case "directSocket":
return {
id: connection.id,
type: connection.type,
path: connection.path,
uri: serializeSocketHostTarget("unix", connection.path),
};
case "directPipe":
return {
id: connection.id,
type: connection.type,
path: connection.path,
uri: serializeSocketHostTarget("pipe", connection.path),
};
case "remoteSsh":
return {
id: connection.id,
type: connection.type,
host: connection.host,
...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }),
...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }),
uri: serializeSshTransportUri({
host: connection.host,
sshPort: connection.sshPort,
daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
}),
};
case "relay": {
const useTls =
connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint);
return {
id: connection.id,
type: connection.type,
relayEndpoint: connection.relayEndpoint,
useTls,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
uri: serializeRelayConnectionUri({
offer: {
v: 2,
serverId: profile.serverId,
daemonPublicKeyB64: connection.daemonPublicKeyB64,
relay: { endpoint: connection.relayEndpoint, useTls },
...(connection.minimumSecurityMode
? { minimumSecurityMode: connection.minimumSecurityMode }
: {}),
},
}),
};
}
case "routed":
return {
id: connection.id,
type: connection.type,
originServerId: connection.originServerId,
uri: null,
};
}
});
const error = snapshot?.lastError ?? null;
return {
serverId: profile.serverId,
label: profile.label,
status: snapshot?.connectionStatus ?? "offline",
appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay },
connections,
activeConnectionId: snapshot?.activeConnectionId ?? null,
error: error && profile.password ? error.split(profile.password).join("[redacted]") : error,
authFailureReason: snapshot?.authFailureReason ?? null,
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
};
}
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) {
export function createPluginHosts(
source: PluginHostsSource,
signal: AbortSignal,
options: {
openSettings(serverId: string): void;
isManagedLocalHost(serverId: string): Promise<boolean>;
},
) {
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",
}));
let snapshot: readonly PluginHost[] = [];
let serialized = "";
const entries = new Map<string, { data: ProjectedHost; handle: PluginHost }>();
function assertHost(id: string) {
if (signal.aborted) throw new Error("Plugin has stopped");
if (!source.getHosts().some((host) => host.serverId === id))
throw new Error(`Unknown Paseo host: ${id}`);
}
async function perform(id: string, action: () => Promise<void>): Promise<void> {
const secret = source.getHosts().find((host) => host.serverId === id)?.password;
try {
await action();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(secret ? message.split(secret).join("[redacted]") : message);
}
}
function readHosts(): { hosts: PluginHost[]; key: string } {
const projected = source
.getHosts()
.map((profile) => projectHost(profile, source.getSnapshot(profile.serverId)));
const ids = new Set(projected.map((host) => host.serverId));
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
const hosts = projected.map((data) => {
let entry = entries.get(data.serverId);
if (!entry) {
const id = data.serverId;
const read = () => entry!.data;
const handle: PluginHost = {
get serverId() {
return read().serverId;
},
get label() {
return read().label;
},
get status() {
return read().status;
},
get appearance() {
return read().appearance;
},
get connections() {
return read().connections;
},
get activeConnectionId() {
return read().activeConnectionId;
},
get error() {
return read().error;
},
get authFailureReason() {
return read().authFailureReason;
},
get lastOnlineAt() {
return read().lastOnlineAt;
},
get createdAt() {
return read().createdAt;
},
get updatedAt() {
return read().updatedAt;
},
async update(patch: PluginHostPatch) {
await perform(id, async () => {
assertHost(id);
if (patch.label !== undefined) await source.renameHost(id, patch.label);
if (patch.appearance?.color !== undefined) {
assertHost(id);
await source.setHostColor(id, patch.appearance.color);
}
if (patch.appearance?.badgeDisplay !== undefined) {
assertHost(id);
await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay);
}
});
},
async remove() {
await perform(id, async () => {
assertHost(id);
if (await options.isManagedLocalHost(id))
throw new Error("Cannot remove the desktop daemon host");
assertHost(id);
await source.removeHost(id);
});
},
async removeConnection(connectionId) {
await perform(id, async () => {
assertHost(id);
await source.removeConnection(id, connectionId);
});
},
openSettings() {
assertHost(id);
options.openSettings(id);
},
};
entry = { data, handle };
entries.set(id, entry);
} else entry.data = data;
return entry.handle;
});
return { hosts, key: JSON.stringify(projected) };
}
function resolve(serverId: string): DaemonClient {
if (signal.aborted) throw new Error("Plugin has stopped");
@@ -49,11 +269,16 @@ export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal
}
}
const next = readHosts();
if (JSON.stringify(snapshot) === JSON.stringify(next)) return;
snapshot = next;
if (serialized === next.key) return;
serialized = next.key;
snapshot = next.hosts;
for (const listener of listeners) listener();
}
snapshot = readHosts();
{
const initial = readHosts();
snapshot = initial.hosts;
serialized = initial.key;
}
const unsubscribeRuntime = source.subscribeAll(refresh);
const unsubscribeHosts = source.subscribeHostList(refresh);
function stop() {
@@ -67,6 +292,15 @@ export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal
else signal.addEventListener("abort", stop, { once: true });
return {
getSnapshot: () => snapshot,
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
if (signal.aborted) throw new Error("Plugin has stopped");
const notify = () => listener(snapshot);
listeners.add(notify);
listener(snapshot);
return () => {
listeners.delete(notify);
};
},
subscribe(listener: () => void) {
if (signal.aborted) return () => {};
listeners.add(listener);
+5 -1
View File
@@ -1,3 +1,4 @@
import { buildSettingsHostRoute } from "@/utils/host-routes";
import { buildPluginSettingsRoute } from "./settings/routes";
import { router } from "expo-router";
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
@@ -9,7 +10,7 @@ import type { PluginNavigation } from "./actions";
export function createPluginNavigation(input: {
serverId: string;
workspaceId: string | null;
}): PluginNavigation {
}): PluginNavigation & { openHostSettings(serverId: string): void } {
const { serverId, workspaceId } = input;
function placement(location: PluginPanelLocation) {
if (location !== "explorer") return undefined;
@@ -20,6 +21,9 @@ export function createPluginNavigation(input: {
return { mode: "pane" as const, paneId };
}
return {
openHostSettings(hostId) {
router.push(buildSettingsHostRoute(hostId));
},
openSettings(pluginId, screenId) {
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
},
@@ -47,8 +47,14 @@ function registry(
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
@@ -22,8 +22,14 @@ function registry(version: string) {
getSnapshot: () => null,
subscribeAll: () => () => {},
subscribeHostList: () => () => {},
renameHost: async () => {},
setHostColor: async () => {},
setHostBadgeDisplay: async () => {},
removeHost: async () => {},
removeConnection: async () => {},
},
installation.lifetime.signal,
{ openSettings() {}, isManagedLocalHost: async () => false },
),
paseo: installation.paseo,
i18n: installation.i18n,
+5 -2
View File
@@ -2221,7 +2221,10 @@ export class HostRuntimeStore {
}));
}
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
async setHostBadgeDisplay(
serverId: string,
badgeDisplay: HostBadgeDisplay | null,
): Promise<void> {
await this.updateHostAppearance(serverId, (host) => ({
...host,
appearance: { ...host.appearance, badgeDisplay },
@@ -3034,7 +3037,7 @@ export interface HostMutations {
beginLinkPairing: () => LinkPairing;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise<void>;
removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
}
+91
View File
@@ -0,0 +1,91 @@
import type { PluginHostSummary } from "./index.js";
export type PluginHostStatus = PluginHostSummary["status"];
export type PluginHostColor =
| "none"
| "violet"
| "sky"
| "emerald"
| "orange"
| "pink"
| "indigo"
| "teal"
| "red"
| "amber"
| "blue";
export type PluginHostBadgeDisplay = "name" | "icon" | "hidden";
export interface PluginHostAppearance {
readonly color: PluginHostColor;
readonly badgeDisplay: PluginHostBadgeDisplay | null;
}
export type PluginHostConnection =
| {
readonly id: string;
readonly type: "directTcp";
readonly uri: string;
readonly endpoint: string;
readonly useTls: boolean;
}
| {
readonly id: string;
readonly type: "directSocket";
readonly uri: string;
readonly path: string;
}
| {
readonly id: string;
readonly type: "directPipe";
readonly uri: string;
readonly path: string;
}
| {
readonly id: string;
readonly type: "remoteSsh";
readonly uri: string;
readonly host: string;
readonly sshPort?: number;
readonly daemonPort?: number;
}
| {
readonly id: string;
readonly type: "relay";
readonly uri: string;
readonly relayEndpoint: string;
readonly useTls: boolean;
readonly daemonPublicKeyB64: string;
readonly minimumSecurityMode?: "protected";
}
| {
readonly id: string;
readonly type: "routed";
readonly uri: null;
readonly originServerId: string;
};
export interface PluginHostPatch {
label?: string;
appearance?: Partial<PluginHostAppearance>;
}
export interface PluginHost extends PluginHostSummary {
readonly appearance: PluginHostAppearance;
readonly connections: readonly PluginHostConnection[];
readonly activeConnectionId: string | null;
readonly error: string | null;
readonly authFailureReason: "password_required" | "incorrect_password" | null;
readonly lastOnlineAt: string | null;
readonly createdAt: string;
readonly updatedAt: string;
update(patch: PluginHostPatch): Promise<void>;
remove(): Promise<void>;
removeConnection(connectionId: string): Promise<void>;
openSettings(): void;
}
export interface PluginHostAddInput {
connection: string;
label?: string;
}
export type PluginHostAddResult =
| { status: "saved"; host: PluginHost }
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
| { status: "cancelled" };
export declare function useHosts(): readonly PluginHost[];
export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void;
+13 -1
View File
@@ -86,7 +86,19 @@ export interface PluginHostSummary {
readonly status: "idle" | "connecting" | "online" | "offline" | "error";
}
/** Live configured hosts. Supplied by the app's client bundle loader. */
export declare function useHosts(): readonly PluginHostSummary[];
export declare const useHosts: typeof import("./hosts.js").useHosts;
export declare const observeHosts: typeof import("./hosts.js").observeHosts;
export type {
PluginHost,
PluginHostStatus,
PluginHostColor,
PluginHostBadgeDisplay,
PluginHostAppearance,
PluginHostConnection,
PluginHostPatch,
PluginHostAddInput,
PluginHostAddResult,
} from "./hosts.js";
/** Borrow an online host's API under this installation's lifetime. */
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
+43 -14
View File
@@ -3,6 +3,7 @@ import {
useHosts,
usePaseo,
type PluginSurfaceProps,
type PluginHost,
} from "@getpaseo/plugin/client";
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -33,27 +34,55 @@ export function Hosts({ theme }: PluginSurfaceProps) {
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>
{hosts.map((host) => (
<HostRow key={host.serverId} host={host} color={theme.colors.foreground} list={list} />
))}
<Text style={styles.text}>{result}</Text>
</View>
);
}
function HostRow({
host,
color,
list,
}: {
host: PluginHost;
color: string;
list: (serverId: string) => Promise<void>;
}) {
const style = useMemo(() => ({ color }), [color]);
const rowStyle = useMemo(() => ({ gap: 4 }), []);
const listAgents = useCallback(() => void list(host.serverId), [host, list]);
const update = useCallback(
() => void host.update({ label: `${host.label} (build)`, appearance: { color: "sky" } }),
[host],
);
const open = useCallback(() => host.openSettings(), [host]);
const remove = useCallback(() => void host.remove(), [host]);
return (
<View style={rowStyle}>
<Text style={style}>
{host.label}: {host.status} ·{" "}
{host.connections.map((connection) => connection.uri ?? "via another host").join(", ")}
</Text>
<Pressable accessibilityRole="button" onPress={listAgents}>
<Text style={style}>List agents</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={update}>
<Text style={style}>Rename and color</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={open}>
<Text style={style}>Host settings</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={remove}>
<Text style={style}>Remove host</Text>
</Pressable>
</View>
);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
import { observeHosts } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
import { Hosts } from "./client/hosts";
@@ -11,5 +12,8 @@ function HostsItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
export default function contribute(client: PluginClientContext) {
client.addScreen({ id: "main", title: "Host agents", Component: Hosts });
client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem });
return () => {};
return observeHosts((hosts) => {
for (const host of hosts)
if (host.authFailureReason) console.log(`${host.label} requires attention`);
});
}
+1 -1
View File
@@ -1 +1 @@
{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } }
{ "id": "host-agents", "requirements": { "paseo": ">=0.15.0" } }
+12 -5
View File
@@ -2078,7 +2078,7 @@ 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
### Hosts
Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback
to run SDK operations on one of them:
@@ -2124,8 +2124,15 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
}
```
`useHosts(): readonly PluginHostSummary[]` includes offline hosts and updates when hosts,
labels, or statuses change.
`useHosts(): readonly PluginHost[]` includes offline hosts and updates when projected fields change.
`observeHosts(listener)` immediately delivers the same list and returns an unsubscribe function.
Set `requirements.paseo` in the manifest to the release that ships these host capabilities.
Each host handle provides `appearance`, `connections`, `activeConnectionId`, `error`,
`authFailureReason`, `lastOnlineAt`, `createdAt`, and `updatedAt`. Connections expose their
credential-free `uri`, or `null` for routed hosts. Call `host.update({ label, appearance })`,
`host.removeConnection(id)`, `host.remove()`, or `host.openSettings()` to manage a host.
Removing the last connection removes the host. The desktop daemon host cannot be removed by a plugin.
| Summary field | Type or values | Meaning |
| ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
@@ -2150,8 +2157,8 @@ on the target daemon. Acquire the API when performing an action to use the curre
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
Plugins see connection targets and daemon identities but cannot read saved passwords. 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