查看 Markdown

插件参考

从插件快速上手开始创建你的第一个插件。

要迁移已有的插件?请按照独立的运行时入口迁移指南操作。

本地插件是安装到某个 Paseo daemon 中的目录来源。插件可以贡献:

  • 为 Paseo 客户端添加 React Native 界面,以及侧边栏顶部和底部项;
  • 作为工作区标签页打开的工作区面板和 Agent 面板;
  • 用于查看或编辑匹配文件的文件面板;
  • Command Center 中的全局、工作区和 Agent 操作;
  • 消息 Composer 中的斜杠命令;
  • 经过转换的、以及由 daemon 推送的 Agent 时间线行;
  • 设置 → 外观 中的浅色和深色主题;
  • 在 daemon 旁运行、经过 schema 校验的 RPC 处理函数;
  • 通过 TypeScript SDK 执行的普通 Paseo 操作;
  • 消息 Composer 中可搜索的外部资源。

插件代码是受信任的,没有沙盒。客户端界面在 Paseo 应用中运行。后端贡献在一个子进程中运行,可以访问 daemon 所在的机器,包括它的文件、进程、凭据和网络。

已连接的主机

客户端界面中的 usePaseo().hosts 与服务端处理函数或生命周期钩子中的 context.paseo.hosts 使用相同的 SDK 主机 API。它们描述所选 daemon 保存的连接。 现有的 useHosts() 和 getPaseoClient(serverId) 仍描述应用已知的主机。

const remote = await context.paseo.hosts.client("srv_build_box");
try {
  return await remote.workspaces.list();
} finally {
  await remote.dispose();
}

返回的 API 拥有自己的观察。销毁插件 API 时也会释放由它派生的作用域。 远程调用应留在拥有它的处理函数或生命周期作用域内。调用 B 不会把 B 的插件生命周期钩子转发到 A。 通过路由插件创建的注册项保留其来源身份,其中包含经过认证的链路和发起调用的插件。

项目文件

paseo plugin init /absolute/path/to/my-plugin 会创建:

my-plugin/
  paseo-plugin.json
  index.client.tsx
  index.server.ts
  client/greeting.tsx
  server/greeting.ts
  shared/greeting.ts
  package.json
  tsconfig.json

必需的根 manifest 是 paseo-plugin.json:

{
  "id": "my-plugin",
  "description": "Reviews changes before merge",
  "requirements": { "paseo": ">=0.8.0" }
}
字段是否必填行为
id是默认的安装 ID。
name否注册表和网站使用的非空显示名称;与安装 ID 无关。
icon否插件包内 PNG 文件的相对路径。
media否包内图片或视频路径的数组,或 HTTPS URL。空数组是有效的。
description否在 设置 → 插件 中显示在插件 ID 下方的非空摘要。
translations否其他语言的展示文本,由注册表读取,Paseo 会忽略它。见其他语言。
requirements否支持的 Paseo 版本,见下文。
build否准备命令,见 CLI 参考。

路径相对于 paseo-plugin.json,使用正斜杠,并且不能包含 .. 片段。即使 media 在运行时 manifest 中是可选的,可视化插件的注册表展示页仍然需要一张界面图片。接受的格式和展示页要求见图标和截图。Paseo 会校验这些引用,但不会打开本地资源或获取 URL。

未知的顶层字段会被忽略。已知字段仍然会被校验,requirements 中的未知键会被拒绝,以免拼写错误的约束悄悄跳过兼容性检查。使用 name、icon 或 media 的 manifest 需要 Paseo 0.11.0 或更高版本;较旧的 daemon 会在安装时拒绝这些字段。

版本要求

requirements 是一个可选对象。它目前支持的键 paseo 接受一个 npm semver 范围。省略 requirements.paseo 表示 <0.8.0:即该插件早于第一个破坏性的插件版本。Paseo 0.8 及更高版本会拒绝它,并附上迁移指南的链接。空字符串、无效的范围以及未知的 manifest 要求键都会被拒绝。

范围兼容的版本
>=0.8.00.8.0 及之后的版本,包括预发布版本和将来的破坏性版本
^0.8.00.8.x 版本,包括预发布版本
>=0.8.3 <0.9.00.8.3 到最后一个 0.8 补丁版本,包括预发布版本

预发布的 Paseo 版本也满足其稳定核心版本(major.minor.patch)所满足的范围,因此 0.8.0-beta.1 满足 >=0.8.0,但不满足 <0.8.0。

>=0.12.0-beta.4 这样的预发布最低版本也会通过稳定核心 0.12.0 接受 0.12.0-beta.3。若要排除同一核心的较早构建,请使用不同的稳定核心版本。

paseo plugin init 会写入 >= 加当前 CLI 版本,并为类型检查固定匹配的 SDK。采用更新的 API 时,请提高最低版本。当某个之后的版本不兼容时,请添加上限;只有下限并不能保证免受将来破坏性变更的影响。

daemon 会在安装、运行准备命令或加载插件之前检查自己的版本,并在启动、启用和重新加载时再次检查。被拒绝的更新会保留已安装的修订。每个已连接的应用在执行客户端代码之前会检查自己的版本,并在 设置 → 插件 中显示不兼容。兼容的 daemon 并不能让较旧的应用变得兼容。没有客户端入口的插件不要求已连接的应用匹配。

例如:Plugin "review" requires Paseo >=0.8.0. Your daemon is 0.7.2. 请使用兼容的插件修订,或更新提示中的运行时。0.8 之前的版本不理解这个 manifest 字段,也无法显示这条新的诊断信息。

运行时入口

入口运行时接收何时需要
index.client.tsxPaseo 应用,每个客户端PluginClientContext插件有任何 UI、回调、主题或附件来源时
index.server.tsdaemon 子进程PluginServerContext插件贡献处理函数、钩子、设置持久化或 Provider 时

至少需要一个入口;两者都接受 .ts 或 .tsx。仍然只有旧 index.ts 的目录会加载失败,并指向迁移指南。

插件、界面、侧边栏项、工作区面板、Command Center 项、附件来源和斜杠命令的 ID 以小写字母开头,只包含小写字母、数字或连字符。

生成的 package.json 把 @getpaseo/plugin 和其他宿主模块安装为开发依赖,用于本地类型检查和测试。它们的运行时实例由 Paseo 提供。使用者在添加插件时不会安装它们。

其他每个模块都位于以下三个目录之一。在其中嵌套没有问题;位于插件根目录的模块会导致编译错误。

目录编译进用于
client/仅应用的包React、React Native、hook、样式、界面、面板和回调。
server/仅 daemon 的包Node API、本地资源、凭据和 RPC 处理函数。
shared/两者Zod RPC 契约以及两个运行时都会导入的普通值。

运行时翻译

尚未发布的 API。 发布插件前,请将 requirements.paseo 设置为首次包含这些 API 的版本。翻译示例 暂用 >=0.12.1,实际引入版本尚未确定。

将 JSON 资源随客户端代码一起打包。必须提供英语(en);其他语言使用规范语言标签,例如 zh-CN。资源由嵌套对象和字符串叶节点组成。其他语言可省略键,缺失时回退到英语。定义翻译时会拒绝未知键、无效资源、非规范语言标签,以及与英语不一致的插值占位符。

// client/messages.ts
import { defineTranslations } from "@getpaseo/plugin/client";
import en from "./locales/en.json";
import zhCN from "./locales/zh-CN.json";

export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } });

在插件的 tsconfig.json 中启用 resolveJsonModule。Paseo 在本地打包这些导入,不通过网络加载。资源对示例:

client/locales/en.json:

{
  "title": "Workspace notes",
  "add": "Add note",
  "notes_one": "{{count}} note",
  "notes_other": "{{count}} notes"
}

client/locales/zh-CN.json:

{
  "title": "工作区笔记",
  "add": "添加笔记",
  "notes_other": "{{count}} 条笔记"
}

在 React UI 中使用插件的 hook:

import { useTranslation } from "@getpaseo/plugin/client";
import { Text } from "react-native";
import { messages } from "./messages";

function NoteCount({ count }: { count: number }) {
  const { t, locale } = useTranslation(messages);
  return <Text accessibilityLanguage={locale}>{t("notes", { count })}</Text>;
}

对于由宿主持续渲染的标签,使用 messages.message(key, options?):

client.addScreen({
  id: "notes",
  title: messages.message("title"),
  Component: Notes,
});

PluginText 为 string | PluginMessage。消息引用支持界面标题(包括标题函数)、设置与侧边栏标题、工作区与文件面板标题、Command Center 标题、斜杠命令描述与参数提示、按钮标题与标签、嵌套菜单标题、附件来源标题/选择器标题/搜索占位文本,以及主题名称。现有的字符串仍然有效。保持 ID、斜杠命令名称、图标、URL 和命令搜索关键词不变。

描述符类型保留现有的字符串默认值。为翻译描述符显式标注类型时,传入文本类型参数,例如 PluginScreenContribution<PluginText>。内联注册会自动推导类型。

回调中的临时文本使用 client.i18n.t(messages, "add"),它读取调用时的应用语言。client.i18n.locale 和 hook 的 locale 返回解析后的应用语言,即使某条消息回退到英语。不要将 t 的返回值用作持续注册的标签。

键由英语资源推导。notes 等复数基础键要求数字类型的 count;i18next 根据语言选择 _zero、_one、_two、_few、_many 或 _other 形式。命名插值使用 {{name}},调用时传入 { name: value }。TypeScript 将导入 JSON 的字符串值拓宽为 string,因此 JSON 插值名称在运行时检查;内联字面量资源还能推导必需的插值名称。{{amount, number}} 等 i18next 格式化语法使用解析后语言的 Intl 格式化器。

显式选择的应用语言优先于设备语言;“跟随系统”使用应用支持的语言解析规则。连接到同一 daemon 的不同应用各自保留语言设置。切换语言会更新已挂载的 UI 和宿主标签,不重新加载插件、不重置组件状态,也不替换 RPC/查询缓存。网页和原生的模态框与弹出层均收到相同的安装上下文。

RPC 返回与语言无关的结构化数据,在客户端翻译呈现内容。保持用户草稿、agent 输出、原始 daemon 错误、日志与终端文本不变。manifest 列表翻译和 OVERVIEW.<locale>.md 是独立的发布元数据,参见你的列表页面。

运行时模块

Paseo 从对应的入口构建每个包。把 client/ 导入 daemon 的包、把 server/ 导入应用的包,或在应用的包中任何位置导入 Node 模块,都会导致编译错误。服务端导入 React、React Native 或客户端 SDK 入口也会失败。共享代码只能导入共享代码:不能导入 Node、React、特定运行时的 SDK 入口或特定运行时的类型。

SDK 根入口(@getpaseo/plugin)只包含共享数据、schema 和与运行时无关的辅助函数。从 /client 导入客户端 context 和 hook,从 /server 导入服务端 context 和生命周期契约,从 /client/ui 导入 UI。这些规则同样适用于类型导入和传递依赖。/client/host 是应用宿主的私有模块;插件不能导入它。

客户端运行时

Paseo 为客户端代码提供以下模块:

模块用于
@getpaseo/plugin共享数据、defineRpc、defineSettings、defineAttachmentSource、RpcInput 和 RpcOutput
@getpaseo/plugin/client/uiPaseo UI 组件和 UI hook
@getpaseo/plugin/client客户端贡献 context、usePaseo、useRpc、useSettings 和数据 hook
@tanstack/react-query请求状态和缓存
react组件和 hook
react/jsx-runtime编译后的 JSX
react-native跨平台 UI
zod共享 schema

宿主拥有与之配套的 React 和渲染器版本。SDK 的 React peer 范围允许补丁版本差异,以便工具链和 Node 使用者使用;它不会改变应用固定的 React 版本,也不保证与其他宿主的渲染器兼容。

这些确切的模块标识符使用宿主的运行时实例。请求其他宿主模块的客户端包会以 Module "<name>" is not available in plugin client code 失败。

不要导入 lucide-react-native、react-native-svg 或 DOM 库。把贡献的 icon 字段设为一个 Lucide 图标名称、内联 SVG 或组件;Paseo 会校验图标并渲染它。参见图标。

跨平台规则

客户端代码通过 React Native Web 在 iOS、Android 和浏览器中运行。在浏览器中正常、在手机上崩溃的组件是最常见的插件 bug。规则如下:

应该不应该
使用 react-native 中的 View、Text、Pressable、ScrollView、TextInput使用 <div>、<span>、<button> 或任何 HTML 元素
使用由 theme.colors 和 layout.compact 构建的 style 对象使用 className、CSS 字符串或写死的颜色
使用 onPress使用 onClick、onMouseEnter 或其他 DOM 处理函数
使用 Linking、Clipboard 之类的 React Native API在组件中使用 window、document、localStorage、navigator、location

脚手架的 tsconfig.json 省略了 DOM 库。请让跨平台组件远离 DOM 全局对象;不要添加 /// <reference lib="dom" />,也不要在 lib 中加入 "DOM"。layout.platform 携带与 React Native 的 Platform.OS 相同的值,用于渲染决策。

播放音频

调用 client.playAudio({ base64, mimeType }): Promise<void> 在当前客户端(浏览器、Electron、iOS 或 Android)上播放音频文件。传入插件 RPC 返回的 base64 文件即可;不需要浏览器全局对象或平台检查。

const audio = await client.rpc(renderSpeech, { text: "Your agent needs you" });
// renderSpeech returns { base64: string, mimeType: "audio/wav" }.
await client.playAudio(audio);

promise 在播放结束时 resolve,在文件无效、播放失败或插件卸载时 reject。调用共享 Paseo 的语音播放队列,并按顺序播放。卸载插件会取消它正在播放和排队中的音频。语音播放控件也可以中断这个共享队列。播放不会请求麦克风权限。

为了可移植性,请使用 PCM WAV 或 MP3 文件。其他编解码格式取决于客户端的解码器。接受 MIME 参数。请传入完整的音频文件;不支持原始 PCM 采样。浏览器要求用户交互后才允许播放声音;请处理 reject 并提供一个播放按钮。该函数在运行插件客户端的设备上播放,而不是在 daemon 上,并且不保证应用被挂起或关闭时仍能播放。

使用 ExternalLink 在 Paseo 之外打开文档:

