OpenCode 服务端流式响应 和 用户确认机制
OpenCode 服务端流式响应 和 用户确认机制
1. 响应流式返回机制
1.1 流式处理流程
位置: src/session/processor.ts:45-417
async process(streamInput: LLM.StreamInput) {
const stream = await LLM.stream(streamInput)
for await (const value of stream.fullStream) {
switch (value.type) {
case "text-delta":
if (currentText) {
currentText.text += value.text
// 实时更新 delta
await Session.updatePartDelta({
sessionID, messageID, partID,
field: "text",
delta: value.text,
})
}
break
case "tool-call":
// 创建/更新工具调用 part
await Session.updatePart({...})
break
// ...更多事件类型
}
}
}
1.2 Bus 事件发布
位置: src/session/index.ts:660-665
export const updatePart = fn(UpdatePartInput, async (part) => {
Database.use((db) => {
db.insert(PartTable).values({...}).onConflictDoUpdate({...}).run()
Database.effect(() =>
Bus.publish(MessageV2.Event.PartUpdated, { part }),
)
})
return part
})
1.3 Delta 更新
位置: src/session/index.ts:669-680
export const updatePartDelta = fn({...}, async (input) => {
Bus.publish(MessageV2.Event.PartDelta, input)
})
1.4 Bus.publish 的完整调用链路
1.4.1 核心问题:Bus.publish(MessageV2.Event.Updated, { info: msg }) 的作用是什么?
这行代码的作用是将消息更新事件广播给所有订阅者,实现服务端到客户端的实时通信。
1.4.2 完整调用链路图
┌─────────────────────────────────────────────────────────────────────────┐
│ 服务端 (Node.js/Bun) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Session.updateMessage(msg) │
│ │ │
│ └── Database.use((db) => { │
│ db.insert(...).run() // 执行数据库操作 │
│ Database.effect(() => // 注册副作用函数 │
│ Bus.publish(MessageV2.Event.Updated, { info: msg }) │
│ ) │
│ }) │
│ │ │
│ └── 数据库操作完成后,执行所有 effects │
│ │
│ 2. Bus.publish() [src/bus/index.ts:41]
│ │ │
│ ├── 构建 payload: { type: "message.updated", properties: { info } } │
│ │ │
│ ├── 调用本地订阅者 │
│ │ └── for (key of [def.type, "*"]) │
│ │ └── for (sub of subscriptions.get(key)) │
│ │ └── sub(payload) // 调用订阅回调 │
│ │ │
│ └── GlobalBus.emit("event", { directory, payload }) [全局事件总线] │
│ │
│ 3. 服务器 SSE 端点 [src/server/server.ts:539]
│ │ │
│ └── Bus.subscribeAll(async (event) => { │
│ await stream.writeSSE({ │
│ data: JSON.stringify(event) // 通过 SSE 发送给客户端 │
│ }) │
│ }) │
│ │
└───────────────────────────┬─────────────────────────────────────────────┘
│ HTTP SSE (Server-Sent Events)
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 客户端 (TUI/Desktop/Web) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 4. SSE 连接接收事件 [src/cli/cmd/tui/context/sync.tsx]
│ │ │
│ └── for await (const event of events.stream) { │
│ switch (event.type) { │
│ case "message.updated": │
│ // 更新本地状态存储 │
│ setStore("message", sessionID, [...]) │
│ // 触发 UI 重新渲染 │
│ } │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────────┘
1.4.3 Database.effect 的作用
位置: src/storage/db.ts:105-125
export function use<T>(callback: (trx: TxOrDb) => T): T {
try {
return callback(ctx.use().tx)
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
for (const effect of effects) effect() // 数据库操作完成后执行所有 effects
return result
}
throw err
}
}
export function effect(fn: () => any | Promise<any>) {
try {
ctx.use().effects.push(fn) // 将副作用函数添加到队列
} catch {
fn() // 如果不在 Database.use 上下文中,直接执行
}
}
设计原因:
- 确保事件只在数据库操作成功完成后才发布
- 如果数据库操作失败,事件不会被发布
- 避免在事务中间发布事件导致状态不一致
1.4.4 Bus.publish 的实现
位置: src/bus/index.ts:41-64
export async function publish<Definition extends BusEvent.Definition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
const payload = {
type: def.type, // "message.updated"
properties, // { info: msg }
}
const pending = []
// 1. 调用本地订阅者(按事件类型和通配符 "*")
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
for (const sub of match ?? []) {
pending.push(sub(payload))
}
}
// 2. 发送到全局事件总线(跨进程通信)
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
}
1.4.5 服务器端 SSE 端点
位置: src/server/server.ts:529-546
.get("/events", async (c) => {
return streamSSE(c, async (stream) => {
// 发送连接成功事件
stream.writeSSE({
data: JSON.stringify({
type: "server.connected",
properties: {},
}),
})
// 订阅所有 Bus 事件
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event), // 将事件发送给客户端
})
})
// 心跳保活
const heartbeat = setInterval(() => {
stream.writeSSE({ data: "{}" })
}, 30000)
// 等待连接关闭
await new Promise((resolve) => {
stream.onAbort(resolve)
})
unsub()
clearInterval(heartbeat)
})
})
1.4.6 客户端订阅处理
位置: src/cli/cmd/tui/context/sync.tsx:228-266
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
// 新 session,创建消息数组
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
// 查找消息位置(使用二分查找,因为消息按 ID 排序)
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
// 更新现有消息
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
} else {
// 插入新消息
setStore("message", event.properties.info.sessionID, produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}))
}
// 限制内存中的消息数量
if (messages.length > 100) {
// 移除最旧的消息
const oldest = messages[0]
// ...
}
break
}
1.4.7 GlobalBus 的作用
位置: src/bus/global.ts
import { EventEmitter } from "events"
export const GlobalBus = new EventEmitter<{
event: [
{
directory?: string
payload: any
},
]
}>()
作用:
- 提供跨模块的事件通信能力
- 在
src/server/routes/global.ts中用于跨 worktree 的事件同步 - 支持多 worktree 场景下的事件共享
1.4.8 其他订阅者
除了 SSE 端点,MessageV2.Event.Updated 还有其他订阅者:
-
ShareNext - 自动同步消息到远程分享服务
Bus.subscribe(MessageV2.Event.Updated, async (evt) => { await sync(evt.properties.info.sessionID, [{ type: "message", ... }]) }) -
CLI Run 模式 - 处理一次性命令的输出
if (event.type === "message.updated" && event.properties.info.role === "assistant") { // 处理输出... }
1.5 事件类型汇总
| 事件类型 | 触发时机 | 用途 |
|---|---|---|
message.updated |
消息创建/更新 | UI 更新消息列表 |
message.removed |
消息删除 | UI 移除消息 |
message.part.updated |
Part 创建/更新 | UI 更新消息内容 |
message.part.delta |
Part 文本增量 | 实时文本流显示 |
message.part.removed |
Part 删除 | UI 移除内容 |
session.updated |
Session 更新 | UI 更新会话信息 |
session.status |
状态变化 | 显示 busy/idle/retry 状态 |
permission.asked |
请求权限 | 显示确认对话框 |
1.6 CLI Run 模式:一次性命令与流式数据
1.6.1 CLI Run 模式概述
CLI Run 模式(opencode run "message")是一种非交互式执行模式,用于:
- 在脚本中自动化执行任务
- CI/CD 集成
- 批量处理
核心问题:大模型是流式返回数据的,而 CLI Run 需要执行一次性命令后退出。如何结合?
解决方案:分离"命令发送"和"事件消费",通过 SSE(Server-Sent Events)实现流式输出。
1.6.2 架构图
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLI Run 模式架构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CLI 客户端 (run.ts) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. 订阅事件流 │ │
│ │ sdk.event.subscribe() → GET /global/event (SSE) │ │
│ │ │ │
│ │ 2. 发送命令 │ │
│ │ sdk.session.prompt() → POST /session/:id/message │ │
│ │ 或 sdk.session.command() → POST /session/:id/command │ │
│ │ │ │
│ │ 3. 监听事件流,实时渲染 │ │
│ │ for await (const event of events.stream) { ... } │ │
│ │ │ │
│ │ 4. 收到 status === "idle" 时退出 │ │
│ │ if (event.type === "session.status" && status.type === "idle") │ │
│ │ break │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↑ HTTP/SSE │
│ ↓ │
│ Server 端 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ GlobalBus (EventEmitter) │ │
│ │ ↑ publish() │ │
│ │ │ │ │
│ │ SessionPrompt.loop() │ │
│ │ ├── processor.process() → 处理流式响应 │ │
│ │ │ └── Session.updatePart() → Bus.publish() │ │
│ │ │ ↓ │ │
│ │ │ GlobalBus.emit("event", ...) │ │
│ │ │ ↓ │ │
│ │ │ SSE endpoint → 推送给客户端 │ │
│ │ │ │ │
│ │ └── SessionStatus.set({ type: "idle" }) → 发送 idle 事件 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
1.6.3 代码流程详解
位置:src/cli/cmd/run.ts
Step 1:订阅事件流
// run.ts:437
const events = await sdk.event.subscribe() // 调用 GET /global/event
// SDK 内部使用 SSE 连接
// 返回一个 AsyncIterable,可以 for await 遍历
Step 2:启动事件监听循环
// run.ts:440-545
async function loop() {
const toggles = new Map<string, boolean>()
for await (const event of events.stream) {
// 处理各种事件类型...
// ★ 关键:收到 idle 状态时退出
if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
) {
break // ← 退出循环,CLI Run 完成
}
}
}
Step 3:发送命令(并行执行)
// run.ts:578-601
loop().catch((e) => {
console.error(e)
process.exit(1)
})
// 立即发送命令,不等待 loop() 完成
if (args.command) {
await sdk.session.command({
sessionID,
agent,
model: args.model,
command: args.command,
arguments: message,
})
} else {
await sdk.session.prompt({
sessionID,
agent,
model,
parts: [...files, { type: "text", text: message }],
})
}
关键设计:loop() 和 sdk.session.prompt() 是并行执行的:
loop()开始监听事件流sdk.session.prompt()触发服务器端处理- 事件通过 SSE 实时推送给
loop() - 处理完成后,服务器发送
idle状态,loop()退出
1.6.4 事件处理逻辑(客户端)
位置:src/cli/cmd/run.ts:440-545
关键理解:
- 事件是流式接收的(通过 SSE)
- 输出是混合模式:部分立即输出,部分完成后输出
输出时机分类:
| 事件/条件 | 输出时机 | 说明 |
|---|---|---|
message.updated |
立即输出 | 打印 > agent · model 头部 |
tool + completed |
完成时输出 | 工具执行完才打印结果 |
task + running |
立即输出 | 子任务开始就显示进度 |
text + time.end |
完成时输出 | 文本生成完才打印 |
reasoning + time.end |
完成时输出 | 推理完成才打印 |
与 TUI 模式的区别:
| 模式 | 文本显示方式 | 处理的事件 |
|---|---|---|
| TUI | 逐字符流式显示 | message.part.delta(每次几个字符) |
| CLI Run | 块式输出(文本完成后) | message.part.updated(仅 time.end 时) |
代码逻辑:
// run.ts:443-530
for await (const event of events.stream) { // ← 流式接收事件(SSE)
// 1. 助手消息开始 → ★ 立即输出头部
if (event.type === "message.updated" && event.properties.info.role === "assistant") {
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
}
// 2. Part 更新事件
if (event.type === "message.part.updated") {
const part = event.properties.part
// 2a. 工具完成 → 完成时输出
if (part.type === "tool" && part.state.status === "completed") {
tool(part) // 渲染工具输出(bash、read、write 等)
}
// 2b. Task 工具运行中 → ★ 立即输出(例外)
if (part.type === "tool" && part.tool === "task" && part.state.status === "running") {
task(props<typeof TaskTool>(part)) // 显示子任务开始
}
// 2c. 文本完成 → 完成时输出(time.end 存在)
if (part.type === "text" && part.time?.end) {
const text = part.text.trim()
if (!text) continue
if (!process.stdout.isTTY) {
process.stdout.write(text + EOL)
} else {
UI.empty()
UI.println(text)
UI.empty()
}
}
// 2d. 推理完成 → 完成时输出
if (part.type === "reasoning" && part.time?.end && args.thinking) {
UI.println(`Thinking: ${part.text.trim()}`)
}
}
// 3. 错误处理 → 立即输出
if (event.type === "session.error") {
UI.error(String(props.error.name))
}
// 4. 权限请求 → 立即输出并自动拒绝
if (event.type === "permission.asked") {
UI.println(`permission requested: ${permission.permission}; auto-rejecting`)
await sdk.permission.reply({ requestID: permission.id, reply: "reject" })
}
}
输出时间线示例:
时间 0s: message.updated → ★ 立即打印 "> claude · claude-3-opus"
时间 0.1s: tool part (running) → 不输出
时间 1s: tool part (completed) → ★ 输出工具结果(如:$ npm test)
时间 1.1s: task part (running) → ★ 立即显示 "• Task名称"
时间 2s: text part (time.end) → ★ 输出完整文本
时间 2.1s: status = idle → 退出循环
为什么文本/工具结果是块式输出?
- 稳定性:CLI Run 常用于脚本/CI,不需要打字机动画效果
- 可预测性:输出不会因为网络波动而中断
- 管道友好:输出可以安全地管道到其他命令
为什么头部和 Task 是立即输出?
- 头部信息:让用户知道模型已开始响应
- Task 进度:显示子任务正在执行,提供进度反馈
1.6.5 服务器端如何发送事件
事件发布链路:
processor.process()
↓
Session.updatePart(part)
↓
MessageV2.Event.PartUpdated.publish()
↓
Bus.publish(MessageV2.Event.PartUpdated, properties)
↓
GlobalBus.emit("event", { directory, payload })
↓
SSE endpoint (/global/event)
↓
stream.writeSSE({ data: JSON.stringify(event) })
↓
CLI 客户端收到事件
SSE 端点(src/server/routes/global.ts:41-109):
.get("/event", async (c) => {
return streamSSE(c, async (stream) => {
// 1. 发送连接事件
stream.writeSSE({
data: JSON.stringify({
payload: { type: "server.connected", properties: {} },
}),
})
// 2. 注册事件处理器
async function handler(event: any) {
await stream.writeSSE({
data: JSON.stringify(event), // ← 将事件推送给客户端
})
}
GlobalBus.on("event", handler)
// 3. 心跳保活
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
payload: { type: "server.heartbeat", properties: {} },
}),
})
}, 10_000)
// 4. 等待断开
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
})
})
})
})
1.6.6 如何判断处理完成
关键机制:SessionStatus
// src/session/status.ts
export namespace SessionStatus {
export type Info =
| { type: "idle" } // 空闲,处理完成
| { type: "busy" } // 忙碌,正在处理
| { type: "retry", attempt: number, message: string, next: number } // 重试中
export function set(sessionID: string, status: Info) {
// 发布状态变更事件
Bus.publish(Event.Status, { sessionID, status })
if (status.type === "idle") {
delete state()[sessionID] // 清理状态
return
}
state()[sessionID] = status
}
}
状态变化时机(src/session/processor.ts):
async process(streamInput: LLM.StreamInput) {
try {
const stream = await LLM.stream(streamInput)
for await (const value of stream.fullStream) {
switch (value.type) {
case "start":
SessionStatus.set(input.sessionID, { type: "busy" }) // ← 开始处理
break
// ... 处理各种事件
}
}
} catch (e) {
// 错误处理
} finally {
SessionStatus.set(input.sessionID, { type: "idle" }) // ← 处理完成
}
}
CLI Run 检测完成:
// run.ts:524-529
if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
) {
break // ← 收到 idle 状态,退出循环
}
1.6.7 流式数据与一次性命令的结合
核心思想:
| 概念 | 说明 |
|---|---|
| 命令是一次性的 | HTTP POST 请求,发送后立即返回 |
| 数据是流式的 | 通过 SSE 实时推送,边处理边输出 |
| 退出是事件驱动的 | 收到 idle 状态时退出 |
时序图:
CLI Client Server
│ │
│──── GET /global/event (SSE) ────────→│
│←───── Connected ────────────────────│
│ │
│──── POST /session/:id/prompt ───────→│
│ (立即返回) │
│ │
│ ┌─── loop() 开始处理 ──┤
│ │ │
│←──── message.part.updated ───────────│ (工具开始)
│←──── message.part.updated ───────────│ (工具完成)
│←──── message.part.updated ───────────│ (文本完成)
│ │ │
│ └─── loop() 结束 ──────┤
│←──── session.status (idle) ─────────│
│ │
│──── 关闭 SSE 连接 ──────────────────→│
│ │
↓ ↓
退出 等待下一个请求
1.6.8 输出格式
CLI Run 支持两种输出格式:
1. 默认格式(格式化输出)
$ opencode run "读取 package.json 并告诉我版本号"
> claude · claude-3-opus
→ Read package.json
← Edit package.json
- "version": "1.0.0"
+ "version": "1.0.1"
根据 package.json,当前版本号是 1.0.0。
2. JSON 格式(原始事件)
$ opencode run "hello" --format json
{"type":"step_start","timestamp":1234567890,"sessionID":"xxx","part":{...}}
{"type":"tool_use","timestamp":1234567891,"sessionID":"xxx","part":{...}}
{"type":"text","timestamp":1234567892,"sessionID":"xxx","part":{...}}
{"type":"step_finish","timestamp":1234567893,"sessionID":"xxx","part":{...}}
实现:
// run.ts:429-435
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true // 跳过默认格式化
}
return false
}
// 使用
if (part.type === "tool" && part.state.status === "completed") {
if (emit("tool_use", { part })) continue // JSON 模式:已输出,跳过
tool(part) // 默认模式:格式化输出
}
1.6.9 与 TUI 模式的对比
| 特性 | CLI Run 模式 | TUI 模式 |
|---|---|---|
| 交互性 | 非交互 | 全交互 |
| 输出方式 | 流式(SSE) | 流式(SSE + WebSocket) |
| 退出条件 | status === "idle" |
用户主动退出 |
| 权限处理 | 自动拒绝 | 用户确认 |
| 适用场景 | 脚本、CI/CD | 日常开发 |
共享机制:
两者使用完全相同的事件发布/订阅机制:
- 服务器端:同样的
Bus.publish()和GlobalBus.emit() - 客户端:同样的
sdk.event.subscribe()和 SSE 连接
区别只在于:
- CLI Run:单次执行,收到 idle 就退出
- TUI:持续监听,用户可以继续输入
2. 用户确认机制
2.1 完整流程概览
┌─────────────────────────────────────────────────────────────────────────────┐
│ 用户确认机制完整流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【服务端 - 工具执行层】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 1. 触发起点:工具执行时调用 ctx.ask() │ │
│ │ 位置:src/session/prompt.ts:787-794 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【服务端 - 权限处理层】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 2. PermissionNext.ask() 评估规则 │ │
│ │ 位置:src/permission/next.ts:131-161 │ │
│ │ - 如果 action === "deny" → 抛出 DeniedError │ │
│ │ - 如果 action === "allow" → 直接通过 │ │
│ │ - 如果 action === "ask" → 创建 Promise,存储到 pending │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【服务端 - 事件发布层】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 3. Bus.publish(Event.Asked, info) │ │
│ │ 位置:src/permission/next.ts:155 │ │
│ │ ↓ │ │
│ │ GlobalBus.emit("event", { directory, payload }) │ │
│ │ 位置:src/bus/index.ts:59-62 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【传输层 - SSE】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 4. SSE 端点推送事件 │ │
│ │ 位置:src/server/routes/global.ts:80-84 │ │
│ │ stream.writeSSE({ data: JSON.stringify(event) }) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【客户端 - 事件接收层】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 5. sdk.event.listen() 接收事件 │ │
│ │ 位置:src/cli/cmd/tui/context/sync.tsx:107 │ │
│ │ ↓ │ │
│ │ 6. 存储到 store.permission[sessionID] │ │
│ │ 位置:src/cli/cmd/tui/context/sync.tsx:128-147 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【客户端 - UI 渲染层】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 7. PermissionPrompt 组件渲染确认对话框 │ │
│ │ 位置:src/cli/cmd/tui/routes/session/permission.tsx:128-465 │ │
│ │ 显示选项:Allow once / Allow always / Reject │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【用户交互】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 8. 用户按键选择(←/→ 切换,Enter 确认) │ │
│ │ 位置:src/cli/cmd/tui/routes/session/permission.tsx:560-592 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【客户端 - 发送回复】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 9. sdk.client.permission.reply() 发送用户选择 │ │
│ │ 位置:src/cli/cmd/tui/routes/session/permission.tsx:446-455 │ │
│ │ HTTP POST /permission/:requestID/reply │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【服务端 - 处理回复】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 10. PermissionNext.reply() 处理回复 │ │
│ │ 位置:src/permission/next.ts:163-234 │ │
│ │ - 从 pending 中取出请求 │ │
│ │ - resolve() 或 reject() Promise │ │
│ │ - 发布 permission.replied 事件 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【服务端 - 工具继续执行】 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 11. Promise resolve → 工具继续执行 │ │
│ │ Promise reject → 抛出错误,工具执行中断 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.2 第一步:触发起点 - 工具执行时调用 ctx.ask()
位置:src/session/prompt.ts:762-794
触发场景:当工具(如 Bash、Edit、Write)需要执行敏感操作时,会调用 ctx.ask() 请求用户确认。
代码流程:
// src/session/prompt.ts:762-794
const context = (args: any, options: ToolCallOptions): Tool.Context => ({
// ... 其他字段
// ★ 权限请求方法
async ask(req) {
await PermissionNext.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
})
},
})
// 工具执行时调用
tools[item.id] = tool({
async execute(args, options) {
const ctx = context(args, options)
// 工具内部调用 ctx.ask() 触发权限请求
const result = await item.execute(args, ctx)
return result
},
})
工具调用示例(src/tool/websearch.ts:66-77):
async execute(params, ctx) {
// ★ 调用 ctx.ask() 触发权限请求
await ctx.ask({
permission: "websearch",
patterns: [params.query],
always: ["*"],
metadata: { query: params.query, ... },
})
// 如果用户允许,继续执行
const response = await fetch(...)
return { output: ..., title: ..., metadata: {} }
}
2.3 第二步:服务端评估规则
位置:src/permission/next.ts:131-161
export const ask = fn(
Request.partial({ id: true }).extend({ ruleset: Ruleset }),
async (input) => {
const s = await state()
const { ruleset, ...request } = input
// 遍历所有 pattern
for (const pattern of request.patterns ?? []) {
// ★ 评估规则
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule.action })
// 情况 1:明确拒绝
if (rule.action === "deny") {
throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
}
// 情况 2:需要询问用户
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
// ★ 创建 Promise,工具执行在此处暂停
return new Promise<void>((resolve, reject) => {
const info: Request = { id, ...request }
// 存储到 pending,等待用户回复
s.pending[id] = { info, resolve, reject }
// ★ 发布事件,通知客户端
Bus.publish(Event.Asked, info)
})
}
// 情况 3:明确允许
if (rule.action === "allow") continue
}
},
)
规则评估逻辑(src/permission/next.ts:236-243):
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const merged = merge(...rulesets)
// 查找最后一个匹配的规则
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
// 如果没有匹配规则,默认 "ask"
return match ?? { action: "ask", permission, pattern: "*" }
}
规则来源:
| 来源 | 说明 |
|---|---|
agent.permission |
Agent 配置中的权限规则 |
session.permission |
Session 创建时指定的规则 |
s.approved |
用户之前选择 "Always allow" 的规则 |
2.4 第三步:事件发布
位置:src/permission/next.ts:155 → src/bus/index.ts:41-64
// PermissionNext.ask() 中
Bus.publish(Event.Asked, info)
// Bus.publish 内部
export async function publish<Definition extends BusEvent.Definition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
const payload = { type: def.type, properties }
// 1. 通知本地订阅者
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
for (const sub of match ?? []) {
pending.push(sub(payload))
}
}
// 2. ★ 发送到全局事件总线(用于跨进程/跨 worktree)
GlobalBus.emit("event", {
directory: Instance.directory,
payload,
})
return Promise.all(pending)
}
2.5 第四步:SSE 推送给客户端
位置:src/server/routes/global.ts:80-84
// SSE 端点
async function handler(event: any) {
await stream.writeSSE({
data: JSON.stringify(event), // ← 推送给客户端
})
}
GlobalBus.on("event", handler)
事件数据格式:
{
"directory": "/path/to/project",
"payload": {
"type": "permission.asked",
"properties": {
"id": "permission_abc123",
"sessionID": "session_xyz",
"permission": "bash",
"patterns": ["npm test"],
"metadata": { "command": "npm test" },
"always": ["*"],
"tool": { "messageID": "msg_123", "callID": "call_456" }
}
}
}
2.6 第五步:客户端接收并存储
位置:src/cli/cmd/tui/context/sync.tsx:107-148
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
// ★ 处理权限请求事件
case "permission.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
// 首次创建
setStore("permission", request.sessionID, [request])
break
}
// 已存在,插入到正确位置(按 ID 排序)
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
// 处理权限回复事件(从 pending 中移除)
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
}
})
2.7 第六步:UI 渲染确认对话框
位置:src/cli/cmd/tui/routes/session/permission.tsx:128-465
组件结构:
export function PermissionPrompt(props: { request: PermissionRequest }) {
const [store, setStore] = createStore({
stage: "permission" as PermissionStage, // "permission" | "always" | "reject"
})
return (
<Switch>
{/* 阶段 1:主确认界面 */}
<Match when={store.stage === "permission"}>
<Prompt
title="Permission required"
header={...}
body={...}
options={{
once: "Allow once", // 本次允许
always: "Allow always", // 永久允许
reject: "Reject" // 拒绝
}}
escapeKey="reject"
onSelect={(option) => {
if (option === "always") {
setStore("stage", "always") // 进入确认阶段
return
}
if (option === "reject") {
setStore("stage", "reject") // 进入拒绝阶段(可输入反馈)
return
}
// ★ 发送 "once" 回复
sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
})
}}
/>
</Match>
{/* 阶段 2:确认 "Always allow" */}
<Match when={store.stage === "always"}>
<Prompt
title="Always allow"
body={<TextBody title="This will allow ... until OpenCode is restarted." />}
options={{ confirm: "Confirm", cancel: "Cancel" }}
onSelect={(option) => {
if (option === "cancel") {
setStore("stage", "permission")
return
}
// ★ 发送 "always" 回复
sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
}}
/>
</Match>
{/* 阶段 3:拒绝并输入反馈 */}
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
// ★ 发送 "reject" 回复(带反馈消息)
sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
message: message || undefined,
})
}}
onCancel={() => setStore("stage", "permission")}
/>
</Match>
</Switch>
)
}
用户选项:
| 选项 | 行为 | 效果 |
|---|---|---|
| Allow once | 本次允许 | 仅当前操作允许,下次还需确认 |
| Allow always | 永久允许 | 添加到 approved 规则,本次会话内不再询问 |
| Reject | 拒绝 | 抛出 RejectedError,工具执行中断 |
2.8 第七步:键盘交互
位置:src/cli/cmd/tui/routes/session/permission.tsx:560-592
useKeyboard((evt) => {
// ← / h:向左选择
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
// → / l:向右选择
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
// Enter:确认选择
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected) // ← 触发 onSelect
}
// Escape:使用 escapeKey 选项
if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) {
evt.preventDefault()
props.onSelect(props.escapeKey)
}
})
2.9 第八步:发送回复到服务端
位置:src/server/routes/permission.ts:10-45
HTTP 请求:
POST /permission/:requestID/reply
Content-Type: application/json
{
"reply": "once", // "once" | "always" | "reject"
"message": "optional feedback message"
}
服务端处理:
// src/server/routes/permission.ts:35-44
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await PermissionNext.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
})
return c.json(true)
}
2.10 第九步:服务端处理回复
位置:src/permission/next.ts:163-234
export const reply = fn(
z.object({
requestID: Identifier.schema("permission"),
reply: Reply,
message: z.string().optional(),
}),
async (input) => {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) return // 请求不存在或已处理
delete s.pending[input.requestID]
// 发布回复事件(用于客户端同步)
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
// ★ 情况 1:拒绝
if (input.reply === "reject") {
// reject Promise → 工具执行抛出错误
existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError())
// 同时拒绝该 session 的所有其他待处理权限
const sessionID = existing.info.sessionID
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID === sessionID) {
delete s.pending[id]
Bus.publish(Event.Replied, { sessionID, requestID: id, reply: "reject" })
pending.reject(new RejectedError())
}
}
return
}
// ★ 情况 2:本次允许
if (input.reply === "once") {
// resolve Promise → 工具继续执行
existing.resolve()
return
}
// ★ 情况 3:永久允许
if (input.reply === "always") {
// 添加到 approved 规则
for (const pattern of existing.info.always) {
s.approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
existing.resolve()
// 自动批准该 session 中符合新规则的其他待处理权限
const sessionID = existing.info.sessionID
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID !== sessionID) continue
const ok = pending.info.patterns.every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
)
if (!ok) continue
delete s.pending[id]
Bus.publish(Event.Replied, { sessionID, requestID: id, reply: "always" })
pending.resolve()
}
return
}
},
)
2.11 第十步:工具继续执行或中断
Promise 链:
工具调用 ctx.ask()
↓
PermissionNext.ask() 返回 Promise
↓
Promise 被 await 挂起,工具执行暂停
↓
用户回复后,reply() 调用 resolve() 或 reject()
↓
┌─────────────────────────────────────────────────────────────┐
│ resolve() → Promise 完成 → ctx.ask() 返回 → 工具继续执行 │
│ reject() → Promise 失败 → ctx.ask() 抛出错误 → 工具中断 │
└─────────────────────────────────────────────────────────────┘
错误类型:
| 错误类型 | 触发条件 | 效果 |
|---|---|---|
RejectedError |
用户拒绝(无反馈) | 工具中断,模型收到错误消息 |
CorrectedError |
用户拒绝(带反馈) | 工具中断,模型收到用户反馈 |
DeniedError |
配置规则拒绝 | 工具中断,显示匹配的规则 |
2.12 完整时序图
工具执行 服务端 客户端(TUI)
│ │ │
│ ctx.ask() │ │
├────────────────────────→│ │
│ │ │
│ │ evaluate() → "ask" │
│ │ │
│ │ s.pending[id] = {resolve,reject}│
│ │ │
│ await Promise ←────┤ │
│ (工具暂停) │ │
│ │ │
│ │ Bus.publish(Asked) │
│ ├─────────────────────────────→│
│ │ │
│ │ 显示确认对话框│
│ │ [Allow once]│
│ │ [Allow always]│
│ │ [Reject] │
│ │ │
│ │ 用户按键选择│
│ │ │
│ │ POST /permission/reply │
│ │←─────────────────────────────┤
│ │ │
│ │ reply() 处理 │
│ │ - resolve()/reject() │
│ │ - Bus.publish(Replied) │
│ ├─────────────────────────────→│
│ │ 移除确认对话框│
│ │ │
│ Promise 完成 ←─────┤ │
│ (工具继续) │ │
│ │ │
↓ ↓ ↓
2.13 权限配置来源与服务端处理
用户可以通过配置文件或环境变量预设权限规则,让工具自动执行而无需交互确认。
2.13.1 配置方式
方式一:配置文件 (opencode.json)
{
"permission": {
"bash": "allow",
"edit": "allow",
"read": "allow",
"*": "allow"
}
}
方式二:环境变量 (OPENCODE_PERMISSION)
OPENCODE_PERMISSION='{"bash":"allow","edit":"allow"}' opencode run "your message"
方式三:详细格式(按 pattern 区分)
{
"permission": {
"bash": {
"npm test": "allow",
"npm run build": "allow",
"rm -rf *": "deny",
"*": "ask"
},
"edit": {
"src/**": "allow",
"*.json": "deny"
}
}
}
2.13.2 服务端处理流程
┌─────────────────────────────────────────────────────────────────────────────┐
│ 权限配置读取与使用流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【第一步:配置读取】 │
│ 位置:src/config/config.ts:205-207 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ // 1. 读取 opencode.json │ │
│ │ result = merge(result, await loadFile("opencode.json")) │ │
│ │ │ │
│ │ // 2. 读取环境变量(优先级更高) │ │
│ │ if (Flag.OPENCODE_PERMISSION) { │ │
│ │ result.permission = mergeDeep(result.permission, │ │
│ │ JSON.parse(Flag.OPENCODE_PERMISSION)) │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【第二步:转换为 Ruleset】 │
│ 位置:src/permission/next.ts:46-62 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ export function fromConfig(permission: Config.Permission) { │ │
│ │ const ruleset: Ruleset = [] │ │
│ │ for (const [key, value] of Object.entries(permission)) { │ │
│ │ if (typeof value === "string") { │ │
│ │ // 简单格式: { "bash": "allow" } │ │
│ │ ruleset.push({ permission: key, action: value, pattern: "*" })│ │
│ │ } else { │ │
│ │ // 详细格式: { "bash": { "npm test": "allow" } } │ │
│ │ ruleset.push(...Object.entries(value).map(([pattern, action]) =>│ │
│ │ ({ permission: key, pattern: expand(pattern), action }))) │ │
│ │ } │ │
│ │ } │ │
│ │ return ruleset │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【第三步:Agent 初始化时合并规则】 │
│ 位置:src/agent/agent.ts:56-87 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ const state = Instance.state(async () => { │ │
│ │ const cfg = await Config.get() │ │
│ │ │ │
│ │ // 1. 默认规则(代码硬编码) │ │
│ │ const defaults = PermissionNext.fromConfig({ │ │
│ │ "*": "allow", │ │
│ │ doom_loop: "ask", │ │
│ │ read: { "*.env": "ask", "*.env.*": "ask" }, │ │
│ │ question: "deny", │ │
│ │ plan_enter: "deny", │ │
│ │ plan_exit: "deny", │ │
│ │ }) │ │
│ │ │ │
│ │ // 2. 用户配置规则(来自 opencode.json / 环境变量) │ │
│ │ const user = PermissionNext.fromConfig(cfg.permission ?? {}) │ │
│ │ │ │
│ │ // 3. 合并规则(后面的覆盖前面的同名规则) │ │
│ │ const build = { │ │
│ │ name: "build", │ │
│ │ permission: PermissionNext.merge(defaults, specific, user), │ │
│ │ } │ │
│ │ }) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【第四步:工具执行时使用 ruleset】 │
│ 位置:src/session/prompt.ts:787-794 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ const context = (args, options) => ({ │ │
│ │ async ask(req) { │ │
│ │ await PermissionNext.ask({ │ │
│ │ ...req, │ │
│ │ // ★ 合并 agent 和 session 的 ruleset │ │
│ │ ruleset: PermissionNext.merge( │ │
│ │ input.agent.permission, // ← Agent 配置(含用户配置) │ │
│ │ input.session.permission ?? [] // ← Session 创建时传入 │ │
│ │ ), │ │
│ │ }) │ │
│ │ } │ │
│ │ }) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 【第五步:评估规则】 │
│ 位置:src/permission/next.ts:236-243 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ export function evaluate(permission, pattern, ...rulesets) { │ │
│ │ const merged = merge(...rulesets) // 展开所有规则 │ │
│ │ │ │
│ │ // ★ 查找最后一个匹配的规则(后面的覆盖前面的) │ │
│ │ const match = merged.findLast( │ │
│ │ (rule) => Wildcard.match(permission, rule.permission) │ │
│ │ && Wildcard.match(pattern, rule.pattern) │ │
│ │ ) │ │
│ │ │ │
│ │ // 没有匹配则默认 "ask" │ │
│ │ return match ?? { action: "ask", permission, pattern: "*" } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2.13.3 规则优先级(从低到高)
1. 代码中的默认规则 (defaults)
↓
2. Agent 特定规则 (build, plan, explore 等)
↓
3. opencode.json 中的 permission 配置
↓
4. 环境变量 OPENCODE_PERMISSION
↓
5. Session 创建时传入的 permission (CLI Run)
↓
6. 用户选择 "Always allow" 后的动态规则 (s.approved)
优先级示例:
// 规则合并顺序
PermissionNext.merge(
defaults, // 优先级 1
agentSpecific, // 优先级 2
userConfig, // 优先级 3 (opencode.json)
envConfig, // 优先级 4 (环境变量)
sessionPermission, // 优先级 5 (CLI Run 传入)
)
// evaluate() 使用 findLast() 查找
// 后面的规则会覆盖前面的同名规则
2.13.4 evaluate() 的匹配逻辑
// 规则数组(已合并)
const ruleset = [
{ permission: "*", pattern: "*", action: "allow" }, // 默认
{ permission: "read", pattern: "*.env", action: "ask" }, // 覆盖 .env
{ permission: "bash", pattern: "*", action: "allow" }, // 用户配置
]
// 查找 bash + "npm test"
// 1. 匹配 permission: "*" + pattern: "*" → allow
// 2. 匹配 permission: "bash" + pattern: "*" → allow (findLast 返回这个)
// 查找 read + ".env"
// 1. 匹配 permission: "*" + pattern: "*" → allow
// 2. 匹配 permission: "read" + pattern: "*.env" → ask (findLast 返回这个)
2.14 配置后跳过确认的流程
当用户配置了 action: "allow" 规则后,权限检查流程会跳过客户端确认:
┌─────────────────────────────────────────────────────────────────────────────┐
│ 配置 allow 后的流程(无客户端交互) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 工具执行 ctx.ask({ permission: "bash", patterns: ["npm test"] }) │
│ ↓ │
│ PermissionNext.ask() │
│ ↓ │
│ evaluate("bash", "npm test", ruleset) │
│ ↓ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ findLast() 查找匹配规则 │ │
│ │ - permission: "bash" && pattern: "npm test" → 未匹配 │ │
│ │ - permission: "bash" && pattern: "*" → 匹配!action = "allow" │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ rule.action === "allow" → continue (不创建 Promise,不发布事件) │
│ ↓ │
│ ctx.ask() 立即返回 │
│ ↓ │
│ 工具继续执行(无中断) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
对比三种 action 的处理:
| action | 处理方式 | 客户端交互 |
|---|---|---|
"allow" |
直接 continue | 无 |
"deny" |
抛出 DeniedError | 无(直接失败) |
"ask" |
创建 Promise,发布事件 | 需要用户确认 |
2.15 CLI Run 模式下的权限配置
CLI Run 模式默认会自动拒绝权限请求,但可以通过配置让工具自动执行:
2.15.1 默认行为(自动拒绝)
// run.ts:353-369 - Session 创建时的规则
const rules: PermissionNext.Ruleset = [
{ permission: "question", action: "deny", pattern: "*" },
{ permission: "plan_enter", action: "deny", pattern: "*" },
{ permission: "plan_exit", action: "deny", pattern: "*" },
]
// ★ 没有配置工具的 allow 规则
// run.ts:532-544 - 收到权限请求时
if (event.type === "permission.asked") {
UI.println(`permission requested: ${permission.permission}; auto-rejecting`)
await sdk.permission.reply({ requestID: permission.id, reply: "reject" })
}
结果:工具执行被拒绝。
2.15.2 通过配置让工具自动执行
方法一:opencode.json
{
"permission": {
"*": "allow"
}
}
方法二:环境变量
OPENCODE_PERMISSION='{"*":"allow"}' opencode run "read package.json"
方法三:只允许特定工具
OPENCODE_PERMISSION='{"read":"allow","glob":"allow","grep":"allow"}' opencode run "find all ts files"
2.15.3 配置生效后的流程
CLI Run 启动
↓
读取 opencode.json / OPENCODE_PERMISSION
↓
Config.get() 返回 { permission: { "*": "allow" } }
↓
Agent 初始化:agent.permission = [...defaults, ...user]
↓
工具执行 ctx.ask({ permission: "bash", patterns: ["npm test"] })
↓
evaluate("bash", "npm test", ruleset) → action: "allow"
↓
★ 不发布 permission.asked 事件
★ 工具直接继续执行
↓
不会触发 CLI Run 的 auto-reject 逻辑
2.16 完整配置示例
{
"permission": {
"bash": {
"npm *": "allow",
"git *": "allow",
"rm -rf *": "deny",
"*": "ask"
},
"edit": {
"src/**": "allow",
"test/**": "allow",
"*.env": "deny",
"*": "ask"
},
"read": "allow",
"glob": "allow",
"grep": "allow",
"websearch": "allow",
"webfetch": "allow"
}
}

浙公网安备 33010602011771号