Merge remote-tracking branch 'origin/main' into diagnose/3589-contributor-6b87ee9f

This commit is contained in:
paseo-bot[bot]
2026-11-08 06:37:04 +00:00
1387 changed files with 87894 additions and 20485 deletions
+2 -2
View File
@@ -588,7 +588,7 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution }
});
```
Agent hooks exclude the daemon's private disposable helpers. Public background workspaces and their ordinary agents use the same hooks.
Agent hooks exclude the daemon's private disposable helpers. Background agents and the agents in background workspaces use the same hooks.
## Ordering and returned values
@@ -598,7 +598,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ open provider session and save agent configuration
```
+2 -2
View File
@@ -571,7 +571,7 @@ server.addHook("agent.permission.after", async ({ agent, requestId, resolution }
});
```
Agent 钩子不包括 daemon 的私有临时辅助 Agent。公开的后台工作区及其普通 Agent 使用相同钩子。
Agent 钩子不包括 daemon 的私有临时辅助 Agent。后台 Agent 和后台工作区中的 Agent 使用相同钩子。
## 顺序与返回值 {#ordering-and-returned-values}
@@ -581,7 +581,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ open provider session and save agent configuration
```
+3 -2
View File
@@ -73,6 +73,7 @@ Use this table as the complete registration checklist.
| `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` |
| `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) |
| New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` |
| New sidebar replacement view | `client.addSidebarView({ id, title, Component })` in `index.client.tsx`; see [sidebar views](sidebar-views) |
| New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` |
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
| Agent panel with `context: "agent"` | `client.addAgentPanel(panel)` in `index.client.tsx`; omit `context` |
@@ -144,8 +145,8 @@ pill.remove();
```
Move dynamic text from the former component into updates from your model or SDK subscription.
Custom icon components may still use hooks. Composer pills always show icon and label and never
show chevrons. See [buttons](./reference.md#button-descriptor) for menus, popovers, and visibility.
Custom icon components may still use hooks. Composer pills always show icon and label; menus and
popovers add a chevron. See [buttons](./reference.md#button-descriptor) for menus, popovers, and visibility.
## 4. Separate imports
+5
View File
@@ -287,6 +287,11 @@ Paseo refreshes an agent by closing its current provider session and opening it
configuration and persistence. Re-read credentials, environment, global configuration, and MCP
servers during `session.open`. There is no separate reload operation.
Conversation and combined rewind require a core-restored session with persistence. After a
successful rewind, Paseo closes the session and reopens its latest persistence value with
`history: "replay"`. Publish the retained native branch before `session.ready`, including its
children. File-only rewind keeps the current session and conversation history.
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
recreate it while restoring the parent.
+194 -21
View File
@@ -73,16 +73,16 @@ The required root manifest is `paseo-plugin.json`:
}
```
| Field | Required | Behavior |
| -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. |
| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. |
| `icon` | No | Relative path to a PNG inside the plugin package. |
| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
| `translations` | No | Listing text in other languages, read by the registry and ignored by Paseo. See [other languages](/docs/plugins/publishing#other-languages). |
| `requirements` | No | Supported Paseo versions, described below. |
| `build` | No | Preparation commands, described in the CLI reference. |
| Field | Required | Behavior |
| -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. |
| `name` | No | Non-empty display name for the registry, the website, your row in **Settings → Plugins**, and your settings header. Without it, Paseo shows the ID. |
| `icon` | No | Relative path to a PNG inside the plugin package. |
| `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
| `description` | No | Non-empty summary shown below the plugin name in **Settings → Plugins**. |
| `translations` | No | Listing text in other languages, read by the registry and ignored by Paseo. See [other languages](/docs/plugins/publishing#other-languages). |
| `requirements` | No | Supported Paseo versions, described below. |
| `build` | No | Preparation commands, described in the CLI reference. |
Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..`
segments. Registry listings for visual plugins require an interface image even though `media` is
@@ -641,6 +641,13 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre
Register async handlers with `server.addHook(name, handler)`. See [Hooks](/docs/plugins/hooks) for names, payloads, ordering, errors, and complete examples.
## Sidebar views
`addSidebarView({ id, title, description?, Component })` registers an optional replacement for the
sidebar content region. It returns cleanup. The user selects one view in Settings > Sidebar;
registration does not activate it. See [Build a sidebar view](sidebar-views.md) for the props,
ref-based queries and actions, device state and theme tokens.
## Screens and sidebar items
A screen is a full page of plugin UI. A sidebar item is a component Paseo renders in the sidebar
@@ -1309,8 +1316,11 @@ Themes need a host that supports them. A client released before `addTheme` canno
## Settings screens
Register a component with `client.addSettingsScreen({ id, title, icon, Component })` in
`index.client.tsx`. It appears under **Settings → Plugins → your plugin** on that host.
`id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an
`index.client.tsx`. Your plugin's row in **Settings → Plugins** on that host then shows a settings
button (cog) that opens your first screen. When you register several screens, the settings page shows
one tab per screen, labeled with its `title` and `icon`, in registration order. With one screen there
are no tabs. The page header shows your manifest `name`, or your plugin ID when you leave `name`
out; it never includes a screen title. `id` is unique within the installation; `icon` is any [icon](#icons) form. Registration returns an
idempotent remover, and plugin teardown removes remaining screens.
Call `client.openSettings(id)` or a Command Center callback's `openSettings(id)` to open one
@@ -1391,13 +1401,13 @@ export default function contribute(server: PluginServerContext) {
}
```
| Definition field | Contract |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. |
| `scope` | Required `"host"`. All authorized clients of that host share the document. No per-user, device-local, or cross-host synchronization. |
| `version` | Required positive integer describing the schema, independent of the write revision. |
| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. |
| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. |
| Definition field | Contract |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. |
| `scope` | Required `"host"` or `"device"`. Host documents are shared by authorized clients. Device documents stay on this device, keyed by installation and settings ID; use them for layout preferences. |
| `version` | Required positive integer describing the schema, independent of the write revision. |
| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. |
| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. |
Call `useSettings(preferences)` in any contributed component. It returns a discriminated state:
@@ -1904,7 +1914,10 @@ decisions.
`client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor)
and returns the same registration. It targets one agent's composer track alongside Tasks and
Subagents. Composer pills always show the icon and `label` (or `title` when `label` is omitted).
They never show a chevron, including for menus and popovers.
Menus and popovers add an upward chevron on every layout, because their surface opens above the
pill; actions show none, so a pill that runs on tap reads differently from one that opens something.
The track keeps its space above the composer whether or not it has pills, so adding and removing
pills does not move the transcript.
```tsx
const pill = client.addComposerPill({
@@ -2085,6 +2098,8 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection.
To run a helper the user shouldn't see, such as a classifier or summarizer, create it as a [background agent](/docs/sdk/agents#run-a-helper-as-a-background-agent) in an existing workspace.
### Discover hosts and target another host
Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback
@@ -2229,10 +2244,168 @@ export default function contribute(server: PluginServerContext) {
}
```
Inputs and outputs are validated on both sides. RPC names start with a lowercase letter and contain lowercase letters, numbers, dots, hyphens, or underscores. `useRpc()` returns a typed async function. Use TanStack Query for request state, caching, and mutations.
Inputs are parsed once in the plugin process; outputs are parsed once on the client. Send schema inputs from the client and return output-schema inputs from the handler; schema transforms run at the receiving side. RPC names start with a lowercase letter and contain lowercase letters, numbers, dots, hyphens, or underscores. `useRpc()` returns a typed async function. Use TanStack Query for request state, caching, and mutations.
Backend handlers receive the same `PaseoApi` as `{ paseo }`. Their connection belongs to the subprocess and closes when the plugin stops. It does not subscribe to timelines or catalog events until plugin code subscribes. Follow the [SDK event contract](/docs/sdk/events) for cleanup and timeline replacements. Backend code can use Node APIs and dependencies installed in the plugin directory.
## Stream events from the daemon {#subscriptions}
A subscription runs a daemon handler for as long as one client listens and sends that client typed
events. Use it for watchers, tails, and live media; use plugin RPC for a single answer.
Define the contract in `shared/`:
```ts
// shared/diff.ts
import { defineSubscription } from "@getpaseo/plugin";
import { z } from "zod";
export const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ cwd: z.string(), from: z.string() }),
event: z.object({ type: z.literal("changes"), files: z.array(z.string()) }),
});
```
Handle it in `index.server.ts`. The handler receives `push` and an abort `signal` next to `paseo`:
```ts
server.handle(diffWatch, ({ cwd, from }, { push, signal }) => {
const watcher = watchChanges(cwd, from, (files) => void push({ type: "changes", files }));
signal.addEventListener("abort", () => watcher.close(), { once: true });
});
```
Subscribe from a surface with `useSubscription(contract, input, onEvent)`:
```tsx
const [files, setFiles] = useState<string[]>([]);
const subscription = useSubscription(diffWatch, { cwd, from: "HEAD" }, (event) => {
setFiles(event.files);
});
if (subscription.status === "error") return <Text>{subscription.error.message}</Text>;
```
Outside React, `client.subscribe` takes the same arguments plus options and returns a remover:
```ts
const stop = client.subscribe(diffWatch, { cwd, from: "HEAD" }, onChanges, { signal });
await stop.ready; // the daemon accepted the subscription
stop(); // or await stop.release()
```
RPCs and subscriptions share one name space; registering the same name for both fails the plugin
load.
### Handler lifetime
| The handler... | Result |
| --------------------------------------------- | ------------------------------------------------------------ |
| Throws synchronously, or the input is invalid | The subscribe call fails with the error. No events are sent. |
| Returns nothing | The subscription stays open until the client releases it. |
| Returns a promise that resolves | The subscription closes. |
| Returns a promise that rejects | The subscription closes with the error. |
`signal` aborts when the client releases the subscription, unmounts the hook, changes its input,
disconnects, or the plugin stops. Pushes after the abort are dropped. `push` validates the event
against the contract and resolves once the event has left the plugin process; await it in a loop
to pace a producer.
Each subscriber runs its own handler. Paseo does not share one handler between clients and does
not replay missed events: after a reconnect or a plugin reload, mounted surfaces subscribe again and
the handler starts from the beginning. Share an
expensive watcher inside your plugin when several subscribers need it.
`useSubscription` returns a union on `status`:
| Status | Meaning |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `connecting` | Sent; the daemon has not accepted it yet. |
| `live` | The handler is running. Events arrive through `onEvent`. |
| `closed` | The handler finished. |
| `error` | The subscribe failed, the handler failed, an event failed the contract, or the plugin stopped. |
The hook subscribes again when `input` changes by shallow equality. `onEvent` is always the latest
render's callback, so an inline function does not resubscribe. `client.subscribe` takes
`{ signal, onClosed }`; `onClosed` receives `{ error? }`.
### Bytes
A top-level `Uint8Array` crosses the wire as binary, without JSON or base64. Everything else is
JSON. The rule applies to subscription events, RPC outputs, and RPC inputs. A Node `Buffer` is a
`Uint8Array`.
```ts
export const thumbnail = defineRpc({
name: "thumbnail.render",
input: z.object({ path: z.string(), width: z.number() }),
output: z.instanceof(Uint8Array),
});
// index.server.ts
server.handle(thumbnail, ({ path, width }) => renderPng(path, width));
// client
const renderThumbnail = useRpc(thumbnail);
const png = await renderThumbnail({ path, width }); // Uint8Array
```
Declare bytes with `z.instanceof(Uint8Array)`. An event can be bytes or JSON per push: declare a
union and branch with `event instanceof Uint8Array`. A `Uint8Array` nested in an object fails
validation on the sending side with an error that names the rule; send the bytes as their own
event or RPC value and the metadata as a separate JSON event.
One event or RPC value carries at most 100 MiB less 65 KiB of framing. Push large payloads in
chunks of a few hundred KiB; each `push` waits for the previous one to leave the plugin process.
Uploads from a phone have a lower limit: Android closes the connection when one send exceeds
16 MiB, so split a larger upload into several RPCs.
### Several streams on one subscription
Put a header byte in front of each payload to multiplex streams. Keep the layout in `shared/`, so
the handler and the surface call named functions:
```ts
// shared/media-frames.ts
export const MediaOpcode = { Video: 0x01, Audio: 0x02 } as const;
export function encodeMediaFrame(opcode: number, payload: Uint8Array): Uint8Array {
const bytes = new Uint8Array(1 + payload.byteLength);
bytes[0] = opcode;
bytes.set(payload, 1);
return bytes;
}
export function decodeMediaFrame(bytes: Uint8Array) {
return { opcode: bytes[0], payload: bytes.subarray(1) }; // a view, not a copy
}
export const mediaStream = defineSubscription({
name: "media.stream",
input: z.object({ source: z.string() }),
event: z.instanceof(Uint8Array),
});
```
```ts
useSubscription(mediaStream, { source }, (bytes) => {
const frame = decodeMediaFrame(bytes);
if (frame.opcode === MediaOpcode.Audio) audio.enqueue(frame.payload);
else video.draw(frame.payload);
});
```
Events from one subscription arrive in push order, JSON and binary alike.
`plugin-examples/media-stream` streams video and audio this way and uploads a frame back through
an RPC with a `Uint8Array` input; `plugin-examples/diff-watch` watches a working tree.
### Older hosts
Subscriptions and binary RPC values need a host that advertises them. Against an older host,
`useSubscription` reports `error` asking to update the host, `client.subscribe` rejects `ready`
with the same message, and an RPC with a `Uint8Array` input or output fails. Raise your plugin's
`requirements.paseo` to the release that ships subscriptions.
## Debug backend output
Backend contributions can write to stdout and stderr with normal Node logging:
+150 -10
View File
@@ -73,10 +73,10 @@ my-plugin/
| 字段 | 是否必填 | 行为 |
| -------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `id` | 是 | 默认的安装 ID。 |
| `name` | 否 | 注册表和网站使用的非空显示名称;与安装 ID 无关。 |
| `name` | 否 | 注册表、网站、**设置 → 插件** 中的插件行以及插件设置页标题使用的非空显示名称。没有 `name` 时显示插件 ID。 |
| `icon` | 否 | 插件包内 PNG 文件的相对路径。 |
| `media` | 否 | 包内图片或视频路径的数组,或 HTTPS URL。空数组是有效的。 |
| `description` | 否 | 在 **设置 → 插件** 中显示在插件 ID 下方的非空摘要。 |
| `description` | 否 | 在 **设置 → 插件** 中显示在插件名称下方的非空摘要。 |
| `translations` | 否 | 其他语言的展示文本,由注册表读取,Paseo 会忽略它。见[其他语言](/docs/plugins/publishing#other-languages)。 |
| `requirements` | 否 | 支持的 Paseo 版本,见下文。 |
| `build` | 否 | 准备命令,见 CLI 参考。 |
@@ -510,6 +510,10 @@ export default function contribute(client: PluginClientContext) {
使用 `server.addHook(name, handler)` 注册异步处理函数。请参阅[钩子](/docs/plugins/hooks),了解命名、载荷、顺序、错误处理和完整示例。
## 侧边栏视图 {#sidebar-views}
`addSidebarView({ id, title, description?, Component })` 注册可替换侧边栏内容区域的视图,并返回清理函数。用户在设置的侧边栏页面中选择视图;注册不会激活它。视图属性、基于引用的查询与操作、设备状态和主题令牌见[构建侧边栏视图](sidebar-views.md)。
## 界面和侧边栏项 {#screens-and-sidebar-items}
界面是一整页的插件 UI。侧边栏项是 Paseo 在侧边栏顶部或底部渲染的组件;按下它时做什么由它决定。先注册界面,再注册一个打开它的项:
@@ -1087,7 +1091,7 @@ export default function contribute(client: PluginClientContext) {
## 设置界面 {#settings-screens}
在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。它会出现在该主机的 **设置 → 插件 → 你的插件** 下。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。
在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。之后,该主机 **设置 → 插件** 中你的插件行会显示一个设置按钮(齿轮),点击后打开你的第一个界面。注册多个界面时,设置页面会按注册顺序为每个界面显示一个标签页,使用其 `title` 和 `icon`;只有一个界面时不显示标签页。页面标题显示 manifest 中的 `name`;没有 `name` 时显示插件 ID,且从不包含界面标题。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。
调用 `client.openSettings(id)` 或 Command Center 回调中的 `openSettings(id)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。
@@ -1157,13 +1161,13 @@ export default function contribute(server: PluginServerContext) {
}
```
| 定义字段 | 契约 |
| ------------------------------ | --------------------------------------------------------------------------------------------- |
| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 |
| `scope` | 必须为 `"host"`。该主机所有获得授权的客户端共享这份文档。没有按用户、设备本地或跨主机的同步。 |
| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 |
| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 |
| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 |
| 定义字段 | 契约 |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 |
| `scope` | 必填 `"host"` 或 `"device"`。主机文档由授权客户端共享。设备文档以插件安装和设置 ID 为键保存在当前设备上,用于布局偏好。 |
| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 |
| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 |
| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 |
在任何贡献的组件中调用 `useSettings(preferences)`。它返回一个可区分的状态:
@@ -1779,6 +1783,8 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
如果要运行用户不需要看到的辅助任务,例如分类或摘要,请在已有工作区中把它创建为[后台 Agent](/docs/sdk/agents#run-a-helper-as-a-background-agent)。
### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host}
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
@@ -1924,6 +1930,140 @@ export default function contribute(server: PluginServerContext) {
后端处理函数以 `{ paseo }` 的形式收到同样的 `PaseoApi`。它们的连接属于子进程,并在插件停止时关闭。在插件代码订阅之前,它不会订阅时间线或目录事件。清理和时间线替换请遵循 [SDK 事件契约](/docs/sdk/events)。后端代码可以使用 Node API 以及安装在插件目录中的依赖。
## 从 daemon 推送事件 {#subscriptions}
订阅会在一个客户端监听期间运行 daemon 处理函数,并向该客户端发送带类型的事件。监听文件、追踪日志和实时媒体使用订阅;只需要一次应答时使用插件 RPC。
在 `shared/` 中定义契约:
```ts
// shared/diff.ts
import { defineSubscription } from "@getpaseo/plugin";
import { z } from "zod";
export const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ cwd: z.string(), from: z.string() }),
event: z.object({ type: z.literal("changes"), files: z.array(z.string()) }),
});
```
在 `index.server.ts` 中处理它。处理函数除了 `paseo`,还会收到 `push` 和一个中止 `signal`:
```ts
server.handle(diffWatch, ({ cwd, from }, { push, signal }) => {
const watcher = watchChanges(cwd, from, (files) => void push({ type: "changes", files }));
signal.addEventListener("abort", () => watcher.close(), { once: true });
});
```
在界面中用 `useSubscription(contract, input, onEvent)` 订阅:
```tsx
const [files, setFiles] = useState<string[]>([]);
const subscription = useSubscription(diffWatch, { cwd, from: "HEAD" }, (event) => {
setFiles(event.files);
});
if (subscription.status === "error") return <Text>{subscription.error.message}</Text>;
```
在 React 之外,`client.subscribe` 接受同样的参数和一个选项对象,并返回一个移除函数:
```ts
const stop = client.subscribe(diffWatch, { cwd, from: "HEAD" }, onChanges, { signal });
await stop.ready; // the daemon accepted the subscription
stop(); // or await stop.release()
```
RPC 和订阅共用一个名称空间;同一个名称同时注册为 RPC 和订阅会导致插件加载失败。
### 处理函数的生命周期 {#handler-lifetime}
| 处理函数…… | 结果 |
| ------------------------ | -------------------------------------------- |
| 同步抛出错误,或输入无效 | 订阅调用失败并返回该错误,不会发送任何事件。 |
| 不返回任何值 | 订阅保持打开,直到客户端释放它。 |
| 返回一个兑现的 Promise | 订阅关闭。 |
| 返回一个拒绝的 Promise | 订阅关闭并带上该错误。 |
当客户端释放订阅、卸载 hook、更改输入、断开连接,或者插件停止时,`signal` 会中止。中止之后的推送会被丢弃。`push` 会按契约校验事件,并在事件离开插件进程后兑现;在循环中 await 它可以控制生产者的速度。
每个订阅者运行各自的处理函数。Paseo 不会在多个客户端之间共享同一个处理函数,也不会重放错过的事件:重新连接或重新加载插件之后,已挂载的界面会重新订阅,处理函数从头开始运行。多个订阅者需要同一个昂贵的监听器时,请在插件内部共享它。
`useSubscription` 返回一个以 `status` 区分的联合类型:
| 状态 | 含义 |
| ------------ | -------------------------------------------------------- |
| `connecting` | 已发送,daemon 尚未接受。 |
| `live` | 处理函数正在运行,事件通过 `onEvent` 送达。 |
| `closed` | 处理函数已结束。 |
| `error` | 订阅失败、处理函数失败、事件不符合契约,或者插件已停止。 |
`input` 按浅比较发生变化时,hook 会重新订阅。`onEvent` 始终是最近一次渲染的回调,所以内联函数不会导致重新订阅。`client.subscribe` 接受 `{ signal, onClosed }`;`onClosed` 会收到 `{ error? }`。
### 字节 {#bytes}
顶层的 `Uint8Array` 以二进制形式传输,不经过 JSON 或 base64,其他值都是 JSON。这条规则适用于订阅事件、RPC 输出和 RPC 输入。Node 的 `Buffer` 也是 `Uint8Array`。
```ts
export const thumbnail = defineRpc({
name: "thumbnail.render",
input: z.object({ path: z.string(), width: z.number() }),
output: z.instanceof(Uint8Array),
});
// index.server.ts
server.handle(thumbnail, ({ path, width }) => renderPng(path, width));
// client
const renderThumbnail = useRpc(thumbnail);
const png = await renderThumbnail({ path, width }); // Uint8Array
```
用 `z.instanceof(Uint8Array)` 声明字节。事件可以在每次推送时选择字节或 JSON:声明一个联合类型,并用 `event instanceof Uint8Array` 分支。嵌套在对象中的 `Uint8Array` 会在发送端校验失败,错误信息会说明这条规则;把字节作为单独的事件或 RPC 值发送,元数据作为另一个 JSON 事件发送。
一个事件或 RPC 值最多 100 MiB(减去 65 KiB 的帧头)。大载荷请按几百 KiB 分块推送;每次 `push` 都会等待上一次离开插件进程。手机上传的上限更低:一次发送超过 16 MiB 时 Android 会关闭连接,所以较大的上传请拆成多个 RPC。
### 在一个订阅中传输多条流 {#several-streams-on-one-subscription}
在每个载荷前加一个头字节即可复用多条流。把布局放在 `shared/` 中,让处理函数和界面调用有名字的函数:
```ts
// shared/media-frames.ts
export const MediaOpcode = { Video: 0x01, Audio: 0x02 } as const;
export function encodeMediaFrame(opcode: number, payload: Uint8Array): Uint8Array {
const bytes = new Uint8Array(1 + payload.byteLength);
bytes[0] = opcode;
bytes.set(payload, 1);
return bytes;
}
export function decodeMediaFrame(bytes: Uint8Array) {
return { opcode: bytes[0], payload: bytes.subarray(1) }; // a view, not a copy
}
export const mediaStream = defineSubscription({
name: "media.stream",
input: z.object({ source: z.string() }),
event: z.instanceof(Uint8Array),
});
```
```ts
useSubscription(mediaStream, { source }, (bytes) => {
const frame = decodeMediaFrame(bytes);
if (frame.opcode === MediaOpcode.Audio) audio.enqueue(frame.payload);
else video.draw(frame.payload);
});
```
同一个订阅的事件按推送顺序到达,JSON 和二进制都一样。`plugin-examples/media-stream` 用这种方式传输视频和音频,并通过一个输入为 `Uint8Array` 的 RPC 把一帧上传回去;`plugin-examples/diff-watch` 监听工作树。
### 旧版主机 {#older-hosts}
订阅和二进制 RPC 值需要主机声明支持。连接旧版主机时,`useSubscription` 会报告 `error` 并提示更新主机,`client.subscribe` 的 `ready` 会以同样的信息拒绝,带有 `Uint8Array` 输入或输出的 RPC 会失败。请把插件的 `requirements.paseo` 提高到包含订阅功能的版本。
## 调试后端输出 {#debug-backend-output}
后端贡献可以用普通的 Node 日志写入 stdout 和 stderr:
+177
View File
@@ -0,0 +1,177 @@
# Build a sidebar view
A sidebar view replaces the content between Paseo's header and footer. You receive theme tokens,
active entity refs and navigation methods, then render your own React Native UI. Install the
[sidebar-views example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/sidebar-views)
to try projects, task-based agents, nested agents and label folders.
## Register a view
```tsx
import type {
PluginClientContext,
PluginSidebarViewProps,
WorkspaceRef,
} from "@getpaseo/plugin/client";
import { useWorkspaces, useWorkspace } from "@getpaseo/plugin/client";
import { Pressable, ScrollView, Text } from "react-native";
function WorkspaceRow({
reference,
navigation,
theme,
}: {
reference: WorkspaceRef;
navigation: PluginSidebarViewProps["navigation"];
theme: PluginSidebarViewProps["theme"];
}) {
const workspace = useWorkspace(reference);
if (!workspace) return null;
return (
<Pressable onPress={() => navigation.openWorkspace(reference)}>
<Text style={{ color: theme.colors.foreground }}>{workspace.title ?? workspace.name}</Text>
</Pressable>
);
}
function Workspaces({ navigation, theme }: PluginSidebarViewProps) {
const workspaces = useWorkspaces({ orderBy: "name" });
return (
<ScrollView>
{workspaces.map((reference) => (
<WorkspaceRow
key={reference.key}
reference={reference}
navigation={navigation}
theme={theme}
/>
))}
</ScrollView>
);
}
export default function contribute(client: PluginClientContext) {
return client.addSidebarView({ id: "workspaces", title: "My workspaces", Component: Workspaces });
}
```
Registration adds an option under **Settings > Sidebar > Sidebar view**. The user selects it;
installing the plugin leaves the current view selected. Header navigation, plugin header items,
the callout and footer stay visible. A missing or disabled view falls back to Default view.
A view that throws shows a placeholder with **Use default view**.
The view renders once across connected hosts. `active.workspace` and `active.agent` are refs or
`null`. Use `navigation.openWorkspace`, `openAgent`, `openTerminal` or `openProject` with a ref;
`openProject` starts the new workspace flow. Navigation closes the compact sidebar. Call
`dismiss()` after navigation you implement yourself.
## Query refs, read each row
`useWorkspaces`, `useAgents`, `useProjects` and `useTerminals` return immutable arrays of refs.
A ref has `kind`, `hostId`, `id` and a cross-host `key`. The same entity keeps the same frozen
ref for the app session. Pass it to `useWorkspace`, `useAgent`, `useProject` or `useTerminal`
inside the row. A deleted entity returns `null`.
```tsx
const projects = useProjects({ orderBy: "displayName" }); // Includes empty projects.
const agents = useAgents({
hosts: [hostId],
where: (agent) => agent.workspace.key === workspaceRef.key,
orderBy: ["lastActivityAt", "desc"],
});
const folders = useWorkspaces({
groupBy: (workspace) => workspace.labels, // A workspace can appear in several folders.
});
```
Every query accepts `hosts` (server IDs), `where`, `orderBy` and `groupBy`. Omit `hosts` to query
every connected host. `orderBy` accepts a snapshot field, `[field, "asc" | "desc"]`, or a
comparator. Null fields sort last. Keep comparators cheap; they run when data changes.
`groupBy` returns a string, `{ key, title?, color? }`, or an array of these. Grouped results have
`key`, `title`, `color` and `items`. Groups follow first appearance after sorting; duplicate keys
use the first title. An empty array puts an item in no group.
An unchanged ref sequence keeps its array identity. Groups keep their identity when their
metadata and item sequence stay the same. A workspace status change updates that row without
rendering the list again. A grouping change updates the affected groups. Inline query objects
and functions work without managing subscriptions yourself.
Entity hooks accept a selector and optional equality function:
```tsx
const workspace = useWorkspace(ref); // Interned snapshot, compared by identity.
const name = useWorkspace(ref, (item) => ({ title: item.title })); // Shallow equality.
const selected = useWorkspace(ref, selector, equals);
```
Without a selector, custom equality receives snapshots. With a selector, it receives selections.
Existing workspace panels can continue passing a host-local string ID to `useWorkspace` and
`useAgent`. Prefer refs for a view spanning hosts. `useHosts()` retains its existing
`serverId`, `label` and connection `status` fields.
Workspace snapshots include `project`, `pinnedAt`, `labels`, `background`, `branch`,
`lastPromptAt`, `parentAgent` and `createdAt` (null on older hosts). Use `indicator` and the
localized `indicatorLabel` to display the status Paseo shows. `status` remains the raw daemon
status. Agent snapshots include their workspace and parent refs. Project snapshots provide
`displayName`, `rootPath`, `kind` and `iconRevision`.
## List terminals
The terminals example selects a host, lists its terminals, focuses a terminal tab and closes a
terminal through its handle. `useTerminals({ hosts: [hostId] })` requires a host advertising
`terminalCollection`. A terminal reader throws an update-host error for an unsupported queried
host; other entity hooks continue working. Keep host selection outside an error boundary so the
user can select another host. Omit `hosts` to require the capability on every connected host.
Terminal snapshots contain `workspace`, `title`, `isScript` and `createdAt`. A terminal kept alive
by a worker from before timestamp tracking has `createdAt: null`. Script ownership follows
workspace script runtime entries. The host observation follows terminal creation, closure, title
changes and reconnects, and releases when its last plugin subscriber leaves.
## Act on a ref
`usePaseo()` routes ref-based actions to the ref's host:
```tsx
const paseo = usePaseo();
await paseo.workspaces.ref(workspaceRef).setPinned(true);
await paseo.workspaces.ref(workspaceRef).setLabel("Later", { assigned: true });
await paseo.workspaces.ref(workspaceRef).setLabel("Inbox", { assigned: false });
paseo.projects.ref(projectRef).open();
await paseo.projects.ref(projectRef).createWorkspace();
paseo.terminals.ref(terminalRef).focus();
await paseo.terminals.ref(terminalRef).kill();
```
Workspace and agent handles retain their existing methods. Assigning a new label creates it;
assigning an existing label keeps its color. The label-folder example adds the destination
before removing the source, so a failed assignment preserves the original folder. It owns the
move gesture and error display.
## Keep layout choices on the device
Define a [settings document](reference.md#settings-screens) with `scope: "device"` for collapsed
folders or manual order. `useSettings` reads and writes it through the same revision-based API
as host settings. Device documents belong to the installation and settings ID, persist across
app launches, and are shared across that installation's client surfaces. They are not synced
to other devices. Use `scope: "host"` for data clients should share through the daemon.
## Match the app's density
Render with React Native primitives. The SDK exports no sidebar-view rows, sections, lists,
badges or drag helpers. The existing header/footer item kit is a separate surface.
For a dense row use a minimum height of 28, horizontal padding of 12, a 12-point primary label
with 16-point line height and an 11-point secondary label. Use these supplied tokens:
| Row element | Theme token |
| -------------------------------- | ------------------------ |
| Primary label | `colors.foreground` |
| Secondary label, section heading | `colors.foregroundMuted` |
| Selected row fill | `colors.surface2` |
| Separator | `colors.border` |
| Action or focus accent | `colors.accent` |
| Success indicator | `colors.statusSuccess` |
| Needs input indicator | `colors.statusWarning` |
| Failure indicator | `colors.statusDanger` |
Give pressable rows an accessible label and show controls on touch layouts. The examples use
only plugin-owned components and the tokens passed to the view.
+135
View File
@@ -0,0 +1,135 @@
# 构建侧边栏视图 {#build-a-sidebar-view}
侧边栏视图替换 Paseo 页眉与页脚之间的内容。插件接收主题令牌、当前实体引用和导航方法,使用 React Native 绘制自己的界面。安装[侧边栏视图示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/sidebar-views),体验项目列表、按任务分组的 Agent、嵌套 Agent 和标签文件夹。
## 注册视图 {#register-a-view}
```tsx
import type {
PluginClientContext,
PluginSidebarViewProps,
WorkspaceRef,
} from "@getpaseo/plugin/client";
import { useWorkspaces, useWorkspace } from "@getpaseo/plugin/client";
import { Pressable, ScrollView, Text } from "react-native";
function WorkspaceRow({
reference,
navigation,
theme,
}: {
reference: WorkspaceRef;
navigation: PluginSidebarViewProps["navigation"];
theme: PluginSidebarViewProps["theme"];
}) {
const workspace = useWorkspace(reference);
if (!workspace) return null;
return (
<Pressable onPress={() => navigation.openWorkspace(reference)}>
<Text style={{ color: theme.colors.foreground }}>{workspace.title ?? workspace.name}</Text>
</Pressable>
);
}
function Workspaces({ navigation, theme }: PluginSidebarViewProps) {
const workspaces = useWorkspaces({ orderBy: "name" });
return (
<ScrollView>
{workspaces.map((reference) => (
<WorkspaceRow
key={reference.key}
reference={reference}
navigation={navigation}
theme={theme}
/>
))}
</ScrollView>
);
}
export default function contribute(client: PluginClientContext) {
return client.addSidebarView({ id: "workspaces", title: "My workspaces", Component: Workspaces });
}
```
注册后,用户可以在**设置 → 侧边栏 → 侧边栏视图**中选择该视图。安装插件不会切换当前视图。页眉导航、插件页眉项、提示区域和页脚保持可见。所选视图缺失或被禁用时,显示默认视图。视图抛出异常时,显示带有**使用默认视图**按钮的占位界面。
视图同时展示所有已连接主机的数据。`active.workspace` 和 `active.agent` 是引用或 `null`。将引用传给 `navigation.openWorkspace`、`openAgent`、`openTerminal` 或 `openProject`;`openProject` 打开新建工作区流程。导航会关闭紧凑布局的侧边栏。如果插件自行执行导航,完成后调用 `dismiss()`。
## 查询引用,逐行读取 {#query-refs-read-each-row}
`useWorkspaces`、`useAgents`、`useProjects` 和 `useTerminals` 返回不可变的引用数组。引用包含 `kind`、`hostId`、`id` 和跨主机唯一的 `key`。在一次应用会话中,同一实体使用同一个冻结引用。在行组件内将它传给 `useWorkspace`、`useAgent`、`useProject` 或 `useTerminal`。实体删除后返回 `null`。
```tsx
const projects = useProjects({ orderBy: "displayName" }); // Includes empty projects.
const agents = useAgents({
hosts: [hostId],
where: (agent) => agent.workspace.key === workspaceRef.key,
orderBy: ["lastActivityAt", "desc"],
});
const folders = useWorkspaces({
groupBy: (workspace) => workspace.labels, // A workspace can appear in several folders.
});
```
每个查询都支持 `hosts`(服务器 ID)、`where`、`orderBy` 和 `groupBy`。省略 `hosts` 时查询所有已连接主机。`orderBy` 接受快照字段、`[field, "asc" | "desc"]` 或比较函数。空值排在最后。比较函数会在数据变化时执行,应保持开销较小。
`groupBy` 返回字符串、`{ key, title?, color? }` 或这些值组成的数组。分组结果包含 `key`、`title`、`color` 和 `items`。分组按排序后首次出现的顺序排列;重复的键使用第一个标题。返回空数组表示该项不属于任何分组。
引用序列不变时,数组对象保持不变。分组的元数据和项序列不变时,分组对象保持不变。工作区状态变化只更新对应行,不会重新渲染整个列表。分组变化会更新受影响的分组。查询对象和函数可以内联编写,无需自行管理订阅。
实体钩子支持选择器和可选的相等比较函数:
```tsx
const workspace = useWorkspace(ref); // Interned snapshot, compared by identity.
const name = useWorkspace(ref, (item) => ({ title: item.title })); // Shallow equality.
const selected = useWorkspace(ref, selector, equals);
```
没有选择器时,自定义相等比较函数接收快照;有选择器时接收选择结果。现有工作区面板仍可向 `useWorkspace` 和 `useAgent` 传入主机本地的字符串 ID。跨主机视图应使用引用。`useHosts()` 保留现有的 `serverId`、`label` 和连接 `status` 字段。
工作区快照包含 `project`、`pinnedAt`、`labels`、`background`、`branch`、`lastPromptAt`、`parentAgent` 和 `createdAt`(旧主机上为 null)。使用 `indicator` 和本地化的 `indicatorLabel` 显示 Paseo 的状态。`status` 保持 daemon 原始状态。Agent 快照包含工作区和父 Agent 引用。项目快照提供 `displayName`、`rootPath`、`kind` 和 `iconRevision`。
## 列出终端 {#list-terminals}
终端示例支持选择主机、列出终端、聚焦终端标签页,以及通过句柄关闭终端。`useTerminals({ hosts: [hostId] })` 要求主机声明 `terminalCollection` 能力。查询不支持该能力的主机时,终端读取会抛出要求更新主机的错误;其他实体钩子继续工作。将主机选择器放在错误边界之外,方便用户切换主机。省略 `hosts` 时,所有已连接主机都必须支持该能力。
终端快照包含 `workspace`、`title`、`isScript` 和 `createdAt`。在时间戳跟踪引入前启动的 worker 所保留的终端,其 `createdAt` 为 null。脚本归属来自工作区脚本运行时条目。主机观察跟踪终端创建、关闭、标题变化和重新连接,并在最后一个终端读取订阅者离开时释放。
## 通过引用执行操作 {#act-on-a-ref}
`usePaseo()` 将基于引用的操作发送到引用所属的主机:
```tsx
const paseo = usePaseo();
await paseo.workspaces.ref(workspaceRef).setPinned(true);
await paseo.workspaces.ref(workspaceRef).setLabel("Later", { assigned: true });
await paseo.workspaces.ref(workspaceRef).setLabel("Inbox", { assigned: false });
paseo.projects.ref(projectRef).open();
await paseo.projects.ref(projectRef).createWorkspace();
paseo.terminals.ref(terminalRef).focus();
await paseo.terminals.ref(terminalRef).kill();
```
工作区和 Agent 句柄保留现有方法。分配新标签会创建该标签;分配已有标签会保留其颜色。标签文件夹示例先添加目标标签,再移除源标签,因此分配失败时仍保留原文件夹。插件负责移动手势和错误显示。
## 在设备上保存布局选择 {#keep-layout-choices-on-the-device}
定义 `scope: "device"` 的[设置文档](reference.md#settings-screens),保存折叠文件夹或手动排序。`useSettings` 通过与主机设置相同的修订版本 API 读取和写入。设备文档属于插件安装和设置 ID,在应用重启后保留,并由该安装的客户端界面共享。它们不会同步到其他设备。需要由客户端通过 daemon 共享的数据使用 `scope: "host"`。
## 匹配应用的信息密度 {#match-the-apps-density}
使用 React Native 原语绘制界面。SDK 不导出侧边栏视图的行、分区、列表、徽标或拖拽辅助组件。现有页眉和页脚组件包属于独立界面。
紧凑行的最小高度为 28,水平内边距为 12;主标签字号为 12、行高为 16,次标签字号为 11。使用以下主题令牌:
| 行元素 | 主题令牌 |
| ---------------- | ------------------------ |
| 主标签 | `colors.foreground` |
| 次标签、分区标题 | `colors.foregroundMuted` |
| 选中行背景 | `colors.surface2` |
| 分隔线 | `colors.border` |
| 操作或焦点强调色 | `colors.accent` |
| 成功指示 | `colors.statusSuccess` |
| 需要输入指示 | `colors.statusWarning` |
| 失败指示 | `colors.statusDanger` |
为可点击行提供无障碍标签,并在触摸布局中显示操作控件。示例仅使用插件自己的组件和传给视图的主题令牌。