import { ExternalLink } from "@getpaseo/plugin/client/ui";

export function DocumentationLink() {
  return <ExternalLink href="https://paseo.sh/docs">Open documentation</ExternalLink>;
}

该组件具有无障碍的链接语义,并使用与 openExternalUrl(url: string): Promise<void> 相同的打开方式:

import { openExternalUrl } from "@getpaseo/plugin/client";

export async function openDocumentation() {
  await openExternalUrl("https://paseo.sh/docs");
}

请直接在用户交互中调用该函数,以便浏览器允许打开新标签页。

平台外部链接navigation.openBrowser
Electron系统浏览器可用;创建一个本地工作区浏览器标签页
浏览器 Web带 noopener,noreferrer 的新标签页undefined
iOS / Android操作系统的 URL 处理程序undefined
属性是否必填行为 / 默认值
href: string是绝对的 HTTP(S) 目标地址
children: ReactNode是链接文本或内联的 React Native 内容
accessibilityLabel: string否覆盖从内容推导出的无障碍名称
testID: string否测试标识符;默认不设置
onError(error: unknown): void否接收打开时的错误;默认记录到日志

打开工作区浏览器

在界面或面板中使用 navigation.openBrowser。渲染该操作之前先检查它是否可用。下面这个工作区面板在其他平台上改用外部链接:

import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { Pressable, Text } from "react-native";

export function DocumentationPanel({ navigation, workspaceId, theme }: PluginWorkspacePanelProps) {
  const openBrowser = navigation?.openBrowser;
  const url = "https://paseo.sh/docs";

  if (!openBrowser) {
    return <ExternalLink href={url}>Open documentation</ExternalLink>;
  }

  return (
    <Pressable accessibilityRole="button" onPress={() => openBrowser({ url, workspaceId })}>
      <Text style={{ color: theme.colors.foreground }}>Open in workspace browser</Text>
    </Pressable>
  );
}

navigation.openBrowser({ url, workspaceId, serverId? }): void 会创建并聚焦一个新标签页。它从不会自动退回到外部打开。

选项是否必填行为 / 默认值
url: string是绝对的 HTTP(S) 目标地址
workspaceId: string是已存在于目标主机应用工作区列表中的工作区
serverId: string否默认为界面或面板所选的主机

要以另一台主机为目标,请传入它的 ID 以及该主机的工作区 ID:

openBrowser({ url, workspaceId: remoteWorkspaceId, serverId: remoteServerId });

serverId 选择工作区的所有者。页面运行在你本地的桌面上,即使是远程工作区也是如此;localhost URL 指的是该桌面。

错误与拒绝

条件结果
外部 URL 格式错误或使用非 HTTP(S) 协议被忽略;openExternalUrl resolve,但不会打开任何内容
操作系统打开失败openExternalUrl reject;ExternalLink 调用 onError 或记录错误
浏览器拦截了新的外部标签页无法与成功的 noopener 打开区分
应用内浏览器的 URL 格式错误或使用非 HTTP(S) 协议在创建标签页之前抛出 Only absolute HTTP(S) URLs are supported.
应用内浏览器的工作区 ID 为空在创建标签页之前抛出 workspaceId is required.
目标主机/工作区未知,或其工作区列表尚未加载在创建标签页之前抛出 Workspace is unavailable on the requested host.

跨客户端的、带类型的主机范围持久化请使用设置 API。打开你自己注册的贡献请使用 openSettings、openScreen 和 openPanel。

服务端运行时

Paseo 为服务端代码提供 @getpaseo/plugin、@getpaseo/plugin/server、@getpaseo/plugin/server/provider、@getpaseo/plugin/server/acp 和 zod。后端贡献在 daemon 子进程中运行,可以通过 Node 访问宿主机器。请把文件系统、进程、凭据以及其他本机工作放在 server/ 下。没有 index.server.ts 的插件不会启动子进程。

Provider

直接实现和 ACP 实现、会话生命周期、Composer 设置、时间线渲染器、测试和分发,见构建 Provider 插件。

使用来自 @getpaseo/plugin/server/provider 的 ProviderRegistration 调用 server.registerProvider()。它的连接通过 send() 接收输入,并通过 onEvent() 发出完整的状态快照。send() 只报告是否接受;提示词的处理结果、轮次、配置、持久化、权限和失败都是事件。

消息、结构化命令、引导和命令副作用都使用同一个 session.prompt 输入。在实时的用户时间线条目上重复 clientMessageId,并恰好发布一个与之匹配的 session.prompt_result。把 Provider 创建的子会话发布为带有 parentSessionId 的会话。

Provider 设置是 Paseo 在 Composer 中渲染的开关/选择描述符。把 Provider 私有的选项保留在 session.open 的 ProviderSessionConfig.providerOptions 中。这个记录包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。配置和合并语义见 Provider 选项。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。

Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,再用当前的配置和持久化值打开它。Provider 在 session.open 期间重新读取外部状态。

使用 @getpaseo/plugin/server/acp 中的 runAcpProvider() 适配一个基于命令的 ACP。只针对厂商在发现、配置、通知或工具调用方面的差异添加转换器钩子。

ProviderRegistration.icon 是相对于插件目录的文件路径,例如 icon.svg。它必须解析到该目录内一个遵循 SVG 图标规则的普通文件。Paseo 在插件启动时读取并校验该文件;这个字符串从不是内联 SVG 或 URL。

用量来源

需要 Paseo 0.11。在服务端入口中用 server.registerUsageSource() 注册一个来源。从 @getpaseo/plugin/server/usage 导入它的契约和辅助函数。

一个来源实现两个调用:

interface UsageAccount {
  key: string;
  label?: string;
  harness?: string;
  input: JsonValue;
}

type UsageScope =
  | { kind: "global" }
  | { kind: "session"; provider: string; model?: string; env: Record<string, string> };

interface UsageSourceRegistration {
  id: string;
  label: string;
  icon?: string;
  input: ZodType;
  discover(scope: UsageScope): Promise<UsageAccount[]>;
  fetch(input: unknown): Promise<UsageReport>;
}

type UsageReport =
  | {
      status: "available";
      planLabel?: string;
      windows: UsageWindow[];
      balances?: UsageBalance[];
      details?: UsageDetail[];
    }
  | { status: "unavailable"; problem: UsageProblem }
  | { status: "error"; error: string };

type UsageProblem =
  | { kind: "expired"; expiresAt: string; refreshedBy?: string }
  | { kind: "rejected"; status: number; refreshedBy?: string }
  | { kind: "no_quota"; detail: string };

会话发现只返回该 Agent 运行时使用的那个登录。Agent 弹出框只使用这些输入;它从不尝试来自全局发现或其他 Agent 的登录。对于其他任何 Provider,返回 []。全局发现会列出每个账号,包括已过期的登录。

从会话的启动环境中解析会话账号。没有登录时返回 []。不要在会话发现中扫描默认存储,也不要在全局发现中扫描 Agent。发现是一种查询,没有 Agent 生命周期钩子。已关闭的 Agent 在恢复之前没有会话范围。

输入指定凭据存储的位置;不要把凭据放进输入或报告中。Paseo 在调用 fetch() 之前会按你的 schema 校验每个输入。在任何范围内,相同的键就是同一份报告;只有当有序的登录输入一致时,获取才会共享五分钟的缓存。每次登录获取有 20 秒的截止时间。

内置的会话路由:

来源会话登录存储
ClaudeclaudeCLAUDE_CONFIG_DIR 或默认位置;在 macOS 上,该目录对应的钥匙串条目优先
Claude使用 anthropic/… 模型的 pi、omp该运行环境的 Anthropic 登录存储
CodexcodexCODEX_HOME/auth.json 或默认位置
Codex使用 openai/… 模型的 pi、opencode、omp该运行环境的 OpenAI 登录存储
Cursorcursor该会话的 XDG_CONFIG_HOME、APPDATA 或主目录下 cursor-agent 的 auth.json
Musemuse该会话自己的 Muse 启动配置
其他内置来源任意无会话发现

Claude 会排除 Bedrock、Vertex 以及使用其他 ANTHROPIC_BASE_URL 的会话。Codex 会排除设置了 OPENAI_BASE_URL 的会话。Cursor 会排除通过 CURSOR_API_KEY 或 CURSOR_AUTH_TOKEN 验证的会话,以及在 macOS 上把登录保存在钥匙串中的会话(除非设置了 AGENT_CLI_CREDENTIAL_STORE=file,否则这是默认行为)。

使用稳定的账号键:由 [A-Za-z0-9._-] 中的字符组成,长度为 1–128。它标识被计量配额的账号或组织,并且在 token 轮换后保持不变。不要把凭据或原始邮箱地址用作键。对于敏感的稳定身份,请使用 hashAccountKey();当账号元数据不可用时,使用存储位置标识。标签可以为账号命名,但不会成为账号的身份。

按优先顺序返回登录,并把 harness 设为所属运行环境的显示名称,例如 Codex、OpenCode、Pi 或 OMP。daemon 把输入视为不透明的值,从不从中推导标签。

主机范围的 Usage 弹窗会把键相同的登录归为一张账号卡片,并发尝试它们,在选择结果时优先考虑发现的顺序。如果任一登录返回 available,就显示用量且没有错误。如果全部失败,就把每个登录的错误分行显示,并附上它的运行环境标签和各自的解决方法。发现失败会由 daemon 记录到日志,并且不会产生卡片。

时间窗口的名称必须来自 Provider 的数据:显式的时长、具名的 API 字段(例如 five_hour 或 weekly),或 Provider 自己的周期名称。响应中的位置和重置倒计时并不能确定时长。当 Provider 省略时长时,请使用 “Rolling” 或 “Primary limit” 之类的中性名称,并在适配器旁说明缺失的元数据。

对于数值形式的时长,请使用 @getpaseo/plugin/server/usage 中的 windowFromReportedDuration()(Paseo 0.11+)。传入报告的秒数或 null、一个中性的 unknown 标识和名称,以及可选的稳定配额 scope。该辅助函数会一并推导出 ID、标签和短标签;它不接受对时长标签的覆盖。对于具名的 API 周期,请使用 windowFromUsedPct(),并采用该字段所支持的名称。

窗口 ID 标识的是配额范围和周期,而不是它在响应中的位置、使用率或重置时刻。对于特定于模型的配额,请以 Provider 稳定的功能 ID 作为范围;没有 ID 时退回到报告的限额名称。在位置移动和显示名称变化时保持 ID 不变。不要把旧的、有歧义的 ID 映射到另一个周期:已保存的固定项会在所有账号中按来源和窗口 ID 匹配,因此用户必须重新选择修正后的窗口。

在用户自定义固定项之前,summary: true 会选择来源的默认值。应用会为卡片、侧边栏和开关计算出同一个生效的选择。第一次编辑会把这些默认值保存为快照;显式的空选择会保持为空。

请在 fetch() 中重新读取所选的存储,让 CLI 的 token 轮换生效。不要兑换 refresh token,也不要写入凭据存储:在别处刷新可能会使 CLI 的副本失效,而重写解析过的文件可能会丢弃你没有建模的字段。如果存储在发现之后消失了,请抛出错误;卡片会显示该错误,直到下一次发现将其移除。

使用 unavailable(problem) 说明为什么某个已有的登录无法提供配额。它需要一个 problem;没有不带参数的形式。expiresAt 是 ISO 时间戳。被拒绝的登录会携带上游的 HTTP 状态码。refreshedBy 是一个 CLI 名称,例如 claude、codex、opencode、omp 或 pi。解决方法的句子由 Paseo 负责。不要在该字段中放入说明或面向用户的句子。对于 no_quota,detail 会原样显示。

import { z } from "zod";
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { hashAccountKey, unavailable } from "@getpaseo/plugin/server/usage";
import { findLoginStores, readLogin, readQuota } from "./server/logins";

const input = z.object({ path: z.string() }).strict();

export default function contribute(server: PluginServerContext) {
  server.registerUsageSource({
    id: "example-usage",
    label: "Example",
    icon: "icon.svg",
    input,
    discover: async () =>
      (await findLoginStores()).map((path) => ({
        key: hashAccountKey(path),
        input: { path },
      })),
    fetch: async (value) => {
      const { path } = input.parse(value);
      const login = await readLogin(path);
      if (login.expiresAt <= Date.now())
        return unavailable({
          kind: "expired",
          expiresAt: new Date(login.expiresAt).toISOString(),
          refreshedBy: "example",
        });
      return readQuota(login);
    },
  });
  return () => {};
}

内置的 Claude 发现优先使用 macOS 钥匙串中的登录,只有当钥匙串为空时才使用 Claude Code 的凭据文件。CLAUDE_CONFIG_DIR 选择该文件所在的目录。新的 token 使用 OAuth 资料中的账号和组织 ID;已过期或被拒绝的资料使用存储位置的哈希。Codex 依次优先使用 Codex CLI、OpenCode、Pi 和 OMP,并按保存的元数据或 JWT 声明中的 ChatGPT 账号 ID 分组。Pi 和 OMP 的登录在过期后仍可被发现。OMP 需要 node:sqlite。

不带 ID 调用 usage.list_reports 时会发现账号。带 ID 调用时,它会刷新已知的账号,而不重新发现身份。如果某个存储切换了账号,现有卡片会显示新登录的配额,直到下一次发现。报告会缓存五分钟;forceRefresh 会绕过缓存。条目携带 id、account.label 和 fetchedAt。失败的账号还会携带可选的 loginErrors 条目,其中包含 harness 和每个登录失败的 report。客户端通过 server_info.features.usageSources 判断是否启用此功能。较旧的 provider.usage.list RPC 会为 0.10 客户端映射同样的报告,并把问题渲染到它的 error 字符串中。

来源图标是一个文件路径,规则与 Provider 图标相同。

入口与清理

每个存在的入口都默认导出一个贡献函数,并返回清理函数。客户端入口接收 PluginClientContext;服务端入口接收 PluginServerContext。客户端注册方法返回幂等的移除函数,但标题栏按钮和 Composer 标签除外,它们返回 { update, remove } 句柄。入口的清理函数会在 Paseo 移除剩余的注册之前运行。

import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Main } from "./client/main";

export default function contribute(client: PluginClientContext) {
  client.addScreen({ id: "main", title: "My plugin", Component: Main });
  return () => {};
}

