03. 插件 API
翻译说明: 本页是与 英文源规格 一一对应的机器辅助翻译。代码、协议字段和标识符保持原文;如翻译与英文源事实有歧义,以英文版本为准。
主题变量
pi.themes.setVariables(themeId, values) 需要 ui.theme。宿主只接受调用方自有主题中 已声明变量的值,将其持久化到插件私有设置,并刷新当前激活的主题,无需选择新主题或 重新加载渲染进程。它不接受样式表文本、URL、选择器、图片、字体或任意属性名。
1. 设计原则
- 小巧稳定
- 权限驱动
- 异步优先
- 可审计
- 不暴露宿主内部对象
2. 运行时注入的对象
在插件运行时内部,有一个全局可用:
declare const pi: PluginHostApi;3. API 概述(MVP)
状态说明:下列各节均已交付并由
PluginRuntime执行约束,除非标题或正文明确标为 计划中(Planned)。计划中的接口会在实现前提前记录,供插件作者了解方向; 在实现之前调用会抛出UNSUPPORTED(各接口状态见 §9)。
应用程序
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 返回宿主当前正在呈现的外观,让插件(或它的面板)可以 完全跟随应用的语言与配色:
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
}面板通过桥通道 app.getAppearance 读取同一个值,并在 appearance:changed 事件(见下文)上收到实时更新。插件进程通过 pi.events 收到同一事件。在没有该通道的 旧宿主上,调用以 UNSUPPORTED 拒绝;面板应回退到操作系统偏好和它自己的面板内选择。
app.getLocale 与 getAppearance().locale 是同一个语言标签。插件自有界面(面板、 视图、widget、设置入口、toast、运行时命令标题)据此自行本地化。宿主不再给更多贡献字段 加 { en, "zh-CN" };生成式 contributes.settings 标题保持作者语言纯字符串 (ADR 0280)。宿主拥有的身份文案(manifest.i18n)以及已经落地的 chrome 标签 (ui.title、视图标题、设置入口)仍走既有契约(ADR 0267、ADR 0082)。
app.setTheme(需要 ui.theme,ADR 0260)应用与设置选择器相同的 AppSettings.theme。接受内置偏好或当前已注册的插件主题 id;未知 id 以 INVALID_ARGUMENT 拒绝。宿主会持久化设置、刷新原生 chrome / 面板外观, 并向渲染进程发出 settingsChanged。
主题(需要 ui.theme)
调用方插件自有主题的运行时注册表。生产模式可用,无需卸载/重载(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" }>>- 完整 id 命名空间为
plugin:<pluginId>:<themeId>。 - 对已有 id 的
upsert覆盖 label / base / css。 - 不再有单插件主题数量上限;CSS 体积上限与消毒器仍然生效。
- upsert/remove 后宿主发出
pluginChanged(reason: "themes")并刷新面板外观, 使当前激活主题立即重新着色。
插件
pi.plugin.getId(): string
pi.plugin.getManifest(): PluginManifest
pi.plugin.getSettings(): Promise<Record<string, unknown>>
pi.plugin.setSettings(partial: Record<string, unknown>): Promise<void>
pi.plugin.getDataPath(): Promise<string> // plugin-private directory插件页面会渲染 contributes.settings 中声明的字段,并将修改持久化到插件私有设置文件。 支持生成字符串、数字、布尔、枚举、JSON 和 shortcut 控件。生成式 title / description / enum[].label 是作者语言纯字符串,宿主不会在这些字段上解析 locale map。需要本地化设置页的插件使用自己的面板/视图并读取 pi.app.getLocale, 主题设置通过数据型 scenicThemes 交给宿主渲染;settingsDestinations 不是已实现的公开贡献 (ADR 0287)。快捷键仅属于插件域:只有在 PI-Desktop 窗口聚焦且插件激活范围匹配当前项目时, 才会调用声明的命令;本版本不会注册操作系统全局快捷键。用户编辑后,主机会向插件发送 plugin:settingsChanged,便于刷新内存中的配置。
命令
pi.commands.register(def: {
id: string
title: string
keywords?: string[]
run: () => Promise<void> | void
}): Promise<void>
pi.commands.unregister(id: string): Promise<void>宿主按原值保存命令 ID,不会自动添加前缀。建议使用插件专属命名空间,避免 ID 冲突。 插件可以更新自己注册的命令;若 ID 已由另一个已加载的插件占用,注册操作将以 CONFLICT 错误拒绝。注销其他插件的命令将以 PERMISSION_DENIED 错误拒绝。 注销不存在的 ID 会成功返回,不产生任何变更。禁用或卸载插件时,宿主会移除该插件的 命令,并释放相应 ID。
输入框文本转换(composer.transform)
插件可通过 manifest.contributes.composerTransforms 声明由用户主动触发的文本操作。 非空贡献需要 composer.transform 权限。宿主只会列出已加载且当前获得该权限的插件操作; 操作标题和可选的撤销标题来自 manifest。
type PluginComposerTransformInput = {
id: string;
text: string;
modelKey?: string; // current Composer provider/model key; no credentials
};
type PluginModule = {
onComposerTransform?: (
input: PluginComposerTransformInput,
) => Promise<string> | string;
};回调只收到草稿文本和可选模型 key,不会收到会话 id、转录内容或单独的附件/文件引用元数据。 宿主在调用前移除行内文件引用 token,成功后再恢复。回调返回字符串;输入与输出各限制为 100,000 个字符,调用超时为 110 秒。宿主会重新检查插件是否仍已加载、操作是否仍在声明中、 权限是否仍获授,并审计成功与失败。若用户在等待时编辑、发送或切换会话,Composer 会丢弃 过期结果;成功后提供一步撤销。若插件还要请求模型,必须另外声明对应 API 所需权限,例如 agent.complete 与 models.list。
{
"permissions": ["composer.transform"],
"contributes": {
"composerTransforms": [
{
"id": "enhance",
"title": { "en": "Enhance prompt", "zh-CN": "增强提示词" },
"undoTitle": { "en": "Undo", "zh-CN": "撤销" }
}
]
}
}输入框建议(composer.suggest)
插件可以为用户当前正在查看的会话输入框提供一条短建议。草稿为空时它显示在占位符层, 用户按 Tab 采用、按 Esc 忽略,开始输入也会让它消失。它不是消息、不是气泡, 插件也无法替用户发送。
pi.composer.suggest(input: {
sessionId: string; // the session from session:turnEnded
turnId?: string; // the turn the text answers; an overtaken turn is refused
text: string; // one line; folded and cut at 200 characters
ttlMs?: number; // default 120000; clamped to 1000..600000
}): Promise<{ shown: true } | {
shown: false;
reason:
| "session-not-visible" // that session is not on screen
| "draft-not-empty" // the user has text or an attachment in the draft
| "stale-turn" // the session ran a newer turn
| "replaced" // a newer suggestion took the slot first
| "unsupported"; // no window, no composer, or no answer in time
}>
pi.composer.clearSuggestion(input?: { sessionId?: string }): Promise<{ ok: true }>两个调用都需要 composer.suggest 权限。无法展示不是错误:调用会返回 { shown: false, reason },插件可以记录这次落空并决定是否再花一次补全预算。 参数不合法(sessionId 为空、文本没有可见内容、ttlMs 不是数字)会像其他宿主 API 一样抛 INVALID_PARAMS,没有授予权限则抛 PERMISSION_DENIED。
宿主只在以下条件全部满足时显示建议,并在每次变化后重新检查:命名的会话正是当前显示的会话、 草稿为空(无文本且无附件)、命名的轮次仍是该会话最新的轮次、有输入框处于可输入状态。 每个会话同时最多一条建议;更新的建议会替换它,并把被替换的调用答复为 replaced。 下列任一情况都会清除建议:任何草稿变化(输入、粘贴、附件、composer.insertText、 composer.replaceDraft)、发送、切换会话、新一轮开始、插件卸载或被禁用、 pi.composer.clearSuggestion、页面重载、窗口关闭,以及建议自身的存活时间到期。 采用(Tab)会把文本写入草稿、光标置末,并保留已暂存的附件。
调用会记录审计:插件 id、会话,以及是否显示成功。若插件用模型生成文本,还需单独声明该 API 所需的权限,例如 agent.complete 与 models.list。
pi.events.on("session:turnEnded", async (event) => {
if (event.reason !== "completed") return;
const active = await pi.session.getActive();
if (active?.sessionId !== event.sessionId) return;
const text = await writeNextLine(active); // the plugin's own model request
if (!text) return;
await pi.composer.suggest({
sessionId: event.sessionId,
turnId: event.turnId,
text,
});
});语音(speech.adapter.register)
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>handle 留在插件进程。内置协议 id openai_audio 和 openai_chat_audio 保留。 HTTP 计划由宿主用绑定 provider 的密钥代发,且必须落在该 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 仍是应用内 Toast。原生通知需显式调用 ui.showNativeNotification,并受同一项 manifest notify 权限约束。requestNotificationPermission 通过显示一条简短的确认通知, 执行平台原生权限探测。Electron 没有跨平台的只读通知权限查询 API,因此首次探测前,以及 操作系统未报告结果时,返回 unknown。原生通知仅尽力投递;操作系统策略可能抑制通知横幅, 但不会改变持久任务通知收件箱。点击已送达的插件通知只会恢复并聚焦主窗口,不会激活会话 或创建持久任务通知。
pi.ui.revealView 在会话的工作面板中展示插件自己的 contributes.views,是替代直接操作 宿主内部导航的受支持接口:
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>和视图本身一样需要 ui.view。宿主解析视图并拒绝不能唯一对应一个已声明视图的 viewId(没有声明任何视图、或名字未知为 NOT_FOUND;声明多个却未指名时为 INVALID_ARGUMENT);location 上限 8 KiB。sessionId 会与用户当前正在看的 会话比较:已不是当前会话时以 INVALID_ARGUMENT 拒绝,而不是打开一个没人看的 标签页;没有选中会话时同样拒绝。标签页属于外壳——插件给出视图与主体,由外壳 打开并聚焦——因此 location 仍沿 piViewOpen / view:open 传递,已加载的视图 不会被重载。
pi.ui.openPanel() 打开独立窗口,pi.browser.reveal() 激活 Browser, pi.fs.openDefault / pi.fs.reveal 把文件交给操作系统;三者都不是它。
项目(需要 project.create)
pi.project.create(input: { path: string }): Promise<{
projectId: number
path: string
name: string
}>该方法创建或复用宿主持久化的项目记录,不会切换当前工作区。返回的 projectId 可以显式 传给 pi.session.import() 或 pi.session.importBatch() 的单项。插件传入项目 ID 时必须 持有 project.create;省略 projectId 的导入会保持未绑定状态,projectPath 只是历史来源 元数据,本身不会创建项目。
工作区/fs
pi.projects.list(): Promise<PluginProjectInfo[]>
type PluginProjectInfo = {
projectId: string; // Base-10 projects.id, no prefix; stable for this row's lifetime.
name: string;
primaryPath: string;
roots: Array<{ path: string; name: string; primary: boolean }>;
}
pi.workspace.get(): Promise<{
path: string;
name: string;
projectId?: string;
roots?: Array<{ path: string; name: string; primary: boolean }>;
remote?: { path: string; readOnly: true }; // SSH projects only, see below
} | 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, options?: { workspacePath: 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
mtimeMs?: number // Remote previews include the Host's file timestamp.
}>
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, options?: { workspacePath: 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>projects.list 为持久化 projects 表中的每条记录返回一个条目,包括不属于任何项目组的 记录。结果按 lastOpenedAt 降序排列,时间相同时按数值 id 升序排列,不受置顶状态影响。 projectId 是不带前缀的十进制数据库主键字符串,始终非空;只要记录存在,重命名和重启 都不会改变它。它不同于 workspace.get 返回的可选项目组 id,而是 project.create 返回的数值项目 id 的字符串形式。name 使用匹配的主项目组显示名称,没有匹配组时使用 记录自身的名称。primaryPath 是该记录的路径。roots 始终以该路径开头并标记为主根, 后面按项目组内的顺序列出以该路径为主根的项目组的其他根目录。次级文件夹的记录保留 自己的身份和单一根目录。项目组查询失败时,每条记录只返回自己的主根;项目查询不可用 或失败时返回 [],并由宿主输出诊断。来自已被替换或已停止的 Host 的结果会被丢弃。 远程身份仍是不透明的 remote-project: 路径,不会返回 Host 密钥或凭据。
这些只读元数据不需要权限,也可以通过面板桥通道 projects.list 获取。该接口不会创建、 打开或切换项目,也不会授予项目访问权限。文件系统和会话权限保持不变。
workspace.get 回答主根——path 与其叶子 name 都保持不变——并在该文件夹属于某个项目组 (ADR 0249)时额外给出 projectId 与 roots:项目组按自身顺序登记的全部文件夹,主文件夹在前, 每项为 { path, name, primary }(ADR 0263)。workspace:changed 携带同一对象,两者都由主机持有 的项目组记录回答,因此事件与主动拉取不会互相矛盾。无法解析项目组的主机会省略 projectId 与 roots,也就是插件本来就会处理的 { path, name };读取这些元数据不需要新权限,也不新增 SDK 方法。
fs.readPreview 为一份已存在且可读取的文件做应用内预览分类。它与 fs.readText 使用相同的 fs.read 检查,拒绝目录,并返回 text(上限 512 KiB)、image (上限 5 MiB,data URL)、binary 或 tooLarge。插件不会收到绝对路径。
SSH 项目的 workspace.get 保留不透明的 remote-project: 身份,并新增 remote: { path, readOnly: true }。remote.path 仅用于显示,不能交给本地文件系统。 fs.list 与 fs.readPreview 经配对 owner Host 的可选 workspacePluginFiles 能力读取;工作区根必须位于 Host 文件夹选择器的浏览根内,或精确匹配已在该 Host 注册的规范项目根;Host 继续检查规范路径包含关系、受保护目录、凭据拒绝列表与插件 声明的 scope。 远程预览还返回真实 mtimeMs;其他文件通道仍限本地并明确拒绝远程身份。 缺少能力返回 HOST_VERSION_MISMATCH,不会改读 Desktop 文件系统。
可选 workspacePath 应为视图捕获的工作区身份,不匹配返回 WORKSPACE_CHANGED。 包括未传新参数的旧调用在内,所有读取均在首次等待前捕获根;根、远程绑定或插件 生命周期改变后丢弃过期结果。参见决策。
fs.openDefault 使用操作系统默认关联应用打开一个已存在的文件。它与 fs.readText 使用相同的 fs.read 根目录、符号链接、受保护路径、拒绝列表和范围检查; 目录会被拒绝。主机会记录这次操作。路径默认相对根目录;只有「本项目已注册的文件夹根」 之内的绝对路径才会被接受,而且只有 fs.openDefault、fs.openInBrowser 与 fs.reveal 接受(其他模式一律不接受 绝对路径),该根随即成为这次请求的包含基点(ADR 0249 §5、ADR 0264)——这正是视图用来指 名「非主文件夹里的文件」的形状。
fs.openInBrowser 在当前本地会话的内置工作面板 Browser 中,以新的资源标签页打开一个 已存在的 .html、.htm 或 .xhtml 文件。它与 fs.openDefault 使用相同的 fs.read 根目录、符号链接、受保护路径、拒绝列表和范围检查;目录及其他文件类型会被拒绝。 绝对路径仅在位于当前项目已登记的文件夹根内时有效,并以匹配的文件夹作为路径包含检查的基准。 Browser 插件必须已启用。这种由宿主介导的预览不会授予 browser.cdp,也不会向调用方暴露 Browser 操作或页面内容。Browser 自身继续遵循既有的本地文件导航策略(ADR 0328)。
fs.reveal 在操作系统文件管理器中显示一个已存在且可读取的文件,并在平台支持时选中它。 它使用相同的 fs.read 检查,拒绝目录,并记录成功和失败。路径的接受方式与 fs.openDefault 完全一致:默认相对根目录,落在本项目另一个已注册文件夹根之内时可以是绝对路径(ADR 0264)。
fs.stat 返回一个已存在且可读取文件的大小和修改时间,不加载正文。fs.readRange 返回 最多 8 MiB 的字节及文件总大小。两者与 fs.readText 使用相同的根目录、符号链接、受保护路径、 拒绝列表、范围、用户确认和审计约束;偏移和长度必须是非负安全整数。偏移位于文件末尾或 超出末尾时返回空字节数组。grantId 仅能用于宿主签发的拖放文件授权,并且必须提供匹配的 绝对路径。
路径相对于该模式的 root —— 工作区,或者当该模式声明 root: "userSelected" 时,用户通过 requestDirectory() 选中的目录。 每种模式能到哪些路径由 manifest.fs 决定;范围之外会问用户, 而凭证 deny-list 压过两者(参见 04-plugin-security.md §6)。 remove 不递归,并且把路径移进系统回收站。 在 workspace 根下,路径相对于调用该调用的工具会话所属的项目,面板调用没有工具会话时 回退到可见工作区(ADR 0266)。
list 返回单个目录的条目(按名称排序),使插件可以惰性遍历目录树, 而不必拉取整个仓库的 glob 再自行重组。它施加与 glob 完全相同的守卫, 因为列目录本身就是一次读取:读取范围之外的文件不会出现,被拒绝的名称与 受保护路径不会出现,node_modules 之类的重目录会被跳过。目录始终返回, 因此即使范围很窄也能得到可导航的树;单次调用最多返回 1000 个条目。
Agent
pi.agent.registerTool(tool: {
name: string
description: string
risk: "low"|"medium"|"high"
schema: unknown
planSafeActions?: readonly string[]
execute: (args: unknown, ctx: ToolExecContext) => Promise<unknown>
}): Promise<void>
pi.agent.unregisterTool(name: string): Promise<void>插件工具默认在 Plan/Goal 中隐藏并拒绝。运行时工具注册可通过非空 planSafeActions 显式加入,只有 args.action 位于该列表时才允许执行。有 schema action enum 时会校验取值。 清单描述符应与运行时注册保持一致,真正执行的工具由运行时注册决定。 risk: "low"、会话授权和 auto 不能代替该声明。宿主策略与插件执行器都会校验, 执行器对越界 action 返回 PERMISSION_DENIED。该列表是作者的只读承诺,不是副作用沙盒(ADR 0211)。
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 对宿主驱动的回合会被填充,并与对应的 session:turnEnded 事件(§5)的 turnId 一致。
models(需要 models.list)
pi.models.list(): Promise<PluginModelInfo[]>
type PluginModelInfo = {
key: string // `${providerId}/${modelId}` — first slash splits
providerId: string
providerName: string
modelId: string
label: string
alias?: string
availableForSubagents?: boolean
isDefault?: boolean
supportsReasoning: boolean
thinkingLevels: ThinkingLevel[]
}只返回已启用且已认证的 provider 行(API key、OAuth 或 authKind: "none")。不含密钥。 models.list 也是面板桥通道,选择器页面可以自行填充。宿主传输不可用时返回空列表,不记警告(D080)。
“添加服务”中的 provider 条目
宿主在“设置 → 模型 → 添加服务”中展示清单声明但尚未配置的 API-key provider。 这些条目只是 contributes.providers 的数据投影;插件不会在运行时注册选择器条目, 也不会收到 API key。category 用于对条目分组,可以是普通字符串,也可以是 { en, "zh-CN" } 多语言标签。宿主通过现有的加密 provider 密钥存储保存密钥。 配置完成的条目仍保留在 provider 列表中,但会从“添加服务”中隐藏。 provider.register 授权已足够,不需要额外权限或插件 API 方法。
卡片只显示 provider 名称。可选的 description 文案在悬停或键盘聚焦时以单句提示显示, 搜索也会匹配该描述。选中后的密钥表单会显示端点和插件名称,供用户在保存前最后核对 密钥的发送目标。
只有使用 API key 且声明了 baseUrl 的 provider 才可以声明空的 models 列表。 用户保存密钥后,宿主会请求该端点的模型列表并缓存结果。缓存的模型可供插件拥有的 provider 条目使用,而端点和其他 provider 字段继续由清单定义。若未发现任何模型, 密钥仍会保留,用户可以通过该 provider 的模型控件重试。每个插件的 provider 数量 没有单独上限,清单受插件包已有的大小限制约束。
provider OAuth(需要 provider.oauth)
OAuth provider 声明需要同时拥有 provider.register 和 provider.oauth、设置 baseUrl,并由插件主模块导出 onProviderOAuth。宿主只会为 manifest 中声明的 provider 调用该回调:
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>登录时,用户授权后回调返回凭据。刷新时,宿主把当前凭据传给同一回调,回调返回更新后 的凭据。宿主把凭据加密存于 provider 行对应的 OAuth secret 引用下,并串行执行刷新。 回调只能访问自己的 provider 声明凭据。宿主按请求向 Agent Runtime 传递解析后的访问令牌; 刷新令牌不会发给渲染进程或 Agent Runtime。每个 provider 声明只保存一个账号;退出登录会 清除凭据,但保留 manifest 所有的 provider 行。
插件可以使用宿主提供的登录界面,不必自行打开窗口:
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 支持 text、secret、select 和 manual_code。notify 支持非敏感的 info、authUrl、deviceCode 和 progress 事件;宿主会打开经过校验的 HTTP(S) 授权 链接,并报告浏览器是否成功打开。用户取消、插件卸载或宿主调用超时都会中止回调上下文 的 signal。使用宿主网络 API 请求 OAuth token 时,仍需 net.fetch 和 manifest.net.domains。该权限不提供通用宿主密钥 API。插件入口代码并非操作系统沙箱, 仍可使用原生 Node API,因此只应向可信代码授予该权限。
session(需要 session.read)
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
}插件不能传入 session id。身份来自进行中的 plugins.execute 会话(D333 / D336)。 在工具执行之外调用会以 INVALID_ARGUMENT 失败。子代理行会被省略。插件自己 正在执行的工具调用会从尾部剥离。压缩摘要替换检查点之前的历史。 合计内容上限 200k 字符。
会话跟随(不需要额外权限)
选中会话就是用户正在看的东西,宿主本来就在显示它;跟随它不需要权限, 与 workspace.get 报告当前打开的文件夹同理。
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
}它不接受 session id:宿主报告选中项,而不允许插件去寻址用户没在看的会话。若插件的 激活范围与当前项目不匹配,调用以 PERMISSION_DENIED 拒绝——与 pi.ui.revealView 和视图 IPC 的答案一致,因此按项目激活的插件不会得知另一个项目打开了哪个会话。 null 表示首页、设置页,或没有选中任何聊天会话。activeTurn 是该会话正在跑的 那个持久宿主回合,空闲时为 null,因此插件不必从上一回合的结束事件或草稿变化 去推断「正在运行的任务」;startedAt 是宿主时钟(epoch 毫秒),取自该回合开始的 时刻。
快照是值而不是句柄:两次调用之间会话可能已经切换或结束,因此插件应比较 sessionId,把不一致当作「这个答案属于用户已经离开的会话」。下面的 session:activated 事件在快照变化时投递同一载荷,这才是无需轮询的原因。 session.getLlmContext 是另一项能力:它限定在插件自己进行中的工具调用上, 无法寻址选中会话。
会话自动标题(需要 session.autoTitle)
此能力与 session.read 分开:它不会开放任意转录窗口或消息查询,仅用于在回合完成后生成标题。
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 }>只有活动会话且标题来源仍为 default 时才返回上下文。该来源既覆盖新会话的占位标题,也覆盖 核心自己写入的确定性首条提示兜底标题,因此插件应预期 expectedTitle 是当前已派生的文本, 而不是本地化占位标题。宿主不会返回附件、工具调用、后续回合 或其他转录内容。标题长度为 1–80 个 Unicode 码点,并通过精确的 expectedTitle 比较并设置; 手动重命名或另一项更新发生后返回 { updated: false }。两个方法都需要高风险权限 session.autoTitle,因为持有 agent.complete 的插件可能将首轮文本发送给模型。
插件拥有的会话(P0/P1;需要对应权限)
插件只能导入和管理归属于自身的会话。来源必须在 manifest.contributes.sessionSources 中声明;主机提供本地化来源标签,并生成 持久会话 id 与消息 id。导入会话不会绑定工作区、provider 或 model;只有调用方显式 提供已有的 projectId 时才会绑定项目;原始导入值仍在 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 }>导入以 (pluginId, source, externalId) 幂等。skip 批量导入逐项继续,fail 批量导入在任一项失败时全部回滚。trash 隐藏会话但保留其转录本和来源;purge 同时删除两者并允许重新导入。读取、重命名和删除均按归属限制;未声明来源返回 PERMISSION_DENIED。
当会话显式绑定项目时,projectId 和 bound.workspace 报告该绑定, get().projectPath 解析为绑定项目的当前路径;原始导入的 projectPath 保留在 get().history 中。
导入、重命名或删除成功后,Electron main 会为这次变更发送一次宿主拥有的 sessionsChanged 事件。渲染器沿用现有的 refreshSessions() 权威列表刷新链, Projects 页面也会据此刷新持久项目索引;插件不需要、也不应自行发送侧栏事件。 通过该 API 创建的项目不会自动打开为侧栏项目标签,以保留现有的已关闭项目行为。
主机限制每会话 2,000 条消息、每批 100 个会话、每条消息 512 KiB、每个工具值 256 KiB、每个 payload 32 MiB、JSON 深度 8。每个插件每分钟最多 10 次单条导入、 5 次批量导入和 20 次删除。写入前会移除工具 __pi* 与 piDesktop.* 对象键。 P2/P3(会话创建、消息变更、任意重新绑定、provider/model 绑定、批量删除、标签)不属于本次接口。
自 ADR 0340 起,同一组动词还有 .all 授权:session.read.all、 session.update.all、session.delete.all。它们覆盖所有活动会话,包括用户 在本应用中自己创建的对话。.all 是与 .own 并列的独立授权,不会放宽 .own; 清单同时声明两者时 .all 生效。作用域由 Electron main 依据授权解析后传给 host-core,插件不能自行指定(payload 里的 scope 会被丢弃),host-core 只执行 收到的那个值,缺省视为 own。越界会话一律返回 NOT_FOUND 而非 PERMISSION_DENIED,以免插件借错误码探测它看不到的会话。每次调用都会在审计中 记录授权作用域。
非导入会话按真实形态返回:originKind 为 "created"、source 为 "desktop"、externalId 为 null、modelId/providerId/bound.model 取自 会话行,消息的 origin 为 "desktop";导入会话仍是 "imported"、其声明的 来源 id 与 "external"。
消息级写入只在 .all 下存在:pi.session.appendMessage、replaceMessages (session.update.all)与 pi.session.deleteMessage、truncateFrom (session.delete.all)。它们复用宿主已有的记录操作,因此在回合运行期间会以 TURN_RUNNING 拒绝改写(appendMessage 例外,追加本身就是回合写入自身行的 方式),并共用每分钟 60 次的写入预算。消息可携带可选 id,用同一 id 重试是 幂等的;deleteMessage 删除用户提示时会连同其回复一并删除,目标不存在时返回 deleted: false 而不是报错。rename 现在会占用手动标题来源, session.autoTitle 插件之后无法覆盖该标题。
用量(需要 usage.read)
面向用户仍可见的未删除会话,提供只读的已完成回合事实行。宿主只提供 每个 turn 一行的扁平事实——计数与标识符;绝不包含消息正文、转录投影或任何 写路径。刻意不提供仪表盘形状:连续天数、热力图、分模型占比和会话 排名都是插件在这些事实行之上自己的计算——日后调整指标口径也不会变成 SDK 的破坏性变更。
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
}>语义:
- 只列出未删除会话的已完成 turn。用户删除的会话会从列表中消失。
- 行按
endedAt升序 + keyset 游标排列,窗口填充时翻页依然稳定;仪表盘 展示的排名是插件自己的排序,不是宿主的。 - 窗口跨度至多 365 天;
limit为 1..=500(默认 200)。Electron 侧先校验, 宿主 RPC 边界按同样界限再次校验。缺省与null边界等价;空会话标题返回null。 usage_json缺失或畸形时 cache/reasoning 计数记 0——绝不返回残缺行。
会话协作(需要 desktop.control)
官方 Session Orchestrator 组合了已审查的 desktop-control 目录;这不是第二套 session API, 也不会暴露 Electron 通道或本地 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 返回真实持久目标 sessionId 和宿主投递 messageId。send 可以双向寻址已有 Session ID,并复用该会话的项目、模型、上下文和权限配置;messageId 只标识一条投递, 不是 worker 身份。status 和 result 是有界投影,不会加载完整转录本。cancel 只中断 精确的排队投递或绑定回合,并保留目标会话及其历史。
spawn 中显式指定的 modelKey 属于 AI 自动调度的模型选择,需要该模型自身的 ModelBinding.availableForSubagents 许可;对用户未勾选的模型,宿主在创建 worker 之前 返回 PERMISSION_DENIED。显式写出默认模型自己的键按继承处理,而不是一次模型选择。 省略 modelKey 时选择第一个已就绪且启用了 AI 委派的模型;没有可用项时返回 MODEL_NOT_CONFIGURED,不会回退到默认模型(ADR subagent-model-opt-in)。
list 最多返回 100 个未删除且可接收消息的 Agent 会话,包括并非由 Session Orchestrator 创建的会话。每项只包含会话 ID、标题、状态、更新时间、可读的 Provider/模型标签及有界的 创建关联,不包含转录内容、项目路径、凭据或消息预览。调用方可将返回的会话 ID 传给 send; 权威详情仍应通过 status / result 读取。
spawn 和 send 仅在插件当前 Agent 工具调用期间有效。broker 注入 pluginId、来源 sessionId、来源 turnId 和调用身份;插件参数不能提供或覆盖这些字段。面向用户的插件 面板可使用自有插件身份调用 cancel,但不能用该路径发送或创建工作。宿主执行来源权限 上限、Agent 模式目标、收件箱上限、幂等性以及有界自主跳数,并拒绝子会话再创建子会话。 同时运行多少个 worker 由调用方插件自行决定:宿主只记录创建来源,不施加固定的并发上限。 请求的完成回调是宿主拥有的 completion 消息,链接到源投递,并且只在实际目标回合结算后 最多创建一次;被应用重启栅栏标记为 interrupted 的投递也一样会生成该回执。 完成回执会在目标会话的回合队列中被提升,因此繁忙的目标会在下一个回合边界优先执行它, 而不是排在用户早先排队的输入之后(ADR 0265);它永远不会被注入另一个会话正在运行的回合。 回执正文是可读报告而不是编码包:一行 Sub-session report,然后是 Session:、Status:、 Delivery:,Report: 段放 worker 的最终文本,失败时附 Error:。 send 可以用 delivery: "immediate" 请求同样的处理;默认的 queue 会排到目标已排队输入之后。 回调是会话数据,不是新的用户授权;完成消息不会触发另一个回调。 渲染器可以读取单独的侧边栏协作投影,但插件面板不能绕过此网关调用变更操作。
agent.complete(需要 agent.complete)
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
}>宿主解析凭据,并发起 tools: [] 的一次性补全;插件拿不到密钥。独立提示词增强插件会在 onComposerTransform 回调中使用此 API。includeSessionContext: true 还需要 session.read 以及进行中的 工具会话。宿主会序列化该上下文;若 messages 为空,会追加 Please respond to the request.。 system ≤ 32 KiB;消息合计 ≤ 200k 字符;每个插件每滚动 60 秒 8 次(RATE_LIMITED); 预算 90 秒(TIMEOUT)。Provider 返回 429 或其他暂时性错误时,宿主在同一次调用内按共享的 Provider 重试预算重试(ADR 0206),并遵循 Retry-After 响应头。模型返回空内容时为 INVALID_ARGUMENT。
如果最终仍失败,插件收到的是宿主分类后的错误码,而不是统一的泛化错误:重试预算耗尽后为 PROVIDER_RATE_LIMITED,其他分类包括 PROVIDER_UNAUTHORIZED、CONTEXT_TOO_LARGE、 NETWORK_ERROR,便于插件调整请求节奏并报告原因。代理会转发出错服务给出的错误码, 遵循其他宿主边界相同的 data.errorCode → errorCode → code 优先顺序。
剪贴板/外壳
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 解析 url,只打开 http:、https: 和 mailto:(D330 / ADR 0168)。 其他 scheme 以 INVALID_ARGUMENT 失败。
browser(需要 browser.cdp)
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 在当前主框架的导航提交后返回,包括重定向;不等待较慢的图片或子框架。 browser:state 会立即报告加载状态,导航失败时可包含 loadError。后台的 browser/event/state 更新保持渲染器页面元数据的会话归属;共享的插件界面事件 browser:state 只包含当前选中展示的页面。后台事件不能改变地址栏目标,也不能对其他页面 发起边界或可见性更新。可选的 sessionId 和 tabId 标识由宿主管理的工作面板目标; 插件不能通过导航参数选择这些身份。 持有高风险 browser.cdp 权限时,navigate 可以通过绝对路径或 file: URL 打开已存在的 本地文件;相对路径仍受工作区范围约束。本地文件页面可继续导航到其他已存在的本地文件, 网页则只保留此前在工作区内导航到本地文件的权限。
当前访客页是宿主管理的 WebContentsView(persist:work-browser),每个资源标签保留自己的 页面。Agent 导航会立即作用于调用会话已连接的页面,即使面板隐藏或当前显示其他会话, 也绝不会导航到或返回另一个会话的页面。setBounds 相对于调用插件视图的内容区,并会被 约束在允许的边界内,避免访客页覆盖聊天区或输入框。cdp 默认拒绝;Cookie、存储、目标管理 和网络拦截方法会以 PERMISSION_DENIED 失败。Agent 调用的会话身份来自进行中的 plugins.execute sessionId,而非插件参数(D333 / ADR 0170)。 宿主将每个 Agent 页面操作绑定到可信的在途工具会话。首次导航或 BrowserPreview 建立页面 连接;切换浏览其他侧面板标签不会改变既有连接的目标。目标关闭后返回 UNAVAILABLE, 直到另一次导航或预览连接页面。不带调用上下文的插件 UI 调用仍要求目标页面可见。 内置 Browser 工具不会在操作前调用 reveal();显式 reveal() 仍需等待调用会话的可见标签, 不会从后台会话抢占焦点。 渲染器重载仅分离展示层,Main 保留存活页面、输入状态、历史和 CDP;会话级元数据会恢复 侧面板标签,但不会展开已折叠面板。关闭标签或会话、禁用插件、销毁窗口或退出应用时,会释放 相应页面。此处没有引入重启后的 DOM 恢复,也不允许跨会话共享页面。 隐藏的访客页暂存于按需创建、不可见且启用沙箱的承载窗口,使 Chromium 的输入和截图组件 保持连接,无需可见窗口或原生焦点。展示时,同一视图会移回侧面板;最后一个暂存页面关闭后 释放承载窗口。仅供宿主使用的焦点模拟和页面相对目标坐标不会扩大原始 CDP 权限。 指针或截图操作会固定每个页面的视口,临时关闭节流,并在完成后恢复。BrowserPreview 仍限定在其会话的工作区或临时目录根内,加载不依赖渲染器挂载。
整页 screenshot 按比例缩放,输出像素不超过 1280×2048。视口截图和原始 Page.captureScreenshot CDP 调用保留既有尺寸。
截图(包括原始 Page.captureScreenshot CDP 调用)有 30 秒的宿主端期限。超时会以 TIMEOUT 失败, 宿主会分离卡住的 CDP 会话并释放该页面的截图队列,使后续截图可以重新连接并继续。其他 CDP 方法 沿用现有超时行为。
getHistory 返回由主机明确记录的条目,按最新优先排列,文本和图片按捕获时间混排。 通过 writeText 写入的内容,以及 Composer 用户主动 paste 事件提供的内容会被记录;主机 不会在后台轮询或重新读取系统剪贴板。连续相同内容会合并并刷新时间戳。历史只保留在 内存中,最多保留 30 天、500 条和 256 MiB;单条文本最多 100 KiB UTF-8 字节,图片 最多 50 MiB。图片统一返回 PNG 字节及像素尺寸。没有粘贴过的复制内容不会被记录。
服务(需要 background.service)
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>注册只在本地记录处理器,以便宿主代理调用。宿主在 onLoad 之后调用 start,卸载前调用 stop,并按 05-plugin-lifecycle.md 重启崩溃的插件。 同一进程内 start 是幂等的;对已经运行的服务再次启动不会产生效果。
总线(需要 bus.publish / bus.subscribe)
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 必须出现在 contributes.bus.publish 中; pattern 必须出现在 contributes.bus.subscribe 中。发布者不会收到自己发布的消息。资源上限及威胁模型见 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 为可选项。省略或设为 follow 时保留既有策略:最多跟随五次带 Location 的 3xx 响应,相对地址以当前 URL 为基准,每一跳都重新校验已授予的出网策略。方法、请求头和正文 处理方式保持不变,不代表承诺实现浏览器 Fetch 的重定向改写规则。 manual 返回首次响应的状态码、响应头(含 Location)和正文,不访问目标。 error 对任何 300–399 响应都以 REDIRECT_DISALLOWED 拒绝,即使没有 Location, 也绝不访问目标。其他状态码(包括 429)仍作为普通响应返回。follow 下没有 Location 的 3xx 原样返回;循环重定向超过五跳上限时返回 UNAVAILABLE。整个重定向链及响应正文共用 一个超时预算(TIMEOUT)。非法重定向值会在网络 I/O 前以 INVALID_ARGUMENT 拒绝。
默认传输和注入的单跳传输都由宿主统一处理重定向。每次跟随仍经过既有权限及出网检查, 这些模式不会授予额外网络访问权。策略拒绝会被审计,但不记录响应正文或响应头。
在可能较旧的宿主使用该选项前,应查询 pi.net.getCapabilities(): Promise<{ fetchRedirectModes: ("follow" | "error" | "manual")[] }>,确认其中包含所需模式。 该只读查询无需网络权限,不执行网络 I/O。方法缺失、查询被拒绝或缺少所需模式都表示不支持, 此时不要发送请求。旧宿主可能静默忽略未知 fetch 选项,所以只传入 redirect 不等于能力检测。 当前源码已实现该查询,较旧宿主可能不会公布它。本次没有引入仅限同源的模式。
fetch 原样返回上游响应 —— status、headers、bodyText —— 所以 429 是插件能读到的数据(Retry-After 也在里面),而不是被主机藏起来的错误。宿主 不重试、不限流、也不重新发起请求:遇到 429 之后的重试与退避是插件自己的 策略,响应头就是插件唯一能拿到的退避信号。失败的调用(status >= 400)在 审计里记为 ok: false,并在响应声明了延迟时附带它通告的 retryAfter(§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>需要 net.websocket。connect 会和 fetch 一样被严格限制在 manifest.net.domains 之内,connect / close 会记入审计。帧以宿主事件的 形式到达:net:websocket:open、net:websocket:message、net:websocket:close、 net:websocket:error,每个都带着持有它的 socketId,用 pi.events.on 订阅。 只有持有该套接字的那个插件会收到它们。
套接字由宿主持有,所以插件不能超过四个套接字,不能发送或接收大于 1 MiB 的帧, 也不能排队超过 4 MiB 的未发送数据;每一种都会被拒绝(LIMIT_EXCEEDED),或者 直接关闭连接,而不是让宿主的内存继续增长。一次 connect 会带上 headers 与 protocols,所以按连接认证的端点无需把凭证暴露给插件代码。拒绝会说明原因: 非 ws(s) 的 URL 或畸形的协议令牌是 INVALID_ARGUMENT,握手没有完成是 TIMEOUT,握手失败是 CONNECT_FAILED,套接字不属于该插件是 NOT_FOUND, 主机不在白名单内是 PERMISSION_DENIED。
桌面控制(需要 desktop.control)
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>已审核目录包含 session/open(sessionId),允许插件 UI 打开已有的持久会话。由插件发起的 session/create 和 agent/prompt 只刷新会话状态,不改变渲染器当前活动会话; session/open 则是显式导航。
目录还包含只读操作 agent/pendingInteractive({ sessionId })(sessionId 必填), 返回 { asks, permissions },即该会话待处理的 ask 工具请求与工具权限请求,与渲染器 重建卡片时读取的内容相同。插件借此获取危险操作 agent/askTool/resolve 和 tool/resolvePermission 所需的 requestId。与其他共享目录操作一样,它也可通过 本地 MCP 控制平面调用。
这是第一方插件通往与可选启用的本地 MCP 控制平面共用同一份已审查操作目录的 网关(ADR 0203 / D370)。两份目录的差异仅在于标记为 plugin-only 的操作:六个 session/collaboration/* 操作可以通过该网关调用,却被刻意排除在 MCP 可见目录 之外(tools/list、pi_control_describe 以及 pi_desktop_invoke 的枚举), 因为它们需要已认证的插件调用上下文,且渲染器没有任何变更通道。返回的目录省略 Electron 通道名,插件也永远拿不到 MCP bearer token。调用复用控制器、IPC 处理器、 生命周期检查、完成事件和审计边界;插件无法触达任意 Electron IPC。
dangerous 操作(删除会话、更改权限模式、批准工具)需要两次答复。 confirm: true 是插件的知会,必须先给出(否则返回 CONFIRMATION_REQUIRED)。随后宿主在原生对话框中询问用户,对话框点名目录中 的操作 id、目录描述和一段参数预览;对话框绝不显示插件或模型撰写的文本, 因此一份被提示注入的转录本无法把 session/delete 重新包装成无害的东西。 对话框被关闭、被拒绝,或宿主没有对话框服务,都会在触达控制器之前以 PERMISSION_DENIED 失败。调用会连同插件 id、操作、风险等级和结果状态一起 记入日志;参数值不会复制进审计条目。
麦克风面板(需要 ui.microphone)
只有当清单声明且用户授予了 ui.microphone 时,隔离面板才可以通过浏览器 媒体 API 请求麦克风音频:
navigator.mediaDevices.getUserMedia({ audio: true })宿主的权限处理器为该面板放行 media 权限,并继续拒绝摄像头和其他所有 设备权限。插件拿不到原生麦克风句柄或宿主密钥;浏览器的语音识别和语音合成 仍由页面持有。面板应提供文本回退,并通过其无障碍状态播报权限或识别失败。
场景化设置贡献
contributes.scenicThemes 是声明式的展示贡献,不是插件页面 API。它为同一插件的主题 及已声明的预览资源提供本地化卡片元数据。设置页的 DOM、样式、选择状态、焦点行为、 滑块草稿值和“应用”操作均由宿主持有。插件不会获得设置页桥接接口、渲染进程 DOM 访问权、任意 CSS、JavaScript、导航或操作能力。
宿主通过现有的强类型主题变量边界持久化“应用”操作,且只针对已声明的 --nexus-backdrop-blur 变量。ui.panel 窗口和 contributes.views 保留各自独立的 原生视图实现。
为场景化卡片声明的主题资源可以使用包内相对路径;宿主会在已安装的插件包内解析路径, 再将匹配的 CSS url() 或卡片预览地址改写为 plugin-asset:。声明为绝对路径的资源 继续走外部路径通道。两种通道都不会授予插件任意文件系统访问权(ADR 0288)。
音频(需要 audio.capture.background / audio.playback.background)
可以调用,但本条分支尚未实现设备后端。 pi.audio 存在于插件宿主进程 中,恰好暴露下面这十个方法。每个方法都保留自己的权限要求:六个采集方法 (getInputDevices、openInput、closeInput、getCaptureState、 onInputFrame、offInputFrame)需要 audio.capture.background,四个播放 方法(openOutput、writeOutput、stopOutput、closeOutput)需要 audio.playback.background。没有授权时调用会被拒绝为 PERMISSION_DENIED, 并按权限名记入审计,与其他所有需要把关的 API 完全一致。拿到授权后宿主仍然 没有设备后端,所以每次调用都会以带错误码的 UNSUPPORTED 拒绝:消息是 host api not available: audio.<method>,审计条目是 { api: "audio.<method>", ok: false, errorCode: "UNSUPPORTED" }。八个异步 方法用这个错误拒绝;onInputFrame / offInputFrame 是无法 reject 的同步 注册辅助函数,因此它们直接抛出带同一个 code: "UNSUPPORTED" 的 Error, 而不是注册一个永远不会触发的处理器。不会有任何东西接触设备,也不会产生 任何帧。onInputFrame 是注册回调 —— 它不是事件名 —— 帧形状见 packages/plugin-sdk/src/index.ts 中的 PluginAudioInputFrame。等设备服务 落地后,权限和这套接口都保持不变,只有拒绝会被真实行为取代:设备由宿主 持有,插件只交换 PCM16 帧,永远拿不到设备句柄、MediaStream、操作系统 设备路径或 Node 流,每个插件只允许一条输入流,禁用、卸载或崩溃会停止采集 并丢弃已排队的播放。
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>键盘(需要 keyboard.globalShortcut)
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
}宿主 —— 而不是插件 —— 持有 Electron 的 globalShortcut。插件把一个加速键 映射到自己的一条命令,由宿主完成注册、冲突检查、触发和释放;插件永远 拿不到键盘钩子、before-input-event、原始输入设备或按键事件流,一次触发 也只是属于该插件的一条命令。
command 必须已经由调用插件注册;否则以 INVALID_ARGUMENT 失败。被操作 系统保留、被 PI-Desktop 自己当前占用(默认 Alt+Space 打开插件启动器、 Alt+Shift+W 呼出或隐藏窗口;用户改绑后释放出来的加速键可以再次被插件使用)或 已被另一个插件持有的加速键会被拒绝而不是被抢走,被拒绝的重新注册会保留原来 的绑定。拒绝是返回的结果,不是抛出的异常: registerGlobalShortcut 以 registered: false 解析,并带 error 为 SHORTCUT_CONFLICT、SHORTCUT_UNAVAILABLE(平台拒绝)、 INVALID_ACCELERATOR 或 LIMIT_EXCEEDED(每个插件最多 8 条)。 UNSUPPORTED 和 INVALID_ARGUMENT 会抛出。用同一个 id 再次注册会替换 该条目的加速键。
每条带 default 的 contributes.globalShortcuts 条目会在插件加载后由宿主 注册,但仅当它的命令确实注册成功;没有 default 的条目等待 registerGlobalShortcut 调用。unregisterGlobalShortcut 删除一条条目, 未知 id 时什么都不做;listGlobalShortcuts 列出宿主当前为调用插件持有的 条目。禁用、卸载和崩溃时全部释放,注册 / 注销都会记入审计。
4. 错误模型
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"
| string // see below
message: string
}所有 API 失败都会引发携带 code 的错误。上面列出的是常见取值,不是封闭枚举:宿主 会原样转发出错层设置的 code,没有 code 的错误到达插件时为 PLUGIN_API_FAILED。 桌面宿主目前还会抛出 INVALID_PARAMS(session、usage、project、desktop API 的 输入校验;其他 API 用 INVALID_ARGUMENT)、UNAVAILABLE(浏览器等宿主服务缺失)、 PLUGIN_UNLOADED(调用期间插件被卸载或重载)、CONFLICT(speech 协议已被占用)、 PROVIDER_ERROR(speech 适配器返回空结果)、WORKSPACE_CHANGED、OPEN_FAILED、 REVEAL_FAILED 和 CONNECT_FAILED。只对自己处理的 code 分支,其余按通用失败处理。
宿主还会产生以下错误:
| 错误码 | 触发条件与含义 |
|---|---|
PLUGIN_INVALID_RESULT | onComposerTransform 返回非字符串,或宿主收到的字符串按 JavaScript length 计算超过 100,000 个 UTF-16 码元。应返回不超过该长度的字符串。 |
PLUGIN_TOOL_ABORTED | 插件工具调用已取消、已结束、未知/已过期,或已不属于调用方插件进程。该调用中待完成及迟到的宿主请求会被拒绝。若中止原因为显式 Error,则可能保留其自身错误码。 |
SPEECH_PROTOCOL_UNSUPPORTED | runSpeechAdapter 找不到绑定协议对应的已注册适配器。适配器响应结构无效时使用 INVALID_ARGUMENT,文本/音频为空时使用 PROVIDER_ERROR。此码也见于共享注册表和语音规格。 |
实现依据:apps/desktop/electron/main/plugin-runtime.ts 中的 MAX_COMPOSER_TRANSFORM_TEXT_LENGTH、runComposerTransform、 runSpeechAdapter、sendToChild,同目录 plugin-tool-invocations.ts 中的 run、assertActive、finish,以及 plugin-host-process.mjs 中的 toolAbortedError、cancelInvocation、composer.transform。 这些错误码补充上面的开放集合,不引入新的 SDK 枚举。
5. 事件(主机 -> 插件)
pi.events.on(event, handler)
pi.events.off(event, handler)宿主通过单向帧向插件进程推送事件。pi.events 不是独立通道,而是 pi.bus.subscribe 使用的同一条插件级总线流的别名(plugin-host-process.mjs);因此 on 处理器只会看到 宿主实际发给本插件的全部帧。当前已投递:
bus.message—— 总线投递,以PluginBusMessage为唯一参数。通常使用pi.bus.subscribe接收;events.on可以看到插件所持有的全部订阅的原始事件流。workspace:changed—— 载荷是workspace.get()的对象或null,在缓存的工作区路径变化时发送: 主文件夹的path与name,以及在该文件夹属于某个项目组时的projectId与roots(ADR 0263)。 一次运行中的第一个工作区可能先不带文件夹发送一次、再带文件夹重发一次,因为项目组记录是在那次推送 之后才读取的。plugin:settingsChanged(由插件设置页面编辑触发)appearance:changed—— 载荷是PluginAppearance,在应用配色或语言变化时发送, 因此插件进程可以像打开的面板一样实时重标文案(ADR 0280)。session:modelChanged—{ sessionId, modelKey, thinkingLevel },在成功的session.configure改变 provider、模型或 thinking level 之后发送session:turnEnded—— 载荷为{ sessionId: string; turnId: string; reason: "completed" | "aborted" | "error" }, 在每个宿主回合的拆除结束时发送一次,位于持久化的session.endTurn尝试之后。 “回合”指session.beginTurn创建的那个回合:一次用户提交、一次已批准的计划执行、 或一次定时运行;排队但从未开始的项目不会产生事件。completed、aborted、error是三种终止原因。事件携带终止运行时事件本身标识的turnId,而不是恰好处于活动 状态的那个回合,因此来自更早回合的迟到事件不会结算更新的回合。投递是 即发即忘:没有 ack,也没有重放,因此存活的已订阅插件只收到一次;与插件崩溃、 重载或宿主退出竞态的投递不作保证。收到该事件并不意味着该回合的所有在途 工具都已退出——迟到结果仍可能到达——因此插件必须按turnId串行化或以其他方式 限定清理范围。该事件同样不需要新权限:它走既有的插件事件通道,订阅未知的事件名 也不会报错。依赖该事件的插件必须确认其支持的宿主版本;历史宿主可能不发送这一增量事件。session:activated—— 载荷是PluginActiveSession对象或null,在该快照变化时 发送:用户切到另一个会话(或离开聊天)、选中会话的项目变化、或它的运行回合开始 / 结束。投递与其它事件一样是即发即忘;载荷里的快照就是答案,漏掉一次只需调用 一次pi.session.getActive()补齐。不需要权限,且其中回合 id 与session:turnEnded报告的身份一致。net:websocket:open/net:websocket:message/net:websocket:close/net:websocket:error—— 只发给持有该 socket 的插件;见 §3net下的net.websocket。
处理器抛出的异常会记录到日志,不影响其他监听器或插件。
计划中的事件:无。
6. 面板桥 API
面板 UI 不直接获取完整的 pi,而是使用以下桥接接口:
window.pluginBridge.invoke(channel, payload?)
window.pluginBridge.on(event, handler)同一个桥同时服务插件的两种界面形式:独立的 ui.panel 窗口,以及停靠在工作面板中的 contributes.views 视图(ADR 0104)。通道列表、权限门与 preload 完全相同, 因此同一份 HTML 入口在两种放置方式下都能工作。差别只在于 chrome 与下面这个 视图 location:停靠视图没有窗口控制胶囊、没有拖拽带,其 --pi-plugin-titlebar-height 为 0px 而非 46px。
停靠视图同时跟随宿主的文字大小。它不是应用文档,无法继承渲染器的 --font-scale,因此宿主把「外观 → 字体大小」(D343 / ADR 0180)乘以窗口缩放, 作为该页面的缩放因子,并在每个新文档上重新应用。在该因子下,插件内的一个 CSS 像素与应用内的一个 CSS 像素绘制成同样的 DIP,因此作者原有尺寸的文字与控件在任何 设置下都与周围面板保持比例——既不需要重载,也不要求插件改用宿主设计令牌。 视图作者能观察到的两个后果:页面像素是缩放后页面自己的像素,所以回传给「由宿主 摆放原生内容」的 API(pi.browser 的 browser.setBounds)的矩形由宿主换算; 把布局钉在固定窗口尺寸上的页面需要自行缩放这些值。独立 ui.panel 窗口与浮动 挂件保持自己的基础尺寸:它们是独立窗口,而不是合成进应用内的表面。
独立面板页面采用当前窗口装饰契约时,声明 <meta name="pi-plugin-chrome" content="v2">, 并使用公开变量设置正常文档流中的顶部间距。宿主保留页面自行设置的间距;未声明该标记的页面 继续通过历史的附加偏移机制获得兼容支持。
6.1 浮动挂件
清单可以声明 "ui": { "shape": "widget" }。面板随后会以浮动挂件打开:同一个沙盒化、权限门控的页面,位于透明、无边框窗口中,没有 46px 拖拽带、没有控制胶囊,也没有矩形原生阴影。页面拥有自己的整个矩形,通常绘制一个比矩形更小的轮廓——例如一个圆形球体——因此宿主不得在该轮廓周围绘制边框。
--pi-plugin-titlebar-height为0px,并且无论页面声明什么 chrome 标记,旧式的附加顶部偏移也不会应用。- 放置方式会在页面脚本运行前发布为
document.documentElement.dataset.piPluginPanelShape:panel、widget或view。 - 拖拽使用整窗拖拽映射:空白区域移动窗口,而标准控件(
button、input、a、[tabindex]、……)以及每个标记了data-pi-plugin-no-drag的元素保持可点击。 - 挂件没有胶囊,因此宿主在表面自己的上下文菜单后面提供一个等效菜单:关闭、最小化、始终置顶。插件仍然可以通过
ui.closePanel()关闭自己的挂件。 ui.width/ui.height最小可设为 120×120(面板的最小尺寸仍为 360×280)。ui.alwaysOnTop会把挂件固定在其他窗口之上,ui.resizable对挂件默认为false,对面板默认为true。- 其他一切不变:相同的 preload、相同的
pluginBridge通道、相同的权限门、相同的会话分区和出口策略。
停靠视图还可以被指定一个要展示的对象。工作面板选项卡本来就携带的 location 会投递给任何贡献视图——不再只限 pi.browser(它的地址栏保留自己的导航通道): 创建时它作为视图入口 URL 的 piViewOpen 查询参数传递,文档加载完成后则通过 view:open 事件送达。在首次加载之前到达的 location 改为重启这次加载;已加载的 视图永远不会被导航,因此插件里未保存的改动不会被丢弃,重复打开同一个 location 什么也不做。该载荷对主机是不透明的——每个插件自行决定 location 的含义——它 不需要新权限,也不新增 SDK 方法。
宿主验证来源后把调用转给插件运行时,先处理下表内置通道,再回退到 onPanelInvoke:
| 通道 | 所需权限 |
|---|---|
ui.showToast, ui.closePanel | 已加载面板之外无需新增权限 |
ui.notify | notify |
ui.getNotificationPermission, ui.requestNotificationPermission, ui.showNativeNotification | notify |
plugin.getSettings, workspace.get, projects.list, app.getAppearance | 无 |
app.setTheme, themes.upsert, themes.remove, themes.list, themes.setVariables | 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.requestDirectory, fs.registerDropped | fs.read |
fs.writeText | fs.write |
clipboard.readText, clipboard.getHistory | clipboard.read |
clipboard.writeText | clipboard.write |
shell.openExternal | shell.openExternal |
net.fetch | net.fetch |
net.getCapabilities | 无 |
session.getActive | 无 |
ui.revealView | ui.view |
plugin.setSettings、fs.remove 和任意 Electron IPC 未暴露。宿主未内置实现的通道会转发给 插件的 onPanelInvoke(channel, payload),因此插件可以定义自己的面板 ↔ 主进程通道。 每次自定义通道调用均由 Electron 主进程校验并代理,再由插件子进程执行处理器;面板不能 直接调用子进程。未导出 onPanelInvoke 的插件会从自身进程返回 UNSUPPORTED。 内置技能管理器使用的 skill.list / read / create / update / remove / setEnabled 是它自己的 onPanelInvoke 操作,不是通用宿主桥 API。
面板事件(主机 -> 面板)
window.pluginBridge.on(event, handler) 接收主机推送的事件。宿主会把同样的 事件发给独立面板窗口和停靠的工作面板视图。今天已投递的事件:
appearance:changed—— 载荷是上面的PluginAppearance,在应用的配色或 语言发生变化时发送,因此面板可以实时重新着色和重新标注文案。workspace:changed—— 载荷是workspace.get()的对象或null,在打开的项目变化时发送: 主文件夹的path与name,以及在该文件夹属于某个项目组时的projectId与roots(ADR 0263)。view:open(仅限停靠的工作面板视图;独立ui.panel窗口不会收到)——载荷为{ path: string },即主机要求该视图展示的 location。创建时就带 location 的视图 已经从入口 URL 拿到它;这个事件投递的是之后的 location。session:turnEnded—— 与 §5 的插件进程事件同一载荷,在宿主回合到达终止状态 时发送。session:activated—— 与 §5 的插件进程事件同一载荷,在选中会话、它的项目或它的 运行回合发生变化时发给停靠视图。独立ui.panel窗口属于应用级、不属于某个会话, 因此它是唯一不会收到该事件的表面。
7. 调用审计
必须记录以下任何调用以供审核:
- fs.writeText
- fs.stat、fs.readRange、fs.remove、fs.requestDirectory,以及每一次被拒绝的 fs 调用(连同路径与
errorCode),还有每一次同意的答复及其被问的原因(scope/rate) - fs.openDefault(记录 root-relative 路径以及系统打开是否成功)
- fs.openInBrowser(记录 root-relative 路径以及 Browser 是否接受打开操作)
- fs.reveal(记录 root-relative 路径以及文件管理器显示是否成功)
- fs.readPreview(记录 root-relative 路径以及分类后的
kind) - agent.registerTool 注册后的工具执行(包括从插件 MCP 服务器发现的工具)
- net.fetch
- shell.openExternal
- clipboard.read/write(可能采用采样记录)
- clipboard.getHistory(记录返回的条目数)
- bus.publish / bus.subscribe / bus.unsubscribe(记录主题和扇出数量)
- browser.navigate / evaluate / cdp / openExternal
- 服务启动/停止/重新启动
- models.list(返回行数)
- session.getLlmContext(会话 id、消息数、truncated 标志 —— 不含转录文本)
- session.getActive(只记录选中会话的存在,不含转录)
- ui.revealView(视图 id、会话 id、是否携带 location)
- agent.complete(模型 key、体积、usage —— 不含提示或补全文本)
- provider.oauth(插件 id、声明的 provider id、操作、结果/错误码——绝不记录凭据内容)
日志字段:
- pluginId
- api
- ts
- sessionId?
- ok / errorCode
- status / retryAfter(仅
net.fetch:已完成调用的上游状态码,以及失败调用所 声明的Retry-After—— 绝不记录整个头部集合或响应体)
8. 版本策略
清单编写契约使用 schemaVersion: 1,当前注入 API 没有 apiVersion 属性或协商方法。 新增能力优先保持兼容;检测可选方法,通过 net.getCapabilities 查询重定向能力, 对旧宿主处理 UNSUPPORTED。SDK 类型与实际运行时分发定义可调用接口,文档中的规划名称不会自动成为 API。
9. 实施情况
桌面插件运行时现在实现本地和市场插件使用的 MVP 主机 API 表面:
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, 范围由manifest.fs限定(ADR 0088)agent.registerTool/unregisterTool/agent.completespeech.registerAdapter/unregisterAdapter(speech.adapter.register)models.list、session.getLlmContextsession.getActive与session:activated事件(ui.revealView需要ui.view)onProviderOAuth和pi.providers.oauth.prompt/notify(provider.oauth)clipboard.*、shell.openExternal、net.fetchbrowser.*(访客页 CDP;browser.cdp)services.register/unregister、bus.publish/subscribe、events.on/offkeyboard.registerGlobalShortcut/unregisterGlobalShortcut/listGlobalShortcuts(keyboard.globalShortcut;Electron 的globalShortcut由宿主持有)net.websocket.connect/send/close(net.websocket;套接字由宿主 持有,受白名单限制,有界,随插件一起释放)
pi.audio.* 已存在于插件宿主进程中并且可以调用:十个方法都由 audio.capture.background / audio.playback.background 把关,而这条分支没有 设备后端,所以获得授权的调用会以带错误码的 UNSUPPORTED 拒绝,并记入该方法 自己的审计条目(audio.<method>、ok: false);onInputFrame / offInputFrame 无法 reject,因此同步抛出同一个错误码。不会打开任何设备。
原生插件通知使用 Electron 主进程的通知接口,不会在任务通知收件箱中创建持久化记录, 点击通知也不会激活会话。
技能和 MCP 服务器通过 manifest 贡献,没有通用的运行时注册 API。主题还支持受权限约束的 pi.themes 运行时注册表,Provider 仍由 manifest 管理。输入框、会话导入/读取/更新/删除、 用量、项目及桌面 API 各自遵循独立的授权要求。
所有高风险入口均检查已声明且已授予的权限,并输出审计日志。插件面板不接收完整的 pi 对象,而是使用 window.pluginBridge.invoke。
渲染器 ES 模块可导入 react/jsx-runtime 或 react/jsx-dev-runtime 使用自动 JSX。 宿主会在执行插件入口前,通过 import map 暴露这两个运行时以及 react、react-dom、 react-dom/client。因此在生产和开发构建中,编译后的 JSX、Hook 和上下文都使用宿主的 同一个 React 实例。