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.
Plugin id: updates
@@ -309,7 +310,7 @@ After editing, check the manifest endpoint and browser-console failure cases.
File and terminal helpers run against the selected remote machine.
Remote plugin code is loaded best-effort through the current gateway and cached for the 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 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.
@@ -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 {