Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com> Co-authored-by: Codex powered by ChatGPT-6-Astra <codex@example.com>
26 KiB
opencode server/client 架构研究报告
研究对象:opencode 仓库(版本 1.18.29,克隆位于
<local-clones>/opencode,只读) 研究目的:为 Astrion(Python Agent 项目)的 "gateway 化" 改造提供借鉴 研究方法:源码阅读,所有结论均附文件路径与关键代码证据(行号以本次阅读时为准)
总览(30 秒版)
opencode 有两个并存的 HTTP 服务层:
- 主服务(packages/opencode,TUI 实际使用):Effect
HttpApi定义 +HttpRouter.serve运行在 Node http server 上,路由分 Root(/global/*、/control/*)、Instance(/session/*、/tui/*、/event等)、v2 protocol(/api/*)、Public(UI 静态资源)。OpenAPI spec 从 Effect HttpApi 定义代码生成(bun dev generate→openapi.json→@hey-api/openapi-ts→ TS SDK)。 - 精简实验服务(packages/server + packages/protocol):同样是 Effect HttpApi,把协议定义下沉到
@opencode-ai/protocol,server 只注入 middleware/handler 实现,挂载/api/event、/api/session/*等 v2 路由。
核心设计:TUI 是 server 的瘦客户端,通过生成的 SDK + SSE(/global/event、/event)消费事件;server 通过事件总线反向驱动 TUI(/tui/*)。
1. Server 架构:框架、入口、路由、OpenAPI、SDK 生成
1.1 用的是什么框架
不是 Hono,也不是 Elysia,而是 Effect 生态的 HttpApi / HttpRouter(Effect 自带的声明式 HTTP 框架,effect/unstable/httpapi),底层走 @effect/platform-node 的 NodeHttpServer(node:http)。
证据:
packages/server/src/api.ts:import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi",HttpApi.make("server").add(...)组装所有 group。packages/server/src/routes.ts:HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" });webHandler()用HttpRouter.toWebHandler(...)导出 fetch handler。packages/opencode/src/server/server.ts:HttpRouter.serve(HttpApiApp.createRoutes(opts), ...)+NodeHttpServer.layer(() => server, ...)(createServer 来自node:http)。- 全局搜
hono仅命中 server.ts 一处注释性匹配,无 Hono 依赖。
1.2 入口在哪、谁调用它
三个入口,同一套实现:
| 入口 | 文件 | 说明 |
|---|---|---|
opencode serve(headless) |
packages/opencode/src/cli/cmd/serve.ts |
Server.listen(opts),监听 --port(默认 4096)/--hostname |
| TUI 进程内嵌 worker | packages/opencode/src/cli/tui/worker.ts |
worker 内 Server.listen(input)(rpc.server 方法)或纯内嵌 Server.Default().app.fetch(rpc.fetch) |
| 独立精简 server | packages/cli/src/commands/handlers/serve.ts |
HttpRouter.serve(createRoutes(password), ...),opencode serve(老 CLI) |
packages/opencode/src/server/server.ts 的 listen() 实现端口回退:startWithPortFallback 先试 4096,失败再随机端口。
1.3 路由如何组织
分组(HttpApiGroup)+ 累积式组装,两层:
packages/opencode/src/server/routes/instance/httpapi/api.ts:RootHttpApi= Control + ControlPlane + Global(/global/*)InstanceHttpApi= Config/Experimental/File/Instance/Mcp/Project/Question/Permission/Provider/Session/Sync/Tui/Workspace(/session/*、/tui/*、/permission/*等)OpenCodeHttpApi= Root + Event(/event) + Instance + Server(/api/*) + PtyConnect(WS)
- 每个 group 一个文件,如
groups/session.ts、groups/permission.ts、groups/tui.ts、groups/event.ts;handler 对应handlers/*.ts。
路由路径以组内常量定义(如 groups/session.ts 中 SessionPaths = { permissions: "/session/:sessionID/permissions/:permissionID", ... })。
1.4 OpenAPI spec 是手写还是代码生成?
纯代码生成。Effect HttpApi 的每个 HttpApiEndpoint 用 Schema 声明入参/出参/错误,用 OpenApi.annotations 附加 summary/description/identifier,Effect 在运行时把整个 Api 编译成 OpenAPI 文档:
packages/opencode/src/server/server.ts:export async function openapi() { return OpenApi.fromApi(PublicApi) }- schema 定义集中在
packages/schema/src(zod 风格由 EffectSchema实现,无 TypeBox/zod):session.ts、session-message.ts、session-event.ts、permission.ts、tui-event.ts等。 - 产物:
packages/sdk/openapi.json(openapi 3.1.0,188 个 operationId)。
1.5 SDK 如何从 spec 生成
packages/sdk/js/script/build.ts 全流程:
bun dev generate > openapi.json:调用 CLI generate 命令(packages/opencode/src/cli/cmd/generate.ts)→Server.openapi()输出 spec,并给每个 operation 注入x-codeSamples。@hey-api/openapi-ts(createClient)生成:src/v2/gen/types.gen.ts(TS 类型)src/v2/gen/sdk.gen.ts(OpencodeClient实例化 SDK,paramsStructure: "flat")src/v2/gen/client/*(fetch client,baseUrl 默认http://localhost:4096)
- 若干手工 patch(session.history 分页类型 string→number、SSE 泛型 bug)。
bun prettier+bun tsc校验。
SDK 对外暴露:packages/sdk/js/src/v2/client.ts(createOpencodeClient,支持自定义 fetch、x-opencode-directory 头路由到指定目录),packages/sdk/js/src/v2/server.ts(createOpencodeServer 用 cross-spawn 拉起 opencode serve 子进程)、process.ts。
对 Astrion 的借鉴意义
- 如果 Astrion 也要"HTTP API + 生成 SDK",可以用类似双轨:自己手写或代码生成 OpenAPI v3.1,再接入 openapi-typescript / openapi-generator / hey-api 生成 TS SDK(Python 侧可用 openapi-python-client)。
- "协议包(protocol)与实现(server)分离 + handler 注入"的分层(
@opencode-ai/protocol定义 group 与 error,@opencode-ai/server注入 middleware/handler)值得借鉴,便于多入口复用同一协议。 - OpenAPI 元数据(summary/description)直接写在 schema 附近,文档与代码同源,避免过期。
2. 事件系统:/event、/global/event、事件总线、序号与重连
2.1 两条事件通路
A) 全局总线 /global/event(RootHttpApi,GlobalApi)
- 总线本体:
packages/opencode/src/bus/global.ts—— 单例 NodeEventEmitter(GlobalBus.emit("event", {...})),事件带directory/project/workspace/payload。 - SSE 出口:
packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts的eventResponse():Stream.callback把GlobalBus.on("event")转成 Effect Stream,先发server.connected,10 秒心跳,Stream.pipeThroughChannel(Sse.encode())。 - 事件源:
packages/opencode/src/event-v2-bridge.ts——events.listen(...)把 core EventV2 的每个事件转发到 GlobalBus(payload: {id, type, properties: data}),durable 事件额外发一条{type:"sync", syncEvent:{...}}。
B) 实例流 /event(InstanceHttpApi,EventApi)
packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts:events.listen全量订阅 EventV2,然后按event.location.directory === instance.directory在服务端过滤(WorkspaceRoutingMiddleware用directoryquery/x-opencode-directory头选中实例),发server.connected+ 10s 心跳,遇server.instance.disposed关闭流。
C) v2 精简流 /api/event(packages/server)
packages/server/src/handlers/event.ts:EventV2.allBounded(events, 256)(有界 dropping 队列,容量 256,溢出即断开报错),发server.connected(类型由OpenCodeEventunion 合一),15s 心跳。
2.2 事件类型
由 schema 中 Event.define 声明,按 manifest 汇总(packages/schema/src/event-manifest.ts):
- session 事件(v2 增量式):
packages/schema/src/session-event.ts——session.next.prompted / prompt.admitted / context.updated / synthetic / shell.started|ended / step.started|ended|failed / text.started|delta|ended / reasoning.* / tool.input.*|called|progress|success|failed / retried / compaction.* / revert.*等约 30 种。Delta 类事件(text.delta、reasoning.delta、tool.input.delta)是 live-only、不入库;Ended 类是 replayable 边界。 - v1 事件(面向现有 TUI):
packages/schema/src/v1/session.ts的PartDelta/MessageUpdated等 +permission.asked/replied(v1)与permission.v2.asked/replied(v2)、question.asked/replied、tui.*(tui-event.ts)、server.connected(server-event.ts)、插件/集成事件等。 - 事件体统一形状:
{ id: "evt_xx", type, data/properties, location?, durable?: {aggregateID, seq, version}, metadata? }(packages/schema/src/event.ts)。
2.3 序号 / offset / 重放语义
核心机制在 packages/core/src/event.ts(EventV2 service)+ packages/core/src/event/sql.ts(SQLite):
- 表
event_sequence(aggregate_id PK, seq, owner_id)与event(id PK, aggregate_id, seq, type(版本化), data JSON),uniqueIndex(aggregate_id, seq)。 publish():若是 durable 事件 → 事务内 更新 seq(latest+1)、先跑 projectors(project注册的投影回调,和写库同事务)、再插 EventTable;非 durable 事件只走内存 PubSub。- 事件有每聚合(per-aggregate,如 sessionID)单调 seq 与 version(schema 演进版本);type 落库时是版本化串(
EventV2.versionedType(type, version))。 durable({aggregateID, after})流:先readAfter(aggregateID, after)从 SQLite 重放 seq > after 的历史事件,再Stream.concat内存 pubsub 的实时事件(subscribeDurable用 per-aggregate 的 sliding PubSub 唤醒读库)。→ 断线重连 = 重放历史 + 续传实时,这是"重放事件"路线。- v1 老的那套
/global/event与/event是 live-only、无 seq 无重放。
对外的重放/追平接口(v2):
GET /api/session/:sessionID/event?after=N→StreamSse(SessionEvent.Durable),"Replay durable events after an aggregate sequence, then continue with new durable events"(packages/protocol/src/groups/session.ts)。GET /api/session/:sessionID/history?limit&after→ 分页读 durable 事件(SessionHistory)。- 多 agent 协同:
/sync/replay、/sync/history(packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts)返回{ aggregateID: lastKnownSeq } → seq 之后的事件,用于客户端从同步点追平。
2.4 客户端断线后怎么追数据
- TUI 主路径(v1 事件):
packages/tui/src/context/sdk.tsx里startSSE()调sdk.global.event(),断了之后指数退避重连(1s→30s),不重放 —— 因为/global/event无历史;TUI 靠重新拉取状态补齐:重连后重新 GET session/messages/status,并(实验性)sync.start()开启工作区同步。 - v2 路径(durable 事件):带
afterseq 的订阅天然支持重放续传(2.3)。 - 结论:"轻事件总线(全量广播、可丢、无 seq)+ 有 seq 的 durable 事件(SQLite 持久化、可重放)+ 客户端主动拉状态" 三层组合,视客户端对可靠性的要求选层。
对 Astrion 的借鉴意义
- Python 侧实现 SSE 事件总线很简单(asyncio Queue + EventEmitter 等价物),关键是给事件加 durable 序号并落库,提供
after=N重放订阅,才能让弱网/多客户端可靠追平。 - 把"live-only delta"与"durable 终值"分离(text.delta 不入库、text.ended 入库可重放)是很好的降本设计。
- 服务端按
directory/workspace过滤事件(而非客户端过滤),减少带宽,Astrion 可按 workspace/agent 维度订阅。
3. Session 状态归属:存储、真状态、消息/part 模型
3.1 存哪
SQLite(bun:sqlite + drizzle-orm + Effect 封装),单库文件:
packages/core/src/database/database.ts:PRAGMA journal_mode=WAL; synchronous=NORMAL; busy_timeout=5000; foreign_keys=ON,库文件Global.Path.data/opencode.db(opencode-${channel}.db)。- 表都在
packages/core/src/session/sql.ts:session、message、part、session_message(v2 投影)、session_input、todo、session_context_epoch;事件表在packages/core/src/event/sql.ts。 - 老 v1 的会话(TUI 现行 API 用的
SessionService)读写 SQLite 的session/message/part表;新的 v2 由SessionProjector(packages/core/src/session/projector.ts)把 durable 事件投影进session_message表。
3.2 谁拥有"真状态"
Server(core 进程内):Session(packages/opencode/src/session/session.ts + packages/core/src/v1/session)和 SessionV2(packages/core/src/session,事件溯源风格)都在 server 进程的 Effect 服务里;消息/part 以 SQLite 为持久真源,事件总线只广播变化;客户端(TUI/SDK)是只读投影。v2 的"真状态"本质是 durable 事件日志(SQLite),投影表/API 视图都是派生物 —— 事件即真相。所有权通过 EventSequenceTable.owner_id 表达(claim(aggregateID, ownerID),replay 支持 strictOwner),多进程共享 session 时只能有一个 owner 追加事件。
3.3 数据模型长什么样
- v1 message(
packages/schema/src/v1/session.ts):MessageID = "msg_...",message ={ id, sessionID, time:{created}, role, ... };Part 是 tagged union:Text / Subtask / Reasoning / File / Tool / StepStart / StepFinish / Snapshot / Patch / Agent / Retry / Compaction(export const Part = Schema.Union([...]),discriminator "type")。SQLitemessage.data/part.data以 JSON 存。 - v2 session_message(
packages/schema/src/session-message.ts):SessionMessage.ID = "msg_...",tagged union:agent-switched / model-switched / user / synthetic / system / shell / step-start / step-finish / reasoning / text / tool / ...,带time:{created}、metadata。投影表session_message(id, session_id, type, seq, data JSON),uniqueIndex(session_id, seq)—— 消息有每会话 seq,和事件 seq 对应。 - v2 session_input(
packages/core/src/session/input.ts+sql.ts):输入先admitted_seq落库(durable admit),再promoted_seq被 agent 循环取走 —— 输入也是持久化的。
对 Astrion 的借鉴意义
- "事件日志为真相 + 投影表 + 视图 API"的 CQRS/事件溯源结构在 Python 侧可用
sqlite3/SQLAlchemy+ 事件表(aggregate_id, seq, type, data)实现,成本可控。 - 文件级 Session 与项目隔离(
project_id/directory列)对 Astrion 多 agent、多工作区是现成参考。 - 输入 admit/promote 两段式(先持久化再消费)能天然解决"请求丢失/重复"问题。
4. 权限 / 审批流:POST /session/:id/permissions/:permissionID
4.1 路由在哪
当前主服务的定义在 packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:
SessionPaths = {
...
permissions: `${root}/:sessionID/permissions/:permissionID`, // root = "/session"
}
handler 在 packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx) {
yield* requireSession(ctx.params.sessionID)
yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response })
.pipe(Effect.catchTag("Permission.NotFoundError", ...))
return true
})
(repo 中也存在 v2 等价路径 POST /api/session/:sessionID/permission/:requestID/reply,见 packages/protocol/src/groups/permission.ts。)
4.2 审批请求如何产生
审批服务于两代实现,机制相同(pending map + Deferred 阻塞 + 事件广播):
- 老 v1:
packages/opencode/src/permission/index.ts——ask()先对ruleset + approved求值(evaluate(permission, pattern, ...rulesets),Wildcard.match匹配 rule:allow/deny/ask 默认 ask);需要问人时创建PendingEntry{info, deferred}放入内存pending: Map<ID, PendingEntry>,然后events.publish(Event.Asked, info)广播,最后Deferred.await(deferred)阻塞住 agent 的工具调用直到有人回复。 - 新 v2:
packages/core/src/permission.ts——PermissionV2.ask()(返回{id, effect})与assert()(阻塞式,供 agent 内部用):问人时create(request, agent)把{request, agent, deferred}放进pending: Map<ID, Pending>并events.publish(Event.Asked, request)。
4.3 如何推给客户端
通过事件总线广播,不是 HTTP 推送:
events.publish(Event.Asked, info)→EventV2Bridge(packages/opencode/src/event-v2-bridge.ts)→GlobalBus.emit("event", ...)→/global/eventSSE 推给所有已连接客户端。- TUI 侧:
packages/tui/src/context/permission.tsx/feature-plugins/system/notifications.ts监听permission.asked事件弹审批 UI;同时context/sync.tsx的 casepermission.asked/permission.replied更新本地状态。
4.4 多个客户端同时连接时审批路由给谁
不做"路由",做"共享待办 + 先到先得":
- pending 表在 server 进程内存(
Map<ID, Pending/PendingEntry>),所有客户端共享; permission.asked广播给所有 SS E 连接(TUI、web、其他 SDK 客户端都能看到并都能回复);- 回复接口按
sessionID + requestID定位 pending 项(reply()里pending.get(input.requestID)),任何客户端 POST 都算;Deferred只能被 resolve/fail 一次,后到的回复找不到请求(404 PermissionNotFoundError); - 不存在"审批钉死给某个 client"的机制 —— 谁先回复谁生效。当多个 TUI 同时开着,
requestID是共享主键,service 层不区分连接。 - 附加:回复
"always"时保存规则到permission_saved表(packages/core/src/permission/saved.ts+sql.ts),并把其他 pending 的同类问题自动放行;"reject"会级联拒绝同 session 所有 pending。
对 Astrion 的借鉴意义
- 审批 = "异步请求对象(内存/DB)+ 阻塞等待(Deferred/Promise)+ 事件广播(总线)+ 客户端主动回复(HTTP POST)",这是 HTTP 世界做 human-in-the-loop 的最小可靠模型。
- 多客户端审批共享同一请求 ID、先到先得 意味着 Astrion 不需要做"连接路由",只需保证请求对象全局唯一、回复幂等。
- 用事件(permission.asked)驱动 UI、用 REST 回复的做法,比 RPC 回调更解耦,值得在 gateway 中复用。
5. TUI 与 server 的关系:连接方式与 /tui/* 反向控制
5.1 TUI 进程如何连接 server
两种模式(packages/opencode/src/cli/cmd/tui.ts):
- 内嵌 worker 模式(默认):CLI handler 用
new Worker(file, {...})拉起packages/opencode/src/cli/tui/worker.ts作为 Bun worker 子进程;worker 内Server.Default().app.fetch直接处理请求(不监听端口,URL 伪装成http://opencode.internal):- HTTP 请求走 RPC:
createWorkerFetch(client)→client.call("fetch", {...})→ workerrpc.fetch调Server.Default().app.fetch(request)返回 Response; - 事件走 RPC 事件:
createEventSource(client)→client.on("global.event")把 worker 里GlobalBus.on("event")转发出来的事件喂给 TUI 的 EventSource 接口。
- HTTP 请求走 RPC:
- 外部模式(
--port/--hostname/--mdns):先client.call("server", network)让 worker 真正Server.listen()起 HTTP 端口,TUI 用真实 URL +Authorization头(ServerAuth.headers(),packages/opencode/src/server/auth.ts,OPENCODE_SERVER_PASSWORD)直连。
TUI 内全部通过生成的 SDK(@opencode-ai/sdk/v2 的 createOpencodeClient)访问 API,packages/tui/src/context/sdk.tsx 里 startSSE() 调 sdk.global.event() 开 SSE(见 §2.4 的重连逻辑)。
5.2 /tui/* 反向控制接口的设计意图
定义:packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts(TuiPaths:/tui/append-prompt、open-help、open-sessions、open-themes、open-models、submit-prompt、clear-prompt、execute-command、show-toast、publish、select-session、control/next、control/response)。
意图:把 TUI 当作一个可被 server 及任何客户端(web、插件、MCP、Agent)控制的"界面设备",实现方式不是私有的进程内回调,而是两条标准通道:
- 事件通道(多数 /tui/ 端点)*:handler(
handlers/tui.ts)并不直接调 TUI 内部函数,而是events.publish(TuiEvent.PromptAppend / CommandExecute / ToastShow / SessionSelect, ...)把"UI 指令"作为普通事件发布到事件总线;TUI 作为 SSE 消费者收到tui.toast.show、tui.command.execute(command 恒为session.list、help.show、model.list等字符串命令)后自己执行弹窗/切换。例如:openHelp→publishCommand("help.show")openSessions→publishCommand("session.list")- MCP 认证失败时 server 自己
events.publish(TuiEvent.ToastShow, {title:"MCP Authentication Required", ...})(packages/opencode/src/mcp/index.ts)—— 同一通道、任意调用方。
- 请求/响应队列通道(
/tui/control/next+/tui/control/response):packages/opencode/src/server/shared/tui-control.ts用两个模块级AsyncQueue(packages/opencode/src/util/queue.ts,阻塞式队列)实现submitTuiRequest({path, body})/nextTuiRequest()/submitTuiResponse(body)/nextTuiResponse();TUI 拉取control/next(long-poll)执行、POSTcontrol/response交回结果。适合"必须拿到返回值"的 UI 操作(如 TUI 弹一个选择框,server 等它的结果)。
结论:/tui/* = "通过标准 HTTP 接口把事件塞进总线、由 TUI 自主消费",使 TUI 与 server 彻底解耦 —— 同一台 server 可以同时被 TUI、桌面端、web 端、Agent 进程控制,且 UI 指令事件天然对所有客户端可见。
对 Astrion 的借鉴意义
- "UI 即客户端设备、指令走事件总线、回执走请求队列"是 gateway 化后"远程控制本地交互界面"的标准答案:Astrion 的"弹确认框/提示"可以由任意调用方发布指令事件,前端订阅执行。
- 内嵌 worker + RPC 屏蔽 HTTP 与进程内调用的差异(
rpc.fetch模式),方便单元测试与本机零端口运行;对外则暴露真实端口 + 密码认证。Astrion 可在"进程内 gateway"与"独立 gateway 服务"之间无缝切换。
6. 实例模型:单实例单项目 vs 多项目多会话
是"单 server 进程 + 多 project 实例 + 每目录懒加载"。
- instance 概念:
packages/opencode/src/project/instance-store.ts里InstanceStore.load({directory, ...})—— 每个**目录(directory)**是一个InstanceContext {directory, worktree, project},server 进程用cache: Map<string, Entry>按目录缓存懒加载的实例;实例内包含一套完整 core 服务(Session/Permission/EventV2 等 Effect 层,AppNodeBuilder组装)。 - 路由如何选实例:
WorkspaceRoutingMiddleware(middleware/workspace-routing.ts)读 query 的directory/workspace或 headerx-opencode-directory→InstanceContextMiddleware(middleware/instance-context.ts)store.load({directory})把请求路由到该目录的实例上下文(InstanceRef)。SDK 客户端可在创建时传directory(packages/sdk/js/src/v2/client.ts自动加x-opencode-directory头)。 opencode serve的注释直接说明:"Server loads instances per-request via x-opencode-directory header — no need for an ambient project InstanceContext at startup."(packages/opencode/src/cli/cmd/serve.ts)- workspace(实验性):
WorkspaceV2(control-plane 的workspace.ts)在 directory 之上再加一层,WorkspaceRouteContext {directory, workspaceID};事件按location.directory + workspaceID过滤。 - 会话(session)挂在 project(
SessionTable.project_id)下,同一 server 可同时服务多个 project/session;每个项目有自己的 SQLite 数据(同库分目录),隔离靠 directory/project 列与实例上下文。 - 多实例并发时,单写者原则由
EventSequenceTable.owner_id+claim/strictOwner保证(§3.2)。
对 Astrion 的借鉴意义
- Astrion gateway 可以是"单进程多 workspace 懒加载实例"而非"一项目一进程":进程常驻、按请求头路由实例上下文,大幅简化部署;实例级状态(session、事件流)天然隔离。
- 用请求头/query 选实例的方案(
x-opencode-directory)可作为 Astrion 多租户路由的样板。
opencode 设计要点速查表
| # | 要点 | 一句话 |
|---|---|---|
| 1 | 框架 | Effect HttpApi/HttpRouter(非 Hono),Node http 底层,OpenApi.fromApi 代码生成 OpenAPI 3.1 |
| 2 | SDK | bun dev generate → openapi.json → @hey-api/openapi-ts → TS SDK(fetch client + SSE 类型) |
| 3 | Server 分层 | @opencode-ai/protocol(协议/group) + @opencode-ai/server(middleware/handler 注入) + @opencode-ai/core(领域服务) |
| 4 | 事件总线 | GlobalBus(EventEmitter)→ /global/event SSE(全量广播、live-only、10s 心跳);/event 按 directory 过滤 |
| 5 | 可靠事件 | EventV2:SQLite event/event_sequence 表 + per-aggregate seq + version;durable(after) 先重放后续传 |
| 6 | session 真状态 | Server 进程 + SQLite(WAL);消息/part 是 JSON 投影(v1 message/part 表、v2 session_message 表),事件日志即真相 |
| 7 | 消息模型 | Message/Part 都是 taggged union(type 判别),v2 session_message 带 per-session seq |
| 8 | 审批流 | pending Map + Deferred 阻塞等待 + permission.asked 事件广播;按 requestID 先到先得回复,无连接路由 |
| 9 | TUI 关系 | TUI=瘦客户端:内嵌 Bun worker + RPC fetch/事件,或 --port 真 HTTP + 密码认证;/tui/* 把 UI 指令发布为事件、TUI 自主消费 |
| 10 | 实例模型 | 单进程多项目:按 directory/x-opencode-directory 头懒加载 InstanceContext,实例内一套 core 服务;单写者由事件 ownership 保证 |