From c82486a2e5d5604b3e83a95a23a6817ede5ee30a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 11 May 2026 11:10:31 +0200 Subject: [PATCH] Document Pi Web plugin API --- README.md | 1 + docs/plugins.md | 678 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 679 insertions(+) create mode 100644 docs/plugins.md diff --git a/README.md b/README.md index ac3cabf..1daf0bc 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ This maps naturally to real development work: - View live session status: streaming, compaction, bash activity, token usage, cost, model, and context usage. - Send prompts, shell input, and supported commands through the Pi SDK path. - Reuse your existing Pi auth and model configuration from `~/.pi/agent`. +- Extend the UI with trusted plugins that add actions, workspace panels, and workspace-label metadata. See [Plugin API](docs/plugins.md) for LLM-friendly plugin-building docs. ## Architecture diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..61e19cb --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,678 @@ +# Pi Web plugin API + +Pi Web plugins are trusted browser-side ES modules that extend the Pi Web UI. They are intended to be simple enough that an LLM can create or modify them directly. + +Plugins can currently: + +- add actions to the action palette; +- add workspace tools/panels next to Files, Git, and Terminal; +- add compact items to workspace labels in the workspace list, panel header, and status bar; +- call browser APIs and Pi Web HTTP/WebSocket APIs available to the current browser session; +- serve their own static assets from the plugin directory. + +They do **not** run in the session daemon, do not get a server-side hook API, and are not sandboxed. + +## Trust model + +Plugins run as JavaScript in the browser app. Treat them as trusted code: + +- they can call browser APIs; +- they can `fetch()` Pi Web API endpoints using the current browser access; +- they can read workspace files through Pi Web's file endpoints if the UI can read them; +- they can render arbitrary Lit templates/custom elements in plugin contribution areas; +- they should not be installed from untrusted sources. + +This is for personal, team, and project-local customization, not a sandboxed third-party marketplace. + +## Quick start: local plugin + +Create a folder with a `package.json` and a browser module: + +```bash +mkdir -p /srv/dev/my-pi-web-plugin +cat > /srv/dev/my-pi-web-plugin/package.json <<'JSON' +{ + "private": true, + "piWeb": { + "id": "my-plugin", + "plugin": "pi-web-plugin.js" + } +} +JSON +cat > /srv/dev/my-pi-web-plugin/pi-web-plugin.js <<'JS' +const { html } = globalThis.piWebPluginApi; + +export default { + id: "my-plugin", + name: "My Plugin", + activate: () => ({ + actions: [ + { + id: "workspace.show-path", + title: "Show Current Workspace Path", + group: "My Plugin", + enabled: (context) => context.state.selectedWorkspace !== undefined, + run: (context) => { + window.alert(context.state.selectedWorkspace?.path ?? "No workspace selected"); + }, + }, + ], + workspacePanels: [ + { + id: "workspace.info", + title: "Info", + order: 100, + render: ({ workspace }) => html` +
Info
+
+

${workspace.label}

+

${workspace.path}

