pi.themes.setVariables(themeId, values) requires ui.theme. The host accepts
only values for declared variables on one of the caller's themes, persists them
in private plugin settings, and refreshes an active theme without selecting a
new theme or reloading the renderer. It never accepts stylesheet text, URLs,
selectors, images, fonts, or arbitrary property names.
- Small and stable
- Permission-driven
- Async-first
- Auditable
- Do not expose host internal objects
Inside the plugin runtime, a global is available:
declare const pi: PiPluginHostApi;Status legend: every section below is shipped and enforced by
PluginRuntimeunless its heading or text says Planned. A planned surface is documented ahead of implementation so plugin authors can see the direction; it throwsUNSUPPORTEDuntil it lands (see §9 for the per-surface list).
pi.app.getVersion(): Promise<string>
pi.app.getLocale(): Promise<string>
pi.app.getAppearance(): Promise<PluginAppearance>
pi.app.setTheme(themeId: "system" | "light" | "dark" | `plugin:${string}`): Promise<void>app.getAppearance returns the appearance the host is currently showing so a
plugin (or its panel) can mirror the app's language and color mode exactly:
type PluginAppearance = {
theme: string // raw preference: "light" | "dark" | "system" | "plugin:<pluginId>:<themeId>"
base: "light" | "dark" | "system" // palette the preference resolves to
locale: string // active language tag (e.g. "en", "zh-CN")
pluginTheme: { id: string; base: "light" | "dark"; css: string } | null
}Panels read the same value through the bridge channel app.getAppearance and
receive live updates on the appearance:changed event (below). Plugin processes
receive the same event on pi.events. On hosts older than the channel, the call
rejects with UNSUPPORTED; panels should fall back to the OS preference and
their own in-panel choice.
app.getLocale is the same language tag as getAppearance().locale. Plugin-owned
UI (panels, views, widgets, settings destinations, toasts, runtime command titles)
localizes from this value. The host does not grow { en, "zh-CN" } maps on more
contribution fields; generated contributes.settings titles stay plain strings
(ADR 0280). Host-owned identity (manifest.i18n) and already-shipped chrome
labels (ui.title, view titles, destinations) keep their existing contracts
(ADR 0267, ADR 0082).
app.setTheme (requires ui.theme, ADR 0260) applies the app theme
preference the Settings picker writes. It accepts a built-in preference or a
currently registered plugin theme id; unknown ids reject with
INVALID_ARGUMENT. The host persists AppSettings.theme, refreshes native
chrome / panel appearance, and emits settingsChanged to the renderer.
Runtime registry for the calling plugin's own themes. Works in production without unload/reload (ADR 0260).
pi.themes.upsert(input: {
id: string; // local id, same rules as contributes.themes[].id
label: string;
base: "light" | "dark";
css: string; // sanitized with sanitizeThemeCss
}): Promise<void>
pi.themes.remove(themeId: string): Promise<void>
pi.themes.list(): Promise<Array<{ id: string; themeId: string; label: string; base: "light" | "dark" }>>- Full ids are namespaced
plugin:<pluginId>:<themeId>. upsertof an existing id replaces label / base / css.- There is no per-plugin theme count cap; the CSS size cap and sanitizer still apply.
- After upsert/remove the host emits
pluginChanged(reason: "themes") and refreshes panel appearance, so an updated active theme restyles immediately.
pi.plugin.getId(): string
pi.plugin.getManifest(): PluginManifestV1
pi.plugin.getSettings<T=Record<string, unknown>>(): Promise<T>
pi.plugin.setSettings(partial: Record<string, unknown>): Promise<void>
pi.plugin.getDataPath(): Promise<string> // plugin-private directoryThe installed Plugins page renders every contributes.settings field and
persists edits in the plugin's private settings file. Supported generated
controls are string, number, boolean, select, json, and shortcut.
Generated title / description / enum[].label are author-language strings;
the host does not resolve locale maps on them. A plugin that needs a localized
settings surface ships settingsDestinations and reads pi.app.getLocale
(ADR 0280). Shortcut settings are plugin-local: they invoke the declared
command only while the PI-Desktop app window is focused and while the plugin's
activation scope matches the current project. They are never registered as
OS-global shortcuts in this release. The host emits plugin:settingsChanged
after a user edit so a plugin can refresh in-memory configuration.
pi.commands.register(def: {
id: string
title: string
keywords?: string[]
run: () => Promise<void> | void
}): Promise<void>
pi.commands.unregister(id: string): Promise<void>A plugin may contribute explicit, user-invoked text actions through
manifest.contributes.composerTransforms. A non-empty contribution requires
the composer.transform permission. The host only lists actions from a loaded
plugin whose permission is currently granted; the manifest supplies the action
title and optional undo title.
type PluginComposerTransformInput = {
id: string;
text: string;
modelKey?: string; // current Composer provider/model key; no credentials
};
type PluginModule = {
onComposerTransform?: (
input: PluginComposerTransformInput,
) => Promise<string> | string;
};The callback receives only the draft text and optional model key. It does not
receive a session id, transcript, or separate attachment and file-reference
metadata. The host removes inline file-reference tokens before dispatch and
restores them after success.
The callback returns a string; input and output are each capped at 100,000
characters and the call uses the 110-second plugin-tool timeout. The host
rechecks the plugin load, declaration, and grant, and audits both outcomes.
Composer discards results that arrive after an edit, send, or session switch and
provides one-step undo after success. A plugin that makes a model request
separately declares the permissions required by that API, such as
agent.complete and models.list.
{
"permissions": ["composer.transform"],
"contributes": {
"composerTransforms": [
{
"id": "enhance",
"title": { "en": "Enhance prompt", "zh-CN": "增强提示词" },
"undoTitle": { "en": "Undo", "zh-CN": "撤销" }
}
]
}
}pi.speech.registerAdapter(adapter: {
protocol: string
label: string
roles: Array<"transcribe" | "synthesize">
handle: (input) => Promise<{ kind: "text"; text: string } | { kind: "audio"; mimeType: string; data: string } | { kind: "http"; call: SpeechHttpCall }>
}): Promise<void>
pi.speech.unregisterAdapter(protocol: string): Promise<void>The handle stays in the plugin process. Built-in protocol ids openai_audio
and openai_chat_audio are reserved. HTTP plans are executed by the host with
the bound provider key and must stay on that origin.
pi.ui.openPanel(options?: { title?: string }): Promise<void>
pi.ui.closePanel(): Promise<void>
pi.ui.showToast(message: string, level?: "info"|"warn"|"error"): Promise<void>
pi.ui.notify(input: { title: string; body?: string }): Promise<void>
pi.ui.getNotificationPermission(): Promise<PluginNotificationPermission>
pi.ui.requestNotificationPermission(): Promise<PluginNotificationPermission>
pi.ui.showNativeNotification(input: {
title: string
body?: string
}): Promise<{ shown: boolean; permission: PluginNotificationPermission }>
type PluginNotificationPermission = "granted" | "denied" | "unknown" | "unsupported"ui.notify remains an in-app Toast. Native delivery is opt-in through
ui.showNativeNotification and is guarded by the same manifest notify
permission. requestNotificationPermission performs the platform-native
permission probe by showing a short confirmation notification; Electron does
not expose a cross-platform read-only notification permission API, so
unknown is returned before the first probe and when the operating system does
not report a result. Native delivery is best-effort: an OS policy may suppress
the banner without changing the durable task notification inbox. Clicking a
delivered plugin notification restores and focuses the main window, but never
activates a session or creates a durable task notification.
pi.ui.revealView shows one of the plugin's own contributes.views in the work
panel of a session, which is the supported replacement for reaching into the
host's own navigation:
pi.ui.revealView(input?: {
viewId?: string // one of contributes.views; omittable when there is one
location?: string // the same opaque subject a view already receives
sessionId?: string // the session whose panel shows it; defaults to the active one
}): Promise<void>Requires ui.view, like the view itself. The host resolves the view and refuses
a viewId that does not name exactly one declared view (NOT_FOUND when the
plugin declares none or the name is unknown, INVALID_ARGUMENT when the plugin
declares several and the call names none). location is capped at 8 KiB.
sessionId is compared with the session the user is looking at: a session that
is no longer the active one rejects with INVALID_ARGUMENT instead of opening a
tab nobody is watching, and a call made with no active session rejects the same
way. The tab belongs to the shell — the plugin names the view and the subject,
the shell opens and focuses it — so the location still travels through
piViewOpen / view:open and a live view is never reloaded.
pi.ui.openPanel() opens a separate window, pi.browser.reveal() activates the
Browser, and pi.fs.openDefault / pi.fs.reveal hand a file to the operating
system; none of them is this.
pi.project.create(input: { path: string }): Promise<{
projectId: number
path: string
name: string
}>This creates or reuses a durable host project record without changing the
active workspace. The returned projectId may be passed explicitly to
pi.session.import() or an item in pi.session.importBatch(). The plugin must
hold project.create when it supplies a project id. Omitting projectId keeps
the imported session unbound; projectPath remains historical source metadata
and never creates a project by itself.
pi.workspace.get(): Promise<{
path: string;
name: string;
projectId?: string;
roots?: Array<{ path: string; name: string; primary: boolean }>;
} | null>
pi.fs.readText(pathFromRoot: string): Promise<string>
pi.fs.stat(pathFromRoot: string, grantId?: string): Promise<{
size: number;
mtimeMs: number;
}>
pi.fs.readRange(
pathFromRoot: string,
byteOffset: number,
length: number,
grantId?: string,
): Promise<{ bytes: Uint8Array; totalSize: number }>
pi.fs.readPreview(pathFromRoot: string): Promise<{
kind: "text" | "image" | "binary" | "tooLarge"
content?: string // UTF-8 when kind is "text"
dataUrl?: string // data URL when kind is "image"
size: number
}>
pi.fs.openDefault(pathFromRoot: string): Promise<void>
pi.fs.openInBrowser(pathFromRoot: string): Promise<void>
pi.fs.reveal(pathFromRoot: string): Promise<void>
pi.fs.writeText(pathFromRoot: string, content: string): Promise<void>
pi.fs.glob(pattern: string): Promise<string[]>
pi.fs.list(pathFromRoot: string): Promise<Array<{
name: string;
path: string; // root-relative, usable directly with readText / list
isDirectory: boolean;
size?: number; // files only
mtimeMs?: number; // files only; Unix epoch milliseconds
}>>
pi.fs.remove(pathFromRoot: string): Promise<void>
pi.fs.requestDirectory(): Promise<{ path: string; name: string } | null>workspace.get answers with the primary root — path and its leaf name,
both unchanged — plus, when that folder belongs to a project group (ADR 0249),
projectId and roots: every registered folder of the group in its own order,
primary first, each { path, name, primary } (ADR 0263). workspace:changed
carries the same object, and main answers both from the host-owned group
records, so the event and the pull cannot disagree. A host that cannot resolve
the group omits projectId and roots — the same { path, name } a plugin
already handles — and reading this metadata needs no new permission and adds no
SDK method.
fs.readPreview classifies one existing readable file for in-app display. It
uses the same fs.read checks as fs.readText, rejects directories, and
returns text (capped at 512 KiB), image (capped at 5 MiB, as a data URL),
binary, or tooLarge. The plugin never receives an absolute path.
fs.openDefault opens one existing file with the operating system's default
associated application. It uses the same fs.read root, symlink, protected-path,
deny-list, and scope checks as fs.readText; directories are rejected. The host
audits the operation. A path is root-relative by default, and an absolute path is
accepted only by fs.openDefault, fs.openInBrowser, and fs.reveal (no other mode takes one) when it lies
inside a registered folder root of the open project — which then becomes the
containment base for the request (ADR 0249 §5, ADR 0264). That is the shape a view
uses to name a file in a project folder other than the primary one.
fs.openInBrowser opens one existing .html, .htm, or .xhtml file in a new
resource tab in the active local session's bundled work-panel Browser. It uses
the same fs.read root, symlink, protected-path, deny-list, and scope checks as
fs.openDefault; directories and other file types are rejected. An absolute
path is accepted only when it lies inside a registered folder root of the open
project, with the matched folder as the containment base. The Browser plugin
must be enabled. This host-mediated preview does not grant browser.cdp or expose
Browser actions or page contents to the caller. The Browser itself retains its
existing local-file navigation policy (ADR 0328).
fs.reveal reveals one existing readable file in the operating system's file
manager and selects it when the platform supports that behavior. It uses the
same fs.read checks, rejects directories, and audits both success and failure.
It takes a path exactly as fs.openDefault does: root-relative by default, and
absolute when the file lies inside another registered folder root of the open
project (ADR 0264).
fs.stat returns the size and modification time of one existing readable file
without loading its contents. fs.readRange returns at most 8 MiB of bytes and
the total file size. Both use the same root, symlink, protected-path, deny-list,
scope, consent, and audit gates as fs.readText; offsets and lengths are
non-negative safe integers. An offset at or beyond EOF returns an empty byte
array. A grantId is only valid for a host-issued dropped-file grant and then
requires the matching absolute path.
Paths are relative to the mode's root — the workspace, or the directory the user
picked through requestDirectory() when the mode declares
root: "userSelected". Which paths each mode may reach comes from manifest.fs;
anything outside it prompts the user, and the credential deny-list overrides both
(see 04-plugin-security.md §6). remove is
non-recursive and moves the path to the OS trash.
Under the workspace root, paths are relative to the project of the tool session
that invoked the call, falling back to the visible workspace for a panel call,
which has no tool session (ADR 0266).
list returns one directory's entries, name-sorted, so a plugin can walk a tree
lazily instead of pulling a whole-repo glob and reassembling it. It applies the
same guards as glob, because a listing is a read: files outside the declared
read scope are omitted, denied names and protected paths are omitted, and heavy
directories (node_modules and friends) are skipped. Directories are always
returned so a narrow scope still yields a navigable tree, and at most 1000
entries come back per call.
pi.agent.registerTool(tool: {
name: string
description: string
risk: "low"|"medium"|"high"
schema: unknown
execute: (args: unknown, ctx: ToolExecContext) => Promise<unknown>
}): Promise<void>
pi.agent.unregisterTool(name: string): Promise<void>Registered plugin agent tools are Agent-only contributions. During Plan the
host filters them out of the model tool list and rejects direct execution with
PLUGIN_DISABLED_IN_PLAN, including when the manifest risk is low, the user
has an allow-session grant, or the session permission mode is auto. A
successful plan approval makes the same registered tools eligible again under
the selected Agent permission policy.
type ToolExecContext = {
sessionId: string
turnId?: string
/** Executor model for this session, `providerId/modelId`. Configuration, not transcript. */
modelKey?: string
thinkingLevel?: ThinkingLevel
signal?: AbortSignal
log: (msg: string) => void
}turnId is populated for host-driven turns and matches the turnId of the
corresponding session:turnEnded event (§5).
pi.models.list(): Promise<PluginModelInfo[]>
type PluginModelInfo = {
key: string // `${providerId}/${modelId}` — first slash splits
providerId: string
providerName: string
modelId: string
label: string
supportsReasoning: boolean
thinkingLevels: ThinkingLevel[]
}Only enabled, authenticated provider rows are returned (API key, OAuth, or
authKind: "none"). No secrets. models.list is also a panel-bridge channel
so a picker page can populate itself. When the host transport is unavailable,
the call returns an empty list instead of warning (D080).
The Host exposes unconfigured, manifest-declared API-key providers in Settings
→ Models → Add Service. This is a data-only projection of
contributes.providers; plugins do not register chooser entries at runtime and
receive no API key. category groups the entries and may be a plain string or
an { en, "zh-CN" } label. The Host saves the key in its existing encrypted
provider secret store. A configured row remains in the provider list and is
hidden from Add Service. The provider.register grant is sufficient; there is
no additional permission or plugin API method.
Tiles show the provider name only. Optional description copy appears as a
single-sentence tooltip on hover or keyboard focus; search also checks the
description. The selected key form shows the endpoint and plugin name for a
final destination check before saving.
A provider may declare an empty models list only when it is API-key based and
has a baseUrl. After the user saves a key, the Host requests that endpoint's
model list and caches the answer. The cached models are available to the
plugin-owned row, whose manifest continues to own its endpoint and other
provider fields. If discovery returns no models, the key remains saved and the
user can retry from the provider's model controls. Provider count is not capped
per plugin; the package's existing size limit bounds the manifest.
An OAuth provider contribution needs both provider.register and
provider.oauth, a baseUrl, and an onProviderOAuth export from the plugin's
main module. The host invokes the hook only for a declared contribution:
type PluginProviderOAuthRequest = {
operation: "login" | "refresh"
providerId: string // plugin-local contribution id
loginId?: string // login only; pass to prompt/notify
credential?: PluginProviderOAuthCredential // refresh only; this provider's credential
}
type PluginProviderOAuthCredential = {
accessToken: string
refreshToken?: string
expiresAt?: number // Unix epoch milliseconds
accountLabel?: string
headers?: Record<string, string>
}
type PluginProviderOAuthContext = { signal: AbortSignal }
onProviderOAuth(request, { signal }): Promise<PluginProviderOAuthCredential>During login, return the credential after the user authorizes it. During refresh, the host passes the current credential to the same callback; return the updated credential. The host encrypts it under the provider row's OAuth secret reference and serializes refreshes. A callback can access only its own contribution's credential. The host sends the resolved access token to the Agent Runtime per request; refresh tokens never reach the renderer or Agent Runtime. One account is stored for each provider contribution; sign out clears that credential while the manifest-owned provider row remains.
The plugin can use host-owned login UI without opening its own window:
if (!request.loginId) throw new Error("loginId is required for sign-in")
const loginId = request.loginId
await pi.providers.oauth.notify(loginId, {
kind: "deviceCode",
userCode,
verificationUri,
intervalSeconds,
expiresInSeconds,
})
const code = await pi.providers.oauth.prompt(loginId, {
type: "secret",
message: "Enter the verification code",
})prompt supports text, secret, select, and manual_code. notify
supports non-secret info, authUrl, deviceCode, and progress events; the
host opens valid HTTP(S) authorization URLs and reports whether the browser
opened. The callback context signal is aborted when the user cancels, the plugin
unloads, or the host call times out. OAuth token requests still require
net.fetch and the manifest's net.domains when they use the host network API.
This permission does not grant a general host secret API. Plugin entry code is
not an OS sandbox and can use raw Node APIs, so grant it only to code you trust.
pi.session.getLlmContext(): Promise<PluginLlmContext>
type PluginLlmMessage = {
role: "user" | "assistant" | "tool" | "system"
content: string
toolName?: string
}
type PluginLlmContext = {
sessionId: string
modelKey: string | null
thinkingLevel?: ThinkingLevel
messages: PluginLlmMessage[]
truncated: boolean
}The plugin cannot pass a session id. Identity is the in-flight plugins.execute
session (D333 / D336). Calling this outside a tool execution fails with
INVALID_ARGUMENT. Subagent rows are omitted. An in-flight call of the
plugin's own tool is stripped from the tail. A compaction summary replaces
pre-checkpoint history. Combined content is capped at 200k characters.
The selected session is what the user is looking at, and the host already shows
it; following it needs no permission, the same way workspace.get reports the
open folder.
pi.session.getActive(): Promise<PluginActiveSession | null>
type PluginActiveSession = {
sessionId: string
projectPath: string | null // working directory of the session's project
activeTurn: { turnId: string; startedAt: number } | null
}It takes no session id: the host reports the selection instead of letting a
plugin address a session the user is not looking at. A plugin whose activation
scope does not match the project on screen gets PERMISSION_DENIED, the same
answer pi.ui.revealView and the view IPC give, so a project-scoped plugin never
learns which session another project has open. null means home, the
Settings page, or no chat session selected. activeTurn names the durable host
turn that session is running, and is null while the session is idle, so a
plugin does not have to infer a running task from the previous turn's end event
or from draft changes. startedAt is the host clock in epoch ms at the moment
that turn opened.
A snapshot is a value, not a handle: the session can switch or finish between
two calls, so a plugin compares sessionId and treats a mismatch as "this
answer belongs to a session the user left". The session:activated event below
delivers the same payload whenever the snapshot changes, which is what makes
polling unnecessary. session.getLlmContext is a different capability: it is
scoped to the plugin's own in-flight tool call and cannot address the selected
session.
This capability is separate from session.read: it never exposes a transcript
window or arbitrary message lookup. It exists for plugins that generate a title
after a completed turn.
type PluginAutoTitleContext = {
sessionId: string
expectedTitle: string
userPrompt: string // first user message, at most 1,000 characters
assistantReply?: string // first assistant reply, at most 500 characters
modelKey?: string // providerId/modelId from the session configuration
}
pi.session.getAutoTitleContext(input: {
sessionId: string
}): Promise<PluginAutoTitleContext | null>
pi.session.setAutoTitle(input: {
sessionId: string
expectedTitle: string
title: string
}): Promise<{ updated: boolean }>Context is returned only for an active session whose title source is still
default. That source covers both a new session's placeholder and the
deterministic first-prompt fallback the core writes itself, so a plugin must
expect expectedTitle to be the current derived text rather than a localized
placeholder. The host does not return attachments, tool calls, later turns, or
the rest of the transcript. Title updates accept 1–80 Unicode code points and
use the exact expectedTitle as a compare-and-set; a manual rename or another
update makes the result { updated: false }. Both methods require
session.autoTitle, which is high risk because the first-turn text can be sent
to a model by a plugin holding agent.complete.
Plugins may import and manage only sessions whose origin belongs to that same
plugin. The source must be declared in manifest.contributes.sessionSources;
the host supplies the localized source label and generates the durable session
and message ids. Imported sessions never bind a workspace, provider, or model;
they bind a project only when the caller supplies an existing projectId. The
original import values remain available in get().history.
type PluginSessionSourceContrib = {
id: string
label?: string | { en: string; "zh-CN": string }
}
pi.session.import(input: {
source: string
externalId: string
title: string
projectId?: number | null // explicit id from pi.project.create; omitted is unbound
projectPath?: string | null
modelId?: string | null
providerId?: string | null
createdAt: string // strict RFC3339
updatedAt: string // >= createdAt
messages: Array<{
role: "user" | "assistant" | "tool"
content: string
createdAt: string // monotonic within the session
modelId?: string
providerId?: string
toolName?: string
toolCallId?: string
toolStatus?: "success" | "error"
toolArgs?: unknown
toolResult?: unknown
}>
}): Promise<{ sessionId: string; imported: boolean; skipped: boolean }>
pi.session.importBatch(input: {
source: string
sessions: Array<Omit<PluginSessionImportInput, "source">>
mode?: "skip" | "fail"
}): Promise<PluginSessionBatchImportResult>
pi.session.list(input?: {
limit?: number; cursor?: string; source?: string; updatedAfter?: string
}): Promise<PluginSessionListResult>
pi.session.get(input: { sessionId: string }): Promise<PluginSessionGetResult>
pi.session.listMessages(input: {
sessionId: string; limit?: number; cursor?: string
order?: "asc" | "desc"; contentLimit?: number
}): Promise<PluginSessionMessageListResult>
pi.session.rename(input: { sessionId: string; title: string }): Promise<{ updated: boolean }>
pi.session.delete(input: {
sessionId: string; mode?: "trash" | "purge"
}): Promise<{ deleted: boolean }>Import is idempotent on (pluginId, source, externalId). skip batches
continue per item; fail batches validate and commit atomically. trash hides
the session while retaining its transcript and origin; purge removes both and
allows a later re-import. Reads, rename, and delete are ownership-scoped, and
undeclared sources fail with PERMISSION_DENIED.
When a session has an explicit project binding, projectId and
bound.workspace report that binding, and get().projectPath resolves the
bound project's current path. The original import projectPath remains in
get().history.
After a successful import, rename, or delete, Electron main emits one
host-owned sessionsChanged event for the affected mutation. The renderer
refreshes its authoritative session list and the Projects page refreshes its
durable project index from that list. Plugins do not emit or coordinate this
event themselves. A project created through this API is not automatically
opened as a sidebar tab, preserving the existing closed-project behavior.
The host enforces a 2,000-message/session, 100-session/batch, 512 KiB/message,
256 KiB/tool-value, 32 MiB/payload, and JSON-depth-8 limit. Import is limited
to 10 calls/minute plus 5 batch calls/minute per plugin; delete is limited to
20 calls/minute. Tool __pi* and piDesktop.* object keys are removed before
storage. P2/P3 operations (session create, message mutation, arbitrary re-binding,
provider/model binding, batch delete, and tags) are intentionally not part of
this contract.
Read-only completed-turn facts for the non-deleted sessions the user can still see. The host serves one flat fact row per turn — counters and identifiers only; no message body, no transcript projection, and no write path. Deliberately no dashboard shape: streaks, heatmaps, per-model shares, and top-session rankings are the plugin's own computation on top of these rows, so changing a metric definition later is never a breaking SDK change.
pi.usage.listTurns(input?: {
fromMs?: number // inclusive window start, epoch ms; default toMs - 30 days
toMs?: number // inclusive window end, epoch ms; default now
projectId?: number | null
sessionId?: string
cursor?: string // opaque page cursor from the previous nextCursor
limit?: number // 1..=500 rows; default 200
}): Promise<{
turns: Array<{
turnId: string; sessionId: string; sessionTitle: string | null
projectId: number | null; providerId: string | null; modelId: string | null
startedAt: number; endedAt: number
inputTokens: number; outputTokens: number
cacheReadTokens: number; cacheWriteTokens: number; reasoningTokens: number
}>
nextCursor: string | null
}>Semantics:
- Only completed turns of non-deleted sessions are listed. A session the user deleted leaves the listing.
- Rows are ordered by
endedAtascending with a keyset cursor, so paging is stable while the window fills; the ranking a dashboard shows is its own sort, not the host's. - The window spans at most 365 days;
limitis 1..=500 (default 200). The Electron side validates first, and the host RPC re-checks the same bounds. Absent andnullbounds are equivalent; an empty session title is returned asnull. - A missing or malformed
usage_jsonyields zero cache/reasoning counters — never a partial row.
The official Session Orchestrator composes the reviewed desktop-control catalog; this is not a second session API and it does not expose Electron channels or the local MCP bearer token.
type SessionCollaborationOperation =
| "session/collaboration/spawn"
| "session/collaboration/send"
| "session/collaboration/list"
| "session/collaboration/status"
| "session/collaboration/result"
| "session/collaboration/cancel"
// All calls use pi.desktop.invoke({ operation, args: [input] }).
type SpawnInput = {
task: string
title?: string
modelKey?: string
notifyOnCompletion?: boolean
idempotencyKey?: string
}
type SendInput = {
sessionId: string
content: string
kind?: "task" | "message"
notifyOnCompletion?: boolean
idempotencyKey?: string
}
type ListInput = {}
type StatusInput = { sessionId: string }
type ResultInput = { sessionId: string; messageId?: string; turnId?: string }
type CancelInput = { sessionId: string; messageId?: string }spawn returns a real durable target sessionId and host delivery
messageId. send addresses an existing Session ID in either direction and
reuses that session's project, model, context, and permission configuration;
messageId identifies one delivery and is never a worker identity. status
and result are bounded projections and do not load a full transcript.
cancel interrupts only the exact queued delivery or bound turn and retains
the target session and history.
A named spawn modelKey is an AI-driven delegation choice and needs that
model's own ModelBinding.availableForSubagents opt-in; the host answers
PERMISSION_DENIED for a model the user has not enabled, before creating a
worker. Omitting modelKey still inherits — the first enabled model, else the
default — and naming the default model's own key is that same inheritance
rather than a selection (ADR subagent-model-opt-in).
list returns at most 100 non-deleted Agent sessions that can receive a
message, including sessions created independently of Session Orchestrator. Each
entry contains only its Session ID, title, status, updated time, readable
provider/model labels, and bounded creation links; it does not include a
transcript, project path, credentials, or message previews. The caller can
pass the returned Session ID to send, and status/result remain the
authoritative detail reads.
spawn and send are valid only during the plugin's active Agent tool
invocation. The broker injects pluginId, source sessionId, source turnId,
and an invocation identity; plugin arguments cannot supply or override those
values. A user-facing plugin panel may use cancel with its own plugin
identity, but cannot use that path to send or spawn work. The host enforces
the source permission ceiling, Agent-mode target, inbox and worker limits,
idempotency, and bounded autonomous hops. A requested completion callback is
a host-owned completion message linked to the source delivery and is created
at most once after the actual target turn settles.
The callback is session data, not a new user authorization, and completion
messages do not trigger another callback.
The renderer may read the separate sidebar collaboration projection, but a plugin panel cannot invoke the mutation operations outside this gateway.
pi.agent.complete(input: {
modelKey: string
thinkingLevel?: ThinkingLevel
system?: string
messages?: Array<{ role: "user" | "assistant"; content: string }>
includeSessionContext?: boolean
}): Promise<{
text: string
modelKey: string
thinkingLevel?: ThinkingLevel
usage?: MessageUsage
}>The host resolves credentials and runs a one-shot completion with tools: [].
The plugin never receives a secret. The standalone prompt-enhancement plugin
uses this API from its onComposerTransform callback. includeSessionContext: true also requires session.read and an
in-flight tool session; the host serializes that context and, if messages is
empty, appends Please respond to the request. System prompt
≤ 32 KiB; combined messages ≤ 200k characters; eight calls per plugin per
rolling 60s (RATE_LIMITED); 90s budget (TIMEOUT). Provider 429 and other
transient provider failures are retried inside the same call under the shared
provider retry budget (ADR 0206) and a Retry-After header is honored. Empty
model output is INVALID_ARGUMENT.
When the call still fails, the plugin receives the host's classified code
rather than a single generic failure — PROVIDER_RATE_LIMITED once the retry
budget is exhausted, PROVIDER_UNAUTHORIZED, CONTEXT_TOO_LARGE,
NETWORK_ERROR — so it can pace itself and report the cause. The broker
answers with whichever code the failing service classified, in the same
data.errorCode → errorCode → code precedence every other host boundary
uses.
pi.clipboard.readText(): Promise<string>
pi.clipboard.writeText(text: string): Promise<void>
pi.clipboard.getHistory(): Promise<ClipboardHistoryEntry[]>
type ClipboardHistoryEntry =
| { type: "text"; text: string; capturedAt: string }
| {
type: "image"
format: "png" | "jpeg" | "webp"
data: Uint8Array
width: number
height: number
capturedAt: string
}
pi.shell.openExternal(url: string): Promise<void>openExternal parses url and opens only http:, https:, and mailto:
hrefs (D330 / ADR 0168). Other schemes fail with INVALID_ARGUMENT.
pi.browser.reveal(): Promise<void>
pi.browser.navigate(input: { url?: string; path?: string }): Promise<BrowserState | null>
pi.browser.action(input: { action: "back" | "forward" | "reload" | "stop" }): Promise<void>
pi.browser.setBounds(hole: { x: number; y: number; width: number; height: number }): Promise<unknown>
pi.browser.setVisible(visible: boolean | { visible: boolean }): Promise<void>
pi.browser.getState(): Promise<BrowserState | null>
pi.browser.openExternal(): Promise<void>
pi.browser.snapshot(): Promise<{ tree: string; url: string; title: string }>
pi.browser.screenshot(input?: { fullPage?: boolean }): Promise<{ mimeType: string; data: string; path?: string }>
pi.browser.click(input: { uid: string }): Promise<void>
pi.browser.fill(input: { uid: string; text: string }): Promise<void>
pi.browser.evaluate(input: { expression: string }): Promise<unknown>
pi.browser.console(input?: { limit?: number }): Promise<{ messages: unknown[] }>
pi.browser.cdp(input: { method: string; params?: unknown }): Promise<unknown>navigate returns when the current main-frame navigation commits, including
redirects; it does not wait for slow images or subframes. browser:state reports
loading immediately and optionally includes loadError for failed navigation.
Optional sessionId and tabId identify the host-owned work-panel destination;
plugins do not select these identities through navigation arguments.
With the high-risk browser.cdp permission, navigate may open an existing
local file through an absolute path or file: URL. Relative paths remain
workspace-scoped. A local file page may navigate to other existing local files;
web pages retain only the prior in-workspace file-navigation allowance.
The current guest page is a host-owned WebContentsView (persist:work-browser).
Each resource tab retains its own page. A navigation for a background session is
queued for that session's last selected tab, or its first tab when none exists.
It does not navigate or return another session's visible page.
setBounds is content-relative to the calling plugin view and is clamped so
the guest cannot cover chat/composer. cdp is deny-by-default; cookie,
storage, target, and network-interception methods fail with
PERMISSION_DENIED. Session identity for agent calls comes from the in-flight
plugins.execute sessionId, not from plugin arguments (D333 / ADR 0170).
Page operations (navigate, action, openExternal, getState, snapshot,
screenshot, and CDP calls) are available only while the Browser view is
visible. reveal() asks the host to activate the Browser tab for the calling
session and resolves only after that session's view is visible. A background
session never takes focus; if it cannot become visible, reveal() fails with
UNAVAILABLE. The bundled Browser agent tool calls reveal() before each
operation. BrowserPreview remains the live-reloading workspace-file preview.
Full-page screenshot captures are proportionally scaled to no more than
1280×2048 output pixels. Viewport screenshots and raw Page.captureScreenshot
CDP calls retain their existing dimensions.
Screenshot captures, including raw Page.captureScreenshot CDP calls, have a
30-second host-side deadline. A capture that exceeds it fails with TIMEOUT;
the host detaches the stalled CDP session and releases that page's capture
queue so a later capture can reattach and proceed. Other CDP methods retain
their existing timeout behavior.
getHistory returns newest-first entries explicitly recorded by the host, with
text and images interleaved in capture order. Content written through
writeText and content supplied by the Composer's user-initiated paste event
are recorded; the host does not poll or reread the OS clipboard in the
background. Consecutive identical content is collapsed and refreshes its
timestamp. History is in-memory only and is bounded to 30 days, 500 entries,
and 256 MiB total payload; individual entries are limited to 100 KiB of UTF-8
text or 50 MiB of image bytes. Images are returned as PNG bytes with their
pixel dimensions. A copy that is never pasted is intentionally not captured.
pi.services.register(service: {
id: string // must match a contributes.services[].id
start: (ctx: { log: (msg: string) => void }) => Promise<void> | void
stop?: () => Promise<void> | void
}): void
pi.services.unregister(id: string): Promise<void>Registration is local bookkeeping: it records the handlers so the broker can
call them. The host calls start after onLoad and stop before unload, and
restarts a crashed plugin per 05-plugin-lifecycle.md.
start is idempotent within one process — a second start on an already-running
service is a no-op.
pi.bus.publish(topic: string, payload?: unknown): Promise<void>
pi.bus.subscribe(
pattern: string,
handler: (message: PluginBusMessage) => void,
): Promise<() => Promise<void>> // resolves to unsubscribetype PluginBusMessage = {
topic: string
from: string // publisher plugin id
payload?: unknown
at: string // ISO timestamp assigned by the host
}topic must appear in contributes.bus.publish; pattern must appear in
contributes.bus.subscribe. A publisher is excluded from its own fan-out. Caps
and the threat model are in 04-plugin-security.md §5.1.
pi.net.fetch(input: {
url: string
method?: string
headers?: Record<string, string>
body?: string
timeoutMs?: number
redirect?: "follow" | "error" | "manual"
}): Promise<{ status: number; headers: Record<string, string>; bodyText: string }>redirect is optional: omitted or follow preserves the existing policy (up to
five followed 3xx responses with Location, relative to the current URL, each
checked against the granted egress policy). Existing method/header/body handling
is unchanged; this is not a promise of browser Fetch redirect rewriting.
manual returns the first response's status, headers (including Location) and
body without visiting its target. error rejects any 300–399 response with
REDIRECT_DISALLOWED, even without Location; it never visits the target. Other
statuses, including 429, remain normal responses. In follow mode a 3xx without
Location is returned unchanged; a loop exceeds the five-hop cap with
UNAVAILABLE. One timeout covers the whole chain and response body (TIMEOUT).
Invalid redirect values fail with INVALID_ARGUMENT before network I/O.
The host owns redirect handling for both the default and injected single-hop transport. Every followed hop still passes the existing permission/egress checks; these modes grant no additional network access. Policy refusals are audited without response bodies or headers.
Before using the option on a potentially older host, query
pi.net.getCapabilities(): Promise<{ fetchRedirectModes: string[] }> and require
the desired mode. This read-only query needs no network permission and performs
no network I/O. A missing method, rejected query, or absent mode means
unsupported: do not send the request. Older hosts may silently ignore unknown
fetch options, so passing redirect alone is not capability detection.
This API is unreleased; released 0.17.0 and older do not advertise it.
No same-origin-only mode is introduced by this change.
fetch answers with the upstream response unchanged — status, headers, and
bodyText — so a 429 is data your plugin can read, Retry-After included,
rather than an error the host hides. The host does not retry, throttle, or
re-issue the request: retry and backoff after a 429 are your plugin's own
policy, and the response headers are the only backoff signal you get. A failed
call (status >= 400) is audited as ok: false, together with the
retryAfter it advertised when the response states one (§7).
pi.net.websocket.connect(input: {
url: string
headers?: Record<string, string>
protocols?: string[]
timeoutMs?: number
}): Promise<{ socketId: string }>
pi.net.websocket.send(input: { socketId: string; data: string | Uint8Array }): Promise<void>
pi.net.websocket.close(input: { socketId: string; code?: number; reason?: string }): Promise<void>Requires net.websocket. connect is confined to manifest.net.domains
exactly like fetch, and connect / close are audited. Frames arrive as host
events: net:websocket:open, net:websocket:message, net:websocket:close,
net:websocket:error, each carrying the owning socketId, subscribed to with
pi.events.on. Only the owning plugin receives them.
The host owns the socket, so a plugin cannot exceed four sockets, send or
receive a frame above 1 MiB, or queue more than 4 MiB of unsent data; each of
those is refused (LIMIT_EXCEEDED) or closes the connection rather than growing
the host's memory. A connect carries headers and protocols, so an endpoint
that authenticates per connection works without exposing the credential to
plugin code. Refusals name the reason: INVALID_ARGUMENT for a non-ws(s) URL
or a malformed protocol token, TIMEOUT when the handshake does not finish,
CONNECT_FAILED when it fails, NOT_FOUND for a socket this plugin does not
hold, and PERMISSION_DENIED when the host is not in the allowlist.
pi.desktop.listOperations(): Promise<Array<{
id: string
description: string
risk: "read" | "write" | "dangerous"
}>>
pi.desktop.invoke(input: {
operation: string
args?: unknown[]
confirm?: boolean
}): Promise<unknown>The reviewed catalog includes session/open(sessionId) for a plugin UI to
open an existing durable session. Plugin-originated session/create and
agent/prompt calls refresh session state without changing the active
renderer session; session/open is explicit navigation.
This is the first-party plugin gateway to the reviewed operation catalog shared
with the opt-in local MCP control plane (ADR 0203 / D370). The two catalogs
differ only for operations marked plugin-only: the six
session/collaboration/* operations are callable through this gateway but are
deliberately absent from the MCP-visible catalog (tools/list,
pi_control_describe, and the pi_desktop_invoke enum), because they require
an authenticated plugin invocation context and no renderer mutation channel
exists for them. The returned catalog omits Electron channel names and the
plugin never receives the MCP bearer token. Invocation reuses the controller,
IPC handler, lifecycle checks, completion event, and audit boundary; a plugin
cannot reach arbitrary Electron IPC.
A dangerous operation (session delete, permission-mode change, tool
approval) needs two answers. confirm: true is the plugin's acknowledgement
and is required first (CONFIRMATION_REQUIRED otherwise). The host then asks
the user in a native dialog that names the catalog operation id, its catalog
description, and an argument preview; the dialog never shows plugin- or
model-authored text, so a prompt-injected transcript cannot relabel
session/delete as something benign. A dismissed dialog, a declined dialog,
or a host without a dialog service all fail with PERMISSION_DENIED before
the controller is reached. Calls are logged with the plugin id, operation,
risk, and result status; argument values are not copied into the audit entry.
An isolated panel may request microphone audio through the browser media API
only when the manifest declares and the user grants ui.microphone:
navigator.mediaDevices.getUserMedia({ audio: true })The host permission handler allows the media permission for that panel and
continues to deny camera and every other device permission. The plugin does
not receive a native microphone handle or a host secret; browser speech
recognition and speech synthesis remain page-owned. A panel should provide a
text fallback and announce permission or recognition failures through its
accessible status.
contributes.scenicThemes is a declarative presentation contribution, not a
plugin page API. It provides localized card metadata for same-plugin themes and
declared preview assets. The host owns the Settings DOM, styles, selection,
focus behavior, slider draft, and Apply action. The plugin receives no Settings
bridge, renderer DOM access, arbitrary CSS, JavaScript, navigation, or actions.
The host persists Apply through the existing typed theme-variable boundary and
only for the declared --nexus-backdrop-blur variable. ui.panel windows and
contributes.views retain their independent native-view implementation.
Theme assets declared for scenic cards may be package-relative, in which case
the host resolves them inside the installed plugin package before rewriting the
matching CSS url() or card preview to plugin-asset:. Absolute declared
assets retain the external-path route. Neither route grants a plugin arbitrary
filesystem access (ADR 0288).
Callable, but the device backend is not implemented in this branch.
pi.audio is present in the plugin host process and exposes exactly the ten
methods below. Each one keeps its permission requirement: the six capture
methods (getInputDevices, openInput, closeInput, getCaptureState,
onInputFrame, offInputFrame) require audio.capture.background and the
four playback methods (openOutput, writeOutput, stopOutput,
closeOutput) require audio.playback.background. Without the grant the call
is refused with PERMISSION_DENIED and audited under the permission name,
exactly like every other gated API. With the grant the host still has no device
backend, so every call is answered with a coded UNSUPPORTED refusal: the
message is host api not available: audio.<method> and the audit entry is
{ api: "audio.<method>", ok: false, errorCode: "UNSUPPORTED" }. The eight
asynchronous methods reject with that error; onInputFrame / offInputFrame
are synchronous registration helpers that cannot reject, so they throw an
Error carrying the same code: "UNSUPPORTED" instead of registering a
handler that could never fire. Nothing touches a device and no frame is ever
produced. onInputFrame registers a callback — it is not an event name — and
the frame shape is PluginAudioInputFrame in
packages/plugin-sdk/src/index.ts. When the device service lands, the
permission and this surface stay as they are and only the refusal is replaced
by real behaviour: the host owns the device, a plugin exchanges PCM16 frames
and never receives a device handle, MediaStream, OS device path, or Node
stream, one input stream per plugin is allowed, and disable, unload, or crash
stops capture and drops queued playback.
pi.audio.getInputDevices(): Promise<PluginAudioInputDevice[]>
pi.audio.openInput(options?: PluginAudioOpenInputOptions): Promise<PluginAudioInputSession>
pi.audio.closeInput(streamId: string): Promise<void>
pi.audio.getCaptureState(): Promise<PluginAudioCaptureState>
pi.audio.onInputFrame(handler: (frame: PluginAudioInputFrame) => void): void
pi.audio.offInputFrame(handler: (frame: PluginAudioInputFrame) => void): void
pi.audio.openOutput(options: PluginAudioOpenOutputOptions): Promise<PluginAudioOutputSession>
pi.audio.writeOutput(input: { streamId: string; data: Uint8Array }): Promise<void>
pi.audio.stopOutput(streamId: string): Promise<void>
pi.audio.closeOutput(streamId: string): Promise<void>pi.keyboard.registerGlobalShortcut(input: {
id: string
accelerator: string
command: string
}): Promise<PluginGlobalShortcut>
pi.keyboard.unregisterGlobalShortcut(id: string): Promise<void>
pi.keyboard.listGlobalShortcuts(): Promise<PluginGlobalShortcut[]>
type PluginGlobalShortcut = {
id: string
accelerator: string
command: string
registered: boolean
error?: string
}The host, not the plugin, owns Electron's globalShortcut. A plugin maps an
accelerator to one of its own commands, and the host registers, conflict-checks,
triggers, and releases it; no keyboard hook, before-input-event, raw input
device, or key event stream is ever exposed, and a trigger runs exactly one
command belonging to that plugin.
command must already be registered by the calling plugin; anything else fails
INVALID_ARGUMENT. An accelerator reserved by the operating system, one
PI-Desktop itself currently spends (by default Alt+Space opens the plugin
launcher and Alt+Shift+W shows or hides the window; once the user rebinds one of
them, the freed accelerator is available again), or one held by another plugin
is refused rather than taken over, and a refused re-registration leaves the
previous binding in place.
Refusals are returned, not thrown: registerGlobalShortcut resolves with
registered: false and an error of SHORTCUT_CONFLICT, SHORTCUT_UNAVAILABLE
(platform refusal), INVALID_ACCELERATOR, or LIMIT_EXCEEDED (at most 8
entries per plugin). UNSUPPORTED and INVALID_ARGUMENT are thrown.
Registering an id again replaces that entry's accelerator.
Every contributes.globalShortcuts entry that declares a default is
registered by the host after the plugin's load, but only when its command
actually registered; an entry without a default waits for a
registerGlobalShortcut call. unregisterGlobalShortcut drops one entry and
does nothing for an unknown id; listGlobalShortcuts lists what the host
currently holds for the calling plugin. Everything is released on disable,
unload, and crash, and register / unregister are audited.
type PluginApiError = {
code:
| "PERMISSION_DENIED"
| "NOT_FOUND"
| "INVALID_ARGUMENT"
| "TIMEOUT"
| "UNSUPPORTED"
| "LIMIT_EXCEEDED" // a per-plugin cap is full (e.g. bus subscriptions)
| "RATE_LIMITED" // a rolling window is exhausted (e.g. bus publishes)
| "CONFIRMATION_REQUIRED" // a dangerous desktop operation without confirm: true
| "INTERNAL"
message: string
}All API failures throw an error carrying a code.
pi.events.on(event, handler)
pi.events.off(event, handler)The host pushes events to the plugin process as one-way frames. pi.events
is not a separate channel: it is an alias over the same per-plugin bus stream
that pi.bus.subscribe consumes (plugin-host-process.mjs), so an on
handler sees every frame the host delivers to this plugin and nothing else.
Delivered today:
-
bus.message— a bus delivery, with thePluginBusMessageas the single argument.pi.bus.subscribeis the normal way to receive these;events.onsees the raw stream of every subscription the plugin holds. -
workspace:changed— payload is theworkspace.get()object ornull, sent when the cached workspace path changes: the primarypathandname, plusprojectIdandrootswhen the folder belongs to a project group (ADR 0263). The first workspace of a run may arrive once without the folders and repeat once with them, because the group records are read after that first push. -
plugin:settingsChangedis delivered after edits from the generated Plugins settings UI. -
appearance:changed— payload isPluginAppearance, sent whenever the app palette or language changes, so a plugin process can relabel live the same way an open panel does (ADR 0280). -
session:modelChanged—{ sessionId, modelKey, thinkingLevel }, sent after a successfulsession.configurethat changes provider, model, or thinking level. -
session:turnEnded— payload is{ sessionId: string; turnId: string; reason: "completed" | "aborted" | "error" }, sent once per host turn at the end of its teardown, after the durablesession.endTurnattempt. A turn is the onesession.beginTurncreated: a user submission, an approved plan execution, or a scheduled run, and a queued item that never started produces no event.completed,aborted, anderrorare the three terminal reasons. The event carries theturnIdthe terminal runtime event identified, not whichever turn happens to be active, so a late event from an earlier turn cannot settle a newer one. Delivery is fire-and-forget: there is no ack and no replay, so a plugin that is alive and subscribed receives it once, and a delivery that races a plugin crash, reload, or host quit is not guaranteed. Receiving it does not mean every in-flight tool of that turn has exited — late results can still arrive — so a plugin must serialise or otherwise scope its cleanup byturnId. The event also needs no new permission: it travels on the existing event channel, and subscribing to an unknown event name does not error. No published host emits it yet — 0.14.8 does not include it — so a plugin that depends on it must require the release that actually ships it rather than assume 0.14.7 or 0.14.8. -
session:activated— payload is thePluginActiveSessionobject ornull, sent whenever that snapshot changes: the user selected another session (or left the chat), the selected session's project changed, or its running turn started or stopped. Delivery is fire-and-forget like every other event; the snapshot in the payload is the answer, so a missed delivery is repaired by onepi.session.getActive()call. Needs no permission, and the selected session's turn id is the same identitysession:turnEndedreports.
A throwing handler is logged and does not affect other listeners or the plugin.
Planned events: none.
The Panel UI does not get the full pi directly; instead:
window.pluginBridge.invoke(channel, payload?)
window.pluginBridge.on(event, handler)The same bridge serves both plugin surfaces: a detached ui.panel window and a
contributes.views surface docked in the work panel (ADR 0104). The channel
list, the permission gate, and the preload are identical, so one HTML entry
works in either placement. The only differences are chrome and the view
location below: a docked view has no window-control capsule and no drag band,
and its --pi-plugin-titlebar-height is 0px rather than 46px.
Detached panel pages using the current chrome contract declare
<meta name="pi-plugin-chrome" content="v2"> and use the published variable
for normal-flow top spacing. The host preserves that page-owned spacing. A
page without the marker remains supported through the legacy additive offset.
A manifest may declare "ui": { "shape": "widget" }. The panel then opens as a
floating widget: the same sandboxed, permission-gated page in a transparent,
frameless window with no 46px drag band, no control capsule, and no rectangular
native shadow. The page owns its whole rectangle and normally paints a
silhouette smaller than it — a round orb, for instance — so the host must not
draw a frame around that silhouette.
--pi-plugin-titlebar-heightis0px, and the legacy additive top offset is not applied either, whatever chrome marker the page declares.- The placement is published before page scripts run as
document.documentElement.dataset.piPluginPanelShape:panel,widget, orview. - Dragging uses a whole-window drag map: empty space moves the window, while
standard controls (
button,input,a,[tabindex], …) and every element markeddata-pi-plugin-no-dragstay clickable. - A widget has no capsule, so the host owns an equivalent menu behind the
surface's own context menu: close, minimize, and always on top. A plugin may
still close its own widget through
ui.closePanel(). ui.width/ui.heightare honoured down to 120×120 (a panel's minimum stays 360×280).ui.alwaysOnToppins a widget above other windows, andui.resizabledefaults tofalsefor a widget andtruefor a panel.- Nothing else changes: same preload, same
pluginBridgechannels, same permission gate, same session partition and egress policy.
A docked view may also be given one subject to show. The location a work-panel
tab already carries is delivered to any contributed view — not only
pi.browser, whose address bar keeps its own navigation channel: on creation it
travels as the view entry URL's piViewOpen query parameter, and once the
document has finished loading it arrives as the view:open event. A location
that reaches the host before the first load restarts the load instead, and a
view that is already loaded is never navigated, so unsaved work inside a plugin
is not discarded; re-opening the same location does nothing. The payload is
opaque to the host — each plugin decides what its location means — and it
needs no permission and adds no SDK method.
The host-owned preload forwards only fixed channels to the plugin runtime:
| Channel | Required permission |
|---|---|
ui.showToast, ui.closePanel |
None beyond the loaded panel |
ui.notify |
notify |
ui.getNotificationPermission, ui.requestNotificationPermission, ui.showNativeNotification |
notify |
plugin.getSettings, workspace.get, app.getAppearance |
None |
app.setTheme, themes.upsert, themes.remove, themes.list |
ui.theme |
models.list |
models.list |
fs.readText, fs.stat, fs.readRange, fs.readPreview, fs.openDefault, fs.openInBrowser, fs.reveal, fs.glob, fs.list |
fs.read |
fs.writeText |
fs.write |
clipboard.readText, clipboard.getHistory |
clipboard.read |
clipboard.writeText |
clipboard.write |
shell.openExternal |
shell.openExternal |
net.fetch |
net.fetch |
session.getActive |
None |
ui.revealView |
ui.view |
plugin.setSettings, fs.remove, and arbitrary Electron IPC are not exposed. A
channel the host does not implement itself is forwarded to the plugin's
onPanelInvoke(channel, payload), so a plugin may define its own panel ↔ main
channels; a plugin that exports no onPanelInvoke gets UNSUPPORTED from its own
process. The host-supported channels include skill.list, skill.read,
skill.create, skill.update, skill.remove, and skill.setEnabled.
window.pluginBridge.on(event, handler) receives host-pushed events. The host
sends the same events to detached panel windows and to docked work-panel views.
Delivered today:
appearance:changed— payload is thePluginAppearanceabove, sent whenever the app's palette or language changes, so a panel can restyle and relabel live.workspace:changed— payload is theworkspace.get()object ornull, sent when the open project changes: the primarypathandname, plusprojectIdandrootswhen the folder belongs to a project group (ADR 0263).view:open(docked work-panel views only; a detachedui.panelwindow never receives it) — payload is{ path: string }, the location the host asked this view to show. A view created with a location already carried it in its entry URL; this event delivers a later one.session:turnEnded— the same payload as the plugin-process event in §5, sent when a host turn reaches a terminal state.session:activated— the same payload as the plugin-process event in §5, sent to docked views whenever the selected session, its project, or its running turn changes. A detachedui.panelwindow is application-scoped and does not belong to a session, so it is the one surface that never receives this event.
Any of the following calls must be logged for audit:
- fs.writeText
- fs.stat, fs.readRange, fs.remove, fs.requestDirectory, and every refused fs call (with its path and
errorCode), plus each consent answer and why it was asked (scope/rate) - fs.openDefault (with its root-relative path and whether the OS open succeeded)
- fs.openInBrowser (with its root-relative path and whether the Browser open was accepted)
- fs.reveal (with its root-relative path and whether the file manager reveal succeeded)
- fs.readPreview (with its root-relative path and classified
kind) - execute after agent.registerTool (including tools discovered from a plugin's MCP servers)
- net.fetch
- shell.openExternal
- clipboard.read/write (may be sampled)
- clipboard.getHistory (with the returned entry count)
- bus.publish / bus.subscribe / bus.unsubscribe (with the topic and fan-out size)
- browser.navigate / evaluate / cdp / openExternal
- service start / stop / restart
- models.list (returned row count)
- session.getLlmContext (session id, message count, truncated flag — never transcript text)
- session.getActive (the selected session's presence, never its transcript)
- ui.revealView (view id, session id, whether a location was carried)
- agent.complete (model key, sizes, usage — never prompt or completion text)
- provider.oauth (plugin id, declared provider id, operation, result/error code — never credential contents)
Log fields:
- pluginId
- api
- ts
- sessionId?
- ok / errorCode
- status / retryAfter (
net.fetch: the upstream status of a completed call, and for a failed one theRetry-Afterit stated — never the header set or body)
- The API surface is managed by
apiVersion - MVP
apiVersion = 1 - A deprecated API is retained for at least one major version cycle
The desktop plugin runtime now implements the MVP host API surface used by local and marketplace plugins:
app.*,plugin.*,commands.*,ui.*,workspace.*fs.readText/fs.stat/fs.readRange/fs.readPreview/fs.openDefault/fs.openInBrowser/fs.reveal/fs.writeText/fs.glob/fs.list/fs.remove/fs.requestDirectory, bounded bymanifest.fs(ADR 0088)agent.registerTool/unregisterTool/agent.completespeech.registerAdapter/unregisterAdapter(speech.adapter.register)models.list,session.getLlmContextsession.getActiveand thesession:activatedevent (ui.revealViewneedsui.view)onProviderOAuthandpi.providers.oauth.prompt/notify(provider.oauth)clipboard.*,shell.openExternal,net.fetchbrowser.*(guest CDP;browser.cdp)services.register/unregister,bus.publish/subscribe,events.on/offkeyboard.registerGlobalShortcut/unregisterGlobalShortcut/listGlobalShortcuts(keyboard.globalShortcut; the host owns ElectronglobalShortcut)net.websocket.connect/send/close(net.websocket; host-owned sockets, allowlist-confined, bounded, released with the plugin)
pi.audio.* is present in the plugin host process and callable: all ten
methods are gated by audio.capture.background / audio.playback.background,
and this branch ships no device backend, so an authorized call is answered with
a coded UNSUPPORTED refusal under the method's own audit entry
(audio.<method>, ok: false); onInputFrame / offInputFrame throw the same
code synchronously because they cannot reject. No device is opened.
Native plugin notifications use the Electron main-process notification surface; they do not create durable rows in the task notification inbox and do not activate a session on click.
Declarative contributions have no pi.* counterpart on purpose: skills, themes,
and MCP servers are read from the manifest by the host, so a plugin cannot add
one at runtime.
All high-risk entry points assert declared+granted permissions and emit audit log lines.
Plugin panels no longer receive the full pi object; they use window.pluginBridge.invoke.
Renderer ES modules may import react/jsx-runtime or
react/jsx-dev-runtime for automatic JSX. The host import map exposes both of
its runtimes alongside react, react-dom, and react-dom/client, before
evaluating plugin entries. This keeps compiled JSX, hooks and context on the
host React instance in production and development builds.