搜索文章

输入关键词开始搜索

基于OpenCode做客户端:选型、边界与升级

AI#AI#OpenCode

基于OpenCode做客户端:选型、边界与升级

最近在设计一个 AI 视频创作客户端,需要选一个开源项目作为开发底座。候选有三个:OpenCode、T3 Code 和 Multica。底座选定之后,又接连遇到三个问题:业务代码放哪、上游怎么升级、sidecar 怎样才算接入完成。这篇文章把整个过程做个记录。

先确定需求边界

这个客户端不是通用 IDE,也不是给程序员管理 Coding Agent 的任务看板。它要解决的是一条具体的创作流程:

输入脚本
  → 内容规划
  → 分镜
  → 图片
  → 配音
  → 视频合成
  → 人工确认

同时有几个硬约束:

  • 创作者在 macOS 客户端完成有人监督的本地生产;
  • 客户端关闭后仍要运行的任务,必须提交到服务端,定时触发、API 调用、回调和无人值守生产都由服务端负责;
  • 我们已经有自己的服务端、生产 Pipeline、Job、资产和审核流程;
  • 能力尽量通过 Skill、MCP 和本地 Tool 扩展;
  • 依赖的开源项目必须能够独立升级,不能长期维护源码 Patch。

把边界列出来以后,选型问题就变了。一开始我下意识想比较谁的功能更多、界面更接近最终产品,后来把代码和架构拆开才发现,这个比较方式本身有问题:这三个项目不在同一个层级。真正要决定的不是哪个项目功能更多,而是我们现在缺的是 Agent Runtime、客户端外壳,还是一整套 Agent 管理平台。

三个项目不在同一层

OpenCode 是一个完整的 Coding Agent Runtime,负责模型接入、Session、上下文、工具调用、权限、Skill、MCP 和文件操作。真正执行任务的是它。

T3 Code 是一个多 Coding Agent 的客户端,用 React、Electron、Node.js 和 WebSocket 连接 Codex、Claude Code、Cursor、OpenCode 等 Provider,把不同 Agent 的事件转换成统一的界面状态。它的官方架构里已经有 Orchestration Engine、Checkpoint、事件队列和状态投影。

Multica 更重,它是一套人和 Agent 协作的平台:

Next.js 前端
  → Go Server
    → PostgreSQL
      → Task Queue
        → 本地 Daemon
          → OpenCode / Codex / Claude Code

Multica 的服务器负责 Workspace、Issue、Task Queue 和实时状态,本地 Daemon 再领取任务并调用 Coding Agent,更接近一个 Agent 版的项目管理和执行平台。简单归纳一下:

项目主要职责
OpenCode执行 Agent 任务
T3 Code管理和展示多个 Coding Agent
Multica管理人、Agent、Issue、队列和本地 Runtime

我们缺的是客户端里的 Agent Runtime,OpenCode 正好补这一层:

  • 原生支持 Skill 和 MCP;
  • 有模型和 Provider 管理;
  • 有 Session、事件流和取消能力;
  • 可以通过 SDK、HTTP 和 SSE 接入;
  • 可以调用受控的本地脚本和 Tool。

更重要的是,它不会强迫我们接受另一套任务系统。本地创作用自己的 Manifest 和 Artifact,无人值守任务用自己的服务端 Job,OpenCode Session 只负责交互,不成为业务状态。最终结构可以保持得比较简单:

自有 Electron + React 客户端
  → OpenCode Adapter
    → 原版 OpenCode Sidecar
      → Workflow Skills
        → 本地 Tool / MCP / Python / 媒体脚本

业务服务端
  → Durable Job / Worker / Storage / Webhook

T3 Code 是三个项目里最接近”客户端外壳”的一个,Electron、React、多 Provider、typed WebSocket、Checkpoint、后台 Worker、会话恢复都是现成的。如果目标只是尽快做出一个 Coding Agent 客户端,它很可能比 OpenCode 更合适。问题是我们做的不是 Coding Session 管理器:T3 Code 的 Turn、Diff、Checkpoint 和 Provider Event 都围绕代码开发设计,而视频创作需要的是 Project、Storyboard、Shot、Image、Audio、Video、Lineage 和 Approval。保留两套状态,系统会越来越难解释;把它的状态全部替换掉,又失去了选它的主要价值。另外官方目前仍把项目标记为 early WIP,业务代码一旦直接进入它的 apps/webapps/server,后续升级很可能变成持续解决冲突。所以我会参考它的 Provider Adapter、事件协议和 Electron 打包方式,但不 Fork 它做底座。