清理函数可以是异步的。请释放插件创建的计时器、监听器、套接字和其他资源。在重新加载、停用、移除、断开连接或 daemon 关闭时,Paseo 还会移除注册、卸载界面、reject 待处理的 RPC、关闭插件的 daemon 会话并停止它的子进程。

生命周期钩子

在 index.server.ts 中:

import type { PluginServerContext } from "@getpaseo/plugin/server";

export default function contribute(server: PluginServerContext) {
  server.on("agent.turn_ended", (event) => {
    console.log(event.agent.id, event.outcome);
  });

  return () => {};
}
注册回调接收返回
server.on(name, callback)(event, { paseo, signal })void 或 Promise<void>
server.before(name, callback)({ request }, { paseo, signal })修改后的请求,或返回 undefined 保持不变;支持异步

只要插件处于启用状态,钩子就会在 daemon 上运行,即使没有应用连接。

修改配置并注入 MCP 服务器

在 contribute(server) 中添加这个回调。把占位 URL 替换为你的 MCP 端点。

server.before("agent.create", ({ request }) => {
  if (request.config.provider !== "codex") {
    return request;
  }

  return {
    ...request,
    config: {
      ...request.config,
      providerOptions: {
        ...request.config.providerOptions,
        sandbox_mode: "workspace-write",
        approval_policy: "on-request",
      },
      mcpServers: {
        ...request.config.mcpServers,
        company: {
          type: "http",
          url: "https://tools.example.com/mcp",
        },
      },
    },
  };
});
输入结果
providerOptions.sandbox_mode: "read-only""workspace-write"
providerOptions.web_search: "disabled"由展开运算保留
已有的 mcpServers.search由展开运算保留
已有的 mcpServers.company被替换为上面的条目

所选的 Provider 会校验 providerOptions,并且必须支持配置的 MCP 服务器。显式的 Codex 沙盒和批准选项会覆盖它的模式预设。

在每次打开会话时注入环境变量

server.before("agent.session_open", ({ request }) => {
  return {
    ...request,
    env: {
      ...request.env,
      COMPANY_ENV: "development",
    },
  };
});

它会在创建、恢复、刷新和导入时运行。如果只想在创建时注入,请改为在 agent.create 回调中设置 env。

选择工作区隔离方式

server.before("workspace.create", ({ request }) => {
  if (request.source.kind !== "directory") {
    return request;
  }

  return {
    ...request,
    source: {
      kind: "worktree",
      cwd: request.source.path,
      action: "branch-off",
    },
  };
});

结果:显式的目录创建请求会变成 worktree 请求。已有的工作区以及目录查找/导入操作不受影响。

在轮次结束时发送后续消息

把 server/inspect.ts 复制到你的插件中。该辅助文件从 @getpaseo/protocol/agent-types 导入类型;请把与插件 SDK 版本相同的 @getpaseo/protocol 加入开发依赖,并在加载插件之前安装它们。latestOutputText 会拼接最近一条用户消息之后的文本片段。

import type { PluginServerContext } from "@getpaseo/plugin/server";
import { latestOutputText } from "./server/inspect";

export default function contribute(server: PluginServerContext) {
  server.on("agent.turn_ended", async (event, context) => {
    if (event.outcome.kind === "canceled") {
      return;
    }

    const agent = context.paseo.agents.ref(event.agent.id);
    const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
    const text = latestOutputText(page.entries.map(({ item }) => item));
    if (/out of credits/i.test(text)) {
      await agent.send("Try again.");
    }
  });

  return () => {};
}
Turn ends: "out of credits"
  → plugin sends "Try again."
  → a new turn starts

该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 cursor 和 direction 继续翻页。

这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。

回应权限请求

使用同一个辅助文件中的 shellCommand:

import type { PluginServerContext } from "@getpaseo/plugin/server";
import { shellCommand } from "./server/inspect";

export default function contribute(server: PluginServerContext) {
  server.on("agent.permission_requested", async (event, context) => {
    const command = shellCommand(event.request);
    if (command === null) {
      return;
    }

    const agent = context.paseo.agents.ref(event.agent.id);
    if (/\brm\s+-rf\b/.test(command)) {
      await agent.respondToPermission({
        requestId: event.request.id,
        response: { behavior: "deny", message: "Recursive deletion is blocked." },
      });
      return;
    }

    if (command.trim() === "git status") {
      await agent.respondToPermission({
        requestId: event.request.id,
        response: { behavior: "allow" },
      });
    }
  });

  return () => {};
}
请求结果
rm -rf build拒绝
git status批准
其他命令或请求保持待处理,留给用户
已经处理过的请求SDK 响应失败

这个正则表达式只是一个示例策略,而不是 shell 解析器。权限请求也可能是问题、计划和模式变更;请求权限不会结束当前轮次。

事件

名称事件字段触发时机
agent.createdagent普通创建完成;不包括导入/恢复
agent.closedagent一个运行中的 Agent 运行时关闭
agent.turn_startedagent, turnId实时轮次开始
agent.turn_endedagent, turnId, outcome, 已弃用的 timeline: []实时轮次完成、失败或被取消
agent.permission_requestedagent, request权限请求或问题进入待处理状态
agent.permission_resolvedagent, requestId, resolution待处理的请求得到回应或被清除
agent.archivedagent, archivedAt归档状态已保存
workspace.createdworkspace记录已创建;目录可用
workspace.archivedworkspace归档状态已保存

Agent 事件不包括 daemon 私有的一次性辅助 Agent。公开的后台工作区及其普通 Agent 与其他工作区运行相同的生命周期钩子。归档事件可能早于运行时/worktree 清理;当清理很重要时,请单独观察生命周期何时完成。

agent.turn_ended.timeline 已弃用,并且始终为空。需要历史时,请用 context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 }) 按需获取。钩子负载的大小不会随对话历史增长。

共享的负载结构(@getpaseo/plugin/server):

interface PluginHookAgent {
  id: string;
  workspaceId: string | null;
  parentAgentId: string | null;
  provider: string;
  cwd: string;
  title: string | null;
}

interface PluginHookWorkspace {
  id: string;
  projectId: string;
  cwd: string;
  name: string | null;
  archivedAt: string | null;
}

type PluginTurnOutcome =
  | { kind: "completed" }
  | { kind: "failed"; error: { message: string; code?: string } }
  | { kind: "canceled"; reason: string };
字段结构 / 含义
turnIdProvider 报告的 string 或 null;会话重新打开后可能重复
timelinereadonly AgentTimelineItem[];包含之前对话的完整快照;文本可能跨越多个条目
requestSDK 的 AgentPermissionRequest;kind 为 tool、plan、question、mode 或 other
resolutionSDK 的 AgentPermissionResponse
archivedAt时间戳字符串

before 钩子

名称请求字段可编辑
agent.createconfig,可选的 env除 cwd 外的公开 Agent 配置;env
agent.session_openagentId, workspaceId, provider, cwd, reason, purpose, env仅 env
workspace.createsource,可选的 title,firstAgentContext整个显式创建请求

agent.create.config 使用 AgentSessionConfig:

字段约束
provider, model独立的字段;修改 Provider 可能需要同时修改模型/模式/选项
modeId, thinkingOptionId, featureValues特定于 Provider 的选择
title, systemPromptAgent 配置
providerOptions不透明的、特定于 Provider 的选项
mcpServers, toolPolicyMCP 配置和精确的工具预批准
cwd不能修改

agent.session_open 请求示例:

{
  "agentId": "agent-123",
  "workspaceId": "workspace-456",
  "provider": "codex",
  "cwd": "/projects/shop",
  "reason": "resume",
  "purpose": "interactive",
  "env": { "COMPANY_ENV": "development" }
}
字段取值
workspaceId字符串或 null
reasoncreate、resume、refresh、import
purposeinteractive、history
env启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。

顺序与返回值

Creation request
  → agent.create hooks (plugin-ID order; registration order within each plugin)
  → resolve defaults and validate provider configuration
  → derive launch configuration with Paseo runtime tools and daemon prompt
  → agent.session_open hooks (same ordering; env only)
  → set PASEO_AGENT_ID and PASEO_AGENT_CWD
  → open provider session and save agent configuration
回调返回下一个回调收到
{ ...request, env: { ...request.env, REGION: "eu" } }之前的请求,添加或替换了 REGION
{ ...request, env: { REGION: "eu" } }之前的请求,整个覆盖映射被替换
undefined未改变的请求
抛出错误或返回无效数据操作失败;后面的回调不会运行

没有自动的深度合并。后面的回调可以覆盖前面的值。Agent 配置会被保存;环境变量覆盖项不会随之持久化。

context 与清理

契约行为
context.paseo已连接到此 daemon 的现有 SDK
context.signal在调用超时或插件停止时中止;请把它传给外部请求
输入数据分离的快照;通过返回的请求或 SDK 命令改变状态
注册结果幂等的移除函数,例如 const remove = server.on(...); remove();
重新加载、停用、移除、关闭移除剩余的注册
未知的钩子名称注册失败
钩子超时30 秒;会中止 signal。before 钩子会让待处理的操作失败;事件处理函数会记录一条错误。
事件处理函数出错记录在插件名下;原操作继续进行
事件投递实时、尽力而为;没有重放、持久化或自动重试
事件并发不同的事件可能重叠;不保证回调完成的顺序

完整示例

插件包含
lifecycle-logger全部十一个钩子;隐去环境变量值的 JSON 日志
lifecycle-actions后续消息、权限、环境变量、切换 Provider、选择 worktree
agent-configurationMCP 注入以及 Codex 沙盒/批准选项

用 paseo plugin logs lifecycle-logger 或主机的 daemon.log 读取 logger 的输出。

界面和侧边栏项

界面是一整页的插件 UI。侧边栏项是 Paseo 在侧边栏顶部或底部渲染的组件;按下它时做什么由它决定。先注册界面,再注册一个打开它的项:

client/main.tsx:

import type { PluginScreenProps } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { Text, View } from "react-native";

export function Main({ theme, host, layout }: PluginScreenProps) {
  const styles = useMemo(
    () => ({
      screen: {
        flex: 1,
        padding: layout.compact ? 16 : 24,
        backgroundColor: theme.colors.surface0,
      },
      title: { color: theme.colors.foreground },
      detail: { color: theme.colors.foregroundMuted },
    }),
    [theme, layout.compact],
  );
  return (
    <View style={styles.screen}>
      <Text style={styles.title}>{host.label}</Text>
      <Text style={styles.detail}>{layout.platform}</Text>
    </View>
  );
}

index.client.tsx:

import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
import { Main } from "./client/main";

function MainItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
  return (
    <SidebarRow
      icon="Blocks"
      active={currentScreen?.screenId === "main"}
      onPress={() => openScreen({ screenId: "main" })}
    />
  );
}

export default function contribute(client: PluginClientContext) {
  client.addScreen({ id: "main", title: "My plugin", Component: Main });
  client.addSidebarHeaderItem({ id: "main", title: "My plugin", Component: MainItem });
  return () => {};
}

PluginScreenProps 包含:

字段含义
theme当前 Paseo 主题的带类型 PluginTheme 颜色令牌。
host所选主机的 id 和显示用的 label。
layoutcompact 以及 ios、android 或 web 平台。
params打开界面时传入的参数,键和值都是字符串。没有参数时为 {}。
navigation可选的客户端导航。openAgent({ agentId, serverId? }) 和 openWorkspace({ workspaceId, serverId? }) 在 serverId 上打开目标,省略时在所选主机上打开。openBrowser({ url, workspaceId, serverId? }) 仅在 Electron 上可用;见链接和浏览器。

路由、标题栏、关闭操作、主机选择器、错误边界和 query client 由 Paseo 负责。界面主体由插件负责。

addScreen({ id, title, Component }) 注册一个界面。title 是界面标题栏的显示文本,也可以是接收界面参数并返回显示文本的函数。参数或语言变化时,该函数会再次运行。无效标题和空字符串标题会被拒绝。如果标题函数抛出错误或解析为空字符串,会改为显示界面 ID;界面仍然会渲染。

openScreen({ screenId, params }) 以参数打开一个界面,例如机器人界面要显示的那个机器人。参数保存在界面 URL 的查询字符串中,因此重新加载、前进后退以及指向该界面的链接都会保留它们。任何字符串键都可以;值必须是字符串,其他类型会抛出错误。用其他参数打开同一个界面会显示新的参数。

addSidebarHeaderItem 在侧边栏顶部的列表中添加一行。addSidebarFooterItem 在底部添加一行,位于 添加 project 和底部图标行之间。图标行是固定的;插件不能向其中添加内容。两者都接受 { id, title, Component }。title 是该项在 设置 → 侧边栏 中的标签(用户在那里重新排序和隐藏各项),也是 SidebarRow 的默认标签和无障碍标签。设置页面为这些项显示一个通用的插件图标;来自已弃用的 addSidebarItem 的行保留其注册时的图标。

当插件安装在多台主机上时,侧边栏项按应用当前显示的界面所属的主机渲染。不在任何主机的界面上时,它使用最近一次在该插件的某个界面中选择的主机,或最近一次从它的某个项打开的主机,否则使用第一台主机。

Component 接收 PluginSidebarItemProps:

字段含义
theme, host, layout与 PluginScreenProps 中相同。
currentScreen在该项所属主机上打开的、此插件的界面的 { screenId, params },没有时为 null。
openScreen(input)打开此插件的某个界面:{ screenId, params? }。未知的界面 ID 会抛出错误。
openPopover(Content)在宽布局中把 Content 锚定在被按下的行旁边打开,在紧凑布局中以底部面板打开。

Content 接收 theme、host、layout、close() 和 openScreen(input)。打开界面会关闭弹出框。

用 @getpaseo/plugin/client/ui 中的侧边栏组件渲染该项:

组件属性与行为
SidebarRow必填 onPress;可选 icon(任意一种图标形式)、label、active、trailing、id。占满整行宽度。trailing 渲染在行的可按区域旁边,因此其中的按钮可以单独按下;按下其中其他位置则相当于按下该行。id 用于在测试 ID 中区分同一项的多行。
SidebarSeparator没有属性。侧边栏的分隔线,放在各组行之间。

弹出框锚定在被按下的那一行,无论按下的是行本身还是它的 trailing 内容,因此每行的“More”按钮会在它自己的行旁边打开弹出框。

