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 还有其他订阅者:

  1. ShareNext - 自动同步消息到远程分享服务

    Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
      await sync(evt.properties.info.sessionID, [{ type: "message", ... }])
    })
    
  2. 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            → 退出循环

为什么文本/工具结果是块式输出?

  1. 稳定性:CLI Run 常用于脚本/CI,不需要打字机动画效果
  2. 可预测性:输出不会因为网络波动而中断
  3. 管道友好:输出可以安全地管道到其他命令

为什么头部和 Task 是立即输出?

  1. 头部信息:让用户知道模型已开始响应
  2. 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:155src/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"
  }
}
posted @ 2026-02-19 21:36  不歪歪  阅读(166)  评论(0)    收藏  举报