+
+ `, + }, + ], + workspaceLabelContributions: [ + { + id: "workspace.kind", + order: 10, + items: ({ workspace }) => ({ + type: "text", + text: workspace.isGitRepo ? "git" : "folder", + title: workspace.path, + }), + }, + ], + }), +}; +JS +``` + +Symlink it into Pi Web's local plugin directory: + +```bash +mkdir -p ~/.pi-web/plugins +ln -s /srv/dev/my-pi-web-plugin ~/.pi-web/plugins/my-plugin +``` + +Reload the Pi Web browser tab. Pi Web serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes. + +## Discovery and packaging + +Pi Web builds `/pi-web-plugins/manifest.json` from these sources: + +1. Bundled plugins in the Pi Web package: + + ```text + pi-web-plugins// + ``` + +2. User-local plugins: + + ```text + ~/.pi-web/plugins// + ``` + + Entries may be real directories or symlinks. This is the recommended development workflow. + +3. Installed Pi packages that expose Pi Web plugin metadata. Pi packages may be user or project scoped. + +Plugin directory names and plugin ids should match: + +```text +^[a-z][a-z0-9.-]*$ +``` + +### `package.json` metadata + +A plugin directory is normally configured with top-level `piWeb` metadata: + +```json +{ + "private": true, + "piWeb": { + "id": "my-plugin", + "plugin": "pi-web-plugin.js" + } +} +``` + +For multiple plugin entries in one package, use `piWeb.plugins`: + +```json +{ + "private": true, + "piWeb": { + "id": "my-package", + "plugins": [ + { "id": "review", "module": "dist/review.js" }, + { "id": "dashboard", "module": "dist/dashboard.js" } + ] + } +} +``` + +`piWeb.plugins` may also be an array of module paths: + +```json +{ + "piWeb": { + "id": "my-package", + "plugins": ["dist/review.js", "dist/dashboard.js"] + } +} +``` + +Pi packages may nest the same metadata under `pi.piWeb`: + +```json +{ + "pi": { + "piWeb": { + "id": "my-plugin", + "plugin": "pi-web-plugin.js" + } + } +} +``` + +If a local plugin directory has no `package.json`, Pi Web falls back to `pi-web-plugin.js` in that directory. + +Entry module paths must be safe relative paths inside the plugin root. Pi Web ignores empty, absolute, or `..` paths. + +### Manifest and assets + +The manifest contains each discovered plugin module: + +```json +{ + "plugins": [ + { + "id": "my-plugin", + "module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890", + "source": "local", + "scope": "local" + } + ] +} +``` + +`source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. + +A plugin can fetch its own static assets with URLs under: + +```text +/pi-web-plugins// +``` + +Pi Web prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, and HTML get appropriate content types; other files are served as octet-stream. + +If two discovered plugins use the same id, the first keeps the id and later ones are renamed to `.2`, `.3`, and so on. Avoid relying on this; prefer unique ids. + +## Plugin module shape + +The entry module must default-export a `PiWebPlugin` object: + +```ts +interface PiWebPlugin { + id: string; + name: string; + activate: (context: PluginActivationContext) => PluginContributions; +} + +interface PluginActivationContext { + apiVersion: 1; +} +``` + +Example: + +```js +export default { + id: "my-plugin", + name: "My Plugin", + activate: ({ apiVersion }) => ({ + actions: [], + workspacePanels: [], + workspaceLabelContributions: [], + }), +}; +``` + +`activate()` is called once when the UI loads the plugin. Keep it cheap: define contributions there, but move expensive or async work into actions, custom elements, or explicit user interactions. + +Plugin ids and contribution ids must match: + +```text +^[a-z][a-z0-9.-]*$ +``` + +Contribution ids are local to the plugin. Pi Web qualifies them internally as: + +```text +: +``` + +For example, plugin `info` with action `workspace.show-path` becomes `info:workspace.show-path`. + +## Browser global API + +External plugins can access this global before they export their plugin: + +```js +const { apiVersion, html } = globalThis.piWebPluginApi; +``` + +- `apiVersion`: currently `1`. +- `html`: Lit's `html` template tag. Use this instead of importing `lit` from an external plugin unless you bundle your own dependencies. + +Pi Web does not currently expose typed helper clients to plugins. Use `fetch()` for Pi Web HTTP APIs and browser `WebSocket` for websocket endpoints if needed. + +## Contributions + +`activate()` returns any combination of these contribution arrays: + +```ts +interface PluginContributions { + actions?: PluginAction[]; + workspacePanels?: WorkspacePanelContribution[]; + workspaceLabelContributions?: WorkspaceLabelContribution[]; +} +``` + +### Actions + +Actions appear in the action palette. They can inspect app state and call UI/runtime helpers. + +```js +actions: [ + { + id: "workspace.show-path", + title: "Show Current Workspace Path", + description: "Display the selected workspace path", + shortcut: "mod+shift+p", + group: "Info", + enabled: (context) => context.state.selectedWorkspace !== undefined, + run: (context) => { + window.alert(context.state.selectedWorkspace?.path ?? "No workspace selected"); + }, + }, +] +``` + +Action type: + +```ts +interface PluginAction { + id: string; + title: string; + description?: string; + shortcut?: string; + group?: string; + enabled?: boolean | ((context: PluginRuntimeContext) => boolean); + run: (context: PluginRuntimeContext) => void | Promise; +} +``` + +Runtime context: + +```ts +interface PluginRuntimeContext { + state: AppState; + openActionPalette: () => void; + focusPrompt: () => void; + addProject: () => void | Promise; + selectMainView: (view: "navigation" | "chat" | QualifiedContributionId) => void; + selectWorkspaceTool: (tool: QualifiedContributionId) => void; + refreshFiles: () => void | Promise; + refreshGit: () => void | Promise; + startSession: () => void | Promise; + archiveSession: () => void | Promise; + stopActiveWork: () => void | Promise; +} +``` + +Notes: + +- `state` is a snapshot of current UI state when actions are built. +- `enabled` is evaluated when the action palette asks for actions. +- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`. +- `shortcut` is displayed/handled the same way app actions are; choose shortcuts carefully to avoid conflicts. + +### Workspace panels + +Workspace panels add tools next to built-in workspace tools. They render inside the workspace side panel on desktop and as mobile tabs on smaller screens. + +```js +const { html } = globalThis.piWebPluginApi; + +workspacePanels: [ + { + id: "workspace.info", + title: "Info", + order: 100, + visible: (workspace) => workspace.isGitRepo, + badge: ({ gitStatus }) => gitStatus?.files.length, + render: ({ workspace, gitStatus, onRefreshGit }) => html` +
+ Info + +
+
+

