@vetta-org/plugin-sdk 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
1
+ # 对话 / Agent / 命令 / 文件 / 网络 / 存储 / 设置 / i18n API
2
+
3
+ `ctx` 上除 UI 注册外的能力出口,以及配套 React hook。
4
+
5
+ ```ts
6
+ interface PluginContext {
7
+ plugin: { id: string; version: string };
8
+ permissions: { has(p): boolean; require(p): void };
9
+ ui: PluginUiApi; // 见 ui-slots.md / message-cards.md
10
+ conversation: PluginConversationApi;
11
+ agent: PluginAgentApi;
12
+ command: PluginCommandApi; // 见「命令执行」
13
+ fs: PluginFsApi;
14
+ network: PluginNetworkApi;
15
+ storage: PluginStorageApi;
16
+ secrets: PluginSecretsApi;
17
+ i18n: PluginI18nApi; // 见「插件 i18n」
18
+ getAgentMode(): AgentMode; // 当前工作模式,见「工作模式」
19
+ onAgentModeChanged(listener: (mode: AgentMode) => void): Disposable;
20
+ }
21
+ ```
22
+
23
+ > **MCP 不在 `ctx` 上**:插件内聚 MCP 是 **清单声明式**(`agent.mcpServers`),由宿主聚合进会话,见 [mcp.md](./mcp.md)。
24
+
25
+ 所有对话 API 都作用于**当前活动会话**(桌面同时只展示一个);插件不能枚举或持有任意会话句柄。
26
+
27
+ ## 对话:读状态
28
+
29
+ hook 直接从 `@vetta-org/plugin-sdk` import、在组件里调用,读当前活动对话并自动 rerender。需要 `agent.session.read`。
30
+
31
+ ```tsx
32
+ import { useActiveConversation, useConversationMessages } from "@vetta-org/plugin-sdk";
33
+
34
+ function Sidebar() {
35
+ const convo = useActiveConversation();
36
+ // ConversationState: { id, cwd, sessionPath, model, isStreaming }(无会话时各为 null / false)
37
+ const messages = useConversationMessages();
38
+ // ConversationMessage[]: { id, role: "user"|"assistant"|"compaction", text, timestamp? }
39
+ return <div>{convo.isStreaming ? "生成中…" : `${messages.length} 条消息`}</div>;
40
+ }
41
+ ```
42
+
43
+ ## 对话:事件
44
+
45
+ `ctx.conversation.on(listener)` 推实时事件,返回 `Disposable`。需要 `agent.session.read`。调用方可提前 `dispose()`;插件 activation 结束时宿主也会兜底释放仍存活的订阅,避免重载后的旧 listener 继续收到事件。
46
+
47
+ ```ts
48
+ const sub = ctx.conversation.on((event) => {
49
+ switch (event.type) {
50
+ case "turn-start": break;
51
+ case "turn-end": event.stopReason; break; // "stop" | "aborted" 等
52
+ case "message-added": event.message; break; // ConversationMessage
53
+ case "message-updated": event.delta; break; // 流式文本增量
54
+ case "tool-call-start": event.toolCallId; event.toolName; break;
55
+ case "tool-call-end": event.toolCallId; event.toolName; event.isError; break;
56
+ case "conversation-changed": event.conversation; break; // 活动会话切换(ConversationState)
57
+ }
58
+ });
59
+ // 之后:sub.dispose();
60
+ ```
61
+
62
+ ## 对话:驾驶
63
+
64
+ 需要 `agent.session.write`。
65
+
66
+ ```ts
67
+ await ctx.conversation.sendPrompt("总结一下这个 diff"); // 发起一轮用户对话(渲染成用户气泡)
68
+ ctx.conversation.insertText("草稿文本"); // 仅填输入框,不发送,供用户编辑
69
+ await ctx.conversation.abort(); // 中断当前轮
70
+ ```
71
+
72
+ `sendPrompt` 作用于**活动会话**,宿主没有活动会话时(例如用户停在新会话页)会抛错。插件若在这种状态下也要把话说出去,先建一个:
73
+
74
+ ```ts
75
+ const { cwd } = state; // conversation-changed 给的 ConversationState
76
+ if (!cwd) await ctx.conversation.createSession(myWorkspaceCwd); // resolve 时会话已就绪
77
+ await ctx.conversation.sendPrompt("处理画布上新贴的备注");
78
+ ```
79
+
80
+ `createSession(cwd, { navigate })` 建完即设为活动会话,默认跳转到对话页(与用户手动发送的观感一致);后台任务可传 `navigate: false` 留在当前路由。执行模式跟随宿主当前选择。它**不做复用判断**——已有活动会话时照样新建,要不要新开由插件按自己的语义决定。与 `official.sessions.create`(按 sessionId 显式寻址、与当前路由无关的后台编排)的分工是:`createSession` 仍然是「用户当前正在看的那个会话」这条线。
81
+
82
+ ## 注册 Agent 工具
83
+
84
+ `ctx.agent.registerTool` 让插件用 JS 注册一个 **agent 可见的工具**:coding-agent 只看到工具 shell(schema + 描述),实际执行经 IPC 回到你的 renderer handler。需要 `agent.tools.register`(注册)+ `agent.toolHandler.execute`(执行)。返回 `Disposable`。
85
+
86
+ 工具描述应准确说明自身功能、作用对象和真实前提;确有容易混淆的不同资源时,可补充区分说明。多操作工具还应区分查询与修改,例如查询状态不代表要求导入或运行。Skill 的 frontmatter 保持完整的功能描述,不应为了防误调用而缩窄专业能力、要求用户显式点名或已有项目。是否选用由模型结合用户目标和上下文判断;选用后应正常执行完成任务所需的专业流程,但加载 Skill 本身不构成执行无关动作的授权。
87
+
88
+ 工具描述通过模型的工具定义传递,不再在系统提示词中重复列出;通用选择原则由 Coding Agent 提供。App Action 的发现说明另见 [app-actions.md](./app-actions.md#模型选择边界),不能用工具使用说明替代宿主权限校验。
89
+
90
+ ```ts
91
+ interface PluginAgentToolRegistration<TInput = unknown> {
92
+ id: string; // 插件内唯一
93
+ name?: string; // LLM 可见工具名(默认取 id)
94
+ label?: string; // 宿主 UI 展示名(Work 工具头等)。可用 %catalogKey%;不发给模型
95
+ description: string; // 发给模型的工具描述:准确说明功能、作用对象和真实前提
96
+ parameters: object; // JSON Schema(可用 TypeBox 产出)
97
+ scope_use?: string[]; // 允许出现的对话场景(见下)。fail-closed:缺省/空 = 任何场景都不出现
98
+ requires?: string[]; // 需要的会话能力(如 "knowledge"),一般插件无需设置
99
+ timeoutMs?: number;
100
+ context?: { conversation?: "summary" | "messages" }; // 大上下文 opt-in,缺省只传消息数
101
+ handler: (context: PluginAgentHandlerContext<{
102
+ kind: "tool-call";
103
+ timestamp: number;
104
+ toolCallId: string;
105
+ toolId: string;
106
+ toolName: string;
107
+ input: TInput;
108
+ }>) => unknown | Promise<unknown>;
109
+ }
110
+ ```
111
+
112
+ ```ts
113
+ ctx.agent.registerTool({
114
+ id: "word-count",
115
+ description: "统计一段文本的字数。当用户想知道字数时调用。",
116
+ parameters: {
117
+ type: "object",
118
+ properties: { text: { type: "string" } },
119
+ required: ["text"],
120
+ },
121
+ scope_use: ["conversation", "project"],
122
+ handler: async ({ trigger: { input } }: { trigger: { input: { text: string } } }) => {
123
+ return { count: input.text.length };
124
+ },
125
+ });
126
+ ```
127
+
128
+ - handler 的**返回值**会被宿主格式化成工具结果文本回给模型;该工具结果的 `details` 为 `{ pluginId, toolId, result }`(`result` = 你的返回值)。
129
+ - **返回 `cards` 即可产消息卡片**:若返回值含 `cards: CardDescriptor[]`,宿主会把它**提升**到 `details.cards`(消息卡片的 settled 数据源)并从模型可见文本里**剔除**。配合 `ctx.ui.registerCardRenderer` 即可让插件**用自己的工具**在消息下方渲染卡片——见 [message-cards.md](./message-cards.md#第三方插件如何拿到卡片数据)。
130
+ - 插件激活会等待工具 schema 注册完成;注册 / 注销 / 权限或启停变化会刷新空闲的对话 session。
131
+ - handler 需要读插件自己的配置时,直接闭包引用插件内的配置 store(`ctx.storage` / `ctx.secrets`);
132
+ 宿主不再向 handler 注入配置快照(ADR-0105)。
133
+
134
+ ### `scope_use`:按对话场景限定工具出现范围
135
+
136
+ 工具是否暴露给 agent 由 `scope_use` 决定——它和内置工具完全同一套机制。**fail-closed**:不声明 `scope_use`(或给空数组)= 该工具在**任何场景都不出现**。所以注册 agent 工具时**务必显式声明** `scope_use`。
137
+
138
+ 会话场景 slug(7 个):
139
+
140
+ | slug | 场景 |
141
+ |---|---|
142
+ | `conversation` | 普通对话(`~/.vetta/conversation`) |
143
+ | `project` | 普通项目中对话 |
144
+ | `im-claw` | Claw IM 对话(飞书/微信网关) |
145
+ | `batch` | 批量任务 |
146
+ | `automation` | 自动化/定时任务 |
147
+ | `kb-processing` | 知识库加工 |
148
+ | `cli` | 裸 CLI / SDK(fallback) |
149
+
150
+ 要点:
151
+
152
+ - 只声明工具**真正适用**的场景。例如业务查询工具一般给 `["conversation", "project"]`;批量/加工是非交互后台场景,通常不该出现。
153
+ - `scope_use` 只能“减”——它从“宿主已注入的工具”里过滤,不能让工具凭空出现在未注入插件的场景。
154
+ - 输入栏的开关 badge 也会跟随对应工具的 scope:工具在当前场景不出现时,对应 badge 自动隐藏(见 [ui-slots.md](./ui-slots.md#输入栏动作-registerinputaction) 的 `requiresActiveTool`)。
155
+ - `requires` 是另一条正交轴(会话能力,如 `"knowledge"`);与 `scope_use` 取交集才激活。一般插件无需设置。
156
+ - `agent_mode` **已废弃**(ADR-0071):容忍传入但被忽略,工具在任何模式下可用性与顺序一致。要收窄使用场景,写进 description 的反向触发段。见 [工作模式](#工作模式agent_mode)。
157
+
158
+ ## 注册 Coding Agent Hook
159
+
160
+ `ctx.agent.registerHook()` 动态注册 Coding Agent 原生生命周期 Hook。插件与内置 Codex/Claude
161
+ adapter 进入同一个 Session Hook Runtime,使用相同的事件时机、结果聚合和 Stop 安全语义;Desktop
162
+ 不会另建一套工具 Hook。需要同时授权 `agent.hooks.register` 和 `agent.hookHandler.execute`;当前只对
163
+ 所有插件使用同一套动态 Agent handler 合同。
164
+
165
+ ```ts
166
+ const hook = ctx.agent.registerHook({
167
+ id: "protect-destructive-tools",
168
+ eventName: "PreToolUse",
169
+ scope_use: ["conversation", "project"],
170
+ toolNames: ["bash", "write"], // 可选;缺省/空 = 所有工具
171
+ timeoutMs: 3000,
172
+ handler({ event }) {
173
+ const input = event.toolInput;
174
+ if (
175
+ event.tool.hostName === "bash" &&
176
+ typeof input === "object" && input !== null &&
177
+ "command" in input && input.command === "rm -rf /"
178
+ ) {
179
+ return { action: "block", reason: "拒绝执行危险命令" };
180
+ }
181
+ return { action: "continue" };
182
+ },
183
+ });
184
+
185
+ // 动态卸载;插件停用、重载或卸载时宿主也会自动清理。
186
+ hook.dispose();
187
+ ```
188
+
189
+ `eventName` 与 `event` 是判别联合,handler 的返回值也按事件收窄:
190
+
191
+ | 事件 | 主要字段 | 事件专属结果 |
192
+ | --- | --- | --- |
193
+ | `SessionStart` / `SessionEnd` | 会话来源或结束原因 | 通用 continue / block / stop |
194
+ | `UserPromptSubmit` | prompt、turnId | 通用结果,可追加 context / feedback |
195
+ | `PreToolUse` | tool、toolInput、toolUseId | 可返回 `updatedToolInput` |
196
+ | `PermissionRequest` | tool、toolInput、runIdSuffix | 可返回 allow / deny 决策与消息 |
197
+ | `PostToolUse` / `PostToolUseFailure` | 工具响应或错误 | 通用结果 |
198
+ | `PreCompact` / `PostCompact` | manual / auto trigger | 通用结果 |
199
+ | `SubagentStart` / `SubagentStop` | agentId、agentType、停止信息 | `SubagentStop` 可请求继续 Agent |
200
+ | `Stop` | 最后回复与 Stop 状态 | 可返回 `continue-agent` 与续跑片段 |
201
+
202
+ 通用结果包括 `continue`、`block` 和 `stop`;`continue` 可附加 `additionalContexts` 或
203
+ `feedbackMessage`。事件完整字段以 `PluginCodingAgentHookEvent` 类型为准。宿主内部的 transcript 路径
204
+ 不会下发给插件。
205
+
206
+ - `scope_use` 必填且 fail-closed;`toolNames` 是额外过滤轴。`agent_mode` 已废弃(ADR-0071):容忍传入但被忽略,Hook 在所有工作模式下都按 `scope_use` 与 matcher 触发。
207
+ - 单次事件 dispatch 使用注册表快照;并发注册或注销只影响下一次 dispatch。
208
+ - handler 默认 3 秒超时,Desktop 最多接受 30 秒。异常、超时或非法返回值会记录诊断并 fail-open;只有通过事件专属校验的显式结果会改变 Agent 行为。
209
+ - Hook 不能扩大宿主工具权限,也不能绕过 execution mode / sandbox / confirmation。
210
+ - handler 可使用受权限约束的 `host`;返回值会在 Main 进程首次进入 Hook 领域边界时做结构校验。
211
+
212
+ ## 注册动态系统提示词 Provider
213
+
214
+ `ctx.agent.registerSystemPromptProvider()` 注册一个 TypeScript handler,在每个 **Agent Turn 开始、`before_agent_start` 扩展执行前**求值。它适合按插件设置、模型、会话场景、当前消息或工具状态动态生成和修改提示词。注册需要 `agent.systemPrompt.write`;修改非本插件 block 还需要 `agent.systemPrompt.fullControl`;返回 `setToolEnabled` 或调用 `actions.tools.*` 还需要 `agent.tools.control`;请求续跑还需要 `agent.continuation.register`。
215
+
216
+ ### Turn 与 effect 的生效边界
217
+
218
+ Turn 是宿主为一次任务推进建立的执行边界,其中可以包含多次模型调用和工具调用。动态
219
+ Provider 与 handler effect 遵循以下时序合同:
220
+
221
+ - System Prompt Provider 在一个 Turn 内只执行一次,不会在该 Turn 的每次模型调用前重新执行;
222
+ 它产生的 Prompt/Tool effects 会在同一 Turn 的后续模型调用中重放。
223
+ - 工具 handler 调用 `actions.*` 产生的 effects,在 handler **成功返回后**提交,从当前 Turn
224
+ 的下一次模型调用开始生效;handler 抛错、超时或返回非法结果时不提交这些 effects。
225
+ - 同一 Turn 内对同一个 Prompt block 或工具连续操作时按提交顺序应用,后提交的操作覆盖先前状态。
226
+ 因此工具 handler 可以用 `actions.tools.enable()` 覆盖 Turn 开始时 Provider 写入的 disable。
227
+ - 这些 effects 是 Turn-local 状态,不会作为全局配置持久化。下一个 Turn 会从新接纳的运行时快照
228
+ 开始,并重新执行 Provider。
229
+ - 修改插件自己的内存缓存、文件或设置不会让 Provider 在当前 Turn 重新求值。如果一个工具改变了
230
+ Provider 的判定条件,并要求 Agent 在同一 Turn 继续使用受控工具,该工具必须同时通过
231
+ `actions.tools.*` 写入当前 Turn;仅更新判定条件只能影响下一个 Turn。
232
+
233
+ 下面是通用的“初始化前隐藏后续工具、初始化成功后同轮放行”模式:
234
+
235
+ ```ts
236
+ const DOMAIN_TOOLS = ["domain_inspect", "domain_publish"] as const;
237
+
238
+ ctx.agent.registerSystemPromptProvider({
239
+ id: "domain-tool-gate",
240
+ async handler({ session }) {
241
+ const ready = await hasDomainState(session.cwd);
242
+ return DOMAIN_TOOLS.map((toolName) => ({
243
+ type: "setToolEnabled" as const,
244
+ toolName,
245
+ enabled: ready,
246
+ }));
247
+ },
248
+ });
249
+
250
+ ctx.agent.registerTool({
251
+ id: "domain-initialize",
252
+ name: "domain_initialize",
253
+ description: "Initialize the domain state required by the follow-up tools.",
254
+ parameters: { type: "object", additionalProperties: false },
255
+ scope_use: ["project", "conversation"],
256
+ async handler({ session, actions }) {
257
+ await initializeDomainState(session.cwd);
258
+ for (const toolName of DOMAIN_TOOLS) actions.tools.enable(toolName);
259
+ return { ok: true };
260
+ },
261
+ });
262
+ ```
263
+
264
+ 如果省略 handler 中的 `actions.tools.enable()`,即使初始化已经改变了 Provider 下一次读取的状态,
265
+ 当前 Turn 也不会重新执行 Provider;这些后续工具要到下一个 Turn 才会出现。
266
+
267
+ ```ts
268
+ const domainSettings = new DomainSettingsStore(ctx.storage);
269
+
270
+ ctx.agent.registerSystemPromptProvider({
271
+ id: "domain-guidance",
272
+ timeoutMs: 3000,
273
+ context: { systemPrompt: "full", conversation: "messages" }, // 大上下文 opt-in
274
+ handler(context) {
275
+ const { plugin, session, model, conversation, runtime, trigger, systemPrompt, actions, host } = context;
276
+ return [{
277
+ type: "addBlock",
278
+ block: {
279
+ id: `plugin.${plugin.id}.domain-guidance`,
280
+ content: [
281
+ domainSettings.current().instructions,
282
+ `scenario=${session.scenario}`,
283
+ `model=${model.provider}/${model.id}`,
284
+ `messages=${conversation.messageCount}`,
285
+ `tools=${runtime.activeToolNames.join(",")}`,
286
+ `run=${runtime.runIndex}@${trigger.timestamp}`,
287
+ ].join("\n"),
288
+ priority: 850,
289
+ },
290
+ }];
291
+ },
292
+ });
293
+ ```
294
+
295
+ handler 上下文按稳定职责分组。插件配置不随 handler 上下文复制;在 `activate()` 中创建基于
296
+ `ctx.storage` / `ctx.secrets` 的配置 store,再由 handler 闭包读取:
297
+
298
+ - `plugin`:插件 id、当前 contribution id。
299
+ - `session`:session id、cwd、对话场景。
300
+ - `model`:provider、model id、API、输入能力、context window、最大输出 token。
301
+ - `conversation`:本次调用实际使用的消息快照和消息数(通过 `registration.context.conversation` 控制传 summary 还是 messages)。
302
+ - `runtime`:当前激活/可用工具名、当前 session 的 Agent run 序号。
303
+ - `trigger`:触发类型和时间戳。
304
+ - `systemPrompt`:**可选**。当前 system prompt 快照(base/current 的 blocks 和 rendered 文本)。通过 `registration.context.systemPrompt` 控制传入粒度:`"none"`(缺省,无此字段)、`"blocks"`、`"rendered"`、`"full"`。
305
+ - `actions`:**副作用操作集**,调用后累积到 handler 返回值一并提交,见下表。
306
+ - `host`:宿主简化版 API(`{ fs, conversation }`)。
307
+
308
+ `registration.context` 控制宿主以什么粒度序列化上下文发过来:
309
+
310
+ ```ts
311
+ {
312
+ systemPrompt?: "none" | "blocks" | "rendered" | "full"; // 缺省 "none"
313
+ conversation?: "summary" | "messages"; // 缺省 "summary"
314
+ }
315
+ ```
316
+
317
+ ### 副作用操作集(`actions`)
318
+
319
+ 所有 handler 均可调用,操作暂存到 effects 数组,handler 返回后宿主统一处理:
320
+
321
+ | 方法 | 效果 |
322
+ |------|------|
323
+ | `actions.systemPrompt.addBlock(block)` | 新增 system prompt 块 |
324
+ | `actions.systemPrompt.replaceBlock(id, block)` | 替换块内容 |
325
+ | `actions.systemPrompt.updateBlock(id, patch)` | 更新块的部分字段 |
326
+ | `actions.systemPrompt.removeBlock(id)` | 删除块 |
327
+ | `actions.systemPrompt.setBlockEnabled(id, enabled)` | 开关块 |
328
+ | `actions.tools.setEnabled(name, enabled)` | 开关工具 |
329
+ | `actions.tools.enable(name)` | 启用工具 |
330
+ | `actions.tools.disable(name)` | 禁用工具 |
331
+ | `actions.continuation.request(result)` | 请求续跑(下一轮注入用户消息) |
332
+
333
+ 返回 operation 按数组顺序执行,支持 `addBlock`、`replaceBlock`、`updateBlock`、`removeBlock`、`setBlockEnabled`、`setToolEnabled`、`requestContinuation`。`write` 权限只能操作 `plugin.<本插件 id>.*`;工具开关与续跑操作还会分别校验 `agent.tools.control` 和 `agent.continuation.register`。宿主会校验所有返回值并补齐可信的 block source。handler 异常或超时只跳过该 provider,不阻止模型调用。
334
+
335
+ ## 注册 Agent 自动续跑策略
336
+
337
+ `ctx.agent.registerContinuationProvider()` 注册一个在 Agent 到达自然停止点时执行的策略。它与
338
+ `conversation.sendPrompt()` 不同:不会立即发起新对话,而是在当前 Agent Loop 没有更多工具、
339
+ steering 或 Todo continuation 后,决定是否注入一条用户消息继续下一轮。需要
340
+ `agent.continuation.register`。
341
+
342
+ ```ts
343
+ ctx.agent.registerContinuationProvider({
344
+ id: "workflow-next-step",
345
+ timeoutMs: 3000,
346
+ context?: { conversation?: "summary" | "messages" }; // 大上下文 opt-in
347
+ async handler({ session, plugin, actions }) {
348
+ const task = findPendingTask(session.id);
349
+ if (!task) return null; // 允许 Agent 正常结束
350
+ return {
351
+ text: `继续处理工作流任务:${task.description}`,
352
+ idempotencyKey: task.id,
353
+ };
354
+ },
355
+ });
356
+ ```
357
+
358
+ - Todo continuation 优先;只有 Todo 不要求继续时才检查插件策略。
359
+ - 多个策略按插件 id 和 provider id 稳定排序,每个停止点最多采用一个结果。
360
+ - `idempotencyKey` 在会话内去重,避免同一任务被重复注入。
361
+ - handler 默认 3 秒超时;异常、超时、空文本都按“无需继续”处理,不阻止 Agent 正常结束。
362
+ - 单次 Agent run 最多接受 8 次插件 continuation,防止插件造成无限循环。
363
+ - 返回的 `Disposable`、插件停用、重载或卸载都会注销该策略。
364
+
365
+ ## 命令执行 command
366
+
367
+ `ctx.command.run` 在**宿主主进程**用 `execFile` 跑命令(**不走 shell**,参数数组传递,无注入)。需:
368
+
369
+ 1. 权限 `agent.command.run`
370
+ 2. `plugin.json` 的 `commands` 声明该二进制名
371
+ 3. 用户未在设置里关闭该命令
372
+
373
+ ```ts
374
+ interface PluginCommandApi {
375
+ run(
376
+ file: string,
377
+ args?: string[],
378
+ options?: { cwd?: string; env?: Record<string, string>; timeoutMs?: number },
379
+ ): Promise<{ stdout: string; stderr: string; exitCode: number | null }>;
380
+ }
381
+ ```
382
+
383
+ ```ts
384
+ const { stdout, exitCode } = await ctx.command.run("git", ["status", "--porcelain"], {
385
+ cwd: projectRoot,
386
+ timeoutMs: 30_000, // 宿主会 clamp(当前上限约 120s)
387
+ });
388
+ ```
389
+
390
+ - 未声明 / 用户关闭 / 无权限:拒绝(关闭时宿主会通知用户)。
391
+ - 非零 exit:**resolve** 并带 `exitCode`,不 throw(spawn 失败才 reject)。
392
+ - 粒度 = **可执行文件名**(`git` 的所有子命令共用一条声明)。见 ADR-0032、[manifest commands](./manifest.md#commands)。
393
+
394
+ 示例:`packages/plugins/presets/git`。
395
+
396
+ ## 长驻进程 command.spawn
397
+
398
+ `ctx.command.spawn` 启动**长驻**进程(如本地 dev server,ADR-0054)。治理与 `run` 同模式:清单 `commands` 声明二进制 + 用户可关;但权限是独立的 `agent.command.spawn`。
399
+
400
+ ```ts
401
+ interface PluginCommandApi {
402
+ spawn(
403
+ file: string,
404
+ args?: string[],
405
+ options?: {
406
+ cwd?: string;
407
+ env?: Record<string, string>;
408
+ /** 宿主分配空闲端口,并替换 args/env 值中的字面量 `{{PORT}}`。 */
409
+ allocatePort?: boolean;
410
+ },
411
+ ): Promise<PluginCommandSpawnHandle>;
412
+ }
413
+
414
+ interface PluginCommandSpawnHandle {
415
+ spawnId: string;
416
+ pid: number;
417
+ port?: number; // allocatePort 时有值
418
+ stop(): Promise<void>; // SIGTERM 进程树,宽限后 SIGKILL;幂等
419
+ status(): Promise<{ running: boolean; pid: number; port?: number; exit?: { exitCode: number | null; signal: string | null }; recentOutput: string }>;
420
+ onExit(listener: (exit: { exitCode: number | null; signal: string | null }) => void): Disposable;
421
+ }
422
+ ```
423
+
424
+ - 子进程运行在**独立进程组**:`stop()` 杀整棵树(vite 的 esbuild 子进程等不会残留)。
425
+ - 宿主兜底回收:插件禁用/卸载/重载、App 退出时统一清扫;每插件并发 spawn 上限 8。
426
+ - `recentOutput` 是 stdout+stderr 合并环形缓冲(约 64KB 尾部),用于诊断/进度。
427
+ - 端口竞争极小概率存在:配合 `--strictPort` 类参数,启动失败(onExit)后重试一次即可。
428
+
429
+ 示例:`packages/plugins/presets/vetta-ui-design`(设计引擎 vite dev server 与 `npm install`)。
430
+
431
+ ## 离屏截图 capture.offscreen
432
+
433
+ `ctx.capture.offscreen` 让宿主用**主进程隐藏窗口**加载并截取一个 http(s) 页面(权限 `capture.offscreen`)。与 html-to-image 等 DOM 克隆方案不同,它走真实 Chromium 渲染管线,产出与页面在屏显示逐像素一致的位图(无克隆重排的断行/亚像素偏差),且完全不占插件所在渲染进程的主线程。旧宿主上 `ctx.capture` 为 `undefined`,使用前判空。
434
+
435
+ ```ts
436
+ interface PluginCaptureApi {
437
+ offscreen(options: {
438
+ url: string; // 仅 http(s)
439
+ width: number; // 视口 CSS 尺寸
440
+ height: number;
441
+ sessionKey?: string; // 同 key 串行复用同一窗口;url 未变则跳过重新加载(SPA 切路由零加载)
442
+ prepareScript?: string; // 加载/复用后注入执行(如 postMessage 切路由)
443
+ readyExpression?: string; // 轮询到真值才截(页面自己的「渲染完成」信号)
444
+ settleMs?: number; // 就绪后静置(上限 5s)
445
+ timeoutMs?: number; // 整体超时(上限 60s)
446
+ format?: "jpeg" | "png";
447
+ quality?: number; // 仅 jpeg,0–1
448
+ }): Promise<{ dataUrl: string; scaleFactor: number }>;
449
+ releaseOffscreen(sessionKey: string): Promise<void>; // 幂等;下次同 key 重新加载
450
+ }
451
+ ```
452
+
453
+ - `scaleFactor` 是实际设备像素比(跟随主显示器,Retina 为 2),插件不可指定。
454
+ - 会话窗口闲置约 30s 自动回收;插件禁用/卸载/重载、App 退出时统一清扫;每插件并存会话上限 4。
455
+ - `readyExpression` 求值抛错按「未就绪」处理并继续轮询,直到超时。
456
+
457
+ 示例:`packages/plugins/presets/vetta-ui-design` 的画布位图队列(`src/canvas/offscreen-raster.ts`):一个引擎 dev server 复用一个会话,`prepareScript` 发 `show-frame` 切帧,`readyExpression` 轮询引擎写入的 `window.__vetdPainted`。
458
+
459
+ ## 文件 API
460
+
461
+ `ctx.fs` 受权限门控读写文件(`fs.read` / `fs.write`,缺权限**抛错**)。
462
+
463
+ ```ts
464
+ interface PluginFsApi {
465
+ readDir(dirPath): Promise<PluginFsEntry[]>; // fs.read
466
+ readFile(filePath): Promise<{ content: string; encoding: "utf8" | "base64" }>; // fs.read
467
+ readBinaryFile(filePath): Promise<{ data: string; mimeType: string; size: number }>; // fs.read
468
+ writeFile(filePath, content: string, encoding?: "utf8" | "base64"): Promise<void>; // fs.write;base64 写二进制
469
+ stat(filePath): Promise<{ size; modifiedAt; createdAt } | null>; // fs.read
470
+ rename(oldPath, newPath): Promise<void>; // fs.write
471
+ delete(targetPath): Promise<void>; // fs.write
472
+ move(sourcePath, destDir): Promise<void>; // fs.write
473
+ createDirectory(dirPath): Promise<void>; // fs.write
474
+ listFilesRecursive(rootPath): Promise<{ name; path; relPath }[]>; // fs.read
475
+ }
476
+ // PluginFsEntry: { name, path, isDirectory, size, modifiedAt }
477
+ ```
478
+
479
+ 同一份 `fs` API 也通过工具 handler 的 `host.fs` 暴露给 agent 工具 handler。
480
+
481
+ ## 网络 API
482
+
483
+ `ctx.network.request` 通过宿主主进程发起 HTTP(S) 请求,避免 renderer CORS 差异。需要 `network.fetch`;请求与响应各最多 32 MiB,超时最多 300 秒。调用绑定当前插件的 capability session,插件 id 不由 renderer 传入。
484
+
485
+ ```ts
486
+ const response = await ctx.network.request<{ data: unknown[] }>({
487
+ url: "https://api.example.com/v1/items",
488
+ method: "POST",
489
+ headers: { Authorization: `Bearer ${apiKey}` },
490
+ body: { type: "json", value: { query: "example" } },
491
+ responseType: "json", // "json" | "text" | "base64"
492
+ timeoutMs: 30_000,
493
+ });
494
+ ```
495
+
496
+ `body.type` 也可取 `"multipart"`,通过 `fields` 和 base64 `files` 组装表单。API 返回 `{ ok, status, statusText, headers, body }`,非 2xx 不自动抛错;JSON 错误响应若不是合法 JSON,会以文本返回。响应按流读取,超过上限会立即中止。
497
+
498
+ ## 插件私有存储 API
499
+
500
+ `ctx.storage` 是按插件 id 隔离的持久化文件命名空间,物理目录位于 `~/.vetta/plugin-data/<plugin-id>/`。
501
+ 公开路径都是相对路径;路径穿越和宿主保留的 `.storage` 路径会被拒绝。调用绑定当前插件的 capability
502
+ session,不能伪造其他插件 id。API 以文件和字节为核心,JSON 只是插件选择的序列化格式。
503
+
504
+ ```ts
505
+ await ctx.storage.writeFile("records/item.json", JSON.stringify({ id: "item" }, null, 2), "utf8");
506
+ const text = await ctx.storage.readFile("records/item.json", "utf8");
507
+ const record = text === null ? null : JSON.parse(text);
508
+
509
+ const committed = await ctx.storage.commit([
510
+ { type: "write", path: "city.json", data: JSON.stringify(city), encoding: "utf8" },
511
+ { type: "write", path: "project.json", data: JSON.stringify(project), encoding: "utf8" },
512
+ { type: "remove", path: "obsolete.json" },
513
+ ]);
514
+ const snapshot = await ctx.storage.readSnapshot(["city.json", "project.json"], "utf8");
515
+ // snapshot.revision === committed.revision;两个文件来自同一个已提交 revision
516
+ const keys = await ctx.storage.list("records");
517
+
518
+ const blob = await ctx.storage.putBlob({
519
+ data: base64Bytes,
520
+ mimeType: "image/png",
521
+ });
522
+ // blob: { id, mimeType, url };url 可直接给宿主媒体组件使用
523
+ const bytes = await ctx.storage.readBlob(blob.id);
524
+ const ref = await ctx.storage.getBlobRef(blob.id);
525
+ ```
526
+
527
+ - `storage.read` 门控 `list`、`readFile`、`readSnapshot`、`readBlob`、`getBlobRef`。
528
+ - `storage.write` 门控 `writeFile`、`commit`、`putBlob`、`putBlobFromFile`。
529
+ - `readFile/writeFile/readSnapshot` 必须显式传 `"utf8"` 或 `"base64"`;空文件是合法数据,缺失文件才返回 `null`。
530
+ - `commit` 支持 write/remove,并可传 `{ expectedRevision }` 做乐观并发控制;revision 不匹配时抛
531
+ `CAPABILITY_CONFLICT`。一次提交最多 128 个变化且同一路径不能重复。
532
+ - 多文件提交先写不可变对象与 manifest,最后原子切换 `.storage/HEAD`;切换前中断不会发布半成品。
533
+ - `readSnapshot` 先固定 HEAD,再读取该 revision 的所有文件,因此不会混读新旧批次。
534
+ - blob 按声明的 MIME 类型通过宿主媒体 URL 提供,不限定为图片。
535
+
536
+ 图片生成、供应商协议、编辑谱系等属于插件业务,应由插件基于 `ctx.network`、`ctx.storage` 与 `ctx.agent.registerTool` 组合实现。宿主只保留两类通用 UI 能力:
537
+
538
+ - `ctx.ui.setPromptAttachment(attachment | null)`:绑定下一轮的一次性插件上下文。`attachment` 包含 `id`、`label`、可选 `icon`、`instructions[]` 和 `metadata`;宿主展示胶囊、发送时合并内容并清除。
539
+ - `usePromptAttachment()`:响应式读取当前插件 prompt attachment,可用于插件卡片的选中态。
540
+ - `ctx.ui.previewImage(ref, group?)`:打开宿主全屏图片预览器。
541
+
542
+ `readBinaryFile` 用于需要原始字节的本地文件流程:宿主做路径校验、32 MiB 限额和内容签名 MIME 嗅探,不复用文本预览的编码判断。
543
+
544
+ ## 密钥 API
545
+
546
+ `ctx.secrets` 读写**本插件自己的**密钥,值存宿主加密凭据库(不落明文文件)。归属由 capability
547
+ session 决定,拿不到别的插件的密钥。需要 `secrets.read` / `secrets.write` 权限。
548
+
549
+ ```ts
550
+ interface PluginSecretsApi {
551
+ get(key: string): Promise<string | undefined>;
552
+ has(key: string): Promise<boolean>;
553
+ keys(): Promise<string[]>; // 只返回键名,不返回值
554
+ set(key: string, value: string): Promise<void>; // 写空串等价于删除
555
+ delete(key: string): Promise<void>;
556
+ onChange(listener: (keys: readonly string[]) => void): Disposable;
557
+ }
558
+ ```
559
+
560
+ ```ts
561
+ await ctx.secrets.set("openaiApiKey", input);
562
+ const apiKey = await ctx.secrets.get("openaiApiKey");
563
+ ```
564
+
565
+ 密钥之外的普通配置用 `ctx.storage.readFile("settings.json", "utf8")` /
566
+ `writeFile("settings.json", JSON.stringify(value), "utf8")`。宿主不会自动添加扩展名;配置界面由插件
567
+ 自己渲染——推荐 `ctx.ui.registerWorkspaceView` 的工作区配置页,见
568
+ [manifest.md](./manifest.md#插件配置放哪里)。
569
+
570
+ 配置页里**不要回显已保存的密钥**:用「已保存 / 未配置」状态加一个只写输入框即可。
571
+
572
+ ## 插件 i18n
573
+
574
+ 与宿主语言同步(ADR-0033)。catalog 来自包内 `locales/<lang>.json`,**不需要权限**。
575
+
576
+ ```ts
577
+ interface PluginI18nApi {
578
+ readonly locale: string; // 宿主当前语言
579
+ t(key: string, params?: Record<string, string | number>): string; // 裸 key,支持 {{name}} 插值
580
+ onChange(listener: (locale: string) => void): Disposable;
581
+ }
582
+ ```
583
+
584
+ ```tsx
585
+ import { useTranslation } from "@vetta-org/plugin-sdk";
586
+
587
+ function Panel() {
588
+ const { t, locale } = useTranslation(); // 切语言自动 rerender
589
+ return <button type="button">{t("panel.refresh")}</button>;
590
+ }
591
+ ```
592
+
593
+ - 宿主渲染的插件串用 **`%key%`**;组件内用 **裸 key** 调 `t()`。
594
+ - fallback:当前 locale → `defaultLocale` → 裸 key。详见 [manifest i18n](./manifest.md#i18n)。
595
+
596
+ ## 工作模式(agent_mode)
597
+
598
+ 工作模式(ADR-0046 / ADR-0071)是**任务解释的先验**:它只改变 agent 的系统提示词引导,不影响任何能力的可用性与顺序。用户在**新会话页**选择;选定的模式在会话创建时固化,**会话内不可变**,之后改设置只影响新建的会话。合法模式由宿主的模式注册表定义(当前 `work` / `coding`,未来可扩展),插件不应硬编码枚举。
599
+
600
+ ```ts
601
+ type AgentMode = string; // 模式 id,合法值来自宿主注册表
602
+ ctx.getAgentMode(): AgentMode; // 同步读当前的模式设置(= 新会话默认值)
603
+ ctx.onAgentModeChanged(listener: (mode: AgentMode) => void): Disposable; // 订阅设置变更
604
+ ```
605
+
606
+ > 注意:这两个 API 读到的是**当前设置值**,不是某个已存在会话被固化的模式。用它做 UI 呈现没问题;
607
+ > 不要用它去推断一个正在进行的会话当时选了什么。
608
+
609
+ ```tsx
610
+ function Panel() {
611
+ const [mode, setMode] = useState(ctx.getAgentMode());
612
+ useEffect(() => ctx.onAgentModeChanged(setMode).dispose, []);
613
+ return <div>{mode === "coding" ? "编程模式" : "工作模式"}</div>;
614
+ }
615
+ ```
616
+
617
+ - 开发者可据当前模式做**展示层**定制(不同文案、不同默认视图)。未知模式 id 一律按通用处理。
618
+ - 声明式的 `agent_mode` 字段(插件级 / tool / MCP server / skill frontmatter)**已整体废弃**(ADR-0071):容忍存在但无任何运行时语义。要引导模型少用某个工具,写进该工具 description 的反向触发段(何时**不该**用它及替代做法)。
619
+ - 缺省/空 = 通用(在所有模式下都主推);各级之间相互独立,不再取交集。