OpenCode Client/Server 架构:远程控制 AI Agent
OpenCode Client/Server 架构:远程控制 AI Agent
当我第一次读到 OpenCode README 里那句 "A client/server architecture... the TUI frontend is just one of the possible clients" 时,我愣了好几秒。
大多数 AI coding agent 都把自己绑死在一个特定的界面上——要么是 CLI,要么是 VS Code 插件,要么是 Web App。OpenCode 不一样。它把整个系统拆成了一个 HTTP server 加上任意数量的 client。你在终端跑的 TUI 是一个 client,浏览器里的 Web 界面是一个 client,手机上也可以是 client,甚至 Slack bot 都可以是 client。
这意味着什么?你可以在办公室的 Mac 上跑 OpenCode server,然后通勤路上用手机给它发消息改代码。这个架构设计不是 "以后可能支持" 的路线图项目——它现在就在工作,而且代码写得相当漂亮。
本文提纲
- 从 server.ts 说起:一个 Hono 应用的一生
- REST API 全家桶:sessions、messages、files
- 事件驱动:SSE 流与 Event Sourcing
- mDNS 发现:局域网零配置接入
- 多 Workspace 架构:一个 server 管多个项目
- 远程控制实战:从浏览器到手机
- Slack bot 集成:把 AI 搬进聊天频道
- Fence Middleware:写操作的同步屏障
从 server.ts 说起:一个 Hono 应用的一生
OpenCode 的 server 端基于 Hono 框架构建。如果你没听过 Hono,简单说就是一个超轻量的 Web 框架,支持多种运行时(Node.js、Bun、Deno、Cloudflare Workers 等),路由写法跟 Express 差不多但性能好得多。
来看 server.ts 的核心结构(源码位于 packages/opencode/src/server/server.ts):
const app = new Hono()
.onError(ErrorMiddleware)
.use(AuthMiddleware)
.use(LoggerMiddleware)
.use(CompressionMiddleware)
.use(CorsMiddleware(opts))
.route("/global", GlobalRoutes());一行链式调用,把错误处理、认证、日志、压缩、CORS 全串起来了。这种 middleware 洋葱模型大家应该不陌生——请求从外层穿进去,响应从里层穿出来。
有意思的是 create() 函数的分支逻辑。如果设置了 OPENCODE_WORKSPACE_ID 环境变量,server 只加载单个 workspace 的路由:
if (Flag.OPENCODE_WORKSPACE_ID) {
return {
app: app
.use(InstanceMiddleware(...))
.use(FenceMiddleware)
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
runtime,
}
}否则,它会加载完整的 control plane,包括 workspace 管理、control plane routes、UI routes 等等。这种设计让同一个代码库既能跑单 workspace 模式(轻量),也能跑多 workspace 模式(完整)。
中间件栈的顺序也值得注意:AuthMiddleware 放在 LoggerMiddleware 前面,意味着未认证的请求根本不会产生日志噪音。CompressionMiddleware 跳过了 SSE 流和 message 写入接口——这些场景下压缩反而会增加延迟。
REST API 全家桶:sessions、messages、files
OpenCode server 暴露了一套完整的 RESTful API,覆盖了你能想到的所有操作。默认监听 127.0.0.1:4096,通过 opencode serve 启动。
Sessions 管理
Session 是 OpenCode 里最核心的抽象——一次完整的对话就是一个 session。
GET /session # 列出所有 sessions
POST /session # 创建新 session
GET /session/:id # 获取 session 详情
DELETE /session/:id # 删除 session
PATCH /session/:id # 更新 session 属性
POST /session/:id/fork # 在某个消息处分叉
POST /session/:id/abort # 中止正在运行的 session
POST /session/:id/share # 分享 session(生成链接)
POST /session/:id/revert # 回滚到某条消息fork 接口特别有意思——你可以在对话的任意一条消息处创建分叉,就像 Git 的 branch 一样。尝试了一个方案不满意?不用 undo,直接 fork 一条新路。
Messages 交互
Messages API 是你和 AI agent 交互的主要入口:
GET /session/:id/message # 列出 session 中的消息
POST /session/:id/message # 发送消息并等待回复
POST /session/:id/prompt_async # 异步发送消息(不等回复)
POST /session/:id/command # 执行斜杠命令
POST /session/:id/shell # 运行 shell 命令/message 和 /prompt_async 的区别很重要。前者是同步的——请求会阻塞直到 AI 完成回复(可能要几十秒)。后者立即返回 204 No Content,AI 在后台处理,你需要通过 SSE 事件流来获取结果。
一个典型的 prompt 请求长这样:
{
"parts": [{ "type": "text", "text": "帮我重构这个函数" }],
"model": { "providerID": "anthropic", "modelID": "claude-3-5-sonnet-20241022" },
"agent": "build"
}Files 操作
文件相关的 API 支持搜索、读取和浏览:
GET /find?pattern= # 文本搜索(类似 grep)
GET /find/file?query= # 按文件名搜索
GET /find/symbol?query= # 搜索 workspace symbol
GET /file?path= # 列出目录内容
GET /file/content?path= # 读取文件内容
GET /file/status # 获取文件变更状态
/find/symbol 利用了 LSP 的能力——OpenCode 会在后台启动对应语言的 LSP server,这意味着 symbol 搜索是语义级别的,不是简单的文本匹配。
所有 API 都有 OpenAPI 3.1 规范描述,访问 http://localhost:4096/doc 就能看到完整的 Swagger 文档。OpenCode 还基于这个 spec 自动生成了 TypeScript SDK(@opencode-ai/sdk),所以你写集成代码时全是类型安全的。
事件驱动:SSE 流与 Event Sourcing
这部分是我觉得 OpenCode 架构里最精巧的设计。
SSE 事件流
OpenCode 用 Server-Sent Events(SSE)实现实时事件推送。两个级别的 event endpoint:
/global/event— 全局事件流,包括 server 心跳/event— 实例级别事件流,包含 session 更新、message 到达等
来看看 event.ts 的实现:
return streamSSE(c, async (stream) => {
const q = new AsyncQueue()
q.push(JSON.stringify({
type: "server.connected",
properties: {},
}))
const heartbeat = setInterval(() => {
q.push(JSON.stringify({
type: "server.heartbeat",
properties: {},
}))
}, 10_000)
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
})
stream.onAbort(stop)
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
}) 思路很清晰:连接建立后先发一个 server.connected 事件,然后每 10 秒发心跳保活,同时订阅全局 Bus 把所有事件都推到客户端。AsyncQueue 是个很巧妙的抽象——它把事件的生产(Bus 回调)和消费(SSE 写入)解耦了,避免了背压问题。
Event Sourcing
OpenCode 在数据层采用了 Event Sourcing 模式。所有状态变更都以事件的形式存储在 EventTable 里:
-- 事件表结构(简化)
CREATE TABLE event (
id TEXT PRIMARY KEY,
aggregate_id TEXT, -- 比如 session ID
seq INTEGER, -- 单调递增的序列号
type TEXT, -- 事件类型
data JSON -- 事件数据
);每条事件都有一个 seq 序列号,按 aggregate_id 分组单调递增。这个设计带来了几个好处:
- 完整的审计日志:任何状态变更都可以追溯
- 时间旅行:通过
revert回滚到任意历史状态 - 增量同步:client 只需要告诉 server "我上次看到 session X 的 seq 是 42",server 就能返回 42 之后的所有事件
- 多端一致性:不同的 client 通过事件序列号就能知道自己的状态是否落后
FenceMiddleware 就依赖这个机制——写操作完成后,它会比较操作前后的 seq 差异,把变化量放在 x-opencode-sync 响应头里,client 就知道需要同步哪些 aggregate 了。
mDNS 发现:局域网零配置接入
这个功能让我眼前一亮。
当你启动 server 时加上 --mdns 参数:
opencode serve --hostname 0.0.0.0 --mdns --mdns-domain opencode.localOpenCode 会在局域网内通过 mDNS(Multicast DNS)广播自己的存在。源码实现在 mdns.ts 里:
export function publish(port: number, domain?: string) {
const host = domain ?? "opencode.local"
const name = `opencode-${port}`
bonjour = new Bonjour()
const service = bonjour.publish({
name,
type: "http",
host,
port,
txt: { path: "/" },
})
}使用了 bonjour-service 库来发布 mDNS 服务。这意味着同一局域网内的任何设备——你的 iPad、Android 手机、另一台笔记本——都可以通过 opencode.local:4096 直接发现并连接到你的 OpenCode server。
有个细节:如果 hostname 是 127.0.0.1、localhost 或 ::1(回环地址),mDNS 会被跳过,因为回环地址广播出去别的设备也访问不到。
多 Workspace 架构:一个 server 管多个项目
OpenCode 的 control plane 支持一个 server 实例管理多个 workspace。每个 workspace 可以是本地的目录,也可以是远程的服务。
Workspace 相关的 API:
POST /experimental/workspace # 创建 workspace
GET /experimental/workspace # 列出所有 workspace
GET /experimental/workspace/:id/status # 获取 workspace 状态
DELETE /experimental/workspace/:id # 删除 workspace
POST /experimental/workspace/restore # 恢复 session 到指定 workspaceWorkspaceRouterMiddleware 是这个功能的核心。当一个请求进来时,它先从 URL 参数或 session 信息里提取 workspace ID,然后判断这个 workspace 是 local 还是 remote:
if (target.type === "local") {
return WorkspaceContext.provide({
workspaceID,
fn: () => Instance.provide({
directory: target.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() { return next() },
}),
})
}
// remote workspace → proxy to remote server
const proxyURL = new URL(target.url)
return ServerProxy.http(proxyURL, target.headers, req, workspace.id)本地 workspace 直接在当前进程里处理,远程 workspace 则把请求代理过去。对 client 来说完全透明——不管 workspace 在哪,API 都是一样的。
Workspace 之间的同步也很有意思。syncWorkspaceLoop 会连接到远程 workspace 的 /global/event SSE 流,把所有事件拉回来 replay 到本地。这样即使你的 workspace 分布在不同机器上,本地 server 也能保持一致的状态视图。
远程控制实战:从浏览器到手机
理论讲够了,来看看实际怎么用。
启动 server
opencode serve --hostname 0.0.0.0 --port 4096 --cors http://localhost:5173--cors 很重要——如果你要从 Web 前端访问,需要把前端的 origin 加进去。OpenCode 默认允许 localhost 和 127.0.0.1,也允许 opencode.ai 的子域名(为了官方 Web 客户端)。
用 SDK 控制
import { createOpencodeClient } from "@opencode-ai/sdk"
const client = createOpencodeClient({
baseUrl: "http://192.168.1.100:4096",
})
// 创建 session
const session = await client.session.create({
body: { title: "远程改 Bug" },
})
// 发消息
const result = await client.session.prompt({
path: { id: session.data.id },
body: {
parts: [{ type: "text", text: "修复 login.ts 里的空指针异常" }],
},
})
// 监听事件
const events = await client.event.subscribe()
for await (const event of events.stream) {
console.log(event.type, event.properties)
}这段代码可以跑在任何能访问到 server 的设备上——浏览器、Node.js 脚本、甚至 React Native app。
TUI 远程控制
/tui 系列接口让我觉得很酷——你可以远程操控另一个终端里的 TUI:
POST /tui/append-prompt # 往提示框追加文字
POST /tui/submit-prompt # 提交当前提示
POST /tui/execute-command # 执行命令
POST /tui/show-toast # 显示 toast 通知OpenCode 的 IDE 插件就是用这些接口实现的。当你在 VS Code 里选中一段代码然后点 "Send to OpenCode",IDE 插件实际上是调了 /tui/append-prompt 把代码贴到了 TUI 的输入框里。
安全性
远程 server 别忘了设置密码:
OPENCODE_SERVER_PASSWORD=your-secret opencode serve --hostname 0.0.0.0这会启用 HTTP Basic Auth。也可以通过 URL 参数 ?auth_token=<base64> 传递凭证,方便在不能设置 header 的场景下使用。
Slack bot 集成:把 AI 搬进聊天频道
OpenCode 的 Client/Server 架构让 Slack bot 集成变得异常简单——bot 只需要是一个 HTTP client。
基本思路:
import { createOpencodeClient } from "@opencode-ai/sdk"
import { App } from "@slack/bolt"
const opencode = createOpencodeClient({
baseUrl: "http://localhost:4096",
})
const app = new App({ /* slack config */ })
app.event("app_mention", async ({ event, say }) => {
const session = await opencode.session.create({
body: { title: `Slack: ${event.text}` },
})
// 异步发送 prompt
await opencode.session.prompt({
path: { id: session.data.id },
body: {
parts: [{ type: "text", text: event.text }],
},
})
await say("收到,我在处理了...")
})当然生产环境要复杂一些——你需要处理 session 复用(同一个 channel 的对话应该用同一个 session)、权限控制(谁能让 agent 执行危险操作)、结果通知(AI 完成后主动发消息回去)。但核心就这么简单,因为 OpenCode server 已经把所有复杂逻辑都封装好了。
Fence Middleware:写操作的同步屏障
最后聊一个容易忽略但很关键的设计——FenceMiddleware。
在多 client 场景下,一个写操作(比如创建 session、发送消息)完成后,不同 client 看到的状态可能不一致。FenceMiddleware 解决了这个问题:
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.method === "GET" || c.req.method === "HEAD") return next()
const prev = load() // 快照当前所有 aggregate 的 seq
await next() // 执行实际处理逻辑
const current = diff(prev, load()) // 计算差异
if (Object.keys(current).length > 0) {
c.res.headers.set("x-opencode-sync", JSON.stringify(current))
}
}工作原理:在写操作执行前拍个快照,执行后比较差异,把变化的 aggregate ID 和新的 seq 放到响应头里。Client 收到响应后就知道哪些数据变了,可以精确地增量拉取。
这个机制对于 Slack bot、Web 前端这些需要保持状态同步的 client 来说非常重要。没有它,你只能轮询或者盲等。
用 SDK 的话,事件订阅配合 fence header 就能实现完美的实时同步:
const events = await client.event.subscribe()
for await (const event of events.stream) {
if (event.type === "session.updated") {
updateUI(event.properties)
}
}作者: itech001 来源: 公众号:AI人工智能时代 主页: https://www.theaiera.cn(每日分享最前沿的AI新闻和技术)
本文首发于 AI人工智能时代,转载请注明出处。