${workspace.label}

+

${workspace.path}

+

Changed files: ${gitStatus?.files.length ?? 0}

+
+ `, + }, +] +``` + +Panel type: + +```ts +interface WorkspacePanelContribution { + id: string; + title: string; + order?: number; + visible?: (workspace: Workspace) => boolean; + badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined; + render: (context: WorkspacePanelContext) => TemplateResult; +} +``` + +Panel context: + +```ts +interface WorkspacePanelContext { + workspace: Workspace; + fileTree: FileTreeEntry[]; + expandedDirs: Record; + selectedFilePath: string | undefined; + selectedFileContent: FileContentResponse | undefined; + fileTreeStale: boolean; + gitStatus: GitStatusResponse | undefined; + selectedDiffPath: string | undefined; + selectedDiff: GitDiffResponse | undefined; + selectedStagedDiff: GitDiffResponse | undefined; + gitStale: boolean; + activeTerminalCount: number; + onRefreshFiles: () => void; + onExpandDir: (path: string) => void; + onSelectFile: (path: string) => void; + onRefreshGit: () => void; + onSelectDiff: (path: string) => void; +} +``` + +Useful workspace shape: + +```ts +interface Workspace { + id: string; + projectId: string; + path: string; + label: string; + branch?: string; + isMain: boolean; + isGitRepo: boolean; + isGitWorktree: boolean; +} +``` + +Use existing classes such as `toolbar`, `viewer`, `empty`, and `muted` for panel content when possible. Do not assume a panel owns the whole page; keep layout contained. + +### Workspace label contributions + +Workspace label contributions add compact inline metadata wherever Pi Web displays a workspace label: workspace list, workspace panel header, and status bar. + +Use them for short facts like project environment, local URL, branch status, container name, or health state. + +```js +workspaceLabelContributions: [ + { + id: "dev-url", + order: 10, + visible: ({ workspace, state }) => { + const project = state.projects.find((project) => project.id === workspace.projectId); + return project?.path === "/srv/dev/my-app"; + }, + items: () => ({ + type: "link", + text: "web:5173", + href: "http://localhost:5173", + title: "Open dev server", + target: "_blank", + }), + }, +] +``` + +Label contribution type: + +```ts +interface WorkspaceLabelContribution { + id: string; + order?: number; + visible?: (context: WorkspaceLabelContext) => boolean; + items: (context: WorkspaceLabelContext) => WorkspaceLabelItem | WorkspaceLabelItem[] | undefined; +} + +interface WorkspaceLabelContext { + workspace: Workspace; + state: AppState; +} +``` + +Items are sorted by `order` and then id. Return `undefined` to render nothing. + +#### Text items + +```js +{ type: "text", text: "staging", title: "Staging workspace" } +``` + +#### Link items + +```js +{ + type: "link", + text: "web:5173", + href: "http://localhost:5173", + title: "Open dev server", + target: "_blank" +} +``` + +Pi Web renders the anchor and adds safe defaults such as `rel="noopener noreferrer"` for `_blank` links. `javascript:` and `data:` links are rendered as plain text instead of links. + +#### Render items + +Use render items when a label contribution needs custom UI, async data, or caching. Render items should stay compact and inline. + +```js +const { html } = globalThis.piWebPluginApi; + +class MyWorkspaceBadge extends HTMLElement { + set workspace(value) { + this._workspace = value; + this.textContent = value?.branch === "main" ? "main" : "branch"; + } +} + +if (!customElements.get("my-workspace-badge")) { + customElements.define("my-workspace-badge", MyWorkspaceBadge); +} + +export default { + id: "my-plugin", + name: "My Plugin", + activate: () => ({ + workspaceLabelContributions: [ + { + id: "badge", + order: 10, + items: ({ workspace }) => ({ + type: "render", + render: () => html``, + }), + }, + ], + }), +}; +``` + +## Reading workspace files + +Plugins can use existing Pi Web endpoints. For example, to read a file in a workspace: + +```js +async function readWorkspaceFile(workspace, path) { + const url = + `/api/projects/${encodeURIComponent(workspace.projectId)}` + + `/workspaces/${encodeURIComponent(workspace.id)}` + + `/file?path=${encodeURIComponent(path)}`; + + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Failed to read ${path}: ${response.status}`); + return await response.json(); +} +``` + +Response shape: + +```ts +interface FileContentResponse { + path: string; + language?: string; + encoding: "utf8"; + size: number; + modifiedAt: string; + content: string; + truncated: boolean; + binary: boolean; +} +``` + +Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin. + +## Other useful Pi Web APIs + +Plugins may call any endpoint available to the browser. Common read endpoints: + +```text +GET /api/projects +GET /api/projects/:projectId/workspaces +GET /api/projects/:projectId/workspaces/:workspaceId/tree?path= +GET /api/projects/:projectId/workspaces/:workspaceId/file?path= +GET /api/projects/:projectId/workspaces/:workspaceId/git/status +GET /api/projects/:projectId/workspaces/:workspaceId/git/diff?path=&staged=true|false +GET /api/sessions?cwd= +GET /api/sessions/:sessionId/status +GET /api/sessions/:sessionId/messages?before=&limit= +``` + +Common write/action endpoints: + +```text +POST /api/sessions { "cwd": "/path/to/workspace" } +POST /api/sessions/:id/prompt { "text": "...", "streamingBehavior": "steer" | "followUp" } +POST /api/sessions/:id/shell { "text": "..." } +POST /api/sessions/:id/stop +POST /api/sessions/:id/archive +POST /api/sessions/:id/restore +``` + +Prefer runtime context helpers (`startSession`, `stopActiveWork`, `refreshFiles`, `refreshGit`, etc.) when they cover the interaction. Use direct HTTP calls for plugin-specific data or behavior. + +## Async data and caching + +Pi Web does not provide a plugin cache/invalidation framework. Keep host callbacks cheap: + +- simple contributions should be synchronous and cheap; +- expensive or async work should live inside the plugin; +- custom elements in `type: "render"` label items or panels are a good place to own async loading; +- dedupe fetches and avoid unbounded polling; +- clean up intervals/event listeners in custom elements' `disconnectedCallback()`. + +Example cache pattern: + +```js +const cache = new Map(); +const loading = new Set(); + +class DevUrlBadge extends HTMLElement { + set workspace(value) { + this.workspaceValue = value; + void this.load(); + } + + async load() { + const workspace = this.workspaceValue; + if (!workspace) return; + + if (cache.has(workspace.id)) { + this.renderUrl(cache.get(workspace.id)); + return; + } + if (loading.has(workspace.id)) return; + + loading.add(workspace.id); + try { + const file = await readWorkspaceFile(workspace, "docker/development.local.env"); + const url = parseEnv(file.content).BASE_URL; + cache.set(workspace.id, url); + this.renderUrl(url); + } finally { + loading.delete(workspace.id); + } + } + + renderUrl(url) { + this.textContent = url ?? ""; + } +} +``` + +## LLM checklist for building a plugin + +When asking an LLM to build a Pi Web plugin, give it this checklist: + +1. Create a plugin folder with `package.json` and `pi-web-plugin.js`. +2. Use top-level `piWeb` metadata with `id` and `plugin`, or `piWeb.plugins` for multiple modules. +3. Default-export `{ id, name, activate }` from the module. +4. Use ids matching `^[a-z][a-z0-9.-]*$`. +5. Use `globalThis.piWebPluginApi.html` for Lit templates. +6. Keep `activate()` synchronous and cheap; return contribution definitions only. +7. Add actions for command-palette operations. +8. Add workspace panels for larger workspace UI. +9. Add workspace label contributions for compact inline metadata. +10. Use structured text/link label items when possible; use render items/custom elements for async or cached UI. +11. Use `fetch()` against Pi Web APIs for workspace files, git state, sessions, or plugin-specific behavior not provided by runtime context helpers. +12. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional. +13. After local edits, hard reload the browser and check the console for plugin errors. + +## Troubleshooting + +Check discovery: + +```bash +curl http://127.0.0.1:8504/pi-web-plugins/manifest.json +``` + +Check a plugin module: + +```bash +curl http://127.0.0.1:8504/pi-web-plugins/my-plugin/pi-web-plugin.js +``` + +Common issues: + +- invalid plugin id or contribution id; +- missing default export; +- missing `name` or `activate` function; +- missing `package.json` or incorrect `piWeb.plugin` / `piWeb.plugins` metadata; +- entry module path points outside the plugin root or file does not exist; +- browser cache not refreshed after editing; +- plugin directory is not under `~/.pi-web/plugins` or symlinked there; +- duplicate plugin ids cause later plugins to be renamed in the manifest; +- plugin throws during module import, `activate()`, `visible()`, `enabled()`, `items()`, or `render()`; check the browser console.