Multica 有两个部分值得研究。一个是 Skill 管理:它可以管理 Workspace Skill、本机 Skill 和仓库 Skill,再根据不同 Coding Agent 的发现规则同步到对应目录,对团队共享 Skill 很有参考价值。另一个是 Agent 与任务管理:Issue 分配、Agent 唤醒、并发限制、任务队列和本地 Daemon,都可以作为后续 Automation Admin 的设计参考。

但它和已有服务端职责重叠太多。直接采用的话,调用链会变成 Multica Task → Multica Daemon → OpenCode Session → 业务 Job → 业务 Worker,同时存在多套任务 ID、状态机、重试和恢复逻辑,出问题后很难快速判断是哪一层失败。它的许可证也不是标准 Apache 2.0,对商业嵌入、托管服务和前端 Logo 有额外限制,即使当前内部使用没问题,也会给后续产品化留下不确定性。所以 Multica 适合作为参考,不进入当前产品的核心运行链路。

这里要保留一个反方观点。选择 OpenCode 的代价很明显:要自己开发 Electron + React 客户端。T3 Code 已经有这个外壳,它有没有可能明显减少开发量?这个问题不能只靠文档判断。后面如果要重新评估,我会先做一个很小的 Spike:T3 Code 接 OpenCode Provider,跑一个测试 Skill,调用 typed local tool,生成一个结构化 Artifact。重点不看 Demo 能不能跑,而看四个问题:

  1. Narrative 页面能否放在独立 package,而不修改 T3 核心?
  2. OpenCode 的 Skill、MCP、权限和取消语义有没有丢失?
  3. Artifact 能否保持独立真相,不依赖 T3 Checkpoint?
  4. 升级两个 T3 版本以后,业务代码产生多少冲突?

如果这些问题都能通过,T3 Code 可能成为更省成本的底座。没有这些证据之前,不应该因为它已经有 React 页面就直接改决定。

业务代码不进上游

底座定了,下一个问题是业务页面和业务接口要不要直接写进 OpenCode。

直接改肯定是最快的。OpenCode 已经有客户端、Session、消息流、Tool 调用和 Provider 接入,看到这些能力以后很容易产生几个想法:

  • 在现有 Router 里加几个业务页面;
  • 在 Server 里加业务 API;
  • 把生产状态写进现有数据库;
  • 遇到接口不够用时,直接调用内部函数。

这些做法短期都能工作,Demo 会很快。这个判断的问题是只考虑了第一次开发,没有考虑第二次升级。业务代码一旦分散到上游目录,后面每次升级都是重新合并一次自己的产品。

更麻烦的是语义冲突:上游改了状态机、错误格式或数据库迁移,没有 Git 冲突,代码还能编译,产品行为却已经变了。还有一种情况更隐蔽:今天为一个页面加了字段,半年后没人记得这个字段来自本地修改,新同事看到它就在上游类型里,会继续把更多业务逻辑建在上面。等到真正升级时,团队面对的已经不是一个 Patch,而是一条没有文档的依赖链。这个成本不会出现在第一版排期里,却会在后面每次迭代中重复出现。

只在文档里写”不要修改上游”没有用。开发过程中总会遇到紧急需求,也总会有人觉得”只改这一处没关系”。所以边界要能自动检查。做法是把代码分成两块:

上游管理区
  OpenCode 源码、内置 UI、数据库和 Runtime

自有 Overlay
  客户端壳、Adapter、Skills、Tools、测试和产品页面

然后把规则写进门禁:上游管理区相对锁定的 commit 和 tree 不能出现业务 diff,不能有未跟踪文件,不能 deep import 私有源码,也不能通过 patch、alias 或 postinstall rewrite 绕过去。业务构建还要保证不把生成文件写回上游目录。靠 CodeReview 记忆守不住这条线。

不修改上游,不代表只能把它当黑盒命令行。OpenCode 提供了公开 SDK、HTTP/SSE、配置、Skill 和 MCP。客户端把它作为本地 sidecar 运行,再由一个自有 Adapter 负责:

  • 启动、健康检查和停止 sidecar;
  • 创建和恢复 Session;
  • 接收消息和事件流;
  • 处理中止与 Tool 权限;
  • 把上游 DTO 转成产品自己的 DTO。

