agent-Specialization/cache_research/gateway/opencode_study/opencode_architecture.md
JOJO 6e043389b9 docs(research): Gateway 化改造工作计划与范围复杂度评估
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>
2026-09-07 12:27:59 +08:00

294 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# opencode server/client 架构研究报告
> 研究对象opencode 仓库(版本 1.18.29,克隆位于 `<local-clones>/opencode`,只读)
> 研究目的:为 AstrionPython Agent 项目)的 "gateway 化" 改造提供借鉴
> 研究方法:源码阅读,所有结论均附文件路径与关键代码证据(行号以本次阅读时为准)
---
## 总览30 秒版)
opencode 有两个并存的 HTTP 服务层:
1. **主服务packages/opencodeTUI 实际使用)**Effect `HttpApi` 定义 + `HttpRouter.serve` 运行在 Node http server 上,路由分 Root`/global/*`、`/control/*`、Instance`/session/*`、`/tui/*`、`/event` 等、v2 protocol`/api/*`、PublicUI 静态资源。OpenAPI spec 从 Effect HttpApi 定义**代码生成**`bun dev generate` → `openapi.json``@hey-api/openapi-ts` → TS SDK
2. **精简实验服务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/identifierEffect 在运行时把整个 Api 编译成 OpenAPI 文档:
- `packages/opencode/src/server/server.ts``export async function openapi() { return OpenApi.fromApi(PublicApi) }`
- schema 定义集中在 `packages/schema/src`zod 风格由 Effect `Schema` 实现,无 TypeBox/zod`session.ts`、`session-message.ts`、`session-event.ts`、`permission.ts`、`tui-event.ts` 等。
- 产物:`packages/sdk/openapi.json`openapi 3.1.0188 个 operationId
### 1.5 SDK 如何从 spec 生成
`packages/sdk/js/script/build.ts` 全流程:
1. `bun dev generate > openapi.json`:调用 CLI generate 命令(`packages/opencode/src/cli/cmd/generate.ts`)→ `Server.openapi()` 输出 spec并给每个 operation 注入 `x-codeSamples`
2. `@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 clientbaseUrl 默认 `http://localhost:4096`
3. 若干手工 patchsession.history 分页类型 string→number、SSE 泛型 bug
4. `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 SDKPython 侧可用 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`RootHttpApiGlobalApi**
- 总线本体:`packages/opencode/src/bus/global.ts` —— 单例 Node `EventEmitter``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`InstanceHttpApiEventApi**
- `packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts``events.listen` 全量订阅 EventV2然后按 `event.location.directory === instance.directory` **在服务端过滤**`WorkspaceRoutingMiddleware` 用 `directory` query/`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`(类型由 `OpenCodeEvent` union 合一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** 与 **versionschema 演进版本)**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 事件)**:带 `after` seq 的订阅天然支持重放续传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 存哪
**SQLitebun: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 谁拥有"真状态"
**Servercore 进程内)**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"。SQLite `message.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`
```ts
SessionPaths = {
...
permissions: `${root}/:sessionID/permissions/:permissionID`, // root = "/session"
}
```
handler 在 `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`
```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` 匹配 ruleallow/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/event` SSE 推给所有已连接客户端。
- TUI 侧:`packages/tui/src/context/permission.tsx` / `feature-plugins/system/notifications.ts` 监听 `permission.asked` 事件弹审批 UI同时 `context/sync.tsx` 的 case `permission.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`
1. **内嵌 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", {...})` → worker `rpc.fetch``Server.Default().app.fetch(request)` 返回 Response
- 事件走 RPC 事件:`createEventSource(client)` → `client.on("global.event")` 把 worker 里 `GlobalBus.on("event")` 转发出来的事件喂给 TUI 的 EventSource 接口。
2. **外部模式(`--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控制的"界面设备"**,实现方式不是私有的进程内回调,而是**两条标准通道**
1. **事件通道(多数 /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`)—— 同一通道、任意调用方。
2. **请求/响应队列通道(`/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执行、POST `control/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` 或 header `x-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`(非 HonoNode http 底层,`OpenApi.fromApi` 代码生成 OpenAPI 3.1 |
| 2 | SDK | `bun dev generate` → openapi.json → `@hey-api/openapi-ts` → TS SDKfetch 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 | 可靠事件 | EventV2SQLite `event/event_sequence` 表 + per-aggregate seq + version`durable(after)` 先重放后续传 |
| 6 | session 真状态 | Server 进程 + SQLiteWAL消息/part 是 JSON 投影v1 message/part 表、v2 session_message 表),事件日志即真相 |
| 7 | 消息模型 | Message/Part 都是 taggged uniontype 判别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 保证 |