260908_OpenCode源码分析
OpenCode 是 SST 团队开源的 AI 编程助手,定位与 Claude Code 类似,但用 TypeScript + Bun 实现,并基于 Effect 与 Vercel AI SDK
构建了一套相当工程化的运行时。这篇以 sst/opencode 的真实源码为依据,逐机制拆解它 “为什么是这样设计的”。
本文所有结论均来自 github.com/sst/opencode 默认分支 dev 的源码,并标注packages/opencode/src/ 下的相对路径。代码块为示意代码(保留真实命名与结构,省略无关细节),而非逐行复制,目的是让你抓住控制流与数据流。
读完这篇,你应该能回答:用户敲下回车后,消息怎样流转、工具怎样被调用、上下文超限时怎样压缩、子 Agent 怎样被委派、MCP 工具怎样被发现、权限怎样在每一步被把关。
1. 先纠正几个常见误解
在进入正文前,先把几个容易被 “想当然” 的点钉死,因为它们直接决定了你读源码时的导航方向:
- 没有
src/context/目录。上下文管理(压缩、溢出、摘要)全部住在src/session/
下(compaction.ts/overflow.ts/summary.ts)。如果你按 “经典分层” 去找
context 目录,会扑空。 - **bash 工具实际叫
shell**(tool/shell.ts+tool/shell/),不存在bash.ts。 - **主循环函数是
runLoop**(session/prompt.ts),而不是很多人猜测的session.run/session.chat。session/session.ts其实是 Session 的
CRUD 服务(create / getPart / remove / setTitle),不承载循环逻辑。 - “summary” 有三种,不要混淆:
session/summary.ts算的是文件 diff
摘要(基于 snapshot);对话压缩用的是packages/core/src/session/compaction.ts里的SUMMARY_TEMPLATE;而agent/prompt/summary.txt是 “会话描述摘要 agent” 的
prompt(用于生成会话标题 / 描述,像 PR description)。三者完全不同。 - MCP 工具命名用下划线连接
sanitize(serverName)_sanitize(toolName),不是mcp__server__tool这种双下划线前缀(那是 Claude Code 的风格)。 - “always allow” 权限只存内存,不落盘,重启即失效。要持久化只能手写
opencode.json的permission字段。 - Provider 基于 Vercel AI SDK(约 20 个
@ai-sdk/*包)+ 自研@opencode-ai/llm原生 HTTP 通路并行,不是从零自研协议适配。
记住这几条,下面读起来会顺很多。
2. 整体架构与分包
OpenCode 是一个 monorepo,packages/ 下有 23 个分包。和 “源码解析” 最相关的是这几个:
| 分包 | 职责 |
|---|---|
packages/opencode |
核心 agent 运行时,本文主体 |
packages/core |
共享类型 / Schema / 工具(如 v1/config/*、session/compaction.ts、util/token.ts) |
packages/llm |
自研 LLM 抽象层 @opencode-ai/llm,与 AI SDK 并行的原生 HTTP 通路 |
packages/sdk |
对外 SDK @opencode-ai/sdk |
packages/server / packages/tui / packages/app / packages/desktop |
服务端 / TUI / 桌面 / UI |
packages/cli |
CLI 命令分发 |
而核心运行时 packages/opencode/src/ 内部的目录职责如下:
| 目录 | 职责 |
|---|---|
session/ |
会话主循环、LLM 调用、压缩、消息、系统提示(最核心) |
tool/ |
工具定义、注册表、schema |
provider/ |
Provider / Model 类型、消息与 schema 转换、鉴权 |
permission/ |
权限评估、命令前缀匹配 |
mcp/ |
MCP server 接入、工具发现、OAuth |
agent/ |
Subagent 定义、子 agent 权限派生、prompt 模板 |
server/ |
HTTP API 路由、projectors、事件 |
config/ |
配置解析(agent / command / markdown / plugin / paths) |
storage/ |
文件系统 KV 持久化层 |
auth/ |
opencode 账户鉴权(区别于 mcp/auth.ts 的 MCP token 存储) |
整体的调用层次大致是:
1 | CLI (packages/cli) |
注意
src/context/与src/subagent/这两个 “看起来应该存在” 的目录都不存在 —— 上下文在session/,子 Agent 在agent/+tool/task.ts。
下面进入正题。
3. 会话主循环与工具调用
这是 OpenCode 的心脏。理解了它,其余章节都是在不同侧面给它打补丁。
3.1 入口链:prompt → loop → runLoop
用户发一条消息,最终走到的是 session/prompt.ts 里的三层函数:
prompt(session/prompt.ts,effect 名"SessionPrompt.prompt"):对外入口,创建用户消息后调用loop。loop(session/prompt.ts,effect 名"SessionPrompt.loop"):通过state.ensureRunning(sessionID, lastAssistant(...), runLoop(sessionID))把runLoop交给运行状态机执行。runLoop(session/prompt.ts):真正的while (true)循环体,签名是(sessionID: SessionID) => Effect.Effect<SessionV1.WithParts>。
ensureRunning 定义在 session/run-state.ts,它的作用是保证同一 session 同一时刻只有一个循环在跑 —— 如果上一次assistant 还没结束就来了新输入,会按策略合并或排队,而不是并发触发两个 LLM 流。这是一个朴素但关键的并发护栏。
三层入口与运行状态机的协作关系如下:
关键点是 ensureRunning 用 lastAssistant(...) 作为幂等键:同一 session 的并发输入不会触发两个 LLM流,而是被合并或排队。这是 Effect 结构化并发的典型用法。
3.2 一次循环的 16 步
runLoop 的循环体(session/prompt.ts)展开后是这样的一组步骤。我用伪代码把骨架画出来,注释里标注了来源文件:
1 | // session/prompt.ts |
这 16 步里有几个值得停下来看的设计点。
为什么 “取消息” 要 filterCompactedEffect? 因为压缩不是物理删除旧消息,而是用一条 compaction 消息 “覆盖”掉它。MessageV2.filterCompactedEffect 把这些被覆盖的历史过滤掉,让 LLM 看到的是 “摘要 + 近期对话”,而不是 “摘要 + 已被摘要吃掉的原文”。这是压缩与循环解耦的关键 —— 压缩只产生一条记录,循环每次按需过滤。
为什么 step === 1 才 fork 标题? 标题生成是一次独立的 LLM 调用(用 title agent),它和主对话并行跑,但只需要在循环开始时触发一次。fork 出去不阻塞主循环。
为什么 task 要先于工具调用处理? 因为有些 “轮次”不是真的让模型继续生成,而是系统塞进去的子任务(subtask)或压缩任务(compaction)。tasks 是一个队列,pop出来按类型分流,避免它们和普通生成混在一起。
3.3 工具是怎么注入到 LLM 调用里的
第 11 步的 SessionTools.resolve(session/tools.ts)是工具系统的总装车间。它把两类工具组装成一个Record<string, AITool>:
1 | // session/tools.ts SessionTools.resolve |
注意几个细节:
- 工具的
inputSchema不是直接拿原始 schema,而是先ToolJsonSchema.fromTool(effect Schema → JSON Schema),再ProviderTransform.schema按 model 适配(OpenAI 走sanitizeOpenAISchema、moonshot 去$ref兄弟关键字等)。 - MCP 工具的命名是
sanitize(serverName)_sanitize(toolName),和内置工具混在同一个 dict 里,对 LLM 来说没有区别。 - 每个 execute 内部最终调
item.execute(args, ctx),结果含attachments,并触发tool.execute.before/after插件钩子,再processor.completeToolCall把结果写回 part。
这份 tools 传给 handle.process →llm.stream(session/llm.ts)→LLMRequestPrep.prepare(session/llm/request.ts)的 resolveTools → 最终streamText({ tools, activeTools, toolChoice })(session/llm.ts)。
从工具定义到最终下发到 LLM 的完整链路:
有个有趣的特例:Copilot 模型在没有工具、但历史里含 tool calls 时,会注入一个 _noop工具(session/llm/request.ts)。这是因为某些模型在历史含 tool calls 时强制要求 tools 参数非空,_noop是个不会被调用的占位工具,纯粹为了满足协议。
3.4 processor:消费 LLM 事件流
handle.process(session/processor.ts)是循环里真正干活的地方。它把 LLM 的流式事件归一化成LLMEvent,再用 Stream.tap(handleEvent) 消费:
1 | // session/processor.ts process(简化) |
handleEvent(processor.ts)按事件类型更新 parts(reasoning / text / tool),并向 V1/V2
双写发布事件。这里有几个机制值得拎出来:
流内压缩(needsCompaction)。Stream.takeUntil(() => ctx.needsCompaction)
会在流式生成中发现 “上下文要爆了” 时提前终止流,process 返回 "compact",外层 runLoop
收到后触发压缩再 continue。这意味着压缩不一定要等一轮结束 ——
它可以在生成中途就被触发。这是个很聪明的点:与其等模型生成完一长串然后下一轮才发现超限,不如在流式过程中就监测 token 用量。
doom loop 检测。如果连续 DOOM_LOOP_THRESHOLD = 3 次出现 “相同工具名 + 相同输入”,processor 会触发permission.ask({ permission: "doom_loop", ... })(processor.ts),把 “是否继续”交给用户。这是防止模型陷入 “反复读同一个文件” 死循环的安全阀。
中断处理。Effect.onInterrupt(() => finalizeInterruptedAssistant) 保证用户 Ctrl + C 时,正在跑的 assistant 消息会被妥善标记为中断态,而不是悬空。中断后halt(new DOMException("Aborted", "AbortError"))(processor.ts)。
权限拒绝传播。当 experimental.continue_loop_on_deny !== true 时,工具被拒会置ctx.blocked,下一轮 process 返回 "stop",循环结束。默认行为是 “被拒就停”,避免模型在被拒后疯狂重试。
processor 内部对 LLM 事件流的消费与三条异常分支(流内压缩 / doom loop / 权限拒绝):
3.5 stop 条件全集
把循环的所有退出路径汇总,方便对照:
| 退出条件 | 触发位置 | 含义 |
|---|---|---|
| 自然结束 | prompt.ts |
lastAssistant.finish 且非 tool-calls 且无 tool calls 且 lastUser.id < lastAssistant.id |
result === "stop" |
prompt.ts |
ctx.blocked(权限拒绝)或 assistantMessage.error |
result === "structured" |
prompt.ts |
命中结构化输出 |
result === "finished" + error |
prompt.ts |
content-filter 或结构化错误 |
| 步数软上限 | prompt.ts |
step >= agent.steps,注入 MAX_STEPS_PROMPT 引导收尾(非硬 break) |
| 用户中断 | prompt.ts |
onInterrupt → 标记中断态 |
compaction 返回 "stop" |
prompt.ts |
压缩后仍超限(ContextOverflowError) |
注意 “步数软上限” 不是硬性的 —— 它只是在最后一步注入一段提示让模型收尾,模型理论上仍可以继续。真正硬性的终止是ContextOverflowError(压缩两次都救不回来)和用户中断。
整个主循环可以用一张图概括:
图里两条压缩路径(轮次边界的 isOverflow 与生成中途的 needsCompaction)最终都汇入compaction.create,这是压缩与循环解耦的体现:压缩只产生一条记录,循环每次按需过滤。
4. Provider 抽象与工具协议
主循环只管 “调模型”,但 “怎么调不同厂家的模型” 是 Provider 层的事。
4.1 三层适配:工厂映射 + 定制 loader + transform
OpenCode 的 Provider 适配不是一个大 switch,而是三层叠加:
**第一层:工厂映射 BUNDLED_PROVIDERS**(provider/provider.ts)。它把 npm 包名动态
import 到 AI SDK 的 createXxx 工厂:
1 | // provider/provider.ts (示意) |
动态 import 意味着未使用的 provider 不会进启动开销。
**第二层:定制 loader custom(dep)**(provider.ts)。每个 provider有自己的怪癖,这里集中处理:
anthropic:注入 beta headerinterleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14(交错思考 + 细粒度工具流)。openai/xai:getModel走sdk.responses(modelID)(Responses API,而非 Chat Completions)。github-copilot:按 gpt 版本号选responses或chat。azure:解析resourceName,按useCompletionUrls选chat/responses/messages。amazon-bedrock:区域前缀注入(us./eu./apac./jp./au.)、bearer token / credential chain。
**第三层:消息转换 provider/transform.ts**(约 430 行)。normalizeMessages +message() 针对各 provider 做规范化:
- anthropic / bedrock:过滤空消息。
- claude:清洗
toolCallId。 - mistral:修复
tool→user序列。 - deepseek:给 assistant 补 reasoning。
- interleaved-thinking 字段处理。
还有一个 sdkKey(npm) 把 npm 包名映射到 AI SDK 的 providerOptions key。
4.2 Provider 与 Model 的类型
1 | // provider/provider.ts (示意) |
注意 Info 里没有顶层 url / fetch 字段 ——baseURL 是通过options.baseURL 传给底层 SDK 的(见 native-runtime.ts 中baseURL: input.provider.options.baseURL)。Provider.Interface 暴露list / getProvider / getModel / getLanguage(返回LanguageModelV3)/ closest(模型回退)/ getSmallModel /defaultModel。
4.3 工具 schema 的定义与下发
工具参数用 effect Schema 定义,下发前要转成 JSON Schema 并按 model 适配。这条链路在 tool/json-schema.ts:
1 | // tool/json-schema.ts (示意) |
带 WeakMap 缓存,避免重复转换。下发时(session/tools.ts)每个工具的 schema 还要过一遍ProviderTransform.schema(input.model, ...):
- openai / azure:
sanitizeOpenAISchema(去掉 OpenAI 不支持的关键字)。 - moonshot / kimi:去除
$ref的兄弟关键字、合并 tuple items。 - 其他:透传。
这套 “effect Schema → JSON Schema → model 适配 → AI SDK tool ()” 的四段式,让工具定义保持类型安全(effect Schema编译期校验),同时兼容各模型对 JSON Schema 的不同支持程度。
4.4 tool/*.txt:给 LLM 看的说明书
这是 OpenCode 一个很有意思的约定:每个工具基本都有一个同名 .txt,作为给 LLM 看的使用说明,通过import DESCRIPTION from "./xxx.txt" 以字符串导入,赋给 Tool.define 的description 字段。
1 | read.txt edit.txt write.txt glob.txt grep.txt |
其中 shell.txt 不是静态文本,而是模板,含 ${intro} / ${os} /${shell} / ${workdirSection} / ${commandSection} /${tmp} 占位符,由 ShellPrompt.render(shell/prompt.ts)按平台动态渲染 —— 所以
shell 工具的描述会随 OS、shell 类型、工作目录变化。
task 工具的描述更进一步:registry.ts 会拼接 describeTask(agent),动态列出当前可用的子
agent 类型。这让 LLM 永远知道 “现在能委派哪些 subagent”,而不用硬编码。
5. 工具系统
Provider 层讲完了工具的 “下发”,现在看工具本身的 “定义与执行”。
5.1 Tool.Def 接口
1 | // tool/tool.ts |
注册用 define(id, init)(tool.ts),返回带 .id 的Effect<Info>。
5.2 execute 的包装:解码 → 执行 → 截断
wrap(tool.ts)是每个工具 execute 外面的统一外壳:
1 | // tool/tool.ts wrap(示意) |
三段式很清晰:先解码(参数不对直接 InvalidArgumentsError,不会传到 execute 内部),再执行,最后截断(过长输出落盘到文件,metadata 里记truncated / outputPath)。截断是保护上下文的重要手段 —— 一次 read 读出几万行不能直接塞回消息。
5.3 注册表
tool/registry.ts 的 layer 内各工具经 yield* XxxTool 再Tool.init(xxx) 初始化。内置工具 builtin 数组(约 225-245 行):
1 | invalid, question(条件), shell, read, glob, grep, edit, write, |
tools(model) 会按 model 过滤 —— 比如 gpt-5 系用 ApplyPatchTool 替代Edit/Write(因为 gpt-5 更适合用 patch 格式),websearch 按 provider 开关。插件工具通过fromPlugin(id, def) 把插件 Zod args 转 Tool.Def,并扫描{tool,tools}/*.{js,ts} 动态 import 自定义工具。
5.4 关键工具的 execute 要点
| 工具 | 文件 | execute 要点 |
|---|---|---|
| read | tool/read.ts(ReadTool,execute) |
读文件 / 目录,按行返回 <line>: <content>,默认 2000 行、单行截断 2000 字符;图片 / PDF 作附件 |
| edit | tool/edit.ts |
参数 filePath/oldString/newString,字符串替换,带 diff 修正算法、LSP 诊断验证、按文件加 Semaphore 锁 |
| write | tool/write.ts |
参数 content/filePath,写入文件 |
| shell | tool/shell.ts(ShellTool) |
web-tree-sitter 解析命令 AST,collect 扫描涉及目录 / 文件用于权限询问,再 run 执行;description 由 ShellPrompt.render 动态生成 |
| task | tool/task.ts(TaskTool,execute) |
参数 description/prompt/subagent_type/task_id?/command?/background?;经 Agent.Service.get(subagent_type) 取子 agent,派生子会话权限并运行 |
edit 的 “ 按文件加 Semaphore 锁 “ 值得注意:它防止模型对同一文件并发发多个 edit 导致冲突。shell 用
web-tree-sitter 解析命令 AST 是为了权限询问 —— 它能从命令里提取出会涉及的目录 / 文件(重定向目标、路径参数),用于external_directory 和 bash 权限的 pattern 匹配。
5.5 工具结果回填到消息
工具执行完,结果怎么回到消息里供下一轮 LLM 看?这条链路在 session/processor.ts:
1 | // session/processor.ts completeToolCall(示意) |
流式 tool-result(processor.ts)还会归一化图片附件(image.normalize),发布SessionEvent.Tool.Success/Failed(V2 双写),再 completeToolCall。运行中状态由updateToolCall(processor.ts)实时更新 title/metadata/input,而Context.metadata(tools.ts)让工具能在执行过程中实时回写进度 —— 这就是 TUI 里能看到 “正在读文件…” 的来源。
6. 上下文管理与压缩
长对话必然会超 context window。OpenCode 的压缩机制是它工程化程度最高的部分之一。
6.1 双层持久化
消息存两层:
- 数据库层(主存储):
session/session.ts通过makeRuntime(Database.Service, Database.defaultLayer)建立运行时。消息读取走MessageV2.page({ sessionID, limit, before })(session/session.ts)分页查询 +MessageV2.stream(message-v2.ts)流式读取。底层是packages/effect-drizzle-sqlite/packages/effect-sqlite-node(drizzle +
sqlite)。 - 文件 KV 层:
storage/storage.ts提供文件系统
KV(read/write/update/list/remove),存 session/message/diff/summary 文件元数据,带Migration机制。
为什么要两层?sqlite 负责高频读写和查询(分页、流式),文件 KV 负责可移植的元数据快照和迁移。这种 “热数据进 DB,元数据进文件” 的分层在需要导出 / 迁移场景下很实用。
6.2 overflow.ts:判定是否该压缩
只有 34 行的 session/overflow.ts 是压缩的判定门:
1 | // session/overflow.ts (示意) |
关键点:
cfg.compaction?.auto === false时直接关掉自动压缩(用户可配置)。reserved取配置或min(20000, maxOutputTokens),给输出留 buffer。count优先用 provider 返回的tokens.total(精确),否则用 input + output + cache 之和。
6.3 compaction.ts:压缩流程
session/compaction.ts 的 Interface 暴露 isOverflow /select / process 等。核心是 process(processCompaction):
1 | // session/compaction.ts process(骨架) |
几个设计要点:
select 按 tail_turns(默认2)切分。DEFAULT_TAIL_TURNS = 2 保留最近 2 轮对话不压缩,preserveRecentBudget(区间2000-8000)是保留近期对话的 token 预算。这保证模型始终能看到 “刚刚发生了什么”,而更早的对话被摘要替代。
摘要由独立 LLM 调用产生,不是某种启发式截断。压缩用的 prompt 模板是packages/core/src/session/compaction.ts 的 SUMMARY_TEMPLATE +buildPrompt,含 Goal / Constraints / Progress / Key Decisions / Next Steps / Critical Context
/ Relevant Files 等 section。有 previousSummary 时走 “更新锚定摘要”,没有时走 “新建锚定摘要”——
所以摘要是增量演进的,不是每次重头来。
压缩后仍超限就报错。result === "compact" 表示压缩完还是超限,这时ContextOverflowError,process 返回 "stop",主循环 break。这是真正的 “上下文耗尽”
硬终止。
mode: "compaction" 的消息。压缩产生的是一条特殊 mode 的 assistant 消息,它在MessageV2.filterCompactedEffect 里会 “覆盖” 掉它压缩的那些历史 —— 这就是前面循环里 “取消息要 filterCompacted” 的由来。
6.4 流内压缩 vs 主动压缩
压缩有两种触发路径:
- 主动压缩(循环第 6 步):
compaction.isOverflow为真 →compaction.create({ auto: true })→continue。这是轮次开始前的预防性压缩。 - 流内压缩(processor 内):
Stream.takeUntil(() => ctx.needsCompaction)
在生成中发现超限 →process返回"compact"→ 主循环compaction.create({ auto: true, overflow: !handle.message.finish })→continue。这是生成中途的紧急压缩。
两者最终都走 process,区别在于 “在轮次边界压缩” 还是 “在轮次中途压缩”。
压缩的内部流程(processCompaction)与触发路径:
6.5 token 计数:粗估 vs 精确
OpenCode 的 token 计数有两套:
- 粗估:
packages/core/src/util/token.ts(仅 5
行)——const CHARS_PER_TOKEN = 4; export const estimate = (input) => Math.max(0, Math.round(input.length / 4))。按
“每 4 字符 ≈ 1 token” 估算。用在compaction.ts估算消息体积、compaction.ts估算工具输出大小(prune
流程)、core/session/compaction.ts判断 prompt 是否超 context。 - 精确:provider 返回的
tokens(SessionV1.Assistant.tokens,含
input/output/reasoning/cache),isOverflow优先用tokens.total(overflow.ts)。
为什么要粗估?因为精确 token 要等 LLM 调用返回才知道,但压缩的 select(切分 head / tail)发生在调用之前,这时只能用粗估来判断
“切多少去压缩”。粗估够用,因为 select 只是切个大致边界,真正的超限判定用 tokens.total。
7. 子 Agent / Subagent
OpenCode 的子 Agent 不是独立的运行时,而是复用 session 机制 —— 这是理解它的关键。
7.1 task 工具:唯一的委派入口
子 Agent 的委派完全通过 task 工具(tool/task.ts)完成,没有别的路径。它的 execute
流程(task.ts):
1 | // tool/task.ts TaskTool execute(骨架) |
第 4 步是精髓:ops.prompt 就是前面讲的 session/prompt.ts 的 prompt 入口。**子
Agent 跑的完全是同一个 runLoop**,只是用了一个不同的 agent 定义(不同
prompt、不同模型、不同权限)。这意味着主循环的所有机制(压缩、工具调用、doom loop 检测)对子 Agent 全部适用,零额外实现成本。
子 Agent 委派的全过程,注意第 4 步复用了同一个 runLoop:
background 模式需要环境变量 OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true,异步跑子
agent,完成后通过 BackgroundJob 回调把结果注入父会话。
7.2 Agent.Info 与内置 agent
1 | // agent/agent.ts |
内置 agent(agent.ts 硬编码):
| agent | mode | 用途 |
|---|---|---|
build |
primary | 默认主 agent |
plan |
primary | 规划,禁编辑 |
general |
subagent | 通用子 agent |
explore |
subagent | 只读搜索专家 |
compaction |
primary (hidden) | 上下文压缩 |
title |
primary (hidden) | 标题生成 |
summary |
primary (hidden) | 会话描述摘要 |
mode 字段决定 subagent 与 primary 的区别;defaultInfo(agent.ts)拒绝把 subagent
或 hidden agent 设为默认。
7.3 权限派生
子会话通过 sessions.create({ parentID, agent, permission }) 创建,带 parentID
关联父会话。权限派生函数 deriveSubagentSessionPermission(agent/subagent-permissions.ts):
1 | // agent/subagent-permissions.ts (示意) |
源码注释明确:“Parent agent restrictions only govern that agent; the subagent’s own permissions determine its capabilities”—— 父 agent 的限制只约束父 agent 自己,子 agent 的能力由它自己的权限决定。继承的只是 deny 和 external_directory。task.ts 还额外追加 childToolDenies:把experimental.primary_tools 列出的工具对子 agent deny,防止子 agent 用主 agent 专属工具。
7.3 agent/prompt/*.txt 各 prompt 用途
| 文件 | 绑定 agent | 用途 |
|---|---|---|
explore.txt |
explore | “file search specialist”,强调用 Glob/Grep/Read/Bash 搜索、返回绝对路径、不修改系统 |
compaction.txt |
compaction | “anchored context summarization”,只总结历史、保留文件路径与标识符、不回答对话本身 |
summary.txt |
summary | 会话摘要,像 PR description 写 2-3 句、第一人称、描述改动而非过程 |
title.txt |
title | 标题生成,单行 ≤50 字符、不含工具名、用对话同语言 |
generate.txt |
— | 用于 Agent.generate(LLM 自动生成 agent 配置) |
8. 权限机制
权限是贯穿所有工具调用的横切关注点。OpenCode 用一套 “规则集 + 询问” 模型实现。
8.1 ask / reply 三态
权限服务 permission/index.ts,接口 ask/reply/list。Action 类型是"ask" | "allow" | "deny"(packages/core/src/v1/config/permission.ts)。
工具执行前通过 ctx.ask 触发询问。ctx.ask 的实现(session/tools.ts)把 agent
权限 + session 权限合并为 ruleset 传入:
1 | // session/tools.ts ctx.ask(示意) |
ask 流程(index.ts):
1 | // permission/index.ts ask(骨架) |
事件流 permission.asked / permission.replied(index.ts),TUI / HTTP 端通过reply 接口回送结果。
8.2 evaluate:后定义优先
evaluate(permission, pattern, ...rulesets)(index.ts)用Wildcard.match 同时匹配 permission 名和 pattern,findLast
取最后一条命中规则(后定义优先),无命中默认返回 { action: "ask", permission, pattern: "*" }:
1 | // permission/index.ts evaluate(示意) |
“后定义优先” 意味着配置里后面的规则可以覆盖前面的,这给了用户灵活的 “先放行大类再限制小类” 或反过来能力。
8.3 reply 三态
reply(index.ts):
reject:拒绝并连带取消同 session 其他 pending。once:仅本次放行。always:把always数组里的 pattern 作为 allow 规则 push 到内存approved。
ask 到 reply 的完整交互,以及三态 reply 的不同落点:
8.4 always allow 只存内存
这是开篇强调过的点。reply 的 always 分支只approved.push({ permission, pattern, action: "allow" }) 到内存State.approved(index.ts),无任何文件写入。HTTP handlerhandlers/permission.ts 的 reply 仅调 svc.reply,无 config
写回。config.ts 中只有 permission 读取(546 / 562 行),未发现写回逻辑。
结论:always allow 是 InstanceState 级内存持久化,重启后失效。 用户若要持久化需手动在 opencode.json 的permission 字段预设规则。这是个有意的设计选择 —— 避免 “用户随手 always 了一次危险操作然后就永久放行”。
8.5 权限规则的两层来源
- 配置 schema
ConfigPermissionV1.Info(core/v1/config/permission.ts):Record<permission, Rule>,Rule = Action | Record<pattern, Action>。 - 配置读取(
config/config.ts):从opencode.json读permission字段并mergeDeep,含OPENCODE_PERMISSION环境变量覆盖。 - agent 默认权限(
agent.ts):由Permission.fromConfig构造defaults(如*: allow、doom_loop: ask、read对*.envask、question/plan_*: deny),再与用户配置user
merge。 - 运行时内存
State.approved: PermissionV1.Rule[](index.ts):存
always 批准的规则。
fromConfig(index.ts):string 值展开为 pattern: "*";object 值按每个 key 作为
pattern(支持 ~、$HOME 展开)。所以配置可以很简洁:
1 | { |
9. 扩展机制
OpenCode 的扩展点分散在几个层面,分别对应不同复杂度的定制需求。
9.1 自定义 Tool:插件机制
工具注册表 tool/registry.ts 除了内置工具,还通过 fromPlugin(id, def) 加载插件工具 —— 把插件的 Zod args
转成 Tool.Def。此外它会扫描 {tool,tools}/*.{js,ts} 动态 import
自定义工具文件。这意味着添加一个自定义工具,只需在约定目录放一个导出 Tool.Def 的 .ts/.js 文件,无需改核心代码。
工具按 model 过滤的机制(tools(model))也让扩展工具能声明 “只在某些模型下可用”—— 例如 gpt-5 系用 ApplyPatchTool
替代 Edit/Write。
9.2 自定义 Provider:插件目录
Provider 适配除了 BUNDLED_PROVIDERS 的 AI SDK 工厂映射,还有 plugin/ 目录下的 Provider
插件(azure.ts、xai.ts、github-copilot/copilot.ts、openai/codex.ts、openai/ws.ts
等),负责 OAuth 鉴权与特殊传输(如 Codex 的 WebSocket)。新增一个有特殊鉴权或传输需求的 provider,在 plugin/ 下加一个插件文件即可,不必动BUNDLED_PROVIDERS。
9.3 自定义 Agent:两种途径
前文章节提到过,自定义 agent 有两种途径:运行时合并(cfg.agent 配置覆盖或 disable 内置
agent)和文件加载({agent,agents}/**/*.md 的 frontmatter + 正文 prompt)。markdown 方式尤其友好 —— 一个.md 文件就是一个 agent,frontmatter 写配置、正文写系统 prompt,降低自定义门槛。
9.4 MCP:外部能力接入
MCP 是最重量级的扩展点,允许接入任意外部工具服务器(local stdio / remote HTTP)。MCP 工具自动并入 session 工具表,对 LLM
而言与内置工具无异。cli/cmd/mcp.ts 的 mcp add 把 server 写入opencode.json,OAuth token 持久化到 mcp-auth.json。
9.5 插件钩子
除了 “加东西”,扩展还能 “改行为”。源码里多处 plugin.trigger(...) 钩子允许插件介入核心流程,例如:
experimental.session.compacting(compaction.ts):压缩前允许插件注入上下文或替换 prompt。experimental.compaction.autocontinue:控制压缩后是否自动续接。tool.execute.before/after:工具执行前后钩子。
这套钩子让插件能在不 fork 核心的前提下改变压缩、续接、工具执行等行为,是比单纯 “加工具 / provider” 更深的扩展维度。
- Title: 260908_OpenCode源码分析
- Author: Haoliang Tang
- Created at : 2026-09-08 00:00:00
- Updated at : 2026-09-08 19:39:12
- Link: https://hl-tang.github.io/2026/09/08/260908_OpenCode源码分析/
- License: This work is licensed under CC BY-NC-SA 4.0.