import type { PluginSidebarItemProps, PluginPopoverProps } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
import { Pressable, Text } from "react-native";

function SyncDetails({ theme, close, openScreen }: PluginPopoverProps) {
  return (
    <Pressable onPress={() => openScreen({ screenId: "sync" })}>
      <Text style={{ color: theme.colors.foreground }}>Open sync history</Text>
    </Pressable>
  );
}

function SyncItem({ openPopover }: PluginSidebarItemProps) {
  return <SidebarRow icon="RefreshCw" onPress={() => openPopover(SyncDetails)} />;
}

client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem });

抛出错误的项不会渲染任何内容;侧边栏的其余部分继续正常工作。

一个项中的多行

项的 Component 可以返回 fragment、数组或 null,因此一个项可以渲染一个在运行时变化的列表。设置中只列出该项一次,它作为一个整体移动和隐藏。下面这个项为每个机器人显示一行,并以机器人 ID 作为参数打开该机器人的界面:

import type {
  PluginClientContext,
  PluginScreenProps,
  PluginSidebarItemProps,
} from "@getpaseo/plugin/client";
import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";
import { botName, useBots } from "./client/bots";

function BotScreen({ theme, params }: PluginScreenProps) {
  return <Text style={{ color: theme.colors.foreground }}>Bot {params.botId}</Text>;
}

function BotsItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
  const bots = useBots();
  const openBotId = currentScreen?.screenId === "bot" ? currentScreen.params.botId : null;
  return (
    <>
      <SidebarSeparator />
      {bots.map((bot) => (
        <SidebarRow
          key={bot.id}
          id={bot.id}
          icon="Bot"
          label={bot.name}
          active={bot.id === openBotId}
          onPress={() => openScreen({ screenId: "bot", params: { botId: bot.id } })}
        />
      ))}
    </>
  );
}

export default function contribute(client: PluginClientContext) {
  client.addScreen({
    id: "bot",
    title: (params) => botName(params.botId) ?? "Bot",
    Component: BotScreen,
  });
  client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem });
  return () => {};
}

在运行时添加和移除项

addSidebarHeaderItem 和 addSidebarFooterItem 在入口返回之后仍然可用。它们各自返回一个移除函数;项会在不重新加载的情况下出现和消失:

let removeAlerts: (() => void) | null = null;

function showAlerts(client: PluginClientContext) {
  removeAlerts ??= client.addSidebarFooterItem({
    id: "alerts",
    title: "Alerts",
    Component: AlertsItem,
  });
}

function hideAlerts() {
  removeAlerts?.();
  removeAlerts = null;
}

宿主 UI

在客户端代码中从 @getpaseo/plugin/client/ui 导入由 Paseo 提供的 UI。下面的示例会打开一个受控的弹窗、渲染一个宿主图标,并用 toast 确认操作:

import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";

export function IssueActions({ theme }: PluginSurfaceProps) {
  const [open, setOpen] = useState(false);
  const toast = useToast();

  function saveIssue() {
    toast.show("Issue saved", { variant: "success" });
    setOpen(false);
  }

  return (
    <View>
      <Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
        <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
          <Icon name="Pencil" size={18} color={theme.colors.foreground} />
          <Text style={{ color: theme.colors.foreground }}>Edit issue</Text>
        </View>
      </Pressable>

      <Modal
        title="Edit issue"
        icon={<Icon name="Pencil" size={18} color={theme.colors.foreground} />}
        open={open}
        onOpenChange={setOpen}
      >
        <Modal.Content>
          <Pressable accessibilityRole="button" onPress={saveIssue}>
            <Text style={{ color: theme.colors.foreground }}>Save</Text>
          </Pressable>
        </Modal.Content>
      </Modal>
    </View>
  );
}

Markdown

从 @getpaseo/plugin/client/ui 导入 Markdown,即可在网页、iOS 和 Android 上 使用 Paseo 的 Markdown 样式渲染字符串:

import { Markdown } from "@getpaseo/plugin/client/ui";

<Markdown>{"## 审查\n\n检查**通过**。"}</Markdown>;

children 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。 宿主提供主题、文本选择、代码块、表格、图表和链接处理。 当所在界面提供工作区上下文时,工作区文件链接和图片会使用该上下文。

组件不添加页面内边距或滚动容器。需要时请在外层使用布局或滚动视图。 内容获取以及加载和错误状态由插件负责。

Modal 在紧凑布局中使用底部面板,否则使用居中的对话框。open 状态由插件负责。

属性类型是否必填行为
titlestring是为弹窗及其可见的标题栏提供标签。
iconReactNode否渲染在标题栏中标题的前面。
openboolean是为 true 时显示弹窗内容。
onOpenChange(open: boolean) => void是用户关闭弹窗时收到 false。
childrenReactNode是包含 Modal.Content。

Modal.Content 负责宿主渲染的标题栏下方的主体:

属性类型默认值行为
childrenReactNode必填标题栏下方的主体内容。
styleStyleProp<ViewStyle>—为整个主体视口(包括空白区域)设置样式。用于背景。
contentContainerStyleStyleProp<ViewStyle>内边距 24,间距 16覆盖内容布局。要让行贴边显示,请设置 padding: 0, gap: 0。
scrollablebooleantrue由宿主滚动主体。如果主体高度受限并使用你自己的滚动容器,请设为 false。

普通的 Modal.Content 已经带有内边距并且可以滚动。除非你想要再缩进一层,否则不要再添加一个带内边距的包装。主体样式不会影响宿主的标题栏、拖动手柄和关闭控件。在紧凑的原生布局中,宿主会在底部预留安全区域空间;把内容的内边距设为零只会去掉装饰性的缩进,而不会去掉这部分空间。键盘的避让是单独处理的。

使用 scrollable={false} 时,主体会填满底部面板的可用高度,居中对话框则使用可用高度的 85%。请在你的列表上使用 flex: 1, minHeight: 0。默认可滚动的对话框在宽布局中保持与内容一样的大小。呈现方式取决于窗口尺寸,包括较窄的桌面窗口和较宽的平板。

关闭按钮、背景遮罩、平台的返回操作、Web 上的 Escape 键以及紧凑布局下底部面板的手势都会关闭弹窗。关闭时会调用 onOpenChange(false);插件必须更新 open 才能关闭它。

弹窗的子元素保留插件的运行时 context。usePaseo、useRpc、useWorkspace 和 useAgent 在其中都可以使用。

滚动

当内容可能出现在 Paseo 弹窗中时,请从 @getpaseo/plugin/client/ui 导入 ScrollView 和 FlatList。它们接受 React Native 的属性和 ref,并与底部面板的手势集成。在底部面板之外,它们使用普通的 React Native 滚动。不要直接导入底部面板库。

只使用一个垂直滚动的所有者:要么是默认的弹窗主体,要么是你自己的、配合 scrollable={false} 使用的列表。在默认可滚动主体中嵌套一个固定高度的垂直列表,可能会在 Android 上与底部面板争夺手势。水平滚动可以与宿主的垂直主体共存。

默认主体和 SDK 列表都共享原生底部面板的手势:先向上拖动展开,再开始滚动;在列表顶部向下拖动会收起或关闭面板。scrollable={false} 会移除宿主的滚动容器,但不会改变这些手势。在使用 scrollToEnd 等列表方法之前,请先展开底部面板;在面板达到最大高度之前,它会锁定列表的偏移量。

import { FlatList, Modal } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";

// Inside your controlled Modal:
<Modal.Content
  scrollable={false}
  style={{ backgroundColor: theme.colors.surface1 }}
  contentContainerStyle={{ padding: 0, gap: 0 }}
>
  <FlatList
    style={{ flex: 1, minHeight: 0 }}
    data={items}
    keyExtractor={(item) => item.id}
    renderItem={({ item }) => (
      <Text style={{ padding: 16, color: theme.colors.foreground }}>{item.title}</Text>
    )}
  />
</Modal.Content>;

对于水平标签页,请把 <ScrollView horizontal style={{ flexGrow: 0 }}>…</ScrollView> 放在默认的 Modal.Content 中。让内容的垂直滚动仍由宿主负责。

复制和粘贴

copyText(text): Promise<void> 写入运行应用的设备的剪贴板。请在用户操作中调用它,并在报告成功之前 await 它。如果平台拒绝复制或剪贴板不可用,它会 reject;浏览器权限和安全上下文要求仍然适用。

import { copyText, useToast } from "@getpaseo/plugin/client/ui";

// Inside your component:
const toast = useToast();
async function copyResult() {
  try {
    await copyText(result);
    toast.show("Copied", { variant: "success" });
  } catch {
    toast.error("Could not copy. Select the text and use Copy.");
  }
}

以编程方式复制和原生文本选择是两种不同的交互。使用 <Text selectable> 支持长按选择和系统的复制。弹窗表单请从 @getpaseo/plugin/client/ui 导入 TextInput。它接受 React Native 的输入属性和 ref,支持系统粘贴,并向原生底部面板注册焦点,以便键盘能把表单顶上来。在底部面板之外,它使用普通的输入框。普通的 React Native 输入框也支持粘贴,但不会向底部面板注册焦点;键盘可能会遮住它。系统粘贴不需要任何读取剪贴板的 API。不要在原生插件中使用 DOM 剪贴板代码,也不要使用 react-native 中已弃用的 Clipboard 导出。

可运行的弹窗 UI 示例包含一个带内边距的表单、全宽的行、一个虚拟化列表、水平标签页和一个支持复制/粘贴的输入框。

Toast

useToast() 返回两个方法:

方法行为
show(message, options?)显示一个 toast,除非提供了 durationMs,否则持续 2,200 ms。
error(message)显示一个错误 toast,持续 3,200 ms。

show 接受以下选项:

选项类型默认值
variant"default" | "info" | "success" | "warning" | "error""default"
durationMsnumber2200

显示另一个 toast 会替换当前可见的 toast。空消息会被忽略。

图标

每个贡献的 icon 字段都接受以下三种形式之一,类型为 @getpaseo/plugin/client 中的 PluginIcon:

形式示例适用场景
Lucide 名称icon: "Scan"任意 Lucide 图标。
内联 SVGicon: { svg: reviewIcon }品牌标志,或 Lucide 中没有的图标。
组件icon: ReviewBadge随状态变化的图标,例如计数器。

附件来源接受 Lucide 名称或内联 SVG(PluginSerializableIcon),因为每个附加的条目都会保存其来源的图标。

Paseo 在贡献注册时校验图标。未知的 Lucide 名称或违反规则的 SVG 会导致插件加载失败,并给出原因:

Workspace panel review has an invalid icon: script elements are not allowed

Paseo 按界面的图标尺寸绘制内联 SVG,并把界面的图标颜色作为 currentColor 传入。用 currentColor 绘制即可跟随主题;固定的填充色会保持原样。

组件会收到 PluginIconProps,即 { size, color },并在该尺寸内渲染。头部按钮和 Composer 胶囊传入的是 PluginButtonIconProps。

SVG 规则

内联 SVG、Provider 图标和用量来源图标遵循相同的规则:

  • 只有一个 <svg> 文档,大小不超过 64 KiB。
  • 不含 <script>、<style> 或 <foreignObject> 元素。
  • 不含 onclick 这类事件处理属性,也不含 javascript: URL。
  • href 和 xlink:href 只能指向同一文档中的片段,例如 #mark。

提供 SVG 标记

把标记放在一个客户端模块中:

