Skip to content

03. 插件 API ​

翻译说明: 本页是与 英文源规格 一一对应的机器辅助翻译。代码、协议字段和标识符保持原文;如翻译与英文源事实有歧义,以英文版本为准。

主题变量 ​

pi.themes.setVariables(themeId, values) 需要 ui.theme。宿主只接受调用方自有主题中 已声明变量的值,将其持久化到插件私有设置,并刷新当前激活的主题,无需选择新主题或 重新加载渲染进程。它不接受样式表文本、URL、选择器、图片、字体或任意属性名。

1. 设计原则 ​

  1. 小巧稳定
  2. 权限驱动
  3. 异步优先
  4. 可审计
  5. 不暴露宿主内部对象

2. 运行时注入的对象 ​

在插件运行时内部,有一个全局可用:

ts
declare const pi: PluginHostApi;

3. API 概述(MVP) ​

状态说明:下列各节均已交付并由 PluginRuntime 执行约束,除非标题或正文明确标为 计划中(Planned)。计划中的接口会在实现前提前记录,供插件作者了解方向; 在实现之前调用会抛出 UNSUPPORTED(各接口状态见 §9)。

应用程序 ​

ts
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 返回宿主当前正在呈现的外观,让插件(或它的面板)可以 完全跟随应用的语言与配色:

ts
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)。

ts
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")并刷新面板外观, 使当前激活主题立即重新着色。

插件 ​

ts
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,便于刷新内存中的配置。

命令 ​

ts
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。

ts
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。

json
{
  "permissions": ["composer.transform"],
  "contributes": {
    "composerTransforms": [
      {
        "id": "enhance",
        "title": { "en": "Enhance prompt", "zh-CN": "增强提示词" },
        "undoTitle": { "en": "Undo", "zh-CN": "撤销" }
      }
    ]
  }
}

输入框建议(composer.suggest) ​

插件可以为用户当前正在查看的会话输入框提供一条短建议。草稿为空时它显示在占位符层, 用户按 Tab 采用、按 Esc 忽略,开始输入也会让它消失。它不是消息、不是气泡, 插件也无法替用户发送。

ts
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。

js
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) ​

ts
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。

用户界面 ​

ts
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,是替代直接操作 宿主内部导航的受支持接口:

ts
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) ​

ts
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 ​

ts
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 ​

ts
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)。

ts
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) ​

ts
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 调用该回调:

ts
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 行。

插件可以使用宿主提供的登录界面,不必自行打开窗口:

ts
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) ​

ts
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 报告当前打开的文件夹同理。

ts
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 分开:它不会开放任意转录窗口或消息查询,仅用于在回合完成后生成标题。

ts
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 中返回。

ts
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 的破坏性变更。

ts
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。

ts
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) ​

ts
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 优先顺序。

剪贴板/外壳 ​

ts
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) ​

ts
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) ​

ts
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) ​

ts
pi.bus.publish(topic: string, payload?: unknown): Promise<void>
pi.bus.subscribe(
 pattern: string,
 handler: (message: PluginBusMessage) => void,
): Promise<() => Promise<void>> // resolves to unsubscribe
ts
type 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。

网 ​

ts
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)。

ts
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) ​

ts
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 请求麦克风音频:

ts
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 流,每个插件只允许一条输入流,禁用、卸载或崩溃会停止采集 并丢弃已排队的播放。

ts
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) ​

ts
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. 错误模型 ​

ts
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_RESULTonComposerTransform 返回非字符串,或宿主收到的字符串按 JavaScript length 计算超过 100,000 个 UTF-16 码元。应返回不超过该长度的字符串。
PLUGIN_TOOL_ABORTED插件工具调用已取消、已结束、未知/已过期,或已不属于调用方插件进程。该调用中待完成及迟到的宿主请求会被拒绝。若中止原因为显式 Error,则可能保留其自身错误码。
SPEECH_PROTOCOL_UNSUPPORTEDrunSpeechAdapter 找不到绑定协议对应的已注册适配器。适配器响应结构无效时使用 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. 事件(主机 -> 插件) ​

ts
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 的插件;见 §3 net 下的 net.websocket。

处理器抛出的异常会记录到日志,不影响其他监听器或插件。

计划中的事件:无。

6. 面板桥 API ​

面板 UI 不直接获取完整的 pi,而是使用以下桥接接口:

ts
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.notifynotify
ui.getNotificationPermission, ui.requestNotificationPermission, ui.showNativeNotificationnotify
plugin.getSettings, workspace.get, projects.list, app.getAppearance无
app.setTheme, themes.upsert, themes.remove, themes.list, themes.setVariablesui.theme
models.listmodels.list
fs.readText, fs.stat, fs.readRange, fs.readPreview, fs.openDefault, fs.openInBrowser, fs.reveal, fs.glob, fs.list, fs.requestDirectory, fs.registerDroppedfs.read
fs.writeTextfs.write
clipboard.readText, clipboard.getHistoryclipboard.read
clipboard.writeTextclipboard.write
shell.openExternalshell.openExternal
net.fetchnet.fetch
net.getCapabilities无
session.getActive无
ui.revealViewui.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.complete

  • speech.registerAdapter / unregisterAdapter(speech.adapter.register)

  • models.list、session.getLlmContext

  • session.getActive 与 session:activated 事件(ui.revealView 需要 ui.view)

  • onProviderOAuth 和 pi.providers.oauth.prompt / notify(provider.oauth)

  • clipboard.*、shell.openExternal、net.fetch

  • browser.*(访客页 CDP;browser.cdp)

  • services.register / unregister、bus.publish / subscribe、events.on / off

  • keyboard.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 实例。

本地优先 · 模型可替换 · 插件可扩展。 AIUO.NET