开源项目 opencode 怎么把一个 Agent 内核挂上四五套界面
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
opencode 能把界面拆成好几个独立的包,不是因为前端写得巧,而是因为它的内核压根没有界面这个概念——界面被降级成了 HTTP 客户端。 你打开 packages/tui/src/context/sdk.tsx 会看到,终端界面拿到的全部能力就是一个 createOpencodeClient({ baseUrl, signal, directory, fetch, headers })。没有共享内存,没有直接函数调用,也没有从后端实现模块里捞会话、工具、供应商这些领域逻辑(真正残留的那点跨包引用是什么,第五节会摊开说)。这条边界一旦立住,多挂几套界面就只是多写几个 HTTP 消费者的事。
这篇拆的是「一个内核带多前端」的结构本身。站内已有几篇相邻的文章:Hermes Agent 的两套前端 讲的是另一个项目在同类问题上的取舍,pi 的 TUI 差分渲染 往下钻的是终端画面怎么高效重绘,ECC 的组件化 manifest 关注的是扩展如何声明自己;本篇不碰渲染性能也不碰扩展清单,只回答一件事——界面和内核之间那条缝是怎么切出来的,切完之后你要付什么代价。
一、先数清楚它到底拆出了几块
packages/ 目录下有 32 个包。跟「界面」沾边的不止一个,各自的职责边界相当清楚:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
@opencode-ai/tui | 终端界面的全部渲染、路由、对话框、键位 | packages/tui/src/app.tsx | 想改终端键位、加一个对话框、调工具调用的展示 |
@opencode-ai/app | 浏览器侧的会话界面,以及宿主能力契约 | packages/app/src/context/platform.tsx | 给网页端或桌面端补一个平台能力 |
@opencode-ai/session-ui | 会话相关的可复用组件与上下文 | packages/session-ui/package.json 的 exports | 想让网页和桌面端的会话渲染保持一致 |
@opencode-ai/desktop | Electron 主进程、预加载、宿主脏活 | packages/desktop/src/main/apps.ts | 需要「用本机某个应用打开这个路径」这类能力 |
@opencode-ai/console-app | 控制台这一路的独立应用 | packages/console/app/package.json | 关注控制台侧的功能时 |
@opencode-ai/web | 文档站与文档内容 | packages/web/src/content/docs/web.mdx | 查 opencode web 有哪些参数 |
| mini 界面 | 精简的交互界面,实现留在宿主侧 | packages/opencode/src/cli/cmd/run.ts 的 runMini | 用 --mini 启动时 |
| 服务端与 SDK | 唯一的领域边界 | packages/tui/src/context/sdk.tsx | 界面缺数据、缺操作时 |
多说一句 mini:packages/opencode/src/cli/cmd/attach.ts 里 --mini 这个选项的描述原文是 start the minimal interactive interface,它走的是 runMini 而不是 TUI 包。这条路径提醒你,「界面」在这个仓库里不是一个整齐的抽象类,而是一组各自独立、共用同一个后端的消费者。
二、这条缝是怎么切的:SDK 是唯一的领域边界
specs/tui-package.md 把这次拆分的意图写得很直白。它给 TUI 包定的目标依赖图是这样的:packages/opencode 和 packages/cli 两个可执行包各自依赖 @opencode-ai/tui,后者只依赖 @opencode-ai/sdk。文档里紧跟着一句话,是整份规格的主心骨:
SDK 是 TUI 面向 opencode 的边界;缺少的后端数据或操作,应该加到服务端 API 并重新生成 SDK,而不是去 import 后端实现模块。
这句话的分量在于它堵死了最省事的那条路。当界面缺一个字段时,直接 import 一个后端函数只要一行;而按这份规格,你得先动服务端接口,再跑 ./packages/sdk/js/script/build.ts 重新生成 JS SDK,最后在界面里消费生成出来的 API。三步,比一行慢得多,但换来的是界面可以对着远程服务器跑——规格的不变式里明确写着,远程服务端的用法必须保持可行,TUI 不得要求一个进程内的后端实现。
规格把归属拆成三段,你可以当成一张责任清单来读:
- TUI 包拥有:渲染器生命周期、应用组装、组件路由对话框主题键位、SDK 客户端同步与事件消费、工具调用与结果的展示、面向 TUI 的插件契约与展示插槽、TUI 本地的持久化(提示词历史、暂存、频率排序、选中的模型与主题)。
- CLI 宿主拥有:命令定义与参数解析、服务端与 worker 的启停、鉴权与传输构造、进程级信号策略、配置文件的发现与优先级与迁移、插件包的发现与安装、升级检查与安装元数据。
- 服务端与 SDK 拥有:会话、消息、工作区、文件、供应商、模型、Agent、权限这些领域操作,以及 retry、revert、fork、share 这类后端动作,还有工具分片与插件元数据的稳定线上格式。
值得你抄走的是这份规格自带的验证方式。它在 Verification Gates 一节直接给了几条 rg 命令,用来检查 packages/tui/src 里有没有 from "@/"、有没有出现 @opencode-ai/core 或两个可执行包的名字。把「架构约束」写成一条能跑的命令,比写成一段散文有用得多——这一点跟 Agent 项目里常见的权限约束是同一个道理,可以对照 权限摊大之后怎么收 里的思路看。
三、界面的入口长什么样
终端界面对外只暴露一个 run。它接受的输入定义在 packages/tui/src/app.tsx:
export type TuiInput = {
url: string
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
pluginHost: TuiPluginHost
}
逐个看这些字段,你就明白宿主到底被允许管什么了。url 是服务端地址——本地起的也好,远端的也好,对界面没有区别。headers 让宿主自己去构造鉴权,界面不参与。fetch 可以整个换掉,这是个被真正用起来的口子:packages/cli/src/tui.ts 全文只有 37 行,它传进去的 fetch 是一个包装过的版本,遇到 404 会按路径查一张兜底表返回默认结构,用来跟老接口对齐。界面本身对这件事一无所知。
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({
...transport,
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}
注意 pluginHost 那两个空实现。规格里写了插件宿主缺失或不兼容时要优雅降级、插件 UI 出问题不能挡住基础 TUI 启动——这里就是那条规则的兑现:一个什么都不做的插件宿主是完全合法的输入。
另一条路径是 packages/opencode/src/cli/cmd/attach.ts,它先用 ServerAuth.headers 造好鉴权头,校验一下会话是否存在,再把 url、config、pluginHost、args、directory、headers 交给同一个 run。也就是说「连本地」和「连远端」在界面这一侧是同一段代码,差别只在宿主往里塞了什么。
界面内部的组织方式也值得看一眼。app.tsx 里 run 先用 createCliRenderer 拿到渲染器,配了 externalOutputMode: "passthrough"、targetFps: 60、exitOnCtrlC: false 这些参数,然后把渲染器的销毁登记成一个释放动作;再往下是二十多个 Provider 层层套起来,从 ExitProvider、TuiPathsProvider、ClipboardProvider 一路套到 SDKProvider、SyncProvider、ThemeProvider、EditorContextProvider,最里面才是 App。这种写法看着夸张,但它把「宿主给了什么」和「界面用了什么」在类型上钉死了:想在组件里拿到某个能力,你必须先证明有人在外层提供过它。
四、图形前端这一侧:能力用可选方法声明
终端那条线靠的是把宿主输入显式化,浏览器和桌面端这条线用的是另一招——共享 UI,宿主能力做成契约里的可选方法。
packages/app/src/context/platform.tsx 定义了一个 PlatformBase。openExternal、restart、notify 是必选的,而 openPath、openLocalFile、revealPath、openAttachmentPickerDialog、getPathForFile 这些都是可选的,前四个的注释里直接写着 desktop only,最后一个写的是「解析桌面端 File 的本机源路径」,指向同一件事。类型里还有 PlatformName = "web" | "desktop" 和 DesktopOS = "macos" | "windows" | "linux"。这就是那条缝在 GUI 侧的形状:网页端不实现的部分,类型系统允许它不实现。
桌面端怎么把这些能力填进去,packages/desktop/src/main/apps.ts 是一个很典型的样本。这个文件只干一件事:判断某个应用在不在、把应用名解析成可执行路径。里面全是宿主脏活——checkAppExists 在 win32 和 linux 上直接返回 true,只有 macOS 才去看 /Applications/、/System/Applications/ 和 $HOME/Applications/ 下有没有对应的 .app,都没有就退回去执行 which;resolveWindowsAppPath 先跑 where,优先挑 .exe,挑不到就把 .cmd / .bat 读成文本、在里面找带 .exe 的片段,还要处理 %~dp0 这种相对前缀,再不行就把候选路径的上一级、上两级、上三级目录各扫一遍,按去掉非字母数字后的名字做双向包含的模糊匹配;全都落空时才把 where 给出的第一条路径原样交回去。
关键在于这段代码放在哪。它待在 Electron 主进程里,packages/desktop/src/main/index.ts 把这两个函数注入进依赖对象,packages/desktop/src/main/ipc.ts 用 ipcMain.handle 把它们注册成 check-app-exists 和 resolve-app-path 两个通道,packages/desktop/src/preload/index.ts 再暴露给渲染进程。渲染进程那边,packages/desktop/src/renderer/index.tsx 从 @opencode-ai/app 里引入 AppBaseProviders、AppInterface、PlatformProvider 和 Platform 类型,把这些能力包成一个 platform 对象递下去。
结论对你是有用的:新增一个图形前端,你需要做的不是「实现整套 UI」,而是「实现 Platform 契约里的必选部分」。这跟终端那条线的形状是一样的——宿主负责脏活,界面负责渲染,中间那层是一份显式的契约。
五、边界与代价:它放弃了什么
拆得干净是有账要还的,这份规格自己也没藏着。
编译期的类型安全在工具渲染这一层被主动放弃了。 规格 Section 3 要求工具渲染只按 SDK 上的工具名字符串分发(文档里点名的有 read、write、edit、apply_patch、grep、glob、bash、question、task),工具的输入、输出元数据、插件自定义字段在包边界上一律当 unknown 处理,只在真正需要某个字段的地方加小型类型守卫,未知工具走通用兜底渲染。换来的是不认识的工具不会把界面搞崩,代价是「后端改了字段名」这类问题编译器不会告诉你,只能靠「渲染失败要局部化,坏元数据不能拖垮整个会话视图」这条纪律兜着。
跨界面的本地状态不共享。 规格的不变式里写着 TUI 本地持久化保持本地,除非有明确的产品需求,否则不上升成服务端状态。落到实际使用上就是:提示词历史、暂存的提示词、频率排序、你选的模型和主题,以及 app.tsx 里那一串 KV 开关(terminal_title_enabled、animations_enabled、file_context_enabled、diff_wrap_mode、paste_summary_enabled、session_directory_filter_enabled),都跟着这台机器上的这个客户端走。会话本身是共享的,你对界面的调教不是。
「零依赖」这件事要按文件核,别按规格的复选框读。 规格明说 TUI 包不得依赖 packages/opencode、packages/cli 和 @opencode-ai/core,进度表里十个 Section 也全打了勾。实际扫一遍:packages/tui/src 下 from "@/" 已经是零命中,这一半确实做到了;但仍有 10 个文件从 @opencode-ai/core 引入东西,app.tsx 开头那三行 Global、Flag、InstallationVersion 就是。把这些引用摊开看,性质还挺一致:全局目录、开关标志、安装版本号,以及 clipboard.ts 里的 which、context/kv.tsx 里的 flock、context/theme.tsx 里的 glob 这类工具函数——都是运行环境层面的东西,没有一条是会话、工具、供应商这类领域逻辑。也就是说领域边界这一半守住了,包依赖那一半还欠着。这不是说规格写错了,而是说一份进行中的重构规格记录的是意图,你要判断现状得自己跑那几条 rg。这也是读任何开源项目架构文档时都该保留的习惯,挑一个终端 Agent 项目该看哪些信号 里说的判断路径可以套用。
它明确不管的事情。 命令解析、服务端与 worker 的启停、鉴权与传输构造、进程级信号策略、配置文件发现与迁移、插件安装、升级检查——这些按规格全归 CLI 宿主。你要是想「只用界面包搭个自己的东西」,会发现这一大摊子活得自己补。
还有一层是安全边界,跟拆分方式直接相关。 界面能连远端,意味着服务端本身就是一个可以被网络访问的对象,而这个服务端是能在你机器上跑 shell 命令、直接改你代码文件、把代码内容发给模型服务商的。文档 web.mdx 里那条 caution 说得很直接:没设 OPENCODE_SERVER_PASSWORD 时服务端是不设防的;packages/opencode/src/cli/cmd/web.ts 在启动时也会打一条同样意思的警告。而 --hostname 0.0.0.0 和 --mdns 这两个参数,一个把服务绑到所有网卡,一个直接在局域网里做发现广播。至于模型服务商那一侧的规则,各家不同且会调整,以官方最新说明为准。
六、上手与避坑清单
把 opencode web 当成「就是个网页」。 会踩是因为浏览器界面看起来很无害,但它背后是一个真的在监听端口的服务端,而这个服务端有改文件和跑命令的能力。加上 --hostname 0.0.0.0 或 --mdns 之后,同一网段的人看到的不是一个页面,是一个能操作你工作目录的接口。避法:默认别加这两个参数;确实需要跨设备用,先设好 OPENCODE_SERVER_PASSWORD(用户名默认是 opencode,可以用 OPENCODE_SERVER_USERNAME 改),再开放。
以为自动批准只是少点几下确认。 app.tsx 的命令列表里有一个 permission.mode,切换后的标题写的是启用或禁用自动批准权限。切到自动之后发生的事写在 packages/tui/src/context/sync.tsx 里:界面收到 permission.asked 事件时不再把请求塞进待确认队列,而是直接替你回一个 once,不看是哪个工具在要权限。而会来要权限的,正是 packages/opencode/src/tool/ 下 shell.ts、edit.ts、write.ts、apply_patch.ts 这几个真正落盘和执行的实现。会踩是因为连续被打断确实烦,顺手就关了。避法:只在一次性容器、或专门开出来的工作副本里开自动批准,主仓库保持逐次确认;把它当成一个环境级决定,不是一个心情级开关。
想扩展界面时,照着别的 Agent 项目的经验去 import 后端模块。 会踩是因为在很多项目里这么干是对的,而且最快。在这个仓库里它会直接违反规格里那条依赖约束,而 CI 之外你不一定马上发现。避法:动手前先在 packages/tui/src 上跑一遍规格给的那几条 rg,看现有代码的真实边界在哪;缺数据就去改服务端接口再重新生成 SDK,那才是它设计好的路。
期待几套界面之间共享你的本地设置。 会踩是因为会话数据确实是共享的,容易顺推成「所有东西都共享」。避法:把提示词历史、主题、模型选择这类当成客户端本地状态,别让团队流程依赖它同步。
在 Windows 上直接用 PowerShell 跑 web 界面。 会踩是因为它确实能起来,问题出在文件系统访问和终端集成这些不会立刻报错的地方。避法:文档里明确建议从 WSL 里跑,按文档来。
照着规格的进度勾选框判断当前架构。 会踩是因为那份文档写得太完整,容易被当成现状描述读。避法:把它当意图书,现状用命令扫。上面 @opencode-ai/core 的残留就是现成的例子。
真要动手前,按这个顺序读四个文件就够了:specs/tui-package.md 的 Ownership Boundary 一节,先搞清楚谁该拥有什么;packages/tui/src/app.tsx 顶部的 TuiInput,看清界面对宿主的全部要求;packages/cli/src/tui.ts,一个 37 行的宿主适配器,是这套契约最小的完整用例;packages/app/src/context/platform.tsx,看图形前端那一侧的能力是怎么用可选方法表达的。四个文件读完,你对「这个内核为什么能同时挂住终端和图形界面」的答案,就不再来自别人的转述了。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 拆解终端编码 Agent opencode:事件清单如何撑起 TUI 与插件 和 把开源终端 Agent opencode 铺给团队:策略层锁得住什么、锁不住什么。