OpenCode 工程解析:一个 Terminal Coding Agent 是怎么组织 Session、Loop、Provider 和 Permission 的?
项目类型:Terminal Coding Agent / Agent Server / TUI
赏析目标:理解一个 Coding Agent 产品如何把 Agent 内核、Session、Provider、Tool、Permission 和用户交互组织成一个完整系统
核心问题:当 Coding Agent 不再是一个"后端 Runtime",而是一个"本地 Server + 终端 UI"的产品时,架构会变成什么样?
前面五篇赏析,我们已经把 Agent 的几种形态看遍了:
- LangGraph —— Workflow 编排
- OpenHands —— Coding Agent 的执行环境与 Runtime
- browser-use —— 感知真实界面
- DeerFlow 2.0 —— 长任务 SuperAgent 的容错
- Hermes Agent —— 跨会话自我进化
这五篇有一个共同点:它们全是 Python 写的,而且都更关心"Agent 的后端能力"——怎么执行、怎么容错、怎么记忆、怎么成长。
但一个真正的 Coding Agent 产品,还有另一半:用户怎么和它交互。
这就是 OpenCode 的位置。它和 OpenHands 同属 Coding Agent 赛道,但视角完全不同:
OpenHands
→ AI Software Engineering Agent
→ Runtime / Sandbox / Event(后端执行)
OpenCode
→ Terminal Coding Agent
→ Agent Loop / Session / Provider / Tool / Permission / TUI(产品形态)
而且 OpenCode 是这六篇里唯一一个用 TypeScript(而且是 Effect-TS)写的。这一点本身就值得专门赏析——因为它让你看到,同一个"Coding Agent"问题,用函数式依赖注入和用 Python 类,会长成两种完全不同的架构。
这篇我们就抓住用户最关心的七个核心结构——Session、Agent Loop、Provider、Tool、Permission、Context、TUI,最后再和 OpenHands 做一张架构对比图。
一、先别看代码:OpenCode 解决什么"OpenHands 不解决"的问题?
OpenHands 的核心问题是:Agent 怎么在真实环境里执行代码(Runtime / Sandbox / Event)。它的答案是"把 Agent 放进一个可执行环境"。
OpenCode 的核心问题不一样。它假设执行能力已经有了,然后问:
一个 Coding Agent 产品,怎么组织 Session、Session 里的多轮对话、用哪个模型、能调哪些工具、每个工具能不能执行、以及用户怎么在终端里看着它干活?
换句话说,OpenHands 关心"Agent 的手",OpenCode 关心"Agent 的全身 + 脸"。
这也解释了为什么 OpenCode 会有这么多 OpenHands 没有的模块:session/、permission/、tui/、models.dev 驱动的 provider/。这些都是"产品形态"的东西,不是"执行能力"的东西。
记住这个分工,后面看每个模块都会通。
二、最重要的一张图:OpenCode 是 Server / Client 架构
第一次看 OpenCode 的 packages/ 目录,你会看到 opencode/、tui/、sdk/、server/、app/、web/ 一堆包。但它们的关系其实很简单:
OpenCode 把 Agent 内核做成了一个本地 HTTP Server,TUI、IDE、SDK 都是它的客户端。
证据在 packages/opencode/src/server/server.ts:73:
export async function listen(opts: ListenOptions): Promise<Listener> {
const listener = await Effect.runPromise(listenEffect(opts))
...
}
Server 用 Effect 的 HttpApiApp 暴露一整套 HTTP 接口(server/routes/),TUI 通过 @opencode-ai/sdk 连上去。
这意味着架构是这样的:
这和 OpenHands 的单体 Runtime 是根本性的架构差异:
- OpenHands:Agent、Runtime、Sandbox 在一个进程里,UI 是外挂的。
- OpenCode:Agent 内核是一个独立 Server,UI(TUI)只是众多客户端之一。
这个选择带来一个巨大的好处:同一个 Agent 内核,可以同时被终端、IDE、Web、甚至另一个程序(SDK)使用。你在终端里开着的会话,IDE 插件能看到;你用 SDK 写的脚本,能驱动同一个 Agent。
洞察 1:把 Agent 内核做成 Server 而不是库,"交互"就从"一个 UI"变成了"任意客户端"。这是 Coding Agent 从"工具"走向"产品"的关键一步。
三、闪光点 1:Effect-TS——用函数式 DI 重写 Agent 内核
OpenCode 最让我停下来的一点,是它全面用了 Effect-TS。
前面五个 Python 项目,组织依赖的方式无非是"类 + 构造注入"或"模块级函数"。OpenCode 用的是 Effect 的 Service / Layer / Generator / Schema / TaggedError 全家桶。
看 SessionProcessor 是怎么声明依赖的(session/processor.ts:79 起的 Layer.effect):
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const session = yield* Session.Service
const config = yield* Config.Service
const snapshot = yield* Snapshot.Service
const agents = yield* Agent.Service
const llm = yield* LLM.Service
const permission = yield* Permission.Service
const plugin = yield* Plugin.Service
const summary = yield* SessionSummary.Service
const status = yield* SessionStatus.Service
const image = yield* Image.Service
const events = yield* EventV2Bridge.Service
const database = yield* Database.Service
// ...
}),
)
而在文件末尾,node(processor.ts:713)把这 13 个依赖显式列出:
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [
Session.node, Config.node, Snapshot.node, Agent.node, LLM.node,
Permission.node, Plugin.node, SessionSummary.node, SessionStatus.node,
Image.node, EventV2Bridge.node, Database.node,
],
})
这带来三个 Python 项目很难同时做到的东西:
- 依赖关系完全显式:一个服务依赖谁,看
deps数组一目了然,不用翻构造函数。 - 错误是类型:
Schema.TaggedErrorClass让每种错误(ModelNotFoundError、ProviderInitError、SessionBusyError)都是可辨识、可 exhaustively 处理的类型,而不是一个笼统的Exception。 - 数据模型即 Schema:
session.ts:224的Info = Schema.Struct({...})、所有 Input 都是 Schema,运行时校验和 TypeScript 类型是同一个东西。
代价也很明显:学习曲线陡峭。你第一次看 Effect.gen(function* () { const x = yield* Foo.Service }) 会一头雾水。但一旦理解,它比一个几百行的 Python __init__ 里塞十几个 self.xxx 要清晰得多。
洞察 2:当 Agent 内核的服务多到十几个时,函数式 DI(Effect Layer)比"类构造注入"更能让依赖关系显式化、错误类型化。这是 TypeScript 生态给 Agent 工程带来的独特选项。
四、闪光点 2:Agent Loop 是"流式处理 + 三态返回"
OpenCode 的 Agent Loop 在 SessionProcessor(session/processor.ts)。它的核心不是一个 while 循环,而是一个流式处理管道。
主入口是 process()(processor.ts:641):
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
return yield* Effect.gen(function* () {
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)), // 逐事件处理
Stream.takeUntil(() => ctx.needsCompaction), // 需要压缩就停
Stream.runDrain,
)
}).pipe(
Effect.onInterrupt(/* abort 处理 */),
Effect.catchCauseIf(/* 非中断错误 */),
Effect.retry(SessionRetry.policy({ ... })), // 带状态反馈的重试
Effect.catch(halt),
Effect.ensuring(cleanup()),
)
if (ctx.needsCompaction) return "compact" // ① 需要压缩
if (ctx.blocked || ctx.assistantMessage.error) return "stop" // ② 被阻止/出错
return "continue" // ③ 继续下一轮
})
})
这个设计有三个值得拆的点:
① 流式而不是轮询。 llm.stream() 返回一个 Stream.Stream<LLMEvent>(llm.ts:55),Agent Loop 用 Stream.tap(handleEvent) 逐事件消费(step-start/step-finish/finish/tool-call 等)。模型一边生成,Loop 一边处理——这就是为什么你能在终端里看到 Agent "边想边做"。
② 三态返回,把"循环控制"交还给外层。 process() 返回 "compact" | "stop" | "continue",而不是自己在内部死循环。外层根据这个返回值决定:是触发上下文压缩(compact)、是停止(stop)、还是继续下一轮(continue)。循环的"节奏"被显式建模成了数据,而不是藏在控制流里。
③ 容错是一个组合管道,不是一堆 try/except。 注意那串 .pipe(...):onInterrupt 处理用户中断、retry 处理可重试错误(还带 status.set({type:"retry"}) 的状态反馈)、ensuring(cleanup) 保证清理。这是声明式的错误处理栈。
另外,工具调用的生命周期也被精细管理了:ensureToolCall(创建 pending)→ updateToolCall(流式更新)→ completeToolCall/failToolCall(完结)→ settleToolCall(用 Deferred 释放等待者)。而且 create()(processor.ts:98)在 LLM stream 启动之前就预捕获 snapshot.track()——因为 AI SDK 可能在发出 start-step 事件前就执行了工具,checkpoint 必须提前。
洞察 3:把 Agent Loop 建模成"流式管道 + 三态返回 + 组合容错栈",比一个大 while 循环更能让"什么时候压缩、什么时候停、什么时候重试"变成可推理的数据。
五、闪光点 3:Permission——"这个工具到底能不能执行"的裁决器
这是用户最想看的点:一个 Terminal Agent 怎么决定某个工具能不能执行?
OpenCode 的答案在 permission/index.ts,核心是 evaluate()(:28):
export function evaluate(permission: string, pattern: string, ...rulesets): PermissionV1.Rule {
return rulesets
.flat()
.findLast((rule) =>
Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)
) ?? { action: "ask", pattern: "*" } // 默认问用户
}
拆解这个设计:
① 规则 = 通配符匹配。 每条规则是 { permission, action, pattern },比如 { permission: "bash", action: "allow", pattern: "git *" }。用 Wildcard.match 双向匹配工具名和参数模式。findLast 意味着最后声明的规则优先(后面覆盖前面)。
② 三种裁决:allow / deny / ask。 这是关键——Permission 不是简单的"能/不能",而是三态:
allow:直接放行deny:直接拒绝ask:停下来问用户
③ ask 是交互式的。 ask()(permission/index.ts:67,Effect.fn)遍历所有 patterns,遇到 deny 就抛 RejectedError,遇到 allow 就放行,否则发起一个交互式询问,等用户在终端里确认。还有"always allow"机制(:145),用户可以说"这类操作以后都允许"。
④ 权限检查嵌在工具的 Context 里。 回看 tool/tool.ts:36 的 Context:
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
agent: string
abort: AbortSignal
messages: SessionV1.WithParts[]
metadata(input): Effect<void>
ask(input): Effect<void> // ← 工具通过它请求权限
}
每个工具执行时拿到的 ctx.ask,就是它向 Permission 系统请求裁决的通道。比如 bash 工具执行一条命令前,会先 ask({ permission: "bash", patterns: [command] }),由 Permission 决定放行、拒绝还是问用户。
洞察 4:Terminal Agent 的权限不是"事前白名单",而是"通配符规则 + 三态裁决 + 运行时交互"。这让 Agent 既不会乱来(deny/ask),又不会每一步都打断你(allow/always)。
六、闪光点 4:Tool——惰性 init + 声明式 Def
OpenCode 的工具系统很克制。核心在 tool/tool.ts,一个工具的完整定义是 Def(:55):
export interface Def<Parameters, M> {
id: string
description: string
parameters: Parameters // Schema.Decoder
jsonSchema?: JSONSchema7
execute(args, ctx: Context): Effect<ExecuteResult<M>>
formatValidationError?(error): string
}
而工具的"注册形态"是 Info(:71):
export interface Info<Parameters, M> {
id: string
init: () => Effect<DefWithoutID<Parameters, M>> // 惰性初始化
}
两个设计点:
① 工具是惰性初始化的。 init: () => Effect<Def> 意味着工具的实际定义(尤其是它的 execute)是用到才构造的。这让工具可以在 init 时读取当前 session、配置、环境,生成"上下文相关"的工具。比如 DynamicDescription(tool.ts:16)就是一个 (agent) => Effect<string>,描述可以随 agent 变化。
② 每个工具配一个 .txt 描述文件。 看 tool/ 目录:bash.ts 配 bash.txt、edit.ts 配 edit.txt、read.ts 配 read.txt。工具的"给模型看的描述"被单独放在文本文件里,而不是硬编码在代码里——这让调优工具描述(你专栏第 40 篇讲的 Tool Description 问题)不用改代码。
工具注册在 tool/registry.ts:89 的 ToolRegistry Service,统一收集所有 Info,按需 init。
七、闪光点 5:Provider——models.dev 目录 + AI SDK 抽象
OpenCode 支持几十种模型,Provider 抽象在 provider/provider.ts 的 Interface(:1191):
export interface Interface {
readonly list: () => Effect<Record<ProviderV2.ID, Info>>
readonly getProvider: (providerID) => Effect<Info>
readonly getModel: (providerID, modelID) => Effect<Model, ModelNotFoundError>
readonly getLanguage: (model) => Effect<LanguageModelV3, ModelNotFoundError>
readonly closest: (providerID, query: string[]) => Effect<{...} | undefined>
readonly getSmallModel: (providerID) => Effect<Model | undefined>
readonly defaultModel: () => Effect<{providerID, modelID}, DefaultModelError>
}
两个值得注意的点:
① 模型元数据来自 models.dev 目录。 fromModelsDevProvider()(provider.ts:1318)把 models.dev 的模型目录转成内部 Info,包括成本(cost(),:1217,连 cache read/write、tiers 都建模了)。这意味着新模型上线,OpenCode 不用改代码,只要 models.dev 更新目录。
② 统一抽象到 AI SDK 的 LanguageModelV3。 getLanguage 把任意 provider 的模型统一成 Vercel AI SDK 的 LanguageModelV3。这和 Hermes 的 ProviderRegistry 思路一致,但 OpenCode 更进一步:模型不只是"能调",还带了成本、能力、上下文窗口等元数据,供上层做压缩预算、成本估算。
closest() 和 getSmallModel() 也很有产品味:模糊匹配模型名(用户输错了能找到最接近的)、为小任务(如生成标题)挑便宜的小模型。
八、闪光点 6:Context 管理——修剪 + 压缩的双阈值
Coding Agent 最大的成本是上下文。OpenCode 的上下文管理在 session/compaction.ts,用了两个 token 阈值:
export const PRUNE_MINIMUM = 20_000 // 低于此不用管
export const PRUNE_PROTECT = 40_000 // 保护最近的 40K token 不被剪
工作流程是:Agent Loop 里 ctx.needsCompaction 一旦被置位,process() 就返回 "compact",外层触发压缩。压缩不是简单截断,而是 message-v2.ts 里的 filterCompacted()(:521)把已压缩的消息过滤掉、toModelMessages()(:417)把剩余消息转成模型输入。
这套"修剪(prune)+ 压缩(compaction)"的双层机制,和 Hermes 的 context_engine、DeerFlow 的 durable_context 是同一个问题的不同实现:怎么在 token 预算内,既保住近期上下文,又不丢掉早期关键信息。OpenCode 的特点是把它做成了显式的阈值 + 显式的三态返回值,而不是藏在某个中间件里。
九、闪光点 7:TUI——SolidJS + OpenTUI,交互本身即产品
这是 OpenCode 和 OpenHands 最直观的差异,也是用户特别强调的一点:
Agent 不只是后端 Runtime,交互本身也是 Agent 产品的一部分。
OpenCode 的 TUI 在 packages/tui/,技术栈是 SolidJS + OpenTUI(tui/package.json):
{
"name": "@opencode-ai/tui",
"dependencies": {
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/keymap": "catalog:",
"solid-js": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
...
}
}
这意味着 OpenCode 的终端 UI 是声明式、组件化的(SolidJS 是响应式框架),而不是传统的命令行 print。你可以有实时刷新的消息流、可交互的权限确认弹窗、模糊搜索(fuzzysort)、键盘映射(keymap)。
而且因为它通过 SDK 连 Server(第二节的架构),TUI 只是"一个客户端"——同一套交互能力,理论上可以被 IDE 插件、Web 复用。
洞察 5:把 TUI 做成声明式组件(SolidJS)而不是命令行 print,让"终端里的 Agent 交互"也能有实时流、弹窗、搜索这些现代 UI 能力。交互不是事后加的壳,是产品的一部分。
十、和 OpenHands 的架构对比图
用户特别要求这一节。两个 Coding Agent 放在一起看,差异非常清晰:
| 维度 | OpenHands | OpenCode |
|---|---|---|
| 语言/范式 | Python(类 + 方法) | TypeScript(Effect-TS 函数式 DI) |
| 架构形态 | 单体 Runtime + 外挂 UI | Server/Client 分离(内核=本地 Server) |
| 核心问题 | Agent 怎么执行真实动作 | Agent 产品怎么组织 + 用户怎么交互 |
| Agent Loop | 自建 reasoning-action loop | 流式 process + 三态返回(compact/stop/continue) |
| 决策/执行分离 | Agent(决策) vs Runtime(执行) | Server(内核) vs Client(TUI/IDE/SDK) |
| 权限模型 | SecurityAnalyzer 多层防御 | Permission 通配符规则 + allow/deny/ask |
| 工具系统 | Runtime 内执行 Bash/文件/浏览器 | Tool 惰性 init + ctx.ask 内嵌权限 |
| 模型抽象 | LLM 适配器 | Provider(models.dev 目录 + LanguageModelV3) |
| 执行环境 | Docker/Remote/Modal/Runloop Runtime | 本地 shell(TUI 直接跑) |
| UI | Web UI(外挂) | SolidJS + OpenTUI(声明式终端) |
| 状态/事件 | Event Stream(不可变追加) | Session message-v2 + GlobalBus |
画成对比图:
OpenHands OpenCode
┌──────────────────────┐ ┌──────────────────────────┐
│ Agent │ │ TUI / IDE / SDK / Web │
│ (决策层) │ │ (多个客户端) │
└─────────┬────────────┘ └────────────┬─────────────┘
│ Action │ HTTP / SSE
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ SecurityAnalyzer │ │ Agent 内核 Server │
│ (多层防御) │ │ ┌────────────────────┐ │
└─────────┬────────────┘ │ │ SessionProcessor │ │
│ │ │ (流式 Loop+三态) │ │
▼ │ └───────┬────────────┘ │
┌──────────────────────┐ │ ┌─────┼─────┬────────┐ │
│ Runtime(执行层) │ │ ▼ ▼ ▼ ▼ │
│ Docker/Remote/Modal │ │ Session Provider Tool │
└─────────┬────────────┘ │ Permission Compaction │
│ └──────────────────────────┘
▼
Sandbox(隔离执行)
一句话总结这个对比:
OpenHands 的边界画在"决策 vs 执行"(Agent vs Runtime),OpenCode 的边界画在"内核 vs 客户端"(Server vs TUI/IDE/SDK)。前者是为了安全地执行,后者是为了灵活地交互。
十一、不足:Effect-TS 是把双刃剑
赏析要诚实。
① Effect-TS 的学习曲线是真实成本。 Effect.gen、yield*、Layer、Schema.TaggedErrorClass——这一套对没接触过函数式的开发者是硬门槛。OpenCode 的代码可读性,高度依赖读者是否懂 Effect。这直接抬高了贡献门槛。
② 抽象层很厚。 从 Interface 到 Service 到 Layer 到 node.deps,一个简单的"调模型"要穿三四层抽象。对"快速看懂数据怎么流"不友好。
③ Server/Client 增加了部署复杂度。 单体 Runtime 跑起来就一个进程;OpenCode 要起 Server、连客户端。对"我就想在笔记本上跑个 Coding Agent"的用户,这是额外的复杂度(虽然换来了多客户端的灵活性)。
所以老规矩:不要抄 OpenCode,要学 OpenCode。它的 Effect-TS + Server/Client 是为"多客户端、强类型、可维护的大型 Coding Agent 产品"准备的。你的项目如果只是一个脚本,用不上这套。
十二、七篇赏析全景:六种 Agent 形态
加上 OpenCode,光谱又扩了一格:
| 项目 | 核心命题 | 语言/范式 | 架构形态 |
|---|---|---|---|
| LangGraph | 任务怎么编排 | Python | 声明式图 |
| OpenHands | Agent 怎么执行真实动作 | Python | 单体 Runtime + Sandbox |
| browser-use | Agent 怎么感知界面 | Python | 事件驱动 + watchdog |
| DeerFlow 2.0 | 长任务怎么跑不死 | Python | Harness + LangGraph |
| Hermes Agent | Agent 怎么成长 | Python | 状态机 Loop + Curator |
| OpenCode | Agent 产品怎么组织 + 交互 | TypeScript(Effect) | Server/Client + TUI |
到这一步,你已经从六个角度看过 Agent 了:
Workflow Agent (LangGraph)
↓
Coding Agent 执行 (OpenHands)
↓
感知 Agent (browser-use)
↓
SuperAgent 长跑 (DeerFlow 2.0)
↓
Self-improving (Hermes Agent)
↓
Terminal Agent 产品 (OpenCode)
下一个 Goose(MCP-native Agent)会补上最后一块:当 Agent 把 MCP 当成核心扩展机制,架构会变成什么样。
十三、要记住的图
如果这篇只留一张图,是这张——OpenCode 的"内核 Server + 多客户端":
┌──────────── 客户端层 ────────────┐
│ TUI(SolidJS) IDE SDK Web │
└───────────────┬───────────────────┘
│ HTTP / SSE
┌───────────────▼───────────────────┐
│ Agent 内核 Server │
│ ┌─────────────────────────────┐ │
│ │ SessionProcessor │ │
│ │ llm.stream → tap → 三态返回 │ │
│ └──┬──────┬──────┬──────┬─────┘ │
│ ▼ ▼ ▼ ▼ │
│ Session Provider Tool Permission │
│ (消息) (模型) (执行) (裁决) │
│ │ │ │ │ │
│ └──────┴──Compaction──────────┤
│ (上下文修剪/压缩) │
└───────────────────────────────────┘

如果你能理解"内核 Server vs 多客户端""流式 Loop + 三态返回""Permission 三态裁决"这三件事,这篇赏析就达到了目的。
发布提示:本文基于 OpenCode 官方仓库 sst/opencode(commit e207624,2026-09-06,main 分支)源码赏析。引用的文件名与行号均来自该版本。发 CSDN 前建议:
- 两张架构图(Mermaid + ASCII 对比图)用 draw.io / ProcessOn 重画为正式图(满足"图片 ≥ 2 种类型");
- Effect-TS 部分对不熟悉函数式的读者偏硬核,发布前可补一段"30 秒理解 Effect 的 Service/Layer"的科普框;
- 行号基于 grep 锚定的定义位置(如
processor.ts:641的process),如需精确到行可作最终核对。
更多推荐


所有评论(0)