From c57f24dfa5f632625e1d18178d50648e0cfde19c Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 9 Jun 2026 15:10:32 +0200 Subject: [PATCH] feat: support machine-specific plugins --- .changeset/machine-specific-plugins.md | 5 ++ README.md | 2 +- docs/plugins.html | 10 ++- docs/plugins.md | 21 +++-- pi-web-plugins/updates/package.json | 2 +- src/client/src/api/clients.test.ts | 21 ++++- src/client/src/api/clients.ts | 2 +- .../src/api/federatedRouteContract.test.ts | 3 +- src/client/src/api/parsers.test.ts | 4 +- src/client/src/api/parsers.ts | 7 ++ src/client/src/components/PiWebApp.ts | 14 ++- .../settings/SettingsPluginsPanel.ts | 2 +- src/client/src/plugins/external.ts | 14 ++- src/client/src/plugins/registry.test.ts | 85 +++++++++++++++++++ src/client/src/plugins/registry.ts | 76 ++++++++++++++--- src/client/src/plugins/types.ts | 1 + src/server/app.test.ts | 12 +-- .../machines/machinePluginProxyRoutes.ts | 8 ++ src/server/piWebPluginService.test.ts | 14 ++- src/server/piWebPluginService.ts | 28 +++++- src/shared/apiTypes.ts | 1 + src/shared/federatedRoutes.ts | 1 + 22 files changed, 286 insertions(+), 47 deletions(-) create mode 100644 .changeset/machine-specific-plugins.md diff --git a/.changeset/machine-specific-plugins.md b/.changeset/machine-specific-plugins.md new file mode 100644 index 0000000..e11e01b --- /dev/null +++ b/.changeset/machine-specific-plugins.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Allow PI WEB plugins to mark themselves as machine-specific so the gateway copy stays local-only and remote machines can provide their own status/plugin UI. diff --git a/README.md b/README.md index e7aff9e..ca4f67f 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Remote model-provider credentials and OAuth state stay on the target machine. AP PI WEB production installs can load trusted local UI plugins without rebuilding PI WEB. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata, using documented context helpers for workspace files and terminals. They do not run in the session daemon and are not sandboxed. -The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals. +The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module` plus optional `machineSpecific` metadata, and a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` TypeScript source is the canonical minimal real example, `pi-web-plugins/updates` demonstrates a dynamic status panel, and built-in [Workspace Tasks](docs/plugins.md#workspace-tasks) adds a workspace tab for running configured shell commands in PI WEB terminals. A useful prompt for AI agents: diff --git a/docs/plugins.html b/docs/plugins.html index b7e9f0c..dadaf25 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -191,7 +191,8 @@ After editing, check the manifest endpoint and browser-console failure cases.Updates

Updates adds a conditional Updates workspace tab with PI WEB update, - restart, and installed-service guidance. It is built into PI WEB and enabled by default. + restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the + selected machine's plugin copy when machine federation is active.

@@ -317,6 +318,11 @@ After editing, check the manifest endpoint and browser-console failure cases. +

+ Plugin package metadata can set machineSpecific: true. Use it for plugins like Updates whose + UI should come from the selected PI WEB instance; on remote machines, the gateway copy is hidden unless + the remote machine exposes its own copy. +

For portable plugin assets, prefer URLs relative to the plugin module, such as new URL("./asset.json", import.meta.url). If a remote plugin constructs absolute asset URLs, diff --git a/docs/plugins.md b/docs/plugins.md index 9e8d501..0743670 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -136,12 +136,17 @@ When [machine federation](https://pi-web.dev/machines.html) is enabled, PI WEB a - actions, workspace panels, and workspace labels only appear while that machine is selected; - plugin file and terminal helpers run against that machine; - plugin code is loaded best-effort through the current gateway and cached for the browser page lifetime; -- if the gateway already has an enabled plugin with the same original id, the gateway plugin wins and the remote duplicate stays hidden; +- if the gateway and remote machine both have an enabled plugin with the same original id, `machineSpecific` metadata decides whether the gateway copy is reused or only the selected machine's copy can appear; - remote theme contributions are ignored for now because themes are app-wide; - mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible. Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file. +Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEB machine: + +- Omitted or `false`: use the gateway copy when the same plugin id is also present on a remote machine. This is best for portable UI plugins whose helpers already route through the selected machine. +- `true`: the gateway copy only appears for the local machine. When a remote machine is selected, only that remote machine's copy can appear; if the remote machine does not expose the plugin, the plugin is hidden. This is best for plugins that report machine-local PI WEB status or depend on machine-local plugin code. + For portable plugin assets, prefer URLs relative to the plugin module, for example: ```js @@ -185,7 +190,7 @@ Built-in plugins can be managed from **Settings → Plugins** or with the top-le **Plugin id:** `updates` **What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance. -Updates is enabled by default. To hide it, disable `updates` in **Settings → Plugins** or set: +Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → Plugins** or set: ```json { @@ -286,7 +291,7 @@ A package can expose one or more PI WEB plugin modules. There is exactly one sup "piWeb": { "plugins": [ { "id": "review", "module": "dist/review.js" }, - { "id": "dashboard", "module": "dist/dashboard.js" } + { "id": "dashboard", "module": "dist/dashboard.js", "machineSpecific": true } ] } } @@ -298,6 +303,7 @@ Rules: - Each entry must have an explicit `id` and `module`. - `id` must match `^[a-z][a-z0-9.-]*$`. - `module` must be a safe relative path inside the plugin package root. +- `machineSpecific` is optional and must be a boolean; omit it for the default portable gateway behavior. - Duplicate plugin ids are not auto-renamed; later duplicates are skipped. - Legacy shortcuts such as `piWeb.plugin`, string entries in `piWeb.plugins`, `piWeb.id` fallback ids, and no-`package.json` fallbacks are not supported. @@ -312,13 +318,14 @@ The manifest contains each discovered plugin module: "id": "my-plugin", "module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890", "source": "local", - "scope": "local" + "scope": "local", + "machineSpecific": false } ] } ``` -`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. +`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `machineSpecific` controls whether the gateway copy is valid for remote machines or only each selected machine's own copy can appear. A plugin can fetch its own static assets with URLs under: @@ -450,7 +457,7 @@ interface PluginRuntimeContext { Notes: - `state` is a snapshot of current UI state when actions are built. -- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. +- The stable state fields are `state.selectedWorkspace`, `state.selectedSession`, and `state.piWebStatus`. `state.piWebStatus` describes the currently selected machine's PI WEB runtime, or the gateway/local runtime when the local machine is selected. - Other `state` fields may exist at runtime, but they are private PI WEB internals that may graduate into stable helpers, change shape, or disappear. - `enabled` is evaluated when the action palette asks for actions. - `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`. @@ -784,7 +791,7 @@ PI WEB does not provide a plugin cache/invalidation framework. Keep host callbac If you are an AI agent building or editing a PI WEB plugin, follow this checklist: 1. Create or update a plugin folder with `package.json` and a JavaScript module such as `pi-web-plugin.js`. -2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module }` entries. +2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module, machineSpecific? }` entries. 3. Default-export `{ apiVersion: 1, name, activate }` from the module. 4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`. 5. Use ids matching `^[a-z][a-z0-9.-]*$`. diff --git a/pi-web-plugins/updates/package.json b/pi-web-plugins/updates/package.json index e53b362..0772085 100644 --- a/pi-web-plugins/updates/package.json +++ b/pi-web-plugins/updates/package.json @@ -3,7 +3,7 @@ "private": true, "piWeb": { "plugins": [ - { "id": "updates", "module": "pi-web-plugin.js" } + { "id": "updates", "module": "pi-web-plugin.js", "machineSpecific": true } ] } } diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 3575f0b..224846c 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes"; -import { machinesApi, terminalsApi, workspacesApi } from "./clients"; +import { machinesApi, piWebApi, terminalsApi, workspacesApi } from "./clients"; const workspace: Workspace = { id: "w/1", @@ -31,6 +31,25 @@ afterEach(() => { }); describe("machine-scoped runtime API", () => { + it("reads machine PI WEB status through the gateway route", async () => { + const fetchMock = stubJsonFetch({ + packageName: "@jmfederico/pi-web", + generatedAt: "now", + components: { + web: { component: "web", label: "PI WEB", available: true, stale: false }, + sessiond: { component: "sessiond", label: "PI WEB Session Daemon", available: true, stale: false }, + }, + release: { packageName: "@jmfederico/pi-web", updateAvailable: false }, + commands: {}, + messages: [], + }); + + await piWebApi.piWebStatus("remote a"); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchCall(fetchMock, 0)[0]).toBe("/api/machines/remote%20a/pi-web/status"); + }); + it("reads machine runtime through the gateway route", async () => { const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] }); diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 5530577..da653af 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -43,7 +43,7 @@ import { machineGitDiffUrl, messageUrl } from "./urls"; const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`; export const piWebApi = { - piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse), + piWebStatus: (machineId = "local") => request(machineId === "local" ? "/api/pi-web/status" : `${machinePrefix(machineId)}/pi-web/status`, parsePiWebStatusResponse), piWebRuntime: () => request("/api/pi-web/runtime", parsePiWebRuntimeResponse), }; diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 903f078..f675bc5 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { Workspace } from "../../../shared/apiTypes"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes"; -import { activityApi, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; +import { activityApi, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients"; import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets"; import { workspaceImagePreviewUrl } from "./urls"; @@ -26,6 +26,7 @@ describe("federated route contract", () => { vi.stubGlobal("fetch", fetchMock); await Promise.all([ + ignoreParseFailure(piWebApi.piWebStatus(machineId)), ignoreParseFailure(activityApi.workspaceActivity(machineId)), ignoreParseFailure(projectsApi.projects(machineId)), ignoreParseFailure(projectsApi.addProject("/repo", "Repo", false, machineId)), diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index e12ba43..3bcd3ba 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -33,9 +33,9 @@ describe("API parsers", () => { it("parses PI WEB plugin status responses", () => { expect(parsePiWebPluginsResponse({ - plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], + plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }], })).toEqual({ - plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", enabled: false }], + plugins: [{ id: "info", module: "/pi-web-plugins/info/pi-web-plugin.js?v=1", source: "bundled", scope: "bundled", machineSpecific: true, enabled: false }], }); }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 7e40afe..6245a5d 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -492,6 +492,7 @@ function parsePiWebPluginInfo(value: unknown): PiWebPluginInfo { module: requireString(record, "module"), source: requireString(record, "source"), scope: parsePiWebPluginScope(record["scope"]), + machineSpecific: parseOptionalBoolean(record["machineSpecific"], "machineSpecific") ?? false, enabled: requireBoolean(record, "enabled"), }; } @@ -501,6 +502,12 @@ function parsePiWebPluginScope(value: unknown): PiWebPluginScope { return value; } +function parseOptionalBoolean(value: unknown, key: string): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value !== "boolean") throw new Error(`Expected optional boolean field: ${key}`); + return value; +} + export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse { const record = requireRecord(value); return { diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 17df161..d88cae3 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -266,10 +266,13 @@ export class PiWebApp extends LitElement { } private async refreshPiWebStatus(): Promise { + const machineId = selectedMachineId(this.state); try { - this.setState({ piWebStatus: await piWebApi.piWebStatus() }); + const piWebStatus = await piWebApi.piWebStatus(machineId); + if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus }); } catch (error) { - console.warn("Failed to refresh PI WEB status", error); + if (selectedMachineId(this.state) === machineId) this.setState({ piWebStatus: undefined }); + console.warn(`Failed to refresh PI WEB status for ${machineId}`, error); } } @@ -723,7 +726,9 @@ export class PiWebApp extends LitElement { this.realtime.close(); this.connectRealtime(); this.activeTerminalIds.clear(); + this.setState({ piWebStatus: undefined }); this.git.updatePolling(); + void this.refreshPiWebStatus(); void this.loadPluginsForSelectedMachine(); } @@ -1186,7 +1191,10 @@ export class PiWebApp extends LitElement { const existing = this.machinePluginLoadPromises.get(machine.id); if (existing !== undefined) return existing; - const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id })) + const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { + machineId: machine.id, + shouldLoadPlugin: (entry) => this.plugins.shouldLoadRemotePlugin(entry.id, entry.machineSpecific), + })) .then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); }) .finally(() => { this.machinePluginLoadPromises.delete(machine.id); }); this.machinePluginLoadPromises.set(machine.id, load); diff --git a/src/client/src/components/settings/SettingsPluginsPanel.ts b/src/client/src/components/settings/SettingsPluginsPanel.ts index 3d5d6b1..c6930bb 100644 --- a/src/client/src/components/settings/SettingsPluginsPanel.ts +++ b/src/client/src/components/settings/SettingsPluginsPanel.ts @@ -46,7 +46,7 @@ export class SettingsPluginsPanel extends LitElement {

${plugin.id} - ${plugin.source} · ${plugin.scope} + ${plugin.source} · ${plugin.scope}${plugin.machineSpecific ? " · machine-specific" : ""} ${configuredState}