- Plugin tool exposed names use forced prefix
plugin_<pluginIdSafe>_<toolName>(D015) - enable→load failure auto-disables plugin (D017)
- uninstall deletes plugin data by default (D016)
- plugin settings secrets not allowed in MVP (D018)
- runtime target remains separate process; M4 may use host-managed sandboxed runtime (D009)
- every extension carries
enabledplus an activation scope; both plugins and the user's own MCP servers and skills are global or limited to named projects (D192) - user MCP tools use the prefix
mcp_<serverId>_<toolName>, disjoint from D015's plugin namespace (D193)
Plan policy: plugin agent tools, plugin skills that register tools, and any
unknown plugin contribution are not visible or executable in Plan. This deny
is enforced by host-core before generic plugin permission evaluation and cannot
be bypassed by a low-risk manifest, a session grant, or auto. Plugin tools
remain available to the same Agent after an approved Plan → Agent transition.
Give PI-Desktop extensibility similar to established desktop plugin ecosystems (e.g. VS Code extensions):
- Users can install / enable / disable / uninstall plugins
- Developers can build custom plugins
- Plugins can extend commands, panels, tools, and Agent capabilities
- The platform keeps a security boundary and does not hand full system privileges directly to arbitrary third-party code
In one sentence:
PI-Desktop is the host; plugins are capability packs.
- Directory-based plugin installation
- Capabilities declared in a manifest file
- Feature keyword / command triggers
- A dedicated plugin management page
- Developer mode for loading local plugins
- Plugins are not just small utility panels; they can also extend:
- Agent Tools
- Skills
- MCP bridge
- Session commands
- Settings
- High-risk capabilities must go through the permission framework
- Plugins cannot directly obtain arbitrary Node/Electron privileges by default
- Command plugins: register command palette actions
- Panel plugins: open a plugin UI panel (iframe / webview sandboxed page)
- AgentTool plugins: provide new tools to the agent
- Skill plugins: provide loadable skill documents/flows
- Theme plugins: ship a CSS file that overrides the design tokens
- Work panel view plugins: dock isolated HTML views in the app's work panel
- MCP server plugins: declare stdio or remote HTTP MCP servers whose tools join the agent's tool set
- Background service plugins: keep a supervised resident worker alive
- Inter-plugin message bus: publish/subscribe over declared topics
- Billing / signed plugins
- Enterprise private plugin sources
Current implementation: the Plugins page can browse and install packages from the official marketplace provider. Per-plugin auto-update is opt-in and refuses silent permission expansion. This is not a capability-sandboxed runtime: plugin main processes retain raw Node built-ins, so marketplace packages are unrestricted user-privileged code until the planned sandbox is implemented.
Each plugin is a directory:
my-plugin/
├── manifest.json # required
├── package.json # optional (if it carries build artifacts / dependency metadata)
├── main.js # plugin runtime extension entry (restricted API)
├── preload.js # optional, plugin panel bridge
├── renderer/ # plugin UI (static assets)
│ ├── index.html
│ └── assets/
├── skills/ # optional
├── themes/ # optional (`.css` files declared in contributes.themes)
├── tools/ # optional (declarative tool schema)
├── icon.png
└── README.md
~/.pi-desktop/plugins/
├── installed/
│ └── <plugin-id>/
├── disabled/
└── cache/
Developer mode can load a local path directly, without copying it into installed.
{
"schemaVersion": 1,
"id": "demo.hello",
"name": "Hello Plugin",
"version": "0.1.0",
"description": "Example plugin",
"author": "you",
"main": "main.js",
"ui": {
"panel": "renderer/index.html",
"width": 480,
"height": 360
},
"contributes": {
"commands": [
{
"id": "hello.say",
"title": "Hello: Say",
"keywords": ["hello", "hi"],
"category": "Demo"
}
],
"agentTools": [
{
"name": "hello_echo",
"description": "Echo text back",
"risk": "low",
"schema": {
"type": "object",
"properties": {
"text": { "type": "string" }
},
"required": ["text"]
}
}
],
"skills": ["./skills/hello.md"],
"settings": [
{
"key": "greeting",
"type": "string",
"default": "Hello",
"title": "Greeting"
}
]
},
"permissions": [
"clipboard.read",
"clipboard.write",
"notify",
"fs.read",
"fs.delete",
"agent.tool.register"
],
"fs": {
"read": { "scope": ["**/*"] },
"delete": { "own": true }
},
"engines": {
"piDesktop": ">=0.1.0"
},
"entrypoints": {
"onLoad": "main.js#onLoad",
"onUnload": "main.js#onUnload"
}
}schemaVersionis required and must be1; the Rust host (crates/host-core/src/plugins.rs) rejects manifests without itidis globally unique; reverse-domain naming is recommendedversionfollows semverpermissionsmust be declared explicitly- Undeclared permissions default to none
- A manifest that fails validation is refused
Uses three-layer isolation:
Host Main (PI-Desktop)
├─ PluginManager
├─ PluginPermissionGateway
├─ Plugin Sandbox / Worker
└─ Plugin Panel (Renderer iframe/webview)
- Install / uninstall / enable / disable
- Validate manifest
- Authorization management
- Route command and tool calls
Plugin logic runs in a restricted environment; it is not equivalent to full Electron main privileges.
Implemented today (ADR 0008): each plugin's main module runs in its own
utilityProcess (electron/main/plugin-host-process.mjs) and reaches the host
only through JSON RPC to the broker in electron/main/plugin-runtime.ts, which
enforces the API allowlist and the permission gateway. Plugin code never gets a
host object and cannot require host modules.
Still open: capability sandboxing inside the plugin process (raw Node built-ins are reachable there) and CPU/memory limits.
- Load the plugin page in a dedicated sandboxed
BrowserWindowand isolated per-plugin session partition - Keep a newly created panel hidden until its initial page load settles. Bound
that wait to 15 seconds; if it does not settle, destroy the hidden window and
reject the open request with
PANEL_LOAD_TIMEOUT. Preserve the original error when the page load fails on its own. - Closing a panel (capsule close, disable, uninstall, or crash teardown) must
not read a destroyed
BrowserWindowor itswebContents. The host copies any contents identity needed for drop-record cleanup while the window is still alive so theclosedhandler cannot raiseObject has been destroyed. - Use a frameless window on macOS, Windows, and Linux. The preload reserves exactly a transparent 46px drag band and renders only a minimal fixed top-right capsule with three controls: minimize, maximize/restore, and close. Native traffic lights and host-rendered panel titles are not shown. The drag band is not clickable outside the capsule; development panels show a localized reminder.
- Panel titles may remain a legacy string or a localized
{ "en": string, "zh-CN": string }object for the native window identity and launcher metadata, but the host does not render that title inside the panel. - The capsule follows the loaded plugin page's computed background and text
colors. The active PI-Desktop theme (
light/dark, including a plugin theme's base palette) is the fallback while the page is transparent. - Expose the drag-band height as
--pi-plugin-titlebar-height: 46px; normal-flow content is offset automatically, while fixed/sticky plugin UI must usetop: var(--pi-plugin-titlebar-height, 46px)rather thantop: 0. A plugin-owned toolbar may use-webkit-app-region: drag, with-webkit-app-region: no-dragon its interactive controls. - Current panel pages declare
<meta name="pi-plugin-chrome" content="v2">and use the published variable for their own top spacing. The host does not add padding to these pages, avoiding a duplicated safe band; pages without the marker retain the legacy additive offset for compatibility. - A panel that needs to paint a full-bleed surface beneath the host band may
opt into
<meta name="pi-plugin-chrome" content="v3">. The host keeps the same 46px capsule geometry but splits native dragging into empty-space segments. Standard controls and elements marked withdata-pi-plugin-no-dragare holes in that map, so they can receive pointer input while blank space remains draggable. The defaultv2contract remains strict for existing panels. - Stable scrollbar gutters belong on the panel's actual content scroller, not
on the root
html/bodyviewport as well. Windows' classic scrollbar rendering makes a duplicated root reservation visible as an empty right-side rail outside the plugin surface. - The host preload applies the global 6px, trackless, reveal-while-interacting scrollbar contract to docked and detached plugin panel documents. External pages loaded inside the Browser guest remain page-owned and are not restyled.
- The plugin owns its title, toolbar, and every other visible panel surface.
- Render the host capsule in a closed preload-owned Shadow DOM so plugin CSS cannot restyle its controls
- Can only call the safe APIs exposed by the plugin preload
- Cannot access the host DOM / host store by default
Namespace: pi.plugin.*
pi.app.getVersion()pi.plugin.getManifest()pi.plugin.getSettings()pi.plugin.setSettings(partial)pi.commands.register(command)pi.ui.openPanel(options?)pi.ui.showToast(message)pi.ui.notify(title, body)pi.ui.getNotificationPermission()pi.ui.requestNotificationPermission()pi.ui.showNativeNotification({ title, body? })
pi.workspace.get()pi.fs.readText(path)pi.fs.openDefault(path)// open the selected file with the OS-associated apppi.fs.openInBrowser(path)// open an HTML file in the work-panel Browserpi.fs.reveal(path)// show the selected file in the OS file managerpi.fs.writeText(path, content)// high riskpi.fs.glob(pattern)
pi.project.create({ path })//project.create; returns a durable project id without activating itpi.agent.registerTool(tool)pi.agent.unregisterTool(name)pi.models.list()//models.listpi.session.getLlmContext()//session.read; in-flight tool session onlypi.session.import()/importBatch()//session.import; declared sources onlypi.session.list()/get()/listMessages()//session.read.ownpi.session.rename()//session.update.ownpi.session.delete()//session.delete.ownpi.usage.listTurns()//usage.read; read-only completed-turn facts, no message bodiespi.agent.complete(input)//agent.complete; host-owned one-shot
Skills are contributed declaratively (contributes.skills + agent.prompt.inject),
not invoked by the plugin: the host puts the catalog in the system prompt and the
model loads a body through the built-in Skill tool (D174). Planned, not
currently exposed: pi.agent.appendSystemHint(text). When the body is loaded,
the tool result also identifies the SKILL.md location and the directory to use
when resolving relative references; the catalog remains metadata-only.
pi.services.register({ id, start, stop? })pi.services.unregister(id)
Registration is local bookkeeping; the broker starts a service only when the manifest declared it and the permission was granted, and supervises restarts.
pi.bus.publish(topic, payload)//bus.publishpi.bus.subscribe(pattern, handler)→unsubscribe()//bus.subscribepi.events.on(event, handler)/pi.events.off(event, handler)// host pushes, including the rawbus.messagestream
pi.clipboard.readText()pi.clipboard.getHistory()// text and images, newest firstpi.clipboard.writeText(text)pi.shell.openExternal(url)// confirmation by defaultpi.net.fetch(input)
pi.ui.notify is an in-app Toast. Native plugin notifications use the
Electron main-process notification API and share the manifest notify
permission. requestNotificationPermission() returns the best-effort native
permission state (granted, denied, unknown, or unsupported) after
performing a short native probe. These notifications are not durable task
inbox records and do not activate a session when clicked.
- Arbitrary host-internal Electron objects
- Arbitrary absolute-path access through the brokered
pi.fsAPIs
The broker does not provide Node capabilities directly. However, the current
utility-process plugin runtime is not a Node capability sandbox: plugin code can
reach raw Node built-ins independently of pi.*. The permission model below
therefore applies only to brokered APIs until runtime sandboxing is delivered.
| permission | Risk | Description |
|---|---|---|
ui.panel |
low | Show panel |
ui.view |
low | Contribute work panel views |
ui.theme |
low | Contribute a theme CSS file |
clipboard.read |
medium | Read the current clipboard and host-retained clipboard history |
clipboard.write |
medium | Write clipboard |
notify |
low | System notification |
fs.read |
medium | Read the paths manifest.fs.read lists |
fs.write |
high | Write the paths manifest.fs.write lists |
fs.delete |
high | Delete the paths manifest.fs.delete lists, to the OS trash |
agent.tool.register |
high | Register agent tool |
agent.prompt.inject |
high | Inject prompt; activates contributes.skills |
net.fetch |
high | Network request |
shell.openExternal |
medium | Open external link |
mcp.server.local |
high | Spawn a stdio MCP server |
mcp.server.remote |
high | Connect a remote HTTP MCP server |
background.service |
medium | Keep a resident service running |
bus.publish |
medium | Publish to declared bus topics |
bus.subscribe |
medium | Subscribe to declared bus patterns |
Themes, MCP servers, services, and bus topics are declared in the manifest, so their permission is checked at validation time as well as at runtime — see 13-plugin-permissions-matrix.md.
The three file permissions carry a range as well as a switch: manifest.fs says
which paths each mode may touch, and manifest.net.domains does the same for
egress. A file permission with no declared scope has no standing reach — every
access asks the user. The pre-scope names (fs.read.workspace and friends) still
load and are downgraded to the minimum safe equivalent (ADR 0088).
- Show the declared permission list at install or upgrade review time
- Only granted permissions are passed to the brokered runtime; missing grants
fail the corresponding
pi.*call - Users can revoke permissions on the plugin management page; a reload is required for a running plugin to observe the changed grant set
Per-call confirmation and any policy for direct Node access are not implemented.
The global command palette supports:
- Searching plugin commands
- Keyword triggers
- Recently used
- Grouping by category
Interaction flow:
User opens the command palette
→ types a keyword
→ matches a plugin command
→ executes the command handler
→ opens a panel or triggers an agent/tool
Shortcut (recommended):
- macOS:
Command+Shift+Por custom - support quick launcher invocation later
After a plugin registers a tool:
- PluginManager validates the schema and permissions
- ToolHost wraps the tool
- Every call first passes through permissions and audit
- Actual execution lands in the plugin runtime
- The result is normalized before being returned to the agent
The wrapping layer must add:
- timeout
- argument validation
- error normalization
- audit logging
- a disable switch
discover → validate → install → enable → load → running
↘ disable → unload
↘ uninstall → purge
Hooks:
onInstallonLoadonEnableonDisableonUnloadonUninstall
Implemented today: the runtime (apps/desktop/electron/main/plugin-runtime.ts) invokes onLoad (when a plugin is loaded on load/enable) and onUnload (in the plugin process, before it is stopped, 5s budget); unloading tears down the plugin's registered commands and tools. The other hooks are declared in the API but not yet fired.
Planned: once the full lifecycle lands, hooks fire in this order: install → enable → load → (running) → unload → disable → uninstall. See 05-plugin-lifecycle.md for the detailed sequence.
Failure policy:
- load failure: mark error, do not affect host startup
- tool execution failure: return a tool error, do not crash the main process
The app shell's dedicated Extensions destination owns everything a user adds to the app. Do not duplicate any of it in Settings.
The Extensions destination has two tabs because it is a plugin surface:
| Tab | Contents |
|---|---|
| Installed | Plugins, grouped: needs attention, updates, active, disabled |
| Marketplace | Browse and install |
MCP, Skills, and Subagents are managed in the three independent Settings > Agent pages. They are not duplicated in Extensions. Plugin features remain:
- Local install (choose directory / zip)
- Developer load (path)
- Activation scope (off / this project / everywhere)
- Uninstall
- View permissions
- View logs
- Open plugin directory
Status indicators:
- enabled
- disabled
- error
- dev-loaded
One control serves all three kinds (D192). It is a three-segment track ordered by increasing reach — Off → These projects → Everywhere — so widening and narrowing are the same gesture in opposite directions, plus a summary chip that opens the project picker when the middle segment is active.
Rules the control encodes:
- Choosing "these projects" with nothing picked yet seeds the currently open project, so the common case is one click.
- Switching to "everywhere" or "off" keeps the project list, so going back restores it.
- A project already scoped but no longer in the recent list still appears in the picker, or the scope could never be undone.
- A project-scoped extension with an empty list warns instead of silently doing nothing.
-
The MCP page has independent global and selected-project columns rooted at
~/.agents/serversand<project>/.agents/servers. -
Add and Edit reuse
McpEditorSheet, including stdio/HTTP transport cards, environment/header rows, validation, duplicate checks, and Test connection. -
Enablement is app-local and project records shadow global records before the active runtime filters disabled rows.
-
A previously advertised user MCP tool remains routable after transport loss or a saved connection edit. The next call re-handshakes the current saved server and validates the tool against its fresh list before dispatch; no additional
ToolSearchis required. Unknown names cannot trigger discovery. -
Enablement and project scope are checked before and after recovery. Removing a server or disposing the runtime discards its remembered names; an obsolete in-flight handshake cannot restore them. Concurrent calls share a handshake.
-
A failed recovery reports
UNAVAILABLEand retains the existing failed-server policy (edit or Test connection to retry), rather than repeatedly connecting on each call. Removed tools returnTOOL_NOT_FOUND. Recovery never replays a failedtools/call, which may already have performed a mutation. -
Streamable HTTP
202 Acceptedacknowledgements for notifications and client responses are not JSON-RPC replies. Any acknowledgement body is discarded, including plain-textAccepted; ordinary request replies still follow the JSON/SSE parsing and response-size limits. -
A streamable-HTTP server may write its JSON-RPC reply and keep the SSE stream open afterwards — keep-alives, or a session it ends on its own schedule. Each
text/event-streamevent is dispatched as it arrives, so a handshake or atools/listpage completes on its reply instead of on the end of the stream. The request budget still bounds the exchange: a server that never replies still times out, and a stream left open past its request is aborted. -
The MCP row shows “Authorization required” only when runtime status explicitly reports
authRequired. Missing credentials, an untested connection, and non-authentication failures do not imply OAuth is required. A stored OAuth credential does not hide a subsequent authentication failure. Manual OAuth authorization remains available from the HTTP server menu.
- The page lists bundled skills in a Built-in skills group above the user-owned global and project groups. Each bundled skill has a global enablement switch, enabled by default. The user can turn a bundled skill off without editing or removing its packaged document.
- A disabled bundled skill is omitted from the effective session catalog and
Composer's
/skill:<id>entries. A stale directSkillload reports that the skill is disabled. The activation state lives in the separate<data>/agent-capabilities/skill-builtins.jsonfile so scans of user-owned documents cannot prune it. - Built-in skill activation controls the skill catalog and on-demand document loading only. It does not disable or unregister any agent tool associated with the skill; tool activation remains independent.
- The Skills page has independent global and selected-project columns rooted at
~/.agents/skillsand<project>/.agents/skills. - Each column has a native single-file Import action and a folder Import action
that can select multiple
<name>/SKILL.mdfolders at once. The host imports selected folders independently, preserves sibling resources, and reports partial failures without undoing successful imports. The folder picker reopens at the parent of the last successfully imported source folder when that parent still exists; cancellation and all-failed batches leave that machine-local value unchanged. - A
SKILL.mdimport or scan uses the parent directory as the id when the frontmatter name is not an ASCII slug, and folded YAML descriptions still enter the catalog. A readable document is never dropped because its title is non-ASCII. - The description is the catalog summary and the body is fetched only when the
model invokes
Skill(D174, D194).
Provide:
- Plugin template:
npm create pi-desktop-plugin - manifest schema validator
- Developer hot reload (watch directory)
- Example plugins:
- Hello Panel
- Workspace Greeter Tool
- Clipboard Note
Local development flow:
# develop the plugin
cd plugins/hello
pnpm dev
# in PI-Desktop
Plugins → Load Development Plugin → choose directory| Ecosystem object | Relationship |
|---|---|
| pi Skills | Can be distributed / managed by skill plugins |
| pi Extensions | A plugin contributes them as contributes.agentExtensions with the agent.extension grant; a pi CLI extension imports as a development plugin (D387 / D388, ADR 0214 / 0215, 16-trusted-extensions.md) |
| MCP | A plugin declares MCP servers in contributes.mcpServers; their tools join the agent's tool set |
| Agent Tools | One of the most important plugin extension surfaces |
Principles:
- Do not exclude pi native capabilities
- But on the user side, call everything "plugins"
- Plugins have no permissions by default
- Plugins cannot directly access host renderer state
- Plugins cannot read or write files outside the workspace by default
- Plugin network capability is off by default
- Plugin update / install requires integrity verification (signing later)
- The host core process does not execute arbitrary Electron main code injected by a plugin
- manifest spec
- PluginManager skeleton
- local load / enable-disable
- command registration
- 1 example plugin
- plugin Panel UI
- permission-grant UX
- AgentTool registration and invocation
- plugin settings storage
- zip install
- plugin log center
- developer hot reload
- more official examples
- plugin marketplace
- signing and auto-update
- MCP plugin type
- background service plugins
The original MVP can hold off on opening a "full plugin marketplace", but should reserve:
- plugin directory
- manifest
- PluginManager interface
- at least one built-in / example plugin path
That is:
Have the plugin architecture first, then the plugin ecosystem.
- Users can load a plugin from a local directory
- Plugin commands appear in the command palette
- Plugins can open their own panel page
- Plugins can register a low-risk agent tool and invoke it successfully
- Disabling a plugin immediately deactivates its commands and tools
- A plugin crash does not cause the host to exit
Example plugin in the repo:
examples/plugins/hello