// client/icons.ts
export const reviewIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  <path d="M4 4h16v16H4z" />
</svg>`;

或者把一个 .svg 文件放进 client/ 并导入它。插件编译器会把文件的标记打包为字符串。声明一次该模块,让 TypeScript 接受这个导入:

// client/svg.d.ts
declare module "*.svg" {
  const markup: string;
  export default markup;
}
// index.client.tsx
import reviewIcon from "./client/review.svg";

client.addWorkspacePanel({ id: "review", title: "Review", icon: { svg: reviewIcon } /* … */ });

渲染图标

@getpaseo/plugin/client/ui 中的 Icon 用于在你自己的 UI 中绘制图标。插件包不导入 lucide-react-native 或 react-native-svg。

属性类型是否必填行为
namestringname 或 icon 二选一Lucide 图标名称。
iconPluginIconname 或 icon 二选一上文任意一种图标形式。
sizenumber否图标的宽度和高度。默认为 24。
colorstring否图标颜色,也是 SVG 的 currentColor。请使用插件主题令牌。

未知的名称或违反规则的 SVG 不会渲染任何内容。

时间线条目

插件可以用自己的数据和 React Native 渲染器替换 Agent 时间线中的条目。两项注册都是客户端贡献。Paseo 在构建渲染模型时应用转换器,包括每一次实时的流式更新。

import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client";
import { Text } from "react-native";
import { z } from "zod";

const schema = z.object({ label: z.string() });

function Card({ item, theme }: PluginTimelineItemProps<z.output<typeof schema>>) {
  return <Text style={{ color: theme.colors.foreground }}>{item.data.label}</Text>;
}

export default function contribute(client: PluginClientContext) {
  client.addTimelineTransformer({
    id: "command-card",
    query: { itemType: "tool_call" },
    transform({ item, phase }) {
      return {
        items: [
          {
            type: "plugin",
            kind: "command-card",
            version: 1,
            data: { label: item.name, phase },
          },
        ],
      };
    },
  });
  client.addTimelineRenderer({
    kind: "command-card",
    version: 1,
    schema,
    Component: Card,
  });
  return () => {};
}

query.itemType 是稳定的、粗粒度的选择器。请在 transform 中检查所选的条目,以识别特定的 Provider 或工具。返回 undefined 会保留原条目。返回 items 会替换它;空数组会移除它。条目的 data 必须兼容 JSON。对于实时的助手消息、运行中的工具调用和加载中的推理,phase 输入为 "streaming";对于已提交或已获取的消息以及已完成的工具或推理,它为 "complete"。助手和推理回调在每次更新时都会收到完整的累计文本,包括段落分隔符。Paseo 会在拆分原生 Markdown 或在 Overview 中对工具分组之前调用转换器。被认领的助手消息在整个流式过程中始终是同一个来源条目;如果最初的文本不足以识别它,请在可以识别之前返回 undefined。每个替换项都可以设置一个可选的、插件内部的 id;否则 Paseo 使用它在该来源条目输出中的索引。

渲染器接收 agentId、item、timestamp、theme、host 和 layout。Paseo 在渲染之前用注册的 schema 校验 item.data。请让转换器保持同步且确定。Paseo 按来源条目的引用缓存结果,并从来源行推导替换项的标识,因此对某个流式条目的更新不会重新挂载它的渲染器。要让纯文本像 Paseo 的助手行那样逐步显示,请渲染 @getpaseo/plugin/client/ui 中的 <StreamedText text={item.data.text} phase={item.data.phase} />。它接受 React Native Text 的属性(包括 style),默认使用助手消息的字体排版和前景色。完成后会立即显示全文;流式输出期间由宿主控制显示节奏。需要 Paseo 0.13 或更高版本。

当查询同时匹配原始消息时,聊天查找(Ctrl 或 Cmd+F)会在替换行中查找并高亮文本。宿主会在原始提示词文本或去掉 Markdown 语法的助手回复中搜索,然后由应用在你的渲染器绘制的文本中统计匹配。只由你的渲染器添加的文本不会被单独索引。匹配不会跨越渲染出的块,查找会跳过按钮、图标以及任何带有 aria-hidden="true" 的内容。查找只在 Web 和桌面应用中可用。

从 daemon 追加时间线行

服务端处理函数可以向标准历史记录中添加一行由插件拥有的内容:

import type { PluginHandlerContext } from "@getpaseo/plugin/server";

async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
  await paseo.agents.ref(agentId).timeline.append({
    type: "plugin",
    id: "review",
    kind: "review-result",
    version: 1,
    data: { verdict: "ready" },
  });
}
字段类型是否必填行为
type"plugin"是选择插件时间线变体。
idstring是稳定的、插件内部的标识。复用它会替换旧的行。
kindstring是选择已注册的渲染器。
version正整数是选择渲染器契约的版本。
data兼容 JSON是渲染器负载,JSON 序列化后最多 64 KiB。

daemon 会根据发起调用的插件会话写入 pluginId,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 id 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。daemon 会拒绝超过限制的 data,而不是截断它。支持此操作的 daemon 会声明 server_info.features.pluginTimelineItems。

主题与布局

插件 UI 运行在桌面、浏览器、iOS 和 Android 上,适用于每一个 Paseo 主题。theme 是由当前宿主主题映射而来的带类型 PluginTheme。颜色和间距必须来自这些属性。写死的颜色和没有样式的 Text 会在宿主主题变化时出问题。

当 theme 或 layout.compact 变化时,请重新创建样式。

键必须用于用于
theme.colors.foreground每个主要的 Text标题和正文
theme.colors.foregroundMuted次要的 Text标签和辅助文字
theme.colors.surface0根视图面板背景
theme.colors.surface1凸起的表面卡片和面板
theme.colors.surface2控件表面输入框和次要控件
theme.colors.border表面边界边框和分隔线
theme.colors.accent主要操作的填充按钮和选中状态
theme.colors.accentForeground强调色填充上的文字按钮标签
theme.colors.statusSuccess成功反馈成功消息和指示器
theme.colors.statusWarning警告反馈警告消息和指示器
theme.colors.statusDanger失败文字错误消息和破坏性操作的文字
layout.compact内边距和堆叠在移动端和较窄的窗口中为 true
layout.platform特定于平台的行为ios、android 或 web

不要写死 #000、#fff 或 React Native 的默认文字颜色。主要文字使用 foreground。标签使用 foregroundMuted。当 layout.compact 为 true 时,收紧内边距。

工作区面板和 Agent 面板接收同样的 theme、layout 以及可选的 navigation 字段。

贡献主题

addTheme 向 设置 → 外观 添加一个浅色或深色主题,按其 name 列在内置主题之下。主题是数据,因此不需要组件文件:

import type { PluginClientContext } from "@getpaseo/plugin/client";

export default function contribute(client: PluginClientContext) {
  client.addTheme({
    id: "mocha",
    name: "Catppuccin Mocha",
    appearance: "dark",
    colors: {
      background: "#1e1e2e",
      foreground: "#cdd6f4",
      raised: "#313244",
      control: "#45475a",
      border: "#45475a",
      accent: "#cba6f7",
      mutedForeground: "#a6adc8",
      ring: "#6c7086",
    },
  });
  return () => {};
}

每种颜色都是十六进制字符串;其他格式会导致加载失败。Paseo 会把调色板扩展为内置深色主题所使用的完整令牌集合,因此贡献的主题无需逐一列出,就能覆盖面板、菜单、diff、状态颜色和终端。

颜色用作
background应用、工作区和终端的背景
foreground主要文字、终端前景色和光标
raised卡片、弹出框和悬停的行
control输入框、次要填充以及浅色主题的侧边栏
border边框以及最高层凸起表面的色调
accent按钮、选中和焦点。可选;省略时使用 foreground。
mutedForeground次要文字
ring焦点环、滚动条以及终端的亮黑色

appearance 为 "light" 或 "dark"。Paseo 用它选择对应的表面、状态、diff、语法高亮、终端和阴影的推导方式。

同一时间只有一个贡献的主题处于启用状态。选择一个主题会保存这项选择;如果之后插件被停用或移除,Paseo 会退回到默认主题,而不会让应用失去配色。

主题需要一个支持它们的宿主。在 addTheme 之前发布的客户端无法执行该客户端入口,并会报告 client.addTheme is not a function。请更新客户端。

设置界面

在 index.client.tsx 中用 client.addSettingsScreen({ id, title, icon, Component }) 注册一个组件。它会出现在该主机的 设置 → 插件 → 你的插件 下。id 在同一次安装中唯一;icon 可以是任意一种图标形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。

调用 client.openSettings(id) 或 Command Center 回调中的 openSettings(id) 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。

该组件接收 PluginSurfaceProps。标题栏、返回导航、安全区域、滚动以及居中的设置栏由 Paseo 负责。紧凑的窗口会推入一个全屏的详情页;较宽的窗口会保留设置侧边栏。请在这个框架内用 React Native 组件渲染内容。被停用或移除的插件会留下一个“不可用”界面,其返回导航仍然可用。

具名 UI 组件

从 @getpaseo/plugin/client/ui 导入设置组件。它们可以配合你自己的状态和 RPC 使用;不需要表单包装或存储绑定。

import { useState } from "react";
import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";

export function DisplaySettings() {
  const [visible, setVisible] = useState(true);
  return (
    <SettingsSection title="Display">
      <SettingsCard>
        <SettingsSwitch label="Show metadata" value={visible} onValueChange={setVisible} />
      </SettingsCard>
    </SettingsSection>
  );
}
组件属性与行为
SettingsSection必填 title、children;可选 info 提示、trailing 内容、testID。负责分区的间距和标题。
SettingsCardchildren,可选 testID。负责卡片表面以及直接子元素之间的分隔线。请为映射生成的行提供稳定的 React key。
SettingsRow必填 label;可选 hint、error、children、testID。用于包裹任何自定义控件或内容。
SettingsSwitch行属性,外加必填的 value: boolean、onValueChange;可选 disabled。
SettingsSelect行属性,外加必填的字符串 value、options: { label, value }[]、onValueChange;可选 disabled。使用 Paseo 的自适应菜单。
SettingsInput行属性,外加必填的 onChangeText;可选 initialValue、placeholder、disabled、secureTextEntry、ref。
SettingsAction行属性,外加必填的 actionLabel、onPress;可选 disabled。

SettingsInput 负责正在编辑中的文本。initialValue 在挂载时为它提供初始值。它的 ref 提供 focus()、blur()、getText() 和 replaceText(text),用于显式的编程式修改。在用户保存之前,请把草稿文本保留在持久化值之外。自定义的预览和控件可以放在这些组件旁边或内部。

持久化的值

在 shared/ 中定义一个设置文档:

import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";

export const preferences = defineSettings({
  id: "display",
  scope: "host",
  version: 1,
  schema: z.object({ showMetadata: z.boolean().default(true) }),
});

在 index.server.ts 中、返回清理函数之前注册它。返回的句柄让服务端代码可以读取该文档并响应变化。使用自己数据的界面可以只保留在客户端。

export default function contribute(server: PluginServerContext) {
  const settings = server.registerSettings(preferences);

  settings.subscribe((next) => {
    if (next.status === "ready") {
      console.log("Settings changed", next.revision);
    }
  });

  return () => {};
}
定义字段契约
id匹配 [a-z][a-z0-9_-]* 的小写标识符;在同一次安装中唯一。
scope必须为 "host"。该主机所有获得授权的客户端共享这份文档。没有按用户、设备本地或跨主机的同步。
version必填的正整数,描述 schema 的版本,与写入修订号无关。
schemaJSON 值的 Zod schema。请提供默认值,使解析 {} 能得到一份完整的文档。
migrate(values, fromVersion)可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。

在任何贡献的组件中调用 useSettings(preferences)。它返回一个可区分的状态:

status可用的数据
loading读取尚未完成;不要把默认值当作已保存的值渲染。
ready带类型的 values 和一个不透明的 revision。
invaliderror 和 revision;存储的数据会被保留。
error读取或连接产生的 error。

服务端句柄提供 read() 和 subscribe()。read() 返回与客户端 hook 相同的 ready 或 invalid 状态,包括不透明的修订号。subscribe() 返回一个清理函数,并在每次成功保存、重置或迁移之后 收到新的 ready 状态。无效的写入和修订冲突不会通知订阅者。监听器的失败只会被记录,不会把已提交的写入 变成保存失败。

const current = await settings.read();
if (current.status === "ready") {
  // Use current.values and current.revision.
}

每种状态还提供 saving、saveError 以及以下操作:

  • save(values, revision): Promise<boolean> 校验并保存一份完整的文档。校验失败、冲突或传输失败时, 它返回 false 并设置 saveError;它不会抛出错误。
  • reset(): Promise<boolean> 使用 hook 加载的修订号,显式地把文档替换为 schema 默认值。它可以用来 从无效的存储数据中恢复。
  • reload(): Promise<void> 清除保存错误并重新读取。草稿由你的组件负责;重新加载不会自动丢弃这份草稿。

对于即时生效的开关,把 { ...settings.values, showMetadata } 和 settings.revision 传给 save。 对于草稿编辑器,在打开时同时记下值和修订号。在保存成功或用户放弃草稿之前,一直保留这个修订号。过期的 修订号会让保存被拒绝,草稿和更新后的已保存值都会被保留。

写入是原子的,并在主机上校验。已连接的客户端无需重新加载插件就能收到更新。这些值在 daemon 重启、插件 重新加载、停用和更新之后依然保留。移除一次安装会删除它的设置。重新安装同一个 ID 会从默认值开始。

缺失的文档使用 schema 默认值。无效的数据、失败的迁移以及不受支持的更新版本会产生 invalid,而不会 悄悄重置文件。成功的迁移只会持久化一次新版本。这些文档是普通的主机端 JSON,而不是凭据保险库。 设置相关的 RPC 使用现有的 daemon.manage 权限来执行插件。

完整的设置示例 包含即时生效的控件、带校验的草稿编辑器、自定义内容以及 Command Center 导航。

工作区面板

为工作区或 Agent 上下文注册一个面板:

client/review.tsx:

import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { Text, View } from "react-native";

export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) {
  const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name);
  const agent = useAgent(agentId, ({ id, title }) => ({ id, title }));
  const styles = useMemo(
    () => ({
      screen: {
        flex: 1,
        padding: layout.compact ? 16 : 24,
        backgroundColor: theme.colors.surface0,
      },
      title: { color: theme.colors.foreground },
      detail: { color: theme.colors.foregroundMuted },
    }),
    [theme, layout.compact],
  );
  return (
    <View style={styles.screen}>
      <Text style={styles.title}>{workspaceName}</Text>
      <Text style={styles.detail}>{agent?.title ?? agent?.id}</Text>
    </View>
  );
}

index.client.tsx:

import type { PluginClientContext } from "@getpaseo/plugin/client";
import { ReviewPanel } from "./client/review";

export default function contribute(client: PluginClientContext) {
  client.addWorkspacePanel({
    id: "review",
    title: "Review",
    icon: "Scan",
    context: "agent",
    locations: ["workspace", "explorer"],
    Component: ReviewPanel,
  });
  return () => {};
}

addWorkspacePanel 的字段:

字段是否必填含义
id是插件内部的面板 ID。
title是工作区标签页的标题。
icon是Lucide 名称、{ svg } 或组件。参见图标。
context是workspace 或 agent。
locations否workspace 和/或 explorer。默认为 workspace。
Component是与所选上下文的属性相匹配的 React Native 组件。

工作区面板接收 PluginWorkspacePanelProps:context: "workspace"、theme、host、layout 和 workspaceId。Agent 面板接收 PluginAgentPanelProps:context: "agent"、相同的通用字段和 workspaceId,外加 agentId。

用 useWorkspace(workspaceId, selector) 和 useAgent(agentId, selector) 读取缓存的状态。selector 是必填的。Paseo 对它的结果做浅比较,因此选择 { name, status } 时,无关字段的变化不会触发重新渲染。在一次调用中选择组件渲染的所有字段;不要选择整个快照。

当记录不可用时,两个 hook 都返回 null。否则它们会针对规范化的客户端状态同步运行。快照 DTO 及其嵌套值都是深度只读的,并在运行时被冻结。不要调用插件 RPC 来获取当前的工作区或 Agent。可选的或特定于厂商的补充数据,请在组件渲染之后再获取。

工作区快照字段:

字段类型
idstring
projectIdstring
projectDisplayNamestring
projectRootPathstring
directorystring
projectKind"git" | "non_git" | "directory"
kind"directory" | "local_checkout" | "checkout" | "worktree"
namestring
titlestring | null
status"needs_input" | "failed" | "running" | "attention" | "done"
statusEnteredAtISO 时间戳或 null
archivingAtISO 时间戳或 null
diffStat{ additions: number; deletions: number } | null

Agent 快照字段:

字段类型
idstring
workspaceIdstring
providerstring
status"initializing" | "idle" | "running" | "error" | "closed"
createdAtISO 时间戳
updatedAtISO 时间戳
lastActivityAtISO 时间戳
titlestring | null
cwdstring
modelstring | null
currentModeIdstring | null
thinkingOptionIdstring | null
requiresAttentionboolean
attentionReason"finished" | "error" | "permission" | null
parentAgentIdstring | null
labelsRecord<string, string>

标签页的焦点、拆分、关闭、持久化、查询状态、API/RPC Provider 以及渲染错误边界都由 Paseo 负责。如果恢复的标签页所对应的插件、面板、上下文、工作区或 Agent 不可用,该标签页会保持打开并显示“不可用”消息,而不会让工作区崩溃。

文件面板

注册一个组件,把文件渲染为文件标签页的主体。组件会收到所选主机上该文件的句柄,远程主机也一样。由组件读取文件、显示自己的加载状态,并在用户编辑时写回文件。标签页栏会显示面板切换器、溢出菜单,以及在面板报告有未保存更改时显示一个圆点。

import type { PluginClientContext } from "@getpaseo/plugin/client";
import { PdfPanel } from "./client/pdf-panel";

export default function contribute(client: PluginClientContext) {
  return client.addFilePanel({
    id: "pdf",
    title: "PDF",
    icon: "FileText",
    files: { extensions: ["pdf"] },
    Component: PdfPanel,
  });
}

addFilePanel 接收一个 PluginFilePanelContribution,并返回一个幂等的清理函数。客户端入口被卸载时,Paseo 也会移除该注册。

字段必填含义
id是插件内唯一的 ID,匹配 ^[a-z][a-z0-9-]*$。
title是显示在面板切换器、选择器和 打开方式… 菜单中的非空标签。
icon是Lucide 名称、{ svg } 或组件。参见图标。
files是{ extensions: readonly string[]; filenames?: readonly string[] }。
Component是接收 PluginFilePanelProps 的组件。

扩展名不带前导点,匹配时不区分大小写。文件名按完整的 basename 匹配,区分大小写。选择器不包含路径、glob 或通配符。只按文件名选择时,请使用 extensions: []。至少需要一个扩展名或文件名。

文件面板属性

PluginFilePanelProps 包含主题和布局中介绍的通用属性 theme、host、layout 和可选的 navigation,另外还有:

属性类型含义
workspaceIdstring包含该文件标签页的工作区。
filePluginFile文件的句柄。标签页显示该文件期间保持不变。
tabPluginFileTabsetModified(modified) 会显示标签页的未保存圆点,并在关闭前确认。
locationPluginFileLocationlineStart?、lineEnd? 和 revision,后者在每次导航时递增。

Paseo 在知道文件存在及其大小后就会挂载你的面板,不会先读取文件。file 对象保持不变,它的 stat 字段保存 Paseo 已知的最新版本。请在渲染时读取这些字段;它们变化时不会重新渲染你的面板。面板要通过 watch 监听器,或 useFileText、useFileBytes、useFileUrl 引起的重新渲染,得知它们已变化:

成员含义
path、name、extension主机上的绝对路径、basename,以及不带点的小写扩展名(没有时为 "")。
size、modifiedAt、mimeType以字节计的大小、修改时间戳,以及主机报告的 MIME 类型。
readText()以 UTF-8 读取文件。字节顺序标记(BOM)会被去掉,并由 writeText 写回。
readBytes()读取文件的字节。
url()当前字节的本地 blob:(Web)或 file:(原生)URL,供 DocumentFrame 使用。
writeText(text, options?)如果文件在磁盘上自该句柄上次读取或写入后没有变化,则以 UTF-8 写入文本。
writeBytes(bytes, options?)与上面相同,但写入字节。
watch(listener)用 PluginFileEvent 调用 listener(event);返回取消订阅函数。

PluginFileEvent 是 { type: "changed" } 或 { type: "deleted" }。请根据 type 分支处理;以后的版本可能会增加字段。文件被删除时,你的面板保持挂载。watch 会在一次读取确认文件已不存在后报告 { type: "deleted" },因此某个程序删除文件后又写回时,只会报告 { type: "changed" }。被删除的文件重新出现时,会报告 { type: "changed" }。

如果文件在该句柄上次读取或写入后在磁盘上发生了变化,写入会以 PluginFileConflictError reject。传入 { force: true } 可以强制写入。文件已不存在时,无论是否传入 force,写入都会以 PluginFileDeletedError reject。句柄自己的写入不会通知它的监听器。url() 返回的 URL 由 Paseo 管理:在更新版本的 URL 解析完成或标签页关闭之前,它一直有效。不要用 file.path 拼接 daemon URL;daemon 在远程时,本地 URL 同样可用。

读取文件

大多数面板通过 hook 读取文件。每个 hook 在挂载时读取,在文件于磁盘上变化时重新读取,并在重新读取期间保留当前值:

Hook就绪状态
useFileText(file){ status: "ready", text: string }
useFileBytes(file){ status: "ready", bytes: Uint8Array }
useFileUrl(file){ status: "ready", url: string }

在第一次读取之前,每个 hook 还会返回 { status: "loading" };读取失败时返回 { status: "error", error }。文件已删除时,error 是 PluginFileDeletedError。在内容显示到屏幕上之前,请显示 @getpaseo/plugin/client/ui 中的 Loading;它会填满面板,所以可以把它渲染在仍在布局的内容之上。

import { type PluginFilePanelProps, useFileUrl } from "@getpaseo/plugin/client";
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";

export function PdfPanel({ file, theme }: PluginFilePanelProps) {
  const url = useFileUrl(file);
  if (url.status === "loading") return <Loading />;
  if (url.status === "error") {
    return <Text style={{ color: theme.colors.statusDanger }}>{url.error.message}</Text>;
  }
  return <DocumentFrame url={url.url} testID="pdf-panel" />;
}

编辑文件

hook 的重新读取会采用磁盘上的版本,因此下一次写入会覆盖那里发生的任何变化。持有未保存编辑的面板应只用 readText() 读取一次,然后自己处理 watch:

  1. 用 file.readText() 读取并显示文本。
  2. 每次编辑时,调用 tab.setModified(true),并以防抖方式用 file.writeText(text) 写入。
  3. 写入完成且没有更新的编辑等待写入时,调用 tab.setModified(false)。
  4. 收到 { type: "changed" } 且没有未保存内容时,重新读取。有未保存的编辑时,显示冲突,让用户重新加载或用 { force: true } 覆盖。
  5. 遇到 PluginFileConflictError 时,显示同样的冲突。
  6. 收到 { type: "deleted" } 或遇到 PluginFileDeletedError 时,告诉用户文件已不存在。

请在组件卸载之前写入待保存的编辑:切换面板或关闭标签页都会卸载它。如果面板报告有未保存的更改,Paseo 会在关闭标签页之前询问。Markdown 面板示例实现了这些步骤。

选择与打开方式

插件面板是其选择器匹配的每个文件的候选面板,无论是文本还是二进制文件。内置面板要求主机报告的文件类型:Markdown、HTML 和 源代码 打开文本文件,图片 打开图片。候选面板的顺序是:内置的 Markdown、HTML 和图片面板,然后按主机目录顺序排列的插件面板,最后是每个文本文件都有的 源代码。只有没有其他面板认领文件时,源代码才会默认打开它。

记住的关联在其面板于当前主机上可用时选择该面板。否则 Paseo 打开第一个候选面板。当插件面板是多个候选面板之一且没有适用的关联时,Paseo 每次打开标签页时询问一次,询问显示在它已经打开的面板上方。只有内置面板时从不询问:Markdown 文件用 Markdown 打开,并在切换器中提供源代码。安装了一个 Markdown 插件后,选择器会提供 Markdown — Paseo、Rich Markdown — markdown-panel 和 源代码 — Paseo。认领文本扩展名的插件会与源代码竞争,所以第一次打开时会询问;单独认领某个二进制文件的插件会直接打开,不询问。

不再询问 会在此设备上为该扩展名记住这个选择。当某个候选面板匹配了确切的文件名时,该选择改为应用于那个 basename。不可用的关联在该主机上会被忽略,并为安装了其面板的主机保留。

用户可以用栏中的面板切换器切换当前标签页:它并排显示最多三个面板(手机上为两个),更多的面板放在菜单中;也可以用标签页菜单中的 打开方式…。对于这个标签页,该选择优先于记住的关联,直到标签页被重新定向。栏的溢出菜单和打开方式中的 始终用于 会记住当前显示的面板。从链接或搜索结果在某一行打开文件时,标签页会切换到源代码。没有候选面板的文件显示 二进制预览不可用;二进制文件没有源代码面板。

DocumentFrame

从 @getpaseo/plugin/client/ui 导入 DocumentFrame,用来显示 file.url() 或 useFileUrl 返回的本地文件 URL:

属性必填含义
url是本地文件 URL;Web 上为 blob:,原生上为 file:。其他协议会被拒绝。
testID否该框架的测试标识。

在 Web 上,挂载 iframe 之前会先检查本地 URL 的媒体类型。PDF 使用浏览器的 PDF 查看器,不加沙箱;其他所有类型,包括 HTML 和未知类型,都保持完整沙箱,不允许脚本或同源访问。原生平台使用禁用了 JavaScript、存储和后续导航的 WebView。在平台无法内联显示文档的地方,例如 Android 上的 PDF,DocumentFrame 会改为显示一条说明文件类型的消息,因此请在所有平台上渲染它。

内联 PDF 渲染需要 Paseo 0.13.0 或更高版本的 daemon;更早的 daemon 会把 PDF 报告为 application/octet-stream。

版本要求与示例

文件面板、DocumentFrame 和 Loading 需要 Paseo 0.13.0 或更高版本。请在 paseo-plugin.json 中声明 "requirements": { "paseo": ">=0.13.0" }。daemon 和应用都会检查版本要求;更早的应用会在执行客户端包之前跳过它。用 writeBytes 写入需要 Paseo 0.13.0 或更高版本的 daemon。

完整的目录示例:

  • PDF 面板:useFileUrl 搭配 Loading 和 DocumentFrame。
  • Markdown 面板:打包的 CodeMirror 编辑器,只读取一次、用 writeText 保存并处理冲突,在原生平台上显示只读文本。

Command Center 条目

在 macOS 上按 ⌘K,在 Windows 和 Linux 上按 Ctrl+K 打开 Command Center,然后搜索条目标题。

注册一个操作,并在回调中打开一个面板:

import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";

const refreshReview = defineRpc({
  name: "review.refresh",
  input: z.object({ agentId: z.string(), scope: z.string().optional() }),
  output: z.object({ refreshed: z.boolean() }),
});

client.addCommandCenterItem({
  id: "open-review",
  title: "Open review",
  icon: "Scan",
  keywords: ["inspect"],
  context: "agent",
  async onSelect({ paseo, rpc, workspace, agent, openPanel }) {
    await paseo.workspaces.ref(workspace.id).setTitle(`Review ${agent.id}`);
    await rpc(refreshReview, { agentId: agent.id });
    openPanel("review");
  },
});

addCommandCenterItem 的字段:

字段是否必填含义
id是插件内部的条目 ID。
title是搜索结果的标题。
icon是Lucide 名称、{ svg } 或组件。参见图标。
keywords否额外的 Command Center 搜索词。
context是global、workspace 或 agent。
onSelect是对应上下文的客户端回调。

全局条目出现在该安装所选择的主机上。只有当该主机有一个处于活动状态的缓存工作区时,工作区条目才会出现。只有当获得焦点的工作区标签页是一个 Agent,或者是一个缓存记录属于该工作区的 Agent 上下文插件面板时,Agent 条目才会出现。缺少上下文时条目会被移除,而不是调用插件去获取上下文。

每个回调都会收到:

字段上下文含义
context全部对应的判别字段。
paseo全部所选主机现有的 PaseoApi。
rpc(contract, input)全部对此安装的 daemon 端插件处理函数的带类型调用。
openScreen(input)全部打开此插件注册的某个界面:{ screenId, params? }。
workspace工作区和 Agent同步的工作区快照。
agentAgent同步的、与之匹配的 Agent 快照。
openPanel(id, options?)工作区和 Agent在回调当前的上下文中打开一个已注册的面板。传入 { location: "explorer" } 可以打开到 Explorer。

Agent 回调可以打开 Agent 面板或工作区面板。工作区回调只能打开工作区面板。未知的界面和面板 ID 会明确报错。普通的工作区、Agent、Provider 和 daemon 配置操作请使用 paseo。插件特有的文件系统、凭据、厂商或 daemon 本地工作请使用 rpc。

斜杠命令

注册一个命令,当用户在消息 Composer 中提交 /name args 时,它会在 Paseo 客户端中运行。这段文本 永远不会发送给 Agent:

client.addSlashCommand({
  name: "review",
  description: "Run the review bot",
  argumentHint: "[scope]",
  context: "agent",
  async onSubmit({ args, agent, rpc, openPanel }) {
    await rpc(refreshReview, { agentId: agent.id, scope: args });
    openPanel("review");
  },
});
字段是否必填含义
name是不带前导斜杠的命令名称。
description是Composer 自动补全中的描述。
argumentHint是显示在命令名称之后的简短用法提示。
context是"workspace" 或 "agent"。
onSubmit是对应上下文的客户端回调。

onSubmit 接收对应的 Command Center 回调上下文以及 args。对于 /review src,args 是 "src";Paseo 只去掉剩余部分首尾的空白,解析留给插件。自动补全行、清空输入框和错误 toast 由 Paseo 负责。它不会等待 onSubmit,也不会显示进行中的状态;如需显示,请使用 Composer 胶囊或面板。

优先级依次是内置客户端命令、插件命令、Provider 命令。优先级较低的冲突命令会被省略。内置别名也会占用 它们的名称。插件之间发生冲突时,按稳定的目录顺序排在前面的插件胜出。Composer 中有附件时,命令不会运行。

标题栏按钮

试试按钮示例,它在标题栏和 Composer 中演示了操作、菜单、自定义图标和内容以及可见性更新。它会在几种模式之间切换一个标题栏按钮; 其他操作放在一个名为 Tools 的菜单中。

client.addHeaderButton({ id, workspaceId, button }) 在工作区标题栏右侧的内置操作之前添加一个按钮。 它返回一个带有 update(patch) 和 remove() 的注册对象。

const review = client.addHeaderButton({
  id: "review",
  workspaceId,
  button: {
    title: "Open review",
    icon: "Scan",
    label: "Review",
    behavior: {
      kind: "action",
      onPress() {
        client.openPanel("review", { workspaceId });
      },
    },
  },
});

review.update({ label: "Review · 3" });
review.update({ visible: false });
review.update({ visible: true });
review.remove();

省略 label 即为只有图标的标题栏按钮。在宽布局中,菜单和弹出框会显示一个 V 形箭头。紧凑布局中的标题栏 按钮只使用图标,没有标签和箭头。Paseo 会把多出来的贡献移入一个共享的溢出菜单。位置和溢出由宿主决定。

Composer 胶囊

client.addComposerPill({ id, workspaceId, agentId, button }) 使用同样的按钮描述符, 并返回同样的注册对象。它面向某个 Agent 的 Composer 栏,与 Tasks 和 Subagents 并列。Composer 胶囊始终 显示图标和 label(省略 label 时显示 title)。它们从不显示箭头,菜单和弹出框也一样。

const pill = client.addComposerPill({
  id: "review",
  workspaceId,
  agentId,
  button: {
    title: "Open review",
    icon: "Scan",
    label: "Review",
    behavior: {
      kind: "action",
      onPress() {
        client.openPanel("review", { workspaceId, agentId });
      },
    },
  },
});

对于随 Agent 目录变化的胶囊,请使用显式的自有列表订阅。 本地插件示例 会在每次快照时替换注册,并在入口清理时中止观察,包括尚未完成的引导过程。

按钮描述符

这些契约从 @getpaseo/plugin/client 导出。

字段是否必填含义
title是非空的无障碍标签、提示文字和底部面板标题。
icon是Lucide 名称、{ svg } 或 ComponentType<PluginButtonIconProps>。
label否非空的显示文字。省略时使用该位置的默认值。
visible否默认为 true。为 false 时移除触发器及其占用的布局空间。
disabled否默认为 false。按钮保持可见,但无法交互。
behavior是下面三种形式之一。
type PluginButtonBehavior =
  | { kind: "action"; onPress(): void | Promise<void> }
  | { kind: "menu"; items: readonly PluginButtonMenuEntry[] }
  | { kind: "popover"; Content: React.ComponentType<PluginButtonContentProps> };

操作在客户端运行。在 promise 完成之前,Paseo 会把按钮标记为忙碌,阻止重复按下,并用 toast 显示失败。 失败的操作可以重试。普通操作请使用客户端的 paseo,插件特有的后端工作请使用 rpc。

菜单和弹出框在宽布局中以锚定的浮层打开,在紧凑布局中以底部面板打开。按下整个触发器都会打开浮层;没有 分体按钮的行为。

菜单包含条目和分隔线。ID 由小写字母、数字和连字符组成,以字母开头,并且在该菜单内唯一。

const behavior: PluginButtonBehavior = {
  kind: "menu",
  items: [
    {
      kind: "item",
      id: "refresh",
      title: "Refresh review",
      icon: "RefreshCw",
      behavior: { kind: "action", onPress: refreshReview },
    },
    { kind: "separator", id: "details-divider" },
    {
      kind: "item",
      id: "details",
      title: "Review details",
      behavior: { kind: "popover", Content: ReviewDetails },
    },
  ],
};

条目需要 kind: "item"、id、title 和 behavior。它可选的 icon、visible 和 disabled 遵循按钮的规则。分隔线只包含 kind: "separator" 和 id。在过滤掉隐藏的条目之后,Paseo 会移除开头、 结尾和连续的分隔线。

条目可以使用全部三种行为。嵌套菜单在宽布局中以弹出子菜单打开,在紧凑布局中则以同一个底部面板内带返回 导航的页面打开。自定义内容页面在选中时打开,不会在悬停时打开。选择一个操作会关闭菜单;打开另一个页面 则让菜单保持打开。

自定义图标和弹出框内容

PluginButtonIconProps 包含 theme、host、layout、size、color 以及目标上下文。请在给定的 尺寸内渲染一个 React Native 图标或指示器。图标槽的边界和所有指针交互都由 Paseo 负责。图标组件可以使用 插件 hook。

PluginButtonContentProps 包含 theme、host、layout、目标上下文以及 close()。只渲染主体;锚定、 滚动、内边距和底部面板的呈现由 Paseo 负责。内容可以使用 usePaseo、useRpc、useWorkspace、 useAgent 以及该安装的 React Query 缓存。

目标上下文是以下之一:

{ context: "workspace", workspaceId: string } // Header button
{ context: "agent", workspaceId: string, agentId: string } // Composer pill

更新与生命周期

每个注册都属于一个插件安装、一个位置、一个工作区,以及(对于胶囊)一个 Agent。id 在该目标内是插件 内部的,格式与菜单 ID 相同。同一个 ID 可以用于不同的目标或位置。在同一目标中重复注册会抛出错误。

update(patch: Partial<PluginButton>) 原地修改描述符,保留其标识和顺序。修改 behavior 时,请提供 完整的新 behavior 对象。无效的更新会抛出错误,并且不会改变现有按钮。订阅你自己的模型或客户端 API,并 调用 update 来发布响应式的变化;修改最初的描述符不会更新 UI。

隐藏或禁用按钮会关闭它的浮层。更新它的 behavior 也会关闭浮层。隐藏会保留注册,因此再次显示时会恢复它 原来的位置。它不会取消已经在进行中的操作。

remove() 是幂等的。移除之后的更新不起作用。当插件安装或主机连接被拆除时,Paseo 会移除尚存的按钮。 请从客户端入口返回清理函数,用来释放你的订阅、定时器和其他资源。

使用 Paseo SDK

在界面中执行普通的 Paseo 操作请使用 usePaseo()。它借用所选主机现有的连接;不要再创建一个客户端。

usePaseo() 是你的插件唯一的 Paseo 客户端,在每个界面中都与 setup 收到的 client.paseo 相同。 请在清理函数中释放你订阅的内容;当插件被停用、重新加载或移除,或者它的主机消失时,Paseo 会销毁该客户端, 并结束所有仍然打开的订阅。

import { usePaseo } from "@getpaseo/plugin/client";
import { useEffect, useState } from "react";
import { Text } from "react-native";

function ProjectChanges() {
  const paseo = usePaseo();
  const [changes, setChanges] = useState(0);
  useEffect(() => {
    const observation = paseo.observeEvents(["project.update"]);
    observation.subscribe({ snapshot() {}, update: () => setChanges((count) => count + 1) });
    return () => void observation.release();
  }, [paseo]);
  return <Text>{changes} project changes</Text>;
}
import { type PluginSurfaceProps, usePaseo } from "@getpaseo/plugin/client";
import { Pressable, Text } from "react-native";

function PullRequestAction({ theme }: PluginSurfaceProps) {
  const paseo = usePaseo();

  async function createReviewWorkspace() {
    const workspace = await paseo.workspaces.create({
      title: "Review PR 42",
      source: {
        kind: "worktree",
        cwd: "/absolute/path/to/repository",
        action: "checkout",
        checkoutSource: { kind: "change_request", forge: "github", number: 42 },
      },
    });
    await workspace.agents.create({
      config: { provider: "codex/gpt-5.5" },
      prompt: "Review PR #42.",
    });
  }

  return (
    <Pressable accessibilityRole="button" onPress={() => void createReviewWorkspace()}>
      <Text style={{ color: theme.colors.foreground }}>Create review workspace</Text>
    </Pressable>
  );
}

返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 SDK API 参考。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。

发现主机并指定另一台主机

使用 useHosts() 显示已配置的主机,并在操作回调中使用 getPaseoClient(serverId) 对其中一台主机 执行 SDK 操作:

import { getPaseoClient, useHosts, type PluginSurfaceProps } from "@getpaseo/plugin/client";
import { useMemo, useState, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";

export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactElement {
  const hosts = useHosts();
  const textStyle = useMemo(() => ({ color: theme.colors.foreground }), [theme]);
  const [result, setResult] = useState("");

  async function listAgents(serverId: string): Promise<void> {
    try {
      const { entries } = await getPaseoClient(serverId).agents.list();
      setResult(`${entries.length} agents`);
    } catch (error) {
      setResult(error instanceof Error ? error.message : String(error));
    }
  }

  const rows = hosts.map((host) => ({
    host,
    onPress() {
      void listAgents(host.serverId);
    },
  }));

  return (
    <View>
      {rows.map(({ host, onPress }) => (
        <Pressable key={host.serverId} accessibilityRole="button" onPress={onPress}>
          <Text style={textStyle}>
            {host.label}: {host.status}
          </Text>
        </Pressable>
      ))}
      <Text style={textStyle}>{result}</Text>
    </View>
  );
}

useHosts(): readonly PluginHostSummary[] 包括离线的主机,并在主机、标签或状态变化时更新。

摘要字段类型或取值含义
serverIdstring传给 getPaseoClient 的 ID。
labelstring主机的显示名称。
status"idle"、"connecting"、"online"、"offline"、"error"当前应用的连接状态。SDK 调用需要 "online"。

getPaseoClient(serverId: string): PaseoApi 借用该主机已认证的应用连接。请在客户端入口代码或回调中 调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的 连接。

事件或条件结果与调用方的处理
未知的主机 ID抛出 Unknown Paseo host: <id>;永远不会转到另一台主机。
主机不在线抛出 Paseo host is disconnected: <id>,通过保留的 API 发起的调用也是如此。请在主机上线后重试。
同一连接重新连接保留的 API 在重新连接后仍然可用;观察会自动恢复。
连接设置变化,或应用切换了连接(包括自动故障转移)旧的 API 被释放。请再次调用 getPaseoClient(serverId) 并重新创建订阅。
主机被移除它的 API 被释放;被移除的 ID 变为未知。
client.dispose()释放该 API 及其观察。之后再调用获取函数会返回一个基于应用连接的新 API。再次销毁旧的 API 不会影响新 API 的使用。
发起调用的插件被卸载它借用的所有 API 和观察都会被释放,包括指向其他主机的那些。保留的句柄不能比该安装存活得更久。
界面的主机选择发生变化usePaseo() 跟随所选的主机。显式获取的 API 保持其原来的目标。

你也可以通过普通的 SDK API 释放单个订阅。

插件是受信任的应用代码;跨主机访问是有意设计的。摘要中不包含连接 URL 或凭据,借用的 API 也不提供连接 生命周期控制。请参阅主机 Agent 示例。

添加插件特有的后端行为

插件 RPC 只用于不属于普通 Paseo 操作的工作:读取厂商 API、访问 daemon 本地资源,或者让凭据不经过客户端。

用 Zod 定义一个契约,在子进程中处理它,并从界面中调用它:

shared/greeting.ts:

import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";

export const greeting = defineRpc({
  name: "greeting.create",
  input: z.object({ name: z.string() }),
  output: z.object({ message: z.string() }),
});

client/greeting.tsx:

import { useRpc } from "@getpaseo/plugin/client";
import { greeting } from "../shared/greeting";

export function GreetingButton() {
  const createGreeting = useRpc(greeting);
  // Call createGreeting({ name: "Ada" }) from an event or query.
  return null;
}

server/greeting.ts:

import type { RpcInput } from "@getpaseo/plugin";
import { greeting } from "../shared/greeting";

export function createGreeting({ name }: RpcInput<typeof greeting>) {
  return { message: `Hello, ${name}` };
}

index.client.tsx:

import type { PluginClientContext } from "@getpaseo/plugin/client";
import { GreetingButton } from "./client/greeting";

export default function contribute(client: PluginClientContext) {
  client.addScreen({ id: "main", title: "Greeting", Component: GreetingButton });
  return () => {};
}

index.server.ts:

import type { PluginServerContext } from "@getpaseo/plugin/server";
import { createGreeting } from "./server/greeting";
import { greeting } from "./shared/greeting";

export default function contribute(server: PluginServerContext) {
  server.handle(greeting, createGreeting);
  return () => {};
}

输入和输出在两端都会校验。RPC 名称以小写字母开头,由小写字母、数字、点、连字符或下划线组成。useRpc() 返回一个带类型的异步函数。请求状态、缓存和变更请使用 TanStack Query。

后端处理函数以 { paseo } 的形式收到同样的 PaseoApi。它们的连接属于子进程,并在插件停止时关闭。在插件代码订阅之前,它不会订阅时间线或目录事件。清理和时间线替换请遵循 SDK 事件契约。后端代码可以使用 Node API 以及安装在插件目录中的依赖。

调试后端输出

后端贡献可以用普通的 Node 日志写入 stdout 和 stderr:

console.log("Refreshing issues");
console.error("Issue refresh failed", error);

当插件开始加载、就绪、开始停止以及已经停止时,Paseo 会添加 [paseo] 条目。它把编译和加载失败记录为 stderr 条目,包括在插件子进程启动之前发生的失败。Paseo 还会捕获初始化、RPC 处理函数、清理以及进程失败 期间产生的输出。协议通信使用单独的通道,因此 console.log() 不会破坏插件 RPC。

打开该插件的 设置 → 插件 → Logs,或者通过 daemon CLI 查看同一份最近的日志尾部:

paseo plugin logs my-plugin
paseo plugin logs my-plugin --json
paseo --host <url> plugin logs my-plugin

该命令返回的是一份快照,而不会持续跟随实时输出。要查看更新的条目,请刷新设置视图或再次运行该命令。每个 条目都包含时间戳、stdout 或 stderr 流、序号和消息。

Paseo 在内存中为每个插件最多保留 500 个条目和 256 KiB。单行最多 16 KiB。重新加载、停用、编译失败、 初始化失败和进程失败都会保留日志尾部。移除插件会清空它,daemon 重启则会开始新的日志尾部。结构化的副本 也会写入位于 $PASEO_HOME/daemon.log 的 daemon 日志。

只有 daemon 端的输出会被捕获。客户端界面的日志留在应用运行时中。不要记录凭据、访问令牌或其他机密:已连接 的用户可以读取保留的日志尾部,而 daemon 日志会把它持久保存。

添加 Composer 附件来源

附件来源搜索外部资源,并为 Agent 提示词返回一份稳定的文本快照。请把凭据和厂商调用放在后端处理函数中。

shared/issues.ts:

import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
import { z } from "zod";

export const searchIssues = defineRpc({
  name: "issues.search",
  input: z.object({ query: z.string() }),
  output: z.object({
    items: z.array(
      z.object({
        id: z.string(),
        identifier: z.string(),
        title: z.string(),
        subtitle: z.string().optional(),
        url: z.string().url(),
        text: z.string(),
        resourceType: z.string(),
      }),
    ),
  }),
});

export const issues = defineAttachmentSource({
  id: "issues",
  title: "Acme issue",
  icon: "CircleDot",
  pickerTitle: "Attach Acme issue",
  searchPlaceholder: "Search by identifier or title",
  search: searchIssues,
});

server/issues.ts:

import type { RpcInput } from "@getpaseo/plugin";
import { searchIssues } from "../shared/issues";

export function search({ query }: RpcInput<typeof searchIssues>) {
  return searchAcmeIssues(query);
}

index.client.tsx:

import type { PluginClientContext } from "@getpaseo/plugin/client";
import { issues } from "./shared/issues";

export default function contribute(client: PluginClientContext) {
  client.addAttachmentSource(issues);
  return () => {};
}

index.server.ts:

import type { PluginServerContext } from "@getpaseo/plugin/server";
import { search } from "./server/issues";
import { searchIssues } from "./shared/issues";

export default function contribute(server: PluginServerContext) {
  server.handle(searchIssues, search);
  return () => {};
}

Composer 菜单、搜索选择器、选中后的胶囊、草稿状态和提交都由 Paseo 负责。text 的值就是发送给 Agent 的完整快照。icon 是 Lucide 名称或 { svg };参见图标。

主机与生命周期

插件按 daemon 安装。当同一个贡献存在于多台已连接的主机上时,Paseo 只显示一个侧边栏项,并添加一个主机选择器。所选的主机提供插件包、Paseo API、RPC 传输以及查询缓存。当所选主机离线时,调用永远不会转到另一台主机。

附件来源仍然限定在各个 Composer 所在的主机上。

工作区面板和 Command Center 条目仍然限定在当前活动的主机和确切的缓存上下文中。重新加载会替换它们的 注册。停用、移除、主机断开和求值失败会移除 Command Center 条目,并清除该安装的查询状态。已经恢复的面板 标签页会保持“不可用”状态,直到与之匹配的贡献重新出现或用户关闭它。面板的渲染失败会被限制在插件的错误 边界内。

重新加载会排在 daemon 的其他插件工作之后,例如正在运行的安装或准备命令;当插件再次运行或失败时,daemon 才会给出响应。插件的状态不能当作重新加载的结果来读取:在重新加载排队期间,plugin ls、设置和日志 显示的是插件之前的状态;而在 daemon 停止并启动插件期间,读取状态会得到 failed,因为在启动完成之前插件 不在运行时目录中。

插件来源

把以下来源标识之一粘贴到 设置 → 插件 中,或者传给 paseo plugin install。 paseo plugin add <source> 和 paseo plugin install <source> 互为别名。CLI 从你当前的工作目录解析 相对目录路径,并把 ~ 展开为你本地的主目录,同时保留任何 :plugin/path 后缀。使用 --host 时,请使用 daemon 主机上存在的绝对路径;插件文件在那台主机上读取,CLI 永远不会上传它们。在设置中输入的路径相对于 daemon 的工作目录解析,~ 会展开为 daemon 的主目录。

来源接受的形式示例
插件注册表owner/slug 或 host/owner/slugacme/review
主机目录daemon 主机上的绝对或相对路径/srv/paseo/plugins/review
GitHub 仓库git:owner/repository 或 github:owner/repositorygithub:acme/paseo-review
Git 仓库git:<URL 或 SCP 来源>;对于 URL 和 SCP 来源,前缀可以省略git:https://git.example.com/acme/review.git
npm 包npm:<name>[@<版本、标签或范围>];npm: 可以省略npm:@acme/paseo-review@^1.2.0
来源根目录下的插件在目录、Git 或 npm 来源后追加 :relative/plugin/pathgithub:acme/monorepo:plugins/review

Git URL 使用 https://、http://、ssh://、git:// 或 file://。SCP 来源使用 user@host:path。 file:// 选择的是 Git 获取方式,而不是目录安装。

npm 名称是小写的无作用域 name 或有作用域的 @scope/name。每个部分以字母或数字开头,之后由字母、数字、 .、_ 或 - 组成。在包名之后,可选的 @ 引出一个确切版本、发布标签或 npm semver 范围。省略它表示 latest。包含空格或比较运算符的 shell 参数请加引号。npm 别名、tarball URL 和 npm 的 file: 规格都不是 插件来源标识;对于这些位置,请使用目录或 Git 来源。如果一个无作用域的包同时带有选择器和子目录,请使用 npm: 前缀(npm:review@1.2.0:nested);没有前缀时,user@host:path 是一个 SCP Git 来源。所选的版本、 标签或范围由包注册表校验。

Paseo 按以下顺序解析标识:

  1. 裸的 owner/slug 是注册表 ID;本地目录来源是 . 或 ..,或者以 ./、../、.\、..\、/、~、Windows 盘符(C:\ 或 C:/)或 UNC(\\server\share)前缀开头。
  2. 否则,在解析子目录后缀之前,先识别 npm:、github: 或 git:。 git:// 是 Git URL 的 scheme。显式的前缀会选择对应类型的获取方式。
  3. 只有当末尾的 :relative/plugin/path 后缀不包含空的、. 或 .. 段时,才把它识别为后缀。单独的 . 选择来源根目录。/ 和 \ 都可以分隔后缀中的段;跨主机时请使用 /。URL 端口和 SCP 来源中的 分隔符仍属于来源本身。不满足这些规则的后缀仍属于标识的一部分。
  4. 裸的 owner/slug 通过默认插件注册表解析,host/owner/slug 通过该注册表主机解析。Git URL 和 SCP 来源按 Git 解析。github: 要求 owner/repository 简写;git: 既接受简写,也接受 URL 和 SCP 来源。
  5. 剩下的 npm 包名及其可选的选择器通过主机的注册表解析。其他任何内容都会被拒绝。

默认注册表是 https://plugins.paseo.sh。浏览已发布的插件,并用 paseo plugin add owner/slug 安装。注册表记录决定修订版本和插件路径。显式的 GitHub 简写请使用 git:owner/repository,或者使用完整的 Git URL。设置 PASEO_PLUGIN_REGISTRY 可以使用自托管的默认 URL, 包括路径前缀。私有注册表的凭据在 daemon 配置中使用 pluginRegistries: { "host": { "authorization": "Bearer token" } }。修改这些启动设置后请重启 daemon。 凭据只会发送给该注册表,永远不会发送给制品主机或重定向目标。当默认值变化时,已安装的插件保留它们记录的 来源和注册表 URL。

目录查找在 daemon 主机上进行。应用使用 paseo-plugin.json 中的 ID;CLI 接受 --id <runtime-id> 来覆盖它。 已存在的安装 ID 会被拒绝,并且不会改变它的启用状态或文件。

paseo plugin install /srv/paseo/plugins/review
paseo plugin install github:acme/paseo-review
paseo plugin install git:https://git.example.com:8443/acme/monorepo.git:plugins/review --ref main
paseo plugin install git@git.example.com:acme/review.git
paseo plugin install file:///srv/repos/monorepo:plugins/review
paseo plugin install npm:paseo-review@1.2.0
paseo plugin install npm:@acme/paseo-review@next
paseo plugin install 'npm:@acme/paseo-review@>=1.2.0 <2.0.0' --id review-staging
paseo plugin install npm:@acme/plugins@^1.2.0:plugins/review

--ref 只适用于 Git,接受用于这次安装的分支、标签或提交。没有它时,Paseo 安装远程的默认 HEAD。安装时的 选择器不会约束之后的更新。旧版的 --path relative/plugin/path 选项等同于子目录后缀,对 npm 也是如此。

npm 安装与发布

在 daemon 主机上安装带有 npm 的 Node.js,并让 npm 出现在 daemon 的 PATH 中。daemon 使用该主机的 npm 用户/全局配置和环境来选择注册表和进行认证,包括特定作用域的注册表。客户端不会下载包,也不会运行 npm。 加载、启用和重新加载已安装的插件都不需要 npm。

daemon 在一个隔离的目录中安装每个候选包及其生产依赖。激活时,它会保留完整的依赖树和 package-lock.json。 已安装的包和 lockfile 提供它的当前版本和制品完整性。版本、标签或范围只为这一次安装选择内容。

已安装的 lockfile 记录了这次安装解析出的结果。提交带有运行时依赖的 npm 插件的作者还必须附带 发布锁。关于包的内容、准备和提交,请参阅 发布插件。

下载、依赖安装、manifest 检查、准备命令、编译或激活中的任何一步失败,都会丢弃候选包。其他已安装的插件继续 运行。移除 npm 插件会删除它的托管文件;移除目录插件则会保留你的源目录。

CLI 参考

paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add owner/slug
paseo plugin add git:owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add git:owner/monorepo:plugins/review
paseo plugin ls [id]
paseo plugin update <id>
paseo plugin update --all --check
paseo plugin update --all --yes
paseo plugin update my-plugin --version 1.2.0
paseo plugin update my-plugin --ref v2
paseo plugin reload my-plugin
paseo plugin logs my-plugin
paseo plugin disable my-plugin
paseo plugin enable my-plugin
paseo plugin remove my-plugin

ls 和设置页面显示来源标识和当前安装的修订版本,而不会联系远程。标识包含所选的子目录,不包含安装时的 选择器。

update <id> 检查更新,显示当前和拟更新的修订版本以及可用的审查链接,并请求批准。拒绝则保持已安装的内容 不变。

  • npm 检查包的 latest 版本。它只会提供更新的版本;比 latest 更新的已安装版本会保持安装状态。解析由主机的 npm 注册表/认证配置决定。
  • Git 检查远程当前的默认 HEAD,不论安装时选择的是哪个分支、标签或提交。
  • 目录插件会被跳过;请编辑目录并使用 reload。

--check 只做预览,即使提供了显式目标或 --yes 也是如此。--yes 跳过询问。--all 检查每个已配置的插件, 并分别报告结果;一个失败不会阻止其他插件。这两个标志都不允许普通的 npm 降级。

--version <version|tag|range> 或 --ref <branch|tag|commit> 为某个匹配的插件选择更新内容,并在不再询问 的情况下应用它。显式的 npm 版本可以更旧。下一次普通更新会再次检查 latest。显式目标不能与 --all 一起使用。 JSON 模式和非交互的普通更新需要 --yes。

批准后会获取被审查的那个确切提交或 npm 制品。如果它不可用,或者在审查期间已安装的插件发生了变化,更新会失败 并要求你重新检查。准备或激活失败会保留之前的安装。目前还不支持在应用中手动审查更新。

当目标不是 CLI 的默认 daemon 时,请把 --host <url> 放在管理命令之前。remove 永远不会删除目录来源;对于 Git 和 npm 来源,它会删除托管文件。安装时的 --id 就是运行时 ID,允许同一个目录或仓库被安装多次。

信任你添加的每一个插件。 paseo plugin add 和 paseo plugin install 的意思是“我信任这个代码库”。服务端代码和准备命令在 daemon 主机上以 daemon 用户的权限在沙箱外运行;客户端贡献在 Paseo 内部运行。依赖和未来的更新也属于这个决定的一部分。使用全局 --host 选项时,命令在远程 daemon 主机上运行。

大多数插件应省略 build。只有当暂存的检出必须安装 Paseo 未提供的依赖、生成源代码或资源,或者执行其他必需的 准备步骤时,才使用它:

{
  "id": "review",
  "requirements": { "paseo": ">=0.8.0" },
  "build": [["npm", "ci", "--omit=dev"]]
}

build 是一个由非空 argv 数组组成的列表。在解析出确切的提交和 manifest 之后,Paseo 从暂存的插件目录中直接 运行每个可执行文件,不经过 shell。它永远不会从 lockfile 推断包管理器或命令。安装和更新都会在校验、编译、激活 或替换之前运行 build。失败的命令会报告其输出、丢弃候选包,并保持已安装/正在运行的版本不变。daemon 日志会 记录每条命令及其输出;使用全局 --host 选项时,命令在该 daemon 主机上执行。

在安装或重新加载之前运行 npm run typecheck。请用 CLI 或设置页面管理插件来源条目;安装语法请参阅 插件来源。

daemon 级别的 启用插件 开关位于 设置 → 插件 下。只有当这个开关和插件自身的启用状态都打开时,已配置的插件才会运行,否则它会保持 disabled。

这个开关是 config.json 中根级的 pluginsEnabled 字段。修改后请运行 paseo reload --json。启用会启动所有自身 enabled 值不为 false 的已配置插件;停用会拆除所有插件。不需要重启 daemon。手动编辑的插件来源条目不会被重新加载;这些请使用插件生命周期命令。

加载失败

使用 paseo plugin ls 读取当前的状态和错误。

症状检查
This plugin was made for an older version of Paseo目录中只有 index.ts 入口。请按照迁移指南操作。
Plugin entry points are missing不存在名称完全一致的 index.client.tsx 或 index.server.ts。
server-only module cannot be imported into the plugin client bundle客户端代码导入了 server/。把这部分工作移到 RPC 之后,并从 shared/ 导入它的契约。
client-only module cannot be imported into the plugin server bundle服务端代码导入了 client/。改为从 index.client.tsx 注册该贡献。
Node module cannot be imported into the plugin client bundle客户端代码导入了 node:*。把该操作移到 server/,并通过 RPC 调用它。
侧边栏项缺失确认插件处于 running 状态,该项没有在 设置 → 侧边栏 中被隐藏,它的组件没有抛出错误,并且客户端连接的是该安装所在的主机。
客户端模块不可用只导入上面列出的、由宿主提供的客户端模块。
RPC 被拒绝检查两个 Zod schema 以及 daemon 端处理函数的错误。
修改后的代码没有生效运行 npm run typecheck,然后运行 paseo plugin reload <id>。
重新加载失败读取 paseo plugin ls 和 paseo plugin logs <id>,修复源代码错误后再重新加载;Paseo 不会恢复之前的插件包。
插件意外退出读取 paseo plugin logs <id>,查看保留的初始化、清理、stderr 以及最终崩溃的输出。

赞助商