业务页面只认识自己的 ProjectArtifactJobApproval,不认识 OpenCode 内部对象。以后上游事件字段变化,只需要调整 Adapter 和兼容测试,版本判断不会散落在每个页面里。代价是多写一层适配代码,也可能要自己做客户端壳,但这层代码的作用很明确:把上游变化限制在一个地方。

当然,如果公开接口确实无法实现核心需求,而且团队愿意长期维护一个 Fork,直接修改并不是绝对错误。但这应该是一次正式的产品决策,需要重新估算升级、安全和人员成本,不能通过一次”先改了再说”的代码提交悄悄发生。

升级:一个版本号不够

有了 Overlay,升级流程也要跟着变。我最早只想在锁文件里记一个版本号,以后升级时改成新的语义版本,看起来已经足够明确。但版本号只能说明一个名字,不能证明最终运行的是哪一份代码和哪一个制品:同一个版本可能来自不同 commit,构建环境不同会产生不同二进制,SDK 版本、源码 tree、许可证和兼容测试也可能已经错位。只锁版本号,出问题后还是说不清楚。

版本号适合人阅读,commit 定位源码历史,tree hash 证明某一刻完整源码树的内容,而真正交付给用户的是构建后的 artifact,需要独立的 SHA256,因为编译器、依赖锁、打包脚本和平台差异都可能改变最终文件。所以一个可发布的运行时锁,至少要记录这几项:exact version、对应的 upstream commit 和 tree、配套 SDK 版本、每个平台制品的 SHA256 及获取和构建方式、许可证、已通过的兼容测试,以及当前是否真的允许发布。

最后这项我特意保留为 releaseReady=false。源码锁定完成,不代表发布条件完成。早期可以先记录 version、commit、tree 和 SDK 作为开发基线,但没有正式 artifact、checksum、provenance 和兼容矩阵之前,不能因为 JSON 文件存在就误以为可以发布。把这个状态显式写出来,发布脚本可以直接 fail closed,比依赖某个人记得还缺什么可靠得多。

升级本身也不是改版本号、跑 typecheck、页面能打开就算完。OpenCode 有自己的可执行文件、配置、数据库、事件流、权限模型和运行目录,升级它更像替换客户端内部的一台小服务器。做法是准备 current 和 candidate 两套完整基线:生成 candidate lock 而不覆盖 current,两边跑同一套黑盒 fixture,覆盖 sidecar 启动、健康检查、崩溃重启和停止,Session 创建、恢复、消息、事件流和 abort,Permission、Skill discovery 和 MCP tool call,从脚本到可播放视频的本地完整流程,以及打包态启动而不只是源码开发模式。

兼容测试不能写成”如果是新版本就跳过这个断言”。payload 发生公开且可兼容的变化,就在 Adapter 里归一化;核心能力必须 deep import 或 Patch 上游才能恢复,就直接阻断升级。接口返回 200 也不代表兼容:事件顺序变了、Permission 默认决策变了、Skill 搜索目录变了,页面都可能”看起来能用”,实际已经产生重复 Tool 调用、权限扩大或能力静默缺失。图片和音频这类输出允许因模型不同而变化,但 Manifest、Artifact、错误、权限和完成条件必须满足同一份合同。

只验证全新安装也不够。真实用户已经有配置、缓存、Session 和本地 Workspace,所以还要覆盖从 N-1 升级的 profile、中断或损坏的 profile、路径含空格和中文的环境、sidecar 正在执行时客户端突然重启,以及 Platform Job 已提交时本地正在升级的情况——已经被服务端接受的生产任务,不能因为本地升级而中断或重复。回滚也一样,要回答旧版本能否读取升级后的配置和数据库、Skill Registry 能否恢复上一版、回滚后能否重新通过同一条 Golden Flow。如果需要人工修改 OpenCode 的私有数据库才能回滚,就不能算可回滚。

候选版本差一点通过时,临时改一处上游代码非常诱人,但正确选项只有几个:修改自己的 Adapter、调整非核心体验、继续使用 current,或者正式决定维护 Fork。一旦为了通过而 Patch 候选版本,升级流程就回到了维护补丁队列。

