# opencode 源码分析：一个开源 AI 编程 Agent 是怎样炼成的

> **分析对象**：opencode（`anomalyco/opencode`），版本 1.18.4
> **基于 commit**：`62e4641235d7847dadc60da37cca8a023dd54fc1`（2026-07-23，提交信息 `chore: generate`）
> **分析日期**：2026-07-24
> **代码规模**：monorepo，约 1755 个 `.ts` + 588 个 `.tsx` 文件（`packages/` 下，不含测试与 node_modules）
> **读者对象**：计算机初学者。本文会用大量比喻，并给出真实源码位置（`文件:行号`），你可以按图索骥。

---

> 🗺️ **配套架构图**：本项目在[**七大 Agent 架构图库**](../架构图库.html#ch4)里有一张专门的图——**星形拓扑（为什么多客户端是免费的）**。
> 图库的每张图都先写清「回答什么问题」和「承重墙论点」，并经三轮审阅与渲染验收。

## 第 1 章 项目概览

**opencode 是什么？** 用 README 里的一句话说："The open source AI coding agent"（开源的 AI 编程代理）。你在终端里敲 `opencode`，就会进入一个漂亮的终端界面（TUI），用自然语言让它帮你读代码、改代码、跑命令、查资料。

**谁在做？** GitHub 组织 `anomalyco`（Anomaly 公司，也就是做 SST/Serverless Stack 的那个团队，仓库里能看到 `sst.config.ts`）。项目非常活跃，社区驱动，采用 MIT 许可证（`LICENSE`），README 被翻译成 25 种语言。

**定位与卖点**：

- **Provider 无关（模型自由）**：不像 Claude Code 绑定 Anthropic，opencode 通过 models.dev 目录支持几十家模型提供商（OpenAI、Anthropic、Google、xAI、GitHub Copilot、Bedrock、Vertex、Kimi、DeepSeek……），甚至可以登录你的 ChatGPT Plus / Claude Pro / Copilot 订阅来用。
- **客户端/服务器架构**：opencode 本体是一个 HTTP 服务器，TUI 只是众多客户端之一。还有桌面 App（BETA）、Web 界面、Zed 编辑器（ACP 协议）、GitHub Action、Slack 机器人。
- **终端体验优先**：TUI 做得极其精致（主题、键位、命令面板、子代理导航），是它最出圈的部分。
- **100% 开源**：包括服务端、TUI、SDK、插件系统全部开源。

**技术栈 / 运行时**：TypeScript，跑在 **Bun** 上（见 `AGENTS.md` 的风格指南："Use Bun APIs when possible, like `Bun.file()`"）。monorepo 用 bun workspaces + turbo。两个特别值得注意的依赖：

- **Effect**（`effect` 库）：一种"函数式、带依赖注入和结构化并发"的 TypeScript 编程框架，整个服务端代码全是 `Effect.gen`、`Layer.effect` 风格。这是该项目最硬核的选型，初学门槛高，但换来了可测试性和并发安全。
- **Vercel AI SDK**（`ai` 包）：负责与各家模型 API 通信；同时对 OpenAI/Anthropic/opencode 三家有自研的"原生运行时"（`session/llm/native-runtime.ts:55-59`）。

TUI 用 **OpenTUI**（`@opentui/core`）+ **SolidJS** 渲染（`packages/tui/package.json:55-66`）。存储用 SQLite + Drizzle ORM。Bash 命令解析用 **tree-sitter**（真·语法树，不是正则）。

**与 Claude Code 的异同（一句话版）**：两者都是"终端里的 agent 循环 + 工具调用"，但 Claude Code 是 Anthropic 官方的闭源单模型产品；opencode 是开源、模型自由切换、多客户端的社区替代品，并且兼容读取 Claude Code 的 `CLAUDE.md` 和 `.claude/skills`。

```mermaid
flowchart LR
    subgraph 用户感知
        A["opencode<br/>开源 AI 编程 agent"]
    end
    A --> B["模型自由<br/>几十家 provider"]
    A --> C["多客户端<br/>TUI/桌面/Web/Zed/GitHub"]
    A --> D["生态开放<br/>插件/MCP/技能/自定义工具"]
    E["Claude Code"] -.对比.-> A
    E -.-> F["官方、单家模型、闭源"]
```

---

## 第 2 章 全景架构

如果把 opencode 比作一家餐厅：**TUI 是前厅服务员**（负责接待你），**HTTP 服务器是厨房**（真正做菜），**agent 主循环是厨师长**（决定下一步做什么菜），**工具是各种厨具**，**模型 API 是外卖来的"决策大脑"**——厨师长每做一步都要问大脑"下一步呢？"，大脑说"用 read 工具看那个文件"，厨房就照做并把结果回传给大脑。

关键目录（都在 `packages/` 下）：

| 层 | 目录 | 职责 |
| --- | --- | --- |
| 界面层 | `tui/` | 终端 UI（SolidJS + OpenTUI） |
| 界面层 | `desktop/`、`app/`、`web/` | 桌面 App、移动/其他前端 |
| 客户端 SDK | `sdk/`、`sdk-next/`、`client/` | 生成的 API 客户端 |
| 服务器 | `opencode/src/server/` | HTTP API（Effect HttpApi） |
| 核心循环 | `opencode/src/session/` | 会话、主循环、压缩、提示词 |
| 工具 | `opencode/src/tool/` | 全部内置工具 |
| 模型通信 | `opencode/src/session/llm/`、`llm/` | AI SDK 适配 + 原生运行时 |
| 生态 | `opencode/src/mcp/`、`plugin/`、`skill/`、`command/` | MCP、插件、技能、命令 |
| 基础设施 | `core/`、`schema/`、`protocol/` | 共享核心（含下一代 V2 会话内核） |

```mermaid
flowchart TB
    subgraph 客户端层
        TUI["packages/tui<br/>终端界面"]
        WEB["packages/web"]
        APP["packages/desktop, app"]
        ACP["acp/<br/>Zed 编辑器"]
    end
    subgraph SDK层
        SDK["@opencode-ai/sdk<br/>HTTP 客户端"]
    end
    subgraph 服务器["opencode 服务器（Bun 进程/Worker）"]
        HTTP["server/routes/instance/httpapi<br/>REST + SSE 事件"]
        LOOP["session/prompt.ts<br/>Agent 主循环"]
        PROC["session/processor.ts<br/>流式事件处理"]
        TOOLS["tool/registry.ts<br/>工具注册表"]
        PERM["permission/index.ts<br/>权限仲裁"]
        MCP["mcp/index.ts"]
        STORE["SQLite (Drizzle)<br/>会话持久化"]
    end
    subgraph 外部
        LLM["模型 API<br/>OpenAI/Anthropic/..."]
        MCPS["MCP 服务器"]
    end
    TUI --> SDK --> HTTP --> LOOP --> PROC
    PROC --> TOOLS
    TOOLS --> PERM
    TOOLS --> MCP --> MCPS
    PROC -->|"llm.stream"| LLM
    LOOP --> STORE
```

注意一个事实：TUI 与服务器**不在同一个逻辑层里**。默认情况下服务器跑在同一个 Bun 进程的一个 **Worker 线程**里（后文第 3 章详述），但通信协议与远程模式完全相同——这就是为什么 opencode 能天然支持多客户端。

---

## 第 3 章 启动流程

你在终端敲 `opencode` 之后发生了什么？

**第一步：CLI 入口**。`packages/opencode/src/index.ts` 用 `yargs` 注册了一大堆子命令（`run`、`serve`、`web`、`acp`、`github`、`mcp`、`models`、`providers`、`upgrade`、`stats`……）。注意第 22 行导入的 `TuiThreadCommand`：

```ts
// packages/opencode/src/cli/cmd/tui.ts:73
export const TuiThreadCommand = cmd({
  command: "$0 [project]",
  describe: "start opencode tui",
```

`$0` 是 yargs 的"默认命令"——不带任何子命令时就启动 TUI。中间件里还设置了三个环境变量（`index.ts:75-77`）：`AGENT=1`、`OPENCODE=1`、`OPENCODE_PID`——这样 agent 里跑的 shell 命令能感知"我在 opencode 里"。

**第二步：分流**。`--mini` 走轻量交互界面；带 `--port`/`--hostname` 则进入"外部服务器"模式；默认路径是重点（`cli/cmd/tui.ts:210-247`）：

```ts
// packages/opencode/src/cli/cmd/tui.ts:210
const worker = new Worker(file, { env: ... })
const client = Rpc.client<typeof rpc>(worker)
```

**服务器不是独立进程，而是 Bun 的一个 Worker 线程**。TUI 线程通过 RPC 调用 worker，再把 RPC 包装成一个假的 `fetch`（`createWorkerFetch`，`tui.ts:27-43`），URL 写成 `http://opencode.internal`（`tui.ts:244`）。也就是说：**即使"本地直连"，代码走的也是和远程一模一样的 HTTP 协议**——这是一个非常优雅的架构决策，客户端永远不需要关心服务器在哪。

**第三步：会话恢复与渲染**。校验 `--session` 指定的会话是否存在（`validateSession`），1 秒后异步检查升级（`tui.ts:265-267`），然后 `Effect.runPromise(run({...}))` 启动 TUI 的 SolidJS 渲染树（`tui.ts:271-291`），同时把 `--continue`、`--agent`、`--model`、`--prompt` 等参数传给界面。

**信任/授权环节**：与 Claude Code 的"workspace trust 弹窗"不同，**未找到**启动时的目录信任确认环节。opencode 的信任模型是"运行时逐条授权"：危险的工具调用（写文件、跑命令、访问工作区外目录）会在执行时弹出权限询问（第 7 章详述）。启动参数里的 `--auto` / `--yolo` / `--dangerously-skip-permissions`（`tui.ts:104-119`，注意后两个是 `hidden: true` 的隐藏选项）则是"我全都要"模式，自动批准未被明确 deny 的权限。

```mermaid
sequenceDiagram
    participant U as 用户 (终端)
    participant CLI as src/index.ts (yargs)
    participant TC as cli/cmd/tui.ts
    participant W as Worker 线程 (HTTP 服务器)
    participant T as TUI (SolidJS)
    U->>CLI: opencode
    CLI->>TC: 默认命令 $0
    TC->>W: new Worker(服务器)
    TC->>TC: RPC 包装成 fetch("http://opencode.internal")
    TC->>W: validateSession / 检查升级
    TC->>T: Effect.runPromise 启动渲染
    T->>W: HTTP 请求（经内存 fetch 桥）
    W-->>T: SSE 事件流（消息/权限询问）
```

---

## 第 4 章 输入捕获与分流

**终端 UI 怎么收输入？** TUI 的输入框是 OpenTUI 的 `TextareaRenderable`（`packages/tui/src/component/prompt/index.tsx:1-9` 的 import），一个支持多行、粘贴图片、撤销历史、自动补全的富文本框。键位绑定集中在 `packages/tui/src/config/keybind.ts`（如 `session_interrupt: keybind("escape", ...)` 在 `keybind.ts:97`）。

**回车后如何分流？** 核心在 `submitInner()`（`prompt/index.tsx:946`）。它像一个"快递分拣员"，把输入分成三个包裹：

```ts
// packages/tui/src/component/prompt/index.tsx:1058-1120（精简）
if (store.mode === "shell") {
  void sdk.client.session.shell({ ..., command: inputText })   // ① Shell 模式
} else if (inputText.startsWith("/") && sync.data.command.some(...)) {
  void sdk.client.session.command({ ..., command: command.slice(1), arguments: args })  // ② 斜杠命令
} else {
  sdk.client.session.prompt({ ..., parts: [ { type: "text", text: inputText }, ...] })  // ③ 普通提示词
}
```

**① Shell 模式**：在空输入框按 `!` 进入 shell 模式（键位绑定 `prompt/index.tsx:831-835`），输入框变色，回车后命令**不经过大模型**，直接调 `session.shell`。服务端实现 `shellImpl`（`opencode/src/session/prompt.ts:451`）会伪造一条 user 消息（文本固定为 `"The following tool was executed by the user"`，`prompt.ts:483`）加一条 assistant 的 shell 工具调用记录——目的是让**模型在后续对话里能看到你手动跑过什么命令及其输出**，保持上下文一致。按 ESC 或在空输入时按退格退出 shell 模式（`prompt/index.tsx:846,857`）。

**② 斜杠命令**：要求首字符是 `/` 且命令名在命令表里存在（`prompt/index.tsx:1071-1072` 会校验），然后解析出命令名和参数发给 `session.command`。服务端处理见第 10 章。

**③ 普通提示词**：打包成 `parts`（文本 + 文件附件 + 编辑器选区）发给 `session.prompt`。另有小彩蛋：输入 `exit`/`quit`/`:q` 直接退出程序（`prompt/index.tsx:963`）。

举三个覆盖不同情形的例子，帮你体会分流的意义：

- 你输入 `帮我看看 src 下有多少个 ts 文件` → 走 ③，模型大概率会调 `glob` 工具；
- 你按 `!` 后输入 `find src -name "*.ts" | wc -l` → 走 ①，命令直接执行、零模型开销，但执行记录会留在会话里，之后你问"刚才数了多少？"模型答得上来；
- 你输入 `/commit 把改动提交了吧` → 走 ②，服务端把 `commit.md` 模板和参数"把改动提交了吧"拼成一段提示词，再走 ③ 的流程发给模型。

可以看到，三种入口最终殊途同归地"成为会话历史的一部分"，区别只在**谁先碰这条输入**：终端直接执行、模板引擎展开、还是原样交给模型。

**排队（QUEUED）**：如果 agent 正在忙，你照样可以发消息。服务端 `promptAsync` 处理器（`server/routes/instance/httpapi/handlers/session.ts:311-328`）把它 fork 到后台：user 消息立即落库，主循环在当前回合的安全边界自然会读到它（机制见第 6 章）。TUI 上这类消息会显示 **QUEUED** 徽标（`routes/session/index.tsx:1373,1436`：消息的 id 大于"正在处理的 assistant 消息 id"即为排队中）。

```mermaid
flowchart TD
    A["用户在 Textarea 输入并回车"] --> B{"mode 或首字符?"}
    B -->|"shell 模式 (! 进入)"| C["session.shell<br/>直接执行命令，不问模型"]
    B -->|"'/' + 已注册命令"| D["session.command<br/>模板展开后转提示词"]
    B -->|其他| E["session.prompt<br/>正常走 agent 循环"]
    C --> F["伪造 user 消息 + shell 工具记录<br/>让模型后续可见"]
    D --> G["$ARGUMENTS/$1 替换、!`cmd` 预执行、@file 展开"]
    E --> H{"agent 忙?"}
    H -->|忙| I["消息落库，显示 QUEUED<br/>下一轮循环自动接管"]
    H -->|闲| J["立即启动主循环"]
```

---

## 第 5 章 上下文组装

给模型发请求前，opencode 要"拼一盘菜"——系统提示词（system prompt）。这盘菜分两层拼装：

**第一层：会话层收集原料**（`session/prompt.ts:1257-1269`）：

```ts
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
  sys.skills(agent),
  sys.environment(model),
  instruction.system().pipe(Effect.orDie),
  sys.mcp(agent, session.permission),
  MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [
  ...env,
  ...instructions,
  ...(mcpInstructions ? [mcpInstructions] : []),
  ...(skills ? [skills] : []),
]
```

**第二层：请求层加"底料"并下锅**（`session/llm/request.ts:58-66`）：在以上原料**最前面**加上"人格底料"——如果 agent 自带提示词就用它，否则按模型家族选一份 `.txt` 提示词文件：

```ts
const system = [
  [
    ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
    ...input.system,
    ...(input.user.system ? [input.user.system] : []),
  ].filter((x) => x).join("\n"),
]
```

**底料（按模型定制的人格提示词）**：`session/system.ts:27-42` 的 `provider()` 函数是个"看菜下碟"的分派器——模型 id 含 `claude` 用 `anthropic.txt`，含 `gemini-` 用 `gemini.txt`，含 `gpt` 用 `gpt.txt`（含 `codex` 用 `codex.txt`），含 `kimi` 用 `kimi.txt`，gpt-4/o1/o3 用 `beast.txt`……都不匹配用 `default.txt`。`default.txt` 开头是："You are opencode, an interactive CLI tool that helps users with software engineering tasks"，里面规定了语气（极简、少于 4 行）、主动性、工具使用规范。

**环境信息**：`system.ts:60-96` 生成 `<env>` 块——工作目录、工作区根、是否 git 仓库（`ctx.project.vcs === "git"`）、操作系统平台、当天日期。注意这里**没有**注入 git 分支、最近提交等详细信息，非常克制。

**AGENTS.md / 记忆文件的收集**（`session/instruction.ts`），这是本项目兼容 Claude Code 习惯的地方：

```ts
// packages/opencode/src/session/instruction.ts:60-68
const globalFiles = [
  path.join(global.config, "AGENTS.md"),
  ...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
const instructionFiles = [
  "AGENTS.md",
  ...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []),
  "CONTEXT.md", // deprecated
]
```

收集顺序与优先级（`systemPaths()`，`instruction.ts:110-153`）：

1. **全局**：`~/.config/opencode/AGENTS.md`，否则 `~/.claude/CLAUDE.md`（只取第一个存在的，`break`）；
2. **项目级**：从当前目录**向上**找到工作区根（`findUp`），按 `AGENTS.md → CLAUDE.md → CONTEXT.md` 的顺序，**第一种有匹配的文件名就停**（`instruction.ts:122-133` 的注释："The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor"）；
3. **配置追加**：`opencode.json` 里的 `instructions` 字段，支持 glob 和 **http(s) URL**（远程抓取带 5 秒超时，`instruction.ts:95-103`）。

还有一个聪明的设计：**按需就近发现**。当 agent 用 read 工具读某个文件时，`Instruction.resolve()`（`instruction.ts:179-221`）会从被读文件所在目录向上找附近的 `AGENTS.md`，把内容附在 read 结果里——每条消息只附一次。这模拟了"走进一个房间才看到墙上的守则"。

**MCP 指令与技能清单**：MCP 服务器的 instructions 包进 `<mcp_instructions>`（`system.ts:112-128`）；可用技能列表以"详细版"写进系统提示词（`system.ts:98-110`，注释解释：模型对系统提示词里的详细版吸收更好，工具描述里则放简版）。

把这一切串起来，一份发给模型的 system 文本大致长这样（示意）：开头是"你是 opencode……"的人格底料，接着是 `<env>` 环境块，然后是若干段 `Instructions from: /path/to/AGENTS.md` 加文件原文，再是 `<mcp_instructions>`，最后是技能清单。之后才是对话历史（用户消息、助手消息、工具调用与结果交替排列）。对初学者来说，理解"系统提示词 = 底料 + 环境 + 项目守则 + 生态说明"这个公式，就抓住了所有编程 agent 上下文工程的共性——各家产品（Claude Code、Cursor、opencode）拼的都是这几样，差别只在收集的广度和更新的时机。opencode 的选择是"少而准"：环境信息克制、守则分层收集、按需就近补充，把宝贵的上下文窗口留给对话本身。

```mermaid
flowchart TB
    subgraph 底料
        P["agent.prompt 或<br/>SystemPrompt.provider(model)<br/>anthropic/gpt/kimi/default.txt"]
    end
    subgraph 原料
        E["env: 目录/git/平台/日期"]
        I["instructions:<br/>全局 AGENTS.md → 项目 AGENTS.md<br/>→ config.instructions/URL"]
        M["MCP instructions"]
        S["Skills 清单"]
    end
    P --> J["join('\n') 拼成一段 system 文本"]
    E --> J
    I --> J
    M --> J
    S --> J
    J --> K{"OpenAI OAuth?"}
    K -->|是| L["放进 Responses API 的 instructions 字段"]
    K -->|否| N["作为 role:system 消息<br/>放在消息列表最前"]
    R["read 工具触发<br/>就近 AGENTS.md 补充"] -.-> I
```

---

## 第 6 章 Agent 主循环（心脏）

这是全文最重要的一章。agent 的"心脏"在 **`packages/opencode/src/session/prompt.ts` 的 `runLoop`，第 1081 行**，循环本体是第 **1088** 行的 `while (true)`。

```ts
// packages/opencode/src/session/prompt.ts:1081-1092
const runLoop: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts> = Effect.fn("SessionPrompt.run")(
  function* (sessionID: SessionID) {
    const ctx = yield* InstanceState.context
    let structured: unknown
    let step = 0
    const session = yield* sessions.get(sessionID).pipe(Effect.orDie)

    while (true) {
      yield* status.set(sessionID, { type: "busy" })
      yield* Effect.logInfo("loop", { "session.id": sessionID, step })

      let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(...)
```

**它是不是教科书式的"请求模型 → tool_use → 执行 → 追加 → 再请求"？是，但实现方式有点特别**。opencode 用的是 Vercel AI SDK 的 `streamText`（`session/llm.ts:280`），且**没有传 `stopWhen`**——即默认单步（one provider turn per stream）。多轮推进靠的是 opencode 自己的 `while (true)` 外循环；而**工具执行则内嵌在流里**（边收边执行，见下文）。仓库 `AGENTS.md` 明确写了这条纪律："Preserve one explicit `llm.stream(request)` call per provider turn"。

每一圈循环干这些事：

1. **重读历史**（1092 行）：每轮重新从数据库读消息——这就是为什么"排队消息"能被自然接管，也是多客户端并发安全的来源。
2. **判断该不该结束**（1111-1130 行）：最后一条 assistant 消息的 `finish` 不是 `"tool-calls"`、也没有未完成的工具调用、且它比最后一条 user 消息新 → `break`。注意 1103-1109 行的防御：有些 provider 明明带了工具调用却返回 `"stop"`，此时继续循环把工具结果喂回去。
3. **处理两类"任务"**：`subtask`（斜杠命令产生的子代理任务，1144-1147）和 `compaction`（压缩任务，1149-1159）。
4. **自动压缩检查**（1161-1168）：上一轮 token 用量溢出 → 创建压缩任务，下轮处理。
5. **组装请求**：创建 assistant 消息壳（1186-1201）→ `SessionTools.resolve` 解析本轮可用工具（1226）→ 拼系统提示词（1257-1269，见第 5 章）→ 如果是最后一步，追加 `MAX_STEPS_PROMPT` 作为 assistant 预填（1281 行，`isLastStep` 由 agent 的 `steps` 配置决定，默认 `Infinity`）。
6. **交给处理器跑一个 provider turn**：`handle.process({...})`（1272 行）。

**流式怎么处理？** 在 `session/processor.ts:640-646`：

```ts
const stream = llm.stream(streamInput)
yield* stream.pipe(
  Stream.tap((event) => handleEvent(event)),
  Stream.takeUntil(() => ctx.needsCompaction),
  Stream.runDrain,
)
```

`handleEvent`（`processor.ts:278`）是个大 switch：`text-start/delta/end` 增量写文本 part；`reasoning-*` 写思考 part；`tool-input-*` 流式收工具参数；`tool-call` 登记工具调用；`tool-result` 完成工具；`step-start/step-finish` 记录 git 快照与 token 用量。所有 part 都**实时落库**，TUI 通过 SSE 事件实时渲染。

**有没有"边收边执行"？有。** 工具不是等整段流结束才跑的：AI SDK 在流式过程中一旦收齐某个 tool_call 的参数，就立即调用该工具的 `execute`（在 `session/tools.ts:102-133` 包装），执行结果作为 `tool-result` 事件回到同一条流里，`processor.ts:383-413` 把它标为 completed。所以你会在 TUI 里看到"模型还在吐字，工具已经在跑了"。

**终止条件全集**：

- 正常完成：`finish` 非 `tool-calls` 且无未完成工具调用（`prompt.ts:1111-1130`）；
- `handle.process` 返回 `"stop"`（`processor.ts:680`：权限被拒且配置了不继续，或消息出错）；
- 返回 `"compact"`：流中途发现上下文溢出（`processor.ts:644` 的 `takeUntil` + `processor.ts:477-482`），先压缩再继续；
- 结构化输出完成（`prompt.ts:1288-1293`）；
- 内容过滤（`content-filter`，`prompt.ts:1301-1308`）；
- 达到 `agent.steps` 上限（`MAX_STEPS_PROMPT` 逼模型收尾）；
- 错误/中断：重试策略耗尽（`SessionRetry.policy`，`processor.ts:660-674`）或被用户打断。

**中断（ESC）机制**：TUI 里 ESC 是**双击确认**——按第一次显示 "again to interrupt"，再按才真正调 `sdk.client.session.abort`（`prompt/index.tsx:407-418`）。服务端 `SessionPrompt.cancel` → `run-state.ts:77-86` 触发 Effect 结构化中断 → processor 的 `Effect.onInterrupt` 把消息标记为 AbortError（`processor.ts:648-655`），`cleanup()`（`processor.ts:539-597`）把所有还在 running 的工具标记为 `error: "Tool execution aborted"` 且 `metadata.interrupted = true`——主循环下一轮能识别这些"孤儿"（`prompt.ts:96-100` 的 `isOrphanedInterruptedTool`）而不至于卡死。

**彩蛋：防"鬼打墙"**。如果连续 3 次工具调用的名字和参数**完全一样**（`DOOM_LOOP_THRESHOLD = 3`，`processor.ts:29,356-380`），系统会弹一个名为 `doom_loop` 的权限询问，让用户决定要不要放它继续原地打转。

用一个完整例子走一遍心脏跳动：你说"把 README 里的安装命令改成 brew"。第 1 圈：循环发现最后一条 user 消息没有对应的 assistant 回复，于是拼好系统提示词、注册工具，发起流式请求；模型边吐字边说"我要调 read 工具"，参数收齐的瞬间 read 就被执行（边收边执行），文件内容作为 `tool-result` 落库；模型接着输出"我要调 edit"。第 1 圈结束时 `finish` 是 `"tool-calls"`，循环不退出。第 2 圈：重读历史（此时历史里已包含 read 的结果），再次请求模型；模型确认替换成功后输出一段总结，`finish` 变成 `"stop"`。第 3 圈：循环发现"最后的 assistant 已完成且没有待办工具"，满足退出条件，`break`，会话转 idle。全程每一步的文本、工具调用、用量都实时写入 SQLite 并经 SSE 推给 TUI，所以你在界面上看到的是逐字打字机效果和工具卡片的状态翻转，而不是转圈等待。

```mermaid
flowchart TD
    S["while (true) 每轮开始"] --> A["重读会话消息<br/>filterCompactedEffect"]
    A --> B{"最后 assistant 已完成<br/>且无工具调用?"}
    B -->|是| Z["break，会话转 idle"]
    B -->|否| C{"有待处理任务?"}
    C -->|subtask| D["派生子代理执行"]
    C -->|compaction| E["执行上下文压缩"]
    C -->|无| F{"上一轮 token 溢出?"}
    F -->|是| E
    F -->|否| G["解析工具 + 拼系统提示词<br/>创建 assistant 消息壳"]
    D --> S
    E --> S
    G --> H["processor.process:<br/>llm.stream 流式请求模型"]
    H --> I{"流式事件"}
    I -->|text/reasoning delta| J["实时落库 + SSE 推给 TUI"]
    I -->|tool-call 参数收齐| K["立即执行工具(边收边跑)<br/>权限询问在此发生"]
    K --> L["tool-result 落库"]
    I -->|step-finish| M{"token 溢出?"}
    M -->|是| N["needsCompaction → takeUntil 截断流<br/>返回 compact"]
    M -->|否| O{"finish 原因"}
    O -->|tool-calls| S
    O -->|stop/error| Z
    N --> S
```

另外值得知道：仓库里存在**下一代 V2 会话内核**（`packages/core/src/session/`，配合理念文档 `CONTEXT.md`），引入了"持久化收件箱 + steer/queue 两种投递语义 + Context Epoch"的更严谨模型，目前正在与 V1 并行演进。这说明团队对"循环与输入的边界"这件事想得比多数同类项目更深。

---

## 第 7 章 工具系统与权限

**工具定义结构**（`tool/tool.ts:55-65`）：每个工具是一个 `Tool.Def`——`id`、`description`（从同名 `.txt` 文件读入，如 `read.txt`）、`parameters`（Effect Schema）、`execute(args, ctx)` 返回 `{ title, metadata, output, attachments }`。`Tool.define` 会做两件统一的事：参数解码失败时抛出给模型看的"请重写参数"错误（`tool.ts:24-34`），以及输出截断（`truncate.ts:15-16`：**2000 行 / 50KB**，超出部分写到临时文件并把路径告诉模型）。

**全部内置工具**（注册顺序见 `tool/registry.ts:226-244`）：

| 分组 | 工具 id | 功能 | 关键参数 | 例子 |
| --- | --- | --- | --- | --- |
| 文件读取 | `read` | 读文件/目录，默认 2000 行，行号前缀 | `filePath`、`offset`、`limit` | 读 `src/index.ts` 第 500 行起 |
| 文件读取 | `glob` | 按 glob 模式找文件 | `pattern`、`path?` | `src/**/*.tsx` |
| 文件读取 | `grep` | 正则搜内容（ripgrep） | `pattern`、`path?`、`include?` | `log.*Error` |
| 文件修改 | `edit` | 精确字符串替换，须先 Read | `filePath`、`oldString`、`newString`、`replaceAll?` | 改函数名 |
| 文件修改 | `write` | 整文件覆写，旧文件须先 Read | `filePath`、`content` | 新建组件 |
| 文件修改 | `apply_patch` | 补丁格式编辑（仅 GPT 系模型启用，`registry.ts:292-295`） | patch 文本 | `*** Begin Patch` |
| 执行 | `shell`（bash） | 跑终端命令 | `command`、`timeout?`、`workdir?` | `npm test` |
| 执行 | `task` | 派生子代理 | `description`、`prompt`、`subagent_type`、`task_id?`、`background?` | 派 explore 查代码 |
| 执行 | `execute`（实验） | code-mode，实验开关 | 代码 | 批量调 MCP |
| 计划 | `todowrite` | 维护待办清单 | `todos[]` | 列实施步骤 |
| 计划 | `plan_exit`（实验） | 退出计划模式并问用户是否开工 | 无 | plan agent 收尾 |
| 用户交互 | `question` | 执行中向用户提问（仅 app/cli/desktop 客户端启用，`registry.ts:202`） | `questions[]` | "选哪个方案？" |
| 网络 | `webfetch` | 抓 URL 转 markdown/text/html | `url`、`format?`、`timeout?` | 读文档页 |
| 网络 | `search`（websearch） | 联网搜索（仅 opencode 官方 provider 或 exa/parallel 实验开关，`registry.ts:58-60`） | `query` 等 | 查最新资讯 |
| 网络 | `list_mcp_resources` 等 3 个 | 当有 MCP 服务器声明 resources 能力时动态注册（`tools.ts:27-31`） | `server?`、`uri` | 读 MCP 资源 |
| 元能力 | `skill` | 按名加载技能说明书进上下文 | `name` | 加载 `effect` 技能 |
| 元能力 | `lsp`（实验） | 调用语言服务器：定义/引用/hover/符号等 9 种操作 | `operation` 等 | 找函数定义 |
| 兜底 | `invalid` | 接收修不好的工具调用（`llm.ts:296-312` 的 `experimental_repairToolCall` 把失败调用改写给它） | `tool`、`error` | 模型乱调工具时兜底 |

**bash 类工具的命令解析**是亮点：不是正则，而是 **tree-sitter 语法树**（`tool/shell.ts:9,91-117`）。`parts()` 遍历 AST 抽出每条命令的命令名和参数，用于权限匹配（比如 `git status` 和 `git push` 可以被区别对待），还能识别 `cd`/`rm`/`cp` 等"碰文件"的命令集合（`shell.ts:28-50` 的 `CWD`/`FILES` 常量）。提示词按 shell 类型定制（pwsh/PowerShell/cmd 各有一份注意事项，`shell/prompt.ts:40+`）。

**权限机制：ask / allow / deny 三态 + 通配符规则**。仲裁器在 `permission/index.ts`：

```ts
// packages/opencode/src/permission/index.ts:28-38
export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {
  return (
    rulesets.flat().findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern))
    ?? { action: "ask", permission, pattern: "*" }   // 没匹配到？默认 ask
  )
}
```

规则形如 `{ permission: "edit", pattern: "src/**", action: "allow" }`，**后写的规则优先**（`findLast`）。默认规则集（`agent/agent.ts:119-136`）：一切 `allow`，但 `doom_loop`、`external_directory`（工作区外）、读 `*.env` 是 `ask`。plan agent 则 `edit: deny`（只允许写计划文件）。

**怎么问用户？** 工具执行中调 `ctx.ask(...)`（如 task 工具 `task.ts:120-128`）→ `Permission.ask` 评估规则：deny 直接抛 `DeniedError`；需要问则发 `Permission.Event.Asked` 事件并**挂起在一个 Deferred（承诺）上**（`permission/index.ts:98-106`）——像打电话被转接等候。TUI 弹出对话框，用户三选一（`routes/session/permission.tsx`）：

- **once（仅这次）**：Deferred 放行，不记规则；
- **always（总是）**：把 `always` 里的模式追加进已批准规则，顺便把同会话中其它能被新规则覆盖的挂起请求一并放行（`permission/index.ts:145-166`）；
- **reject（拒绝）**：可以**附带一句反馈**（`CorrectedError`，`permission/index.ts:121-127`），这句反馈会作为工具错误回给模型——"不许跑 rm -rf，请用更安全的方式"，模型能看懂并改方案。同时同会话其它挂起的请求也被连坐拒绝。

再举一个权限规则的实战例子。假设你在 `opencode.json` 里写：`{ "permission": { "edit": { "*": "allow", "docs/**": "ask" }, "bash": { "*": "allow", "git push *": "ask", "rm *": "deny" } } }`。那么：改 `src/a.ts` 直接放行；改 `docs/guide.md` 弹窗问你；跑 `npm test` 直接跑；跑 `git push origin dev` 弹窗；跑 `rm -rf build/` 则连问都不问直接拒绝，模型收到"被拒绝"的工具错误后只能换方案。配合 agent 级规则（比如 plan agent 全局禁编辑），opencode 的权限系统实际上是一张"用户配置 ⊃ 会话规则 ⊃ agent 规则 ⊃ 内置默认"的多层滤网，越具体的规则越靠后写、越优先命中。

```mermaid
flowchart TD
    A["工具 execute 中调 ctx.ask(permission, patterns)"] --> B["evaluate: 合并 agent 规则 + 会话规则 + 已批准规则"]
    B --> C{"findLast 匹配结果"}
    C -->|allow| D["直接执行"]
    C -->|deny| E["抛 DeniedError<br/>工具失败，错误回给模型"]
    C -->|ask / 无匹配| F["发 Asked 事件<br/>Deferred 挂起等待"]
    F --> G["TUI 弹窗：once / always / reject"]
    G -->|once| D
    G -->|always| H["追加 approved 规则<br/>同会话可覆盖的挂起请求一并放行"] --> D
    G -->|reject| I["RejectedError 或带反馈的 CorrectedError<br/>反馈文本回给模型"]
```

---

## 第 8 章 上下文压缩与记忆

**有没有 compact/摘要机制？有，而且是三层。**

**第一层：自动压缩（auto compaction）**。触发阈值在 `session/overflow.ts`：

```ts
// packages/opencode/src/session/overflow.ts:22-37（精简）
export function isOverflow(input) {
  if (input.cfg.compaction?.auto === false) return false
  const count = input.tokens.total || (input + output + cache.read + cache.write)
  return count >= usable(input)   // usable = 上下文上限 - 预留(约 20k 或最大输出)
}
```

每轮结束若用量 ≥ "可用上限"（`COMPACTION_BUFFER = 20_000`，`overflow.ts:8`），主循环就创建压缩任务（`prompt.ts:1161-1168`）。更妙的是**流中熔断**：`step-finish` 时发现溢出会置 `needsCompaction`，`Stream.takeUntil`（`processor.ts:644`）立刻截断这条流先压缩再继续——不用等模型把话说完。

**压缩怎么实现？** `session/compaction.ts`：用一个**隐藏的 compaction agent**（`agent.ts:219-233`，权限 `*: deny`，只许说话不许用工具）对"头部"历史生成摘要。"尾部"保留策略很精细：默认保留最近 2 个用户回合（`DEFAULT_TAIL_TURNS = 2`，`compaction.ts:32`），且保留量受 token 预算约束（可用上下文的 25%，夹在 2k–8k 之间，`compaction.ts:80-85`），预算不够时还会把回合**从中间切开**（`splitTurn`，`compaction.ts:105-128`）。如果是"溢出型压缩"（模型话说到一半被掐），压缩后会把最后一条真实用户消息**重放**一遍让 agent 接着干（`compaction.ts` 中 `input.overflow` 分支）。压缩后开启新的 Context Epoch，摘要替换旧历史。

**第二层：工具输出修剪（prune）**。`compaction.ts` 的 `prune()`：从最新往最旧扫，保护最近 2 个回合和最近 40k token 的工具输出（`PRUNE_PROTECT = 40_000`），更老的已完成工具输出如果累计可释放超过 20k（`PRUNE_MINIMUM = 20_000`）就打上 `compacted` 时间戳、从历史投影中抹掉（数据库里还在，只是不再喂给模型）。`skill` 工具的输出受保护（`PRUNE_PROTECTED_TOOLS`）。

**第三层：单次输出截断**。任何工具单次输出超过 2000 行/50KB 就截断并落临时文件（第 7 章）。

**手动压缩**：TUI 的 `/compact` 命令（`routes/session/index.tsx:559`）随时可触发。

**长期记忆？未找到**跨会话的持久记忆系统（没有类似 Claude Code "memory" 的自动跨会话学习）。跨会话的"记忆"靠会话持久化（SQLite，可 `--continue` 恢复）和 `AGENTS.md` 这类人工维护的文件。

```mermaid
flowchart TD
    A["每轮 step-finish 统计 token"] --> B{"用量 ≥ 上下文上限-预留?"}
    B -->|否| C["继续"]
    B -->|是| D["needsCompaction<br/>takeUntil 熔断当前流"]
    D --> E["compaction agent 总结头部历史<br/>(无工具权限的隐藏 agent)"]
    E --> F["保留尾部: 最近 2 回合<br/>且 ≤ 25% 上下文 (2k~8k token)"]
    F --> G{"溢出型?"}
    G -->|是| H["重放最后一条真实用户消息"]
    G -->|否| I["从摘要后继续"]
    H --> I
    J["后台 prune"] --> K{"老工具输出 > 40k 保护线<br/>且可释放 > 20k?"}
    K -->|是| L["标记 compacted<br/>不再注入模型"]
```

---

## 第 9 章 子 agent 与多 agent

**有没有子代理系统？有，且是核心机制。** opencode 的"agent"是一等公民配置（`agent/agent.ts` 的 `Info` schema：名字、描述、`mode: primary/subagent/all`、权限、模型、温度、提示词、`steps` 上限）。内置 7 个（`agent.ts:140-265`）：

| agent | mode | 用途 |
| --- | --- | --- |
| `build` | primary（默认） | 全能执行，默认权限 |
| `plan` | primary | 计划模式，禁编辑、禁 general 子代理 |
| `general` | subagent | 通用多步任务，可并行 |
| `explore` | subagent | 只读探索代码库（只允许 grep/glob/read/bash 等） |
| `compaction` / `title` / `summary` | hidden | 内部角色：压缩、起标题、生成摘要 |

TUI 里按 **Tab 在 primary agent 之间切换**（`keybind.ts`：`agent_cycle: keybind("tab", "Next agent")`）。

**怎么派生？** 通过 `task` 工具（`tool/task.ts`）。关键设计：

- **子代理 = 子会话**：`task` 工具创建一个 `parentID` 指向当前会话的新 session，独立跑自己的主循环；父会话的 task 工具调用等它完成，把最后文本包进 `<task id="..." state="...">` 返回（`task.ts:64-79`）。
- **深度限制**：沿 parentID 链数深度，默认最多 1 层（`task.ts:104-117`，`cfg.subagent_depth ?? 1`）——子代理默认**不能**再派孙子代理，防止套娃失控。
- **权限继承**：子会话权限由 `deriveSubagentSessionPermission` 从父会话派生。
- **先问用户**：派生前有 `ctx.ask({ permission: "task", patterns: [subagent_type] })`（`task.ts:120-128`）。
- **可恢复**：传 `task_id` 可以回到之前那个子代理会话继续聊（`task.ts:136-138`）。

**前后台**：`background: true` 可异步启动子代理并立即返回——但目前是**实验特性**，必须设环境变量 `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`（`task.ts:97-102`），完成后系统会自动通知主 agent。TUI 侧有配套：左右方向键在子会话间穿梭、上键回父会话（`keybind.ts`：`session_child_cycle` 等），`ctrl+b` 可把同步子代理转入后台（`session_background`）。

为什么要这么小心地限制深度和后台？因为子代理是"会自己花 token 的递归"。如果没有深度限制，一个误解了任务的子代理可能再派十个子代理，每个都开着完整的上下文窗口烧 API 额度；如果没有权限继承和派生询问，子代理就成了绕过用户授权的后门。opencode 的选择是先把最安全的形态（前台、一层、需批准）做成默认，把危险的形态（后台并发、多层嵌套）藏在实验开关后面——这与它整体的权限哲学一致：能力可以有，但默认必须是保守的。

一个典型使用场景：你让主 agent"给这个项目写单元测试"。它评估后发现需要先摸清代码结构，于是派一个 `explore` 子代理（只读、快、上下文独立）去扫描目录和关键文件；子代理返回一份摘要后，主 agent 才开始动手写测试文件。子代理的几十次 grep/read 不会污染主会话的上下文——这既是性能优化，也是上下文窗口的精打细算。

此外，**斜杠命令也能派生子代理**：命令声明 `subtask: true` 时（如内置的 `/review`），参数会打成 `subtask` part 交给主循环的 `handleSubtask` 处理（`prompt.ts:1144-1147, 1439-1451`）。

```mermaid
sequenceDiagram
    participant M as 主 agent (build)
    participant T as task 工具
    participant P as 权限系统
    participant C as 子会话 (explore)
    M->>T: task{subagent_type: "explore", prompt: "找认证逻辑"}
    T->>T: 沿 parentID 链检查深度 ≤ 1
    T->>P: ask(task, explore)
    P-->>T: 用户批准
    T->>C: 创建 parentID=主会话 的新 session
    C->>C: 独立主循环 (只读工具)
    C-->>T: 完成，返回最后文本
    T-->>M: <task id state="completed">结果</task>
    Note over M,C: background=true 时立即返回，<br/>完成后再通知（实验特性）
```

---

## 第 10 章 生态

**斜杠命令清单（总表）**。opencode 的命令分两层：**TUI 应用层命令**（界面操作）和**服务端模板命令**（展开成提示词发给模型）。

TUI 层（`app.tsx` 与 `routes/session/index.tsx` 的 `slashName`）：

| 类别 | 命令 |
| --- | --- |
| 会话 | `/new`、`/sessions`、`/rename`、`/timeline`、`/fork`、`/share`、`/unshare`、`/undo`、`/redo`、`/compact`、`/copy`、`/export`、`/move` |
| 模型与代理 | `/models`、`/agents`、`/variants`、`/connect`、`/mcps` |
| 界面 | `/themes`、`/status`、`/debug`、`/help`、`/editor`、`/skills`、`/timestamps`、`/thinking`、`/org`、`/warp` |
| 退出 | `/exit` |

服务端模板命令：**内置 `/init`（引导生成 AGENTS.md）和 `/review`（评审改动，subtask 模式）**（`command/index.ts:47-76`）；自定义命令放 `.opencode/command/*.md`（支持 YAML frontmatter 指定 `model`、`agent`、`subtask`）；MCP 服务器提供的 prompts 也会变成命令（`command/index.ts` 中 `source: "mcp"`）。

**命令模板的三种魔法**（`session/prompt.ts:command` 函数，`1372-1408`）：

1. `$ARGUMENTS`、`$1`、`$2`……位置参数替换（还支持 `"引号"` 与 `[Image N]` 占位，`1592-1596` 的正则）；
2. `` !`cmd` `` —— 发送前**先在本地执行 shell 命令**并把输出嵌进模板（`1397-1408`），比如模板里写 `` !`git diff --staged` `` 就能把暂存区 diff 喂给模型；
3. `@文件路径` —— `resolvePromptParts`（`prompt.ts:157-191`）把引用展开成文件附件；如果名字恰好是个 agent，则变成 agent 引用（`@explore 查一下……`）。

本仓库自己就在用（dogfooding）：`.opencode/command/commit.md`（提交并 push）、`.opencode/agent/triage.md`（议题分诊 agent）。

**自定义技能（Skills）**：发现 `SKILL.md` 于 `.opencode/{skill,skills}/**`、`~/.claude/skills`、`.agents` 等位置（`skill/index.ts:23-27` 的模式常量）。系统提示词里列清单，模型用 `skill` 工具按需加载正文——"用到才翻说明书"，省 token。

**MCP 支持**：三种传输方式——**stdio、StreamableHTTP、SSE**（`mcp/index.ts:7-9`；远程服务器先尝试 StreamableHTTP，失败回退 SSE，`mcp/index.ts:269-290`），支持 OAuth 授权流程。**工具命名规则**：`sanitize(服务器名)_sanitize(工具名)`（`mcp/catalog.ts:117-119`，非法字符替换为下划线）——例如 `github_get_issue`。每个 MCP 工具调用前都走权限询问，权限名就是工具 key（`session/tools.ts:407`），配置里可以用 `mcp:github:*` 这样的模式批量放行。MCP 服务器若声明 resources 能力，还会动态多出 `list_mcp_resources` 等 3 个工具；MCP 的 instructions 会注入系统提示词。

一个具体的配置例子：在 `opencode.json` 的 `mcp` 字段里声明一个本地 stdio 服务器（`"github": { "type": "local", "command": ["npx", "-y", "@modelcontextprotocol/server-github"], "environment": { "GITHUB_TOKEN": "..." } }`）或远程服务器（`"type": "remote", "url": "https://..."`）。启动后，该服务器的所有工具会以 `github_xxx` 的名字出现在模型的工具清单里，和内置工具平起平坐；模型甚至分不清哪些是内置、哪些来自 MCP——这正是 MCP 协议的价值：**工具生态的标准化插座**。

**插件系统**：`opencode.json` 的 `plugin` 字段声明 npm 包或本地文件（`plugin/loader.ts`、`plugin/meta.ts` 维护安装记录 `plugin-meta.json`）。插件是返回 `Hooks` 的函数（`packages/plugin/src/index.ts:222`），钩子非常全：`config`、`event`、`tool`（注册新工具）、`auth`、`provider`、`chat.message`、`chat.params`（改温度等参数）、`chat.headers`、`permission.ask`、`tool.execute.before/after`、`command.execute.before`、`shell.env`、以及 `experimental.*` 系列。**内置插件**（`plugin/index.ts:65-79`）全是认证类：Codex（ChatGPT 订阅）、GitHub Copilot、GitLab、Poe、Cloudflare ×2、Azure、DigitalOcean、Snowflake、xAI。另外还有更轻的自定义工具目录：`{tool,tools}/*.ts` 会被自动扫描注册（`registry.ts:178-192`）。

**LSP**：`lsp/` 目录实现了语言服务器管理（自动 spawn 对应语言 server），编辑文件后收集诊断信息反馈给模型；`lsp` 工具目前是实验开关（`registry.ts:242`）。

```mermaid
flowchart TB
    subgraph 命令来源
        A["内置 init/review"]
        B[".opencode/command/*.md"]
        C["MCP prompts"]
        D["TUI 应用命令"]
    end
    subgraph 技能
        E[".opencode/skills/**/SKILL.md<br/>~/.claude/skills"]
    end
    subgraph 工具扩展
        F[".opencode/tool/*.ts"]
        G["npm/文件插件 Hooks.tool"]
        H["MCP 服务器工具<br/>server_tool 命名"]
    end
    A --> CMD["Command.Service<br/>$ARGUMENTS / !`cmd` / @file"]
    B --> CMD
    C --> CMD
    CMD --> LOOP["Agent 主循环"]
    E --> SKILL["skill 工具按需加载"]
    F --> REG["ToolRegistry"]
    G --> REG
    H --> REG
    REG --> LOOP
    SKILL --> LOOP
    D --> TUIACT["界面动作（不发模型）"]
```

---

## 第 11 章 功能特性

**认证方式**：凭证存在数据目录的 `auth.json`（`auth/index.ts:11`），三种形态：`api`（API key）、`oauth`（refresh/access/expires，自动刷新）、`wellknown`（`auth/index.ts:15-35`）。`opencode providers login`（`cli/cmd/providers.ts`）按插件声明的认证方法走：OAuth 分 `auto`（开浏览器+本地回调）和 `code`（复制粘贴码）两种子流程（`providers.ts:95-150`），API key 则是交互式输入。也就是说你可以用 **ChatGPT Plus（Codex 插件）、GitHub Copilot 订阅、Claude 订阅** 等登录，也可以走环境变量或自定义 baseURL 的"BYOK"路线。另外 `OPENCODE_AUTH_CONTENT` 环境变量可整体注入凭证（`auth/index.ts`），方便 CI。

**模型选择与切换**：`--model provider/model`（如 `anthropic/claude-sonnet-4-5`）；TUI 里 `/models` 弹窗、`F2` 在最近用过的模型间循环（`keybind.ts`：`model_cycle_recent`）、可收藏。模型目录来自 **models.dev**（`provider/provider.ts:13`），每个模型的上下文上限、价格、能力都从这里来；也支持在 `opencode.json` 里自定义 provider（npm 包 + baseURL）。

**多模型支持（是否 provider 无关）？是。** 同一个会话里不同 agent 可以用不同模型（比如 `.opencode/agent/triage.md` 里写 `model: opencode/gpt-5.4-mini`），不同命令也可以指定模型。对 OpenAI/Anthropic/opencode 三家还有**自研原生运行时**（`native-runtime.ts:55-59`），绕开 AI SDK 直接说"家乡话"，其余 provider 走 Vercel AI SDK 适配层。opencode 自己还有官方聚合 provider（`opencode/...` 前缀，提供 websearch 等增值服务）。

**effort/thinking 配置**：通过"模型变体（variants）"实现。`provider/transform.ts` 会按模型家族算出在 reasoning_effort 上的档位：OpenAI 系从 `none/minimal` 到 `xhigh`（还按模型发布日期决定是否暴露 `none` 档位，`transform.ts:567-572`），Anthropic 新模型有 `low`–`max` 自适应档位（`transform.ts:640+`）。TUI 用 `/variants` 弹窗切换（`dialog-variant.tsx`），`/thinking` 开关思考显示。agent 配置里也可固定 `variant`。

**权限模式**：三态规则（第 7 章）+ 两个"性格"：默认的 `build`（先问后做）与 `plan`（只读规划）。`opencode run --auto`/`--yolo`/`--dangerously-skip-permissions` 自动放行。配置文件 `permission` 字段可精细到 `{ "bash": { "git push *": "ask", "*": "allow" } }`。

**其他特色**：

- **会话分享**：`/share` 把会话同步到 opencode.ai 生成链接（`share/share-next.ts`；`OPENCODE_DISABLE_SHARE` 可禁用）；
- **多客户端**：`opencode serve`（HTTP 服务器）、`opencode web`、`opencode attach`（接入已有服务器）、`opencode acp`（Zed 编辑器的 Agent Client Protocol）、桌面 App（Electron 结构：`packages/desktop` 有 `main/preload/renderer`）、`opencode github`（GitHub Action，在 PR/Issue 里 `@opencode` 召唤）、Slack 包；
- **撤销/重做**：每步记录 git 快照（processor 的 `step-start/step-finish` 调 `snapshot.track()/patch()`），`/undo`、`/redo` 回滚文件改动；
- **TUI 体验**：主题系统、可配键位、命令面板（leader 键）、输入历史与 frecency（频率+最近度排序）、草稿 stash、粘贴图片、外置编辑器（`ctrl+o` 类打开 $EDITOR）、子会话导航、会话置顶与 1-9 快速切换；
- **结构化输出**：`session.prompt` 带 `format: json_schema` 时会注入 `StructuredOutput` 工具强制模型按 schema 交卷（`prompt.ts:74-82,1243-1250`）——这是 `opencode run` 脚本化的基石。

```mermaid
flowchart LR
    subgraph 认证
        A["auth.json<br/>api / oauth / wellknown"]
        B["providers login<br/>OAuth auto/code"]
        C["环境变量<br/>OPENCODE_AUTH_CONTENT"]
    end
    subgraph 模型
        D["models.dev 目录"]
        E["原生运行时<br/>OpenAI/Anthropic/opencode"]
        F["Vercel AI SDK<br/>其余 provider"]
        G["variants: reasoning effort 档位"]
    end
    subgraph 客户端
        H["TUI"]
        I["serve + web/desktop"]
        J["ACP (Zed)"]
        K["GitHub Action / Slack"]
    end
    A --> E
    B --> E
    C --> F
    D --> E
    D --> F
    E --> H
    F --> H
    G --> H
```

---

## 第 12 章 总结：设计哲学与取舍

通读源码后，opencode 给我的感觉是：**一个把"工程正统性"放在首位的开源 Claude Code**。它的独特想法与亮点：

**① 服务器即本体，协议即产品。** 默认模式下 TUI 和服务器在同进程，却依然通过 Worker + RPC + 内存 fetch 走完整的 HTTP 协议（`tui.ts:210-247`）。这意味着"本地单机"和"远程团队"是同一份代码路径——桌面 App、Zed、GitHub Action、第三方 SDK 全部零成本接入。对比 Claude Code 的"CLI 即全部"，opencode 从第一天就把"被集成"当成核心场景。

**② 用 Effect 赌可维护性。** 全服务端用 Effect 重写（依赖注入 Layer、结构化并发、Deferred 权限挂起、流式 Stream），换来的是：中断语义极其干净（ESC 中断的清理路径 `processor.ts:539-597`）、并发安全（每轮重读数据库，排队消息自然接管）、可测试（几乎不用 mock，`AGENTS.md` 明令"Avoid mocks"）。代价也很明显：代码对普通贡献者门槛偏高，`yield*` 满屏飞。这是一个"长期主义"的豪赌。

**③ 对"边界"的偏执。** 这个项目最让人印象深刻的是对边界情况的处理密度：provider 谎报 `stop` 但有工具调用？继续循环（`prompt.ts:1103-1109`）。连续 3 次一模一样的工具调用？doom loop 询问（`processor.ts:356-380`）。工具参数修不好？塞进 `invalid` 工具兜底（`llm.ts:296-312`）。压缩把模型的话说到一半掐断？重放最后用户消息。拒绝权限还能附言"教"模型改正（`CorrectedError`）。仓库根部那份 3 万字的 `CONTEXT.md`，干脆给整个会话运行时定义了一套形式化词汇表（Context Epoch、Safe Provider-Turn Boundary……），并正在落地 V2 内核——这种"先把语义说清楚再写代码"的态度，在开源自研 agent 里很少见。

**明显的取舍**：

- **兼容 Claude Code 生态**（读 `CLAUDE.md`、`.claude/skills`）降低了迁移成本，但也背上了别人的历史包袱（代码里留着 `disableClaudeCodePrompt` 开关和 `CONTEXT.md // deprecated` 注释）。
- **模型自由换来适配复杂度**：`provider/transform.ts` 两千多行全是各家模型的怪癖（哪家支持哪个 effort 档、Copilot 的计费字段、Azure 的 URL 模式……），这是 provider 无关的必然税。
- **激进的重写**：项目处于高速重构期（V1/V2 会话内核并存、legacy bridge 遍地），阅读源码时经常遇到"TODO: remove this hack"（如 `tool.ts:15`）。功能强，但稳定性让位于演进速度。
- **无跨会话长期记忆**：相比 Claude Code 的记忆机制，opencode 选择只做会话内压缩 + 人工 `AGENTS.md`，把"记得用户"这件事交给配置而不是算法——克制，但也意味着个性化要靠用户自己经营。

一句话总结：Claude Code 是"为 Claude 打造的最佳终端 agent"，而 opencode 试图成为"为所有模型、所有客户端打造的开放 agent 平台"——它用 Effect 的严谨、C/S 架构的开放、和满坑满谷的边界处理，朝着这个目标狂奔。

```mermaid
mindmap
  root((opencode<br/>设计哲学))
    开放
      服务器即本体,协议先行
      模型自由(models.dev+原生运行时)
      插件/MCP/技能/命令全开放
      兼容 Claude Code 生态
    严谨
      Effect 依赖注入+结构化并发
      每轮重读数据库保证一致
      doom_loop/orphan/overflow 全防御
      CONTEXT.md 形式化语义
    取舍
      贡献门槛高(Effect+Bun)
      适配税(transform.ts 两千行)
      V1/V2 并存的高速重构
      无跨会话记忆,克制个性化
```

---

*（全文完。文中所有 `文件:行号` 引用均可在 commit `62e46412` 的源码中直接核对；标注"实验"的特性需要对应环境变量或配置开启。）*