锁文件如果只在发布文档里出现,价值也有限。客户端启动 sidecar 前,应该验证目标平台、文件 SHA256、版本和协议能力,校验失败时拒绝启动,并提示重新安装或回滚。这套流程还需要负例:故意修改一个 tree、替换一个 artifact、让 SDK 版本错位,确认检查程序都返回非零结果。否则锁文件写得再完整,也可能只是一个从未被读取的 JSON。

这套做法会不会太重?对一个只在开发机临时运行的小工具,一个版本号加 package lock 可能已经够用。但只要这个 Runtime 会随客户端分发、拥有本地文件权限、能调用模型和 Tool,它就是产品运行时的一部分。小团队不可能跟进每个上游版本,也不需要跟:只选择有明确收益的候选版本,只覆盖 P0 能力和真实 Golden Flow。升级频率可以降低,但每次升级的证据不能省略。

接入验收:Sidecar 不只是 spawn

最后一个问题是客户端怎么接入。最容易想到的方案是 Electron Main 用 spawn 启动一个子进程,等端口能访问以后,Renderer 就开始调用。这个方案做 Demo 没问题,但把崩溃、升级、打包和权限加进来以后,“能启动”只是 sidecar 生命周期里最小的一步。发布态至少要解决六件事:

  • 启动前验证 artifact,版本、平台和 checksum 不匹配时必须停止,不能运行来源不清楚的二进制;
  • 端口不能靠固定数字,正式客户端会遇到并行实例、端口占用和旧进程残留,更稳的做法是动态分配 loopback 端口,由 Main 持有连接信息;
  • 127.0.0.1 不是认证,机器上其他进程同样可以访问 loopback,Renderer 也不应该直接知道 sidecar 的长期凭据,认证和代理应收敛在 Main 或受控 Adapter;
  • 运行目录必须隔离,配置、日志、缓存、数据库和 Skill 根不能污染用户已有的全局工具配置,否则一次客户端升级可能影响用户自己的命令行环境;
  • 健康检查不能只看端口打开,还要确认版本、capabilities、协议和必要 Tool 是否真的可用,端口存在但返回的是另一项服务,同样应该失败;
  • 退出必须有边界,先发优雅停止,等待有限时间,再终止由自己创建的进程,不能用模糊的 pkill 或发现端口占用就杀掉任意进程。

自动重启也容易走偏。sidecar 崩溃就重启,听起来体验最好,但如果启动配置已经坏了,无限重启只会制造 CPU、日志和通知风暴。更合理的是显式状态机:

stopped -> starting -> ready
                 -> unhealthy -> backoff -> restarting
                                      -> crash-loop

重启次数、时间窗口和退避要有上限。进入 crash-loop 后停止自动尝试,保留诊断信息,让用户选择修复、重装或回滚。

Renderer 不应该管理进程。页面只需要看到归一化后的状态,例如 startingreadyrecoveringblocked,以及可以执行的动作,不应该拿到 PID、任意文件路径、原始 token。进程所有权、日志脱敏、认证和升级属于 Main/Host,业务页面只消费稳定的 view model。这层隔离还有一个好处:以后替换 Agent Runtime,页面不需要跟着重写进程逻辑。

所以 sidecar 的第一批测试,我改成了四个很具体的失败场景:端口刚分配就被别的进程占用、artifact checksum 错误、进程启动后 health 一直不通过、短时间连续崩溃。每个测试都检查状态转换、退出码、残留进程和用户可见诊断。特别是失败后不能留下一个”页面正在连接”的假 loading,也不能误杀测试进程之外的任何 PID。等这四个结果稳定,再接 Session 和业务页面,这样即使 UI 还没完成,Host 的生命周期合同也能独立验收。

开发态完全可以使用固定端口、手动启动和更详细的日志,只要模式被明确标记并且不进入发布包。问题不在于开发工具简单,而在于把开发便利当成正式进程合同。

下次再做的顺序

再遇到类似的开源二次开发,我会按这个顺序做:先把需求边界和”缺的是哪一层”写清楚,再在同层级的项目之间做选型,并且给最强的反对方案留一个可检验的 Spike;底座定下来先建 Overlay 目录和上游零差异的自动门禁,让所有业务代码从第一天起就有唯一归属,然后再写页面;第一次升级之前,把锁文件和 current/candidate 演练跑通,包括失败场景和回滚;sidecar 则先写生命周期状态和失败测试,再写 spawn。顺序反过来做,每一步省下的时间,后面都会加利息还回来。