跳到主要内容

数据截至 (上游 commit 0004b748b71c)

运行骨架:一个 instance、一张 Effect 服务图、一份 SQLite

30 秒导读: Kilo Code 看起来是“VS Code 插件 / JetBrains 插件 / CLI 三个产品”,物理上只有一套东西——那张 Effect HttpApi 路由图。所有界面都是壳:IDE 插件 spawn 一个 kilo serve 子进程用 HTTP 连上去,终端 TUI 干脆在进程内直接调同一套路由。本章讲清这套东西内部的三根骨头:instance(按目录切的实例上下文)Effect 服务图(每个模块长得一模一样的那套模板)一份 SQLite(所有实例共享的落盘)

本章是后面所有章节的舞台。会话循环(02)、工具与权限(03)、编辑与快照(04)都跑在这个骨架上——先知道“戏台子长什么样”,再看戏。


1. 一句话骨架

先把结论摆出来,后面每节都是在展开这一句:

一个进程 = N 个 instance;一个 instance = 一个目录 + 一张惰性建起来的 Effect 服务图;所有 instance 共写同一个 kilo.db

三个数量关系值得先记住:

东西数量谁决定的
后端进程每个 IDE 窗口一个;TUI 不另起进程,后端跑在同进程的 worker 里前端 spawn(server-manager.ts:118)/worker 直调(cli/tui/worker.ts:75
instance进程内每个目录一个请求里的 directoryinstance-store.ts:126FSUtil.resolve 归一后当缓存键)
SQLite 文件每台机器每个安装通道一份Database.Client 模块级单例(storage/db.ts:100

注意第二行:缓存键是目录,不是 project。同一个 git 仓库的主工作区和一个 worktree 是两个 instance,但它们指向同一条 project 行。


2. monorepo 布局:谁是内核、谁是壳

仓库是 bun workspaces,packages/* 全量纳管(根 package.json:22-26workspaces.packages,另单列了 packages/sdk/js)。packages/ 下 24 个目录、21 个带 package.json 的真包,真正需要认识的是这几个:

package定位关键入口
packages/opencode内核 + CLI,发布名 @kilocode/clisrc/index.ts;bin 是 kilo / kilocodepackage.json:21-23
packages/core共享 schema 与工具(@opencode-ai/coresrc/session-event.tssrc/model.tssrc/permission.ts
packages/llm自研模型客户端src/llm.tssrc/providers/src/protocols/
packages/sdk生成的 HTTP 客户端 + OpenAPI 快照js/src/v2/openapi.json
packages/kilo-vscodeVS Code 前端(薄壳)src/services/cli-backend/server-manager.ts
packages/kilo-jetbrainsJetBrains 前端(Kotlin 薄壳)backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt
packages/kilo-web-ui浏览器 UI 组件层src/components/
packages/kilo-gatewayKilo 侧扩展:统一网关与鉴权src/provider.tssrc/auth/src/server/
packages/kilo-indexingKilo 侧扩展:代码索引引擎src/indexing/src/tree-sitter/src/plugin.ts

一个命名上的坑:目录叫 opencode、内部包名前缀是 @opencode-ai/,但发布出去是 @kilocode/cli,二进制叫 kilo。原因写在 README 里——Kilo CLI 是 OpenCode 的 fork(README.md:171)。源码里大量 // kilocode_change 注释标出了改动边界,看到这个注释就知道“这段是 Kilo 加的”。

kilo-gatewaykilo-indexing 是“挂在内核上的扩展”,不是独立服务:两者都导出 plugin.ts,通过内核的 Plugin 机制注入。


3. 服务器形态:kilo serve 起了什么

3.1 一张图看清进程

kilo serve --port 0


┌──────────────────┐ Effect.runPromise
│ Server.listen() │ ───────────────────► Effect 世界
│ server.ts:84 │
└────────┬─────────┘
│ 端口回退:0 → 先试 4096 → 再试任意空闲

┌──────────────────────────────────────┐
│ NodeHttpServer + HttpRouter │ ← serverLayer(server.ts:202)
│ ├─ 全局路由 /global/* │
│ ├─ SSE 路由 /event、/global/event │
│ ├─ WebSocket 路由(pty) │
│ ├─ 实例路由(session/file/…) │
│ └─ 兜底静态 UI │
└──────────────────────────────────────┘

▼ stdout: "kilo server listening on http://127.0.0.1:PORT"
前端正则抓端口,建立连接

怎么读:从上往下是启动顺序;最后一步的那行 stdout 是IDE 前端与后端唯一的握手信号——下面 §7 会看到 VS Code 和 JetBrains 都在正则匹配它。

3.2 Listener:进程对外的全部把手

kilo serve 的 handler 只做三件事:解析网络参数、Server.listen(opts)、然后挂起等信号(cli/cmd/serve.ts:14-53ServeCommand)。

值得注意的是它声明了 instance: falsecli/cmd/serve.ts:12)——服务器启动时不绑定任何项目目录,实例是逐请求按需加载的。这一条是整个“多 instance”设计的起点。

listen() 返回的 Listenerserver/server.ts:24-36,构造在 :84-93)只暴露 hostname / port / url / urls / stop,再无其它——进程的生命周期就靠这个把手管。

端口 0 有个不显然的行为:不是“随便给个空闲端口”,而是先试 4096,失败再退到任意空闲端口server/server.ts:131startWithPortFallback)。这是为了兼容旧版行为,让常见场景下端口稳定。

还有一个不监听端口的出口。 Server.Default()server/server.ts:67)导出的是同一套路由包成的纯 fetch(request) => Response handler。同一张路由图,既能挂到 Node HTTP 服务器上(listen),也能当函数直接调——§7.4 的终端 TUI 走的正是后者。

3.3 路由树:五支并联

HTTP 层用的是 Effect 的 HttpApi(类型化 API 定义)+ HttpRouter(裸路由)混合。createRoutesroutes/instance/httpapi/server.ts:297)把 root / event / ptyConnect / instance / server / doc / ui 七支路由 Layer.mergeAll 在一起(:300-308):

路由支装的是什么定义处(server.ts 行)
rootApiRoutes/global/* 与控制面路由:130
eventApiRoutes/event —— 每实例 SSE 流:135
ptyConnectApiRoutespty 的 WebSocket 升级路由:139
instanceRoutes其余实例路由(session/file/…):143 / :164
docRoute + uiRoute/doc OpenAPI + 静态 UI 兜底:175 / :179

具体端点按领域切在 routes/instance/httpapi/groups/ 下的 20 个文件里(session.tsfile.tspermission.tsprovider.tspty.ts…),对应的实现在同级 handlers/groups 定义契约、handlers 写逻辑,这是这一层唯一需要记的分工。

横切关注点全部是 middleware layer,在 middleware/ 下:authorization.ts(鉴权)、workspace-routing.ts(算出这个请求属于哪个目录)、instance-context.ts(把目录变成实例上下文)、compression.tserror.ts 等。

3.4 事件:两条 SSE 通道

Kilo 的事件外推有两条通道,搞混了会看不懂日志:

某个服务 publish 事件

├──────────────► 实例内 PubSub ──► GET /event
│ (bus/index.ts) 每实例一条流

└──────────────► GlobalBus ───────► GET /global/event
(EventEmitter) 全进程一条流
  • /event 走实例内的 Effect PubSubBus 服务(bus/index.ts:64Service)为每个实例惰性建一对 PubSub:一个 wildcard、一份按类型分的 map(bus/index.ts:71-93)。handler 订阅 wildcard 并混入 10 秒心跳(handlers/event.ts:27-33)。
  • /global/event 走一个朴素的 Node EventEmitter——GlobalBusbus/global.ts:22)。handler 用 Stream.callback 把 emitter 桥成流(handlers/global.ts:39)。

两者的关系是扇出:实例 Bus 每次 publish 都会顺手 GlobalBus.emit("event", …),带上 directory / project / workspace 三个标签(bus/index.ts:120-125)。所以 /global/event 是“所有实例事件的汇流”,前端只订它一条就够——VS Code 正是这么做的。

实例被销毁时,Bus 的 finalizer 会先发一条 server.instance.disposed 再关闭 PubSub(bus/index.ts:76-89 配合 InstanceDisposed,定义在 bus/index.ts:23);/event 的流看到这条就 takeUntil 收尾(handlers/event.ts:28)。先通知、再关门,订阅者不会莫名断线。


4. instance:这个项目最核心的抽象

4.1 它要解决的小问题

一个 kilo serve 进程可能同时服务多个目录:VS Code 开了三个工作区文件夹、Agent Manager 同时跑五个 worktree。这些目录各有各的配置、各有各的 LSP、各有各的权限批准记录。

instance 就是“把一个目录相关的所有状态圈起来”的那个圈。

4.2 它其实很小

InstanceContext 只有三个字段(project/instance-context.ts:5-9):

export interface InstanceContext {
directory: string // 当前工作目录
worktree: string // 所属 git worktree 根
project: Project.Info // 所属 project 行
}

directoryworktree 分开,是因为你可能在仓库的子目录里启动。两者一起构成“项目边界”,containsPath 就是那把尺子(project/instance-context.ts:18-24):

export function containsPath(filepath: string, ctx: InstanceContext): boolean {
if (AppFileSystem.contains(ctx.directory, filepath)) return true
if (ctx.worktree === "/") return false // ← 非 git 项目的兜底
return AppFileSystem.contains(ctx.worktree, filepath)
}

那个 worktree === "/" 的判断是个真实踩过的坑:非 git 项目的 worktree 被设成 "/"(见 project/project.ts:217),不特判的话任何绝对路径都会被判定为“在项目内”external_directory 权限就形同虚设。这条边界是 03 章权限闸门的地基。

4.3 一次请求怎么落到实例上

HTTP 请求


workspace-routing 中间件
│ directory = ?query.directory
│ ?? header["x-kilo-directory"]
│ ?? process.cwd()

instance-context 中间件
│ store.load({ directory })

Effect.provideService(InstanceRef, ctx)


handler 里所有 InstanceState 都自动指向这个实例

三段兜底的优先级写在 middleware/workspace-routing.ts:87-89defaultDirectory;把目录换成上下文只有一行——middleware/instance-context.ts:29store.load(...),随后把 InstanceRef 注进整条 Effect(middleware/instance-context.ts:30-33)。

4.4 InstanceStore:缓存 + 去重 + 引导

InstanceStoreproject/instance-store.ts:37layer)是一个 Map<string, Entry> 缓存,value 里放的是 Deferred,不是值本身。

这个选择解决的是并发首次加载:第一个请求建 Deferred 并 fork 引导流程,后续并发请求直接 Deferred.await 同一个(project/instance-store.ts:125-141load)。整段包在 Effect.uninterruptibleMask 里,避免“缓存已写、引导被打断”的半死状态。

引导本身在 bootproject/instance-store.ts:45-68):先由 project.fromDirectory 解析出 worktree 与 project 行(project/project.ts:213),再跑 InstanceBootstrap.run

run 的顺序有讲究(project/bootstrap.ts:38-53):

  1. config.get() —— 所有东西都依赖配置;
  2. plugin.init() —— 插件可以改配置,必须排在其它服务之前;
  3. 最后并发拉起 reference / lsp / format / file / fileWatcher / vcs / snapshot / project,每个都用 catchCause 兜住,单个失败只记 warning、不拖垮实例。

销毁走 dispose / disposeAllproject/instance-store.ts:170:185),disposeAll 还注册成了 layer finalizer(:192),进程退出时自动清场。

4.5 InstanceState.make:按实例惰性建状态

这是全仓最常见的一个写法(32 个文件用到),值得单独讲。

它要解决的小问题: Permission 服务只有一个 layer 实例,但每个目录的批准规则不同——状态得按实例分开存,还不能在启动时就把 N 个目录的状态全建出来。

思路: 把状态包成一个以 directory 为键的 ScopedCache,取的时候现建。

// 示意,非源码
const state = InstanceState.make((ctx) => buildStateFor(ctx)) // 定义“怎么建”
const s = yield * InstanceState.get(state) // 取:当前实例没有就现建

真实实现: effect/instance-state.ts:26-45makeScopedCache.make 建缓存,lookup 里读当前 fiber 上的 InstanceContextget:50)拿 directory 当键。因为是 ScopedCache,每份状态自带 scope,实例销毁时 finalizer 会跑。

销毁的触发链在 effect/instance-registry.ts:每个 InstanceState 注册一个 disposer(instance-state.ts:38),disposeInstance(directory) 一次性把所有 disposer 都调一遍(instance-registry.ts:15)。

一个能直接对照着看的例子:Permission 的 state 在建的时候从 SQLite 读该 project 的已批准规则,并注册 finalizer 把所有 pending 请求以 RejectedError 收尾(permission/index.ts:165-185)。


5. Effect 编码范式:为什么每个模块长得一模一样

5.1 五段式模板

翻开 permission/index.tsagent/agent.tssession/session.tssnapshot/index.ts,结构完全同构(全仓 82 个文件用 Context.Service<>)。模板是:

// 示意,非源码
export interface Interface {
/* 只写方法签名,返回 Effect */
}
export class Service extends Context.Service<Service, Interface>()("@opencode/X") {}
export const use = serviceUse(Service) // 便捷调用
export const layer = Layer.effect(Service, Effect.gen(...)) // 真正的构造
export const defaultLayer = layer.pipe(Layer.provide(...)) // 补齐依赖
export * as X from "./x" // 命名空间导出

对照真源码,Agent 逐行都在:Interfaceagent/agent.ts:76)、Service:90)、use:92)、layer:94)、defaultLayer:604)、export * as Agent:614)。Permission 一样(permission/index.ts:75 / :214 / :216 / :509)。

5.2 为什么值这么做

四条好处,按重要性排:

好处具体是什么
依赖显式服务在 Effect.gen 头部 yield* 出所有依赖,漏了编译期就报错
可替换测试里换成 Layer.succeed(Service)(fake) 就行,不用打桩全局模块
生命周期Layer.effectEffect.addFinalizer 注册的清理,随 scope 关闭自动跑
两套装配同一批 layer 既能拼进 HTTP 服务器,也能拼进 CLI 运行时,只是 provide 顺序不同

最后一条是关键:layer不带依赖的裸构造,defaultLayer 才是自带依赖的开箱即用版。HTTP 服务器用前者(自己统一 provide 一大坨,见 routes/instance/httpapi/server.ts:297-346),让所有服务共享同一份底座;CLI 场景用后者。

5.3 服务图长什么样

两处装配点:

  • HTTP 服务器createRoutes 把一批 *.defaultLayerAgentManager / Notebook / SyncEvent / SessionProjector 等)一次性 Layer.provide 给路由(routes/instance/httpapi/server.ts:297,defaultLayer 批在 :309-317),Observability.layerprovideMerge 压轴(:343-344,注释明说必须垫在服务图之后);早前的 InstanceLayer.layer 已随实例层重构移除。
  • CLI / 桥接AppLayer 交给 ManagedRuntime.make 变成 AppRuntimeeffect/app-runtime.ts:139 / :141 / :144)。

早期版本的 InstanceLayer.layerproject/instance-layer.ts)用 Layer.unwrap + 动态 import()InstanceBootstrap 的整棵实现图延迟到运行时才加载;当前重构已拆掉该文件,InstanceStore 的 layer(project/instance-store.ts:37)改为直接 yield* InstanceBootstrap.Service——因为 InstanceStore 只依赖 bootstrap-service.ts 里那个 9 行的 tag(project/bootstrap-service.ts:7),不依赖实现(实现图在 project/bootstrap.ts:52 提供)——同样切断了“存储层 → 全部业务服务”的编译期循环。

还有一层 Promise 世界的桥:InstanceRuntimeproject/instance-runtime.ts:9-18)把 load / dispose 包成 Promise,给还没迁到 Effect 的调用方用。文件顶部自己写着“迁完就删”,可以当作迁移进度条来读。


6. 持久化底座:一份 SQLite

6.1 Drizzle + SQLite,按运行时二选一

数据库驱动是条件导入的。packages/opencode/package.json:26-33 声明:

"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
}
}

两个实现都只有 8 行,签名相同(都导出 init(path)):Bun 版用 bun:sqlite + drizzle-orm/bun-sqlitestorage/db.bun.ts:4),Node 版用 node:sqliteDatabaseSync + drizzle-orm/node-sqlitestorage/db.node.ts:4)。上层只写一句 import { init } from "#db"storage/db.ts:16),对运行时无感。

6.2 一个进程一个连接

Database.Clientstorage/db.ts:100-146)是模块级单例:loaded 为真就直接返回缓存的 client(:100)。首次打开时设六条 PRAGMA(:107-112),其中三条值得留意:

PRAGMA为什么
journal_modeWAL读写不互斥,SSE 长读不挡写入
busy_timeout5000多前端并发写同一个 db 时不立刻 SQLITE_BUSY
foreign_keysONON DELETE CASCADE 真正生效

外键这条不是装饰:后面专门有代码在吞掉 SQLITE_CONSTRAINT_FOREIGNKEY,处理“会话已删、事件晚到”的竞态(kilocode/session/index.ts:283 的判定,动机见 :270 注释)。

迁移在打开时立刻跑(storage/db.ts:118-133):打包版读注入的 KILO_MIGRATIONS,开发版扫 migration/ 目录(当前 21 个迁移)。

数据库文件路径由 getChannelPath 决定(storage/db.ts:33-41):稳定通道用 kilo.db,其它通道加通道后缀,并保留了从 opencode-*.db 迁移过来的兼容分支。

6.3 表长什么样

核心表都在 packages/core/src/session/sql.tspackages/core/src/project/sql.ts(表定义已从 opencode 包迁到 core 包,storage/schema.ts:1-5 只做 re-export):

Drizzle 符号存什么
projectProjectTableworktree、vcs 类型、sandbox 列表(packages/core/src/project/sql.ts:6
sessionSessionTable会话元信息 + 累计 cost/token(packages/core/src/session/sql.ts:22
messageMessageTable旧模型的消息,payload 整个塞 JSON 列(session/sql.ts:71
partPartTable消息的分片(文本/工具调用/step)(session/sql.ts:85
session_messageSessionMessageTable新模型的消息事件投影(session/sql.ts:123
permissionPermissionTable每 project 一行的已批准规则集(packages/core/src/permission/sql.ts
event_sequence / eventEventSequenceTable / EventTable事件序号与事件本体(packages/core/src/event/sql.ts:4 / :10

时间戳字段是复用的 mixin:Timestampspackages/core/src/database/schema.sql.ts:3),time_updated 用 Drizzle 的 $onUpdate 自动刷新。

6.4 事件 → 投影:写路径只有一条

它要解决的小问题: 状态变更既要落库,又要推给前端,还要能重放。三件事各写一遍,迟早不一致。

思路: 把“发生了什么”定义成事件,然后一个事件对应一个 projector 函数,由框架统一负责“跑 projector + 写事件表 + 发总线”这三件事,且在同一个事务里。

Session.Event.Created 发生


SyncEvent.run(def, data)

▼ Database.transaction ┌───────────────────────┐
├──────────────────────► │ projector(tx, data) │ 改业务表
│ ├───────────────────────┤
├──────────────────────► │ insert event/event_seq│ 记事件(开关控制)
│ └───────────────────────┘

└── 事务提交后 ─────────► bus.publish + GlobalBus.emit

怎么读:左边是一次调用,右边三件事要么全成要么全不成;发总线被推迟到事务之后。

真实实现看 sync/index.ts:312process

  • Database.transaction 开事务(:333),第一句就是 projector(tx, event.data, event):334);
  • 事件表写入被 experimentalWorkspaces 开关包着(:337 起)——默认不落事件表,event sourcing 目前是“有能力但未全开”;
  • 发布走 Database.effect(...):360),这是个“事务提交后再执行”的队列(storage/db.ts:175-182),保证订阅者读到的一定是已提交状态。

projector 的注册在当前版本搬进了 Effect 图:SyncEvent.project(def, fn) 仍返回一个 [def, fn] 对(sync/index.ts:300-304),packages/core/src/session/projector.ts:231layer = Layer.effectDiscard(...) 里逐个 events.project(...) 把两代投影列全,经 SessionProjector.nodeprojector.ts:481)挂进运行时服务图(effect/app-runtime.ts:99)。SyncEvent.init 编 Map 并冻结事件注册表的机制仍在(sync/index.ts:227 / :261frozen = true:261)——init 之后再定义事件会直接抛错,而不是安静地丢事件。

早期那个隐蔽的 init 调用点(server/init-projectors.ts 三行 + server/server.ts:1import "./init-projectors" 副作用)现在还在,但 server/projectors.tsinitProjectors() 已被掏成空壳——投影注册随上面说的 Effect 图自动完成,这个 stub 只留兼容。

6.5 两代投影并存

这是当前代码里最容易看晕的地方,一句话说清:

  • 旧代packages/core/src/session/projector.ts 里把 SessionV1.Event.*(Created/Updated/Deleted/MessageUpdated…,:235 起)投到 session / message / part 三张表。
  • 新代:同一个文件里把 SessionEvent.*(Moved / Prompted / Step…,:263 起)投成 session_message 表的行,统一走 update
  • 两代不再靠数组拼接:早先 session/projectors.ts 一行 ...nextProjectors 把新代摊进旧代数组的写法已随重构消失,两代如今在同一个 Layer.effectDiscard 里并列注册。

新代的读取面是 Session.Info,现在是 Schema.Structpackages/schema/src/session.ts:19),查询直接打 SessionMessageTable(定义在 packages/core/src/session/sql.ts:123)。

中间还有一个EventV2Bridgeevent-v2-bridge.ts:16layer)。core 包里新定义的 EventV2 是发布侧的真源,但旧总线和 SSE 还在等旧格式,于是这层订阅 events.sync:69),按事件有无版本号分流——有版本号走 SyncEvent 投影(:78),没有就直接 bus.publish:85)。文件顶部三行注释明说了“消费者都迁到 EventV2 之后这层就删”。


7. 前端为什么是薄壳

7.1 一张图

VS Code 扩展进程 kilo serve 子进程
┌──────────────────┐ spawn ┌──────────────────┐
│ ServerManager │ ─────────────► │ HTTP + SSE │
│ │ ◄── stdout ─── │ "listening on …" │
├──────────────────┤ 抓端口 │ │
│ KiloClient (SDK) │ ── HTTP ─────► │ /session /file … │
│ SdkSSEAdapter │ ◄── SSE ────── │ /global/event │
└──────────────────┘ └──────────────────┘

7.2 启动握手

VS Code 侧 spawn(cliPath, ["serve", "--port", "0"], …)kilo-vscode/src/services/cli-backend/server-manager.ts:118),用 detached + piped stdio。然后逐行读 stdout,用正则 listening on http://[\w.]+:(\d+) 抓端口(server-utils.ts:6-10,调用点在 server-manager.ts:171)。

握手信息不止端口:环境变量里塞了一次性的 KILO_SERVER_PASSWORDserver-manager.ts:141)当鉴权凭据,还有 KILO_CLIENT=vscodeKILO_PLATFORM 等一串身份标签(:134-145)。

JetBrains 是同一套,连正则都一样:KiloBackendCliManager.kt:154listOf(cli, "serve", "--port", "0"):38PORT_REGEX 与 VS Code 那条逐字相同。

7.3 连回来:一条 SSE + 一个心跳

拿到端口后,ConnectionService 建 SDK 客户端指向 http://127.0.0.1:<port>connection-service.ts:808:722),再把它包进 SdkSSEAdapter:728)。

adapter 消费的是 client.global.event() 这个 AsyncGenerator(sdk-sse-adapter.ts:169)——也就是 §3.4 说的全局那条流,不是每实例那条。外层 while (!aborted) 循环负责重连(:129consumeLoop)。

活性检测是双通道的,这个设计值得抄:

通道怎么判死位置
SSE 心跳服务端每 10s 发一次,15s 没收到就强制重连sdk-sse-adapter.ts:62 / handlers/event.ts:30
HTTP 健康定时轮询 /global/health,失败就主动 sseClient.reconnect()connection-service.ts:739 / :673

两条通道互不依赖,单靠心跳判死会漏掉“流还连着但服务器已僵”的情况。

7.4 终端 TUI:同一套路由,连 TCP 都不开

TUI 是唯一一个不 spawn 子进程、也不监听端口的前端,但它走的仍然是同一套 HTTP 路由。

主线程(渲染) Worker 线程(后端)
┌──────────────────────┐ RPC ┌────────────────────────────┐
│ createWorkerFetch │ ───────────► │ Server.Default().app.fetch │
│ createEventSource │ ◄─────────── │ (cli/tui/worker.ts:75) │
│ createKiloClient │ └────────────────────────────┘
└──────────────────────┘

主线程把 SDK 客户端的 fetch 换成一个走 RPC 的假 fetch(cli/cmd/tui.ts:83createWorkerFetch,装配在 :435,客户端 options 在 :429-438);worker 那头收到之后拼出一个 Request,直接交给 Server.Default().app.fetch(request)cli/tui/worker.ts:75)。整条链路上没有一个 socket。

worker 里另有一个可选的 server() RPC 方法(worker.ts:73-77),调用它才会真的 Server.listen 开端口——那是“把 TUI 的后端暴露给别的客户端”的按需能力,不是 TUI 自己用的通路。

刻意让 TUI 也绕一圈 HTTP 语义(而不是内存直调服务),换来的是TUI 与 IDE 插件走完全相同的代码路径:路由、鉴权中间件、instance 解析、SSE 编码一个都不少。

7.5 同一内核的另一张脸:ACP

kilo acp 走的是完全一样的路子,只是壳更薄:先 Server.listen(opts) 起同一个 HTTP 服务器(cli/cmd/acp.ts:25),再 createKiloClient 指向自己(:30),然后把 stdin/stdout 包成 ndjson 流接上 AgentSideConnection

src/acp/ 目录下的 agent.ts / session.ts / tool.ts / permission.ts 就是把内核概念翻译成 ACP 概念的适配层——不是第二套实现

所以整个项目的“多前端”其实只有一句话:内核只有 HTTP + SSE 一个门,谁都从这个门进来;区别只在这道门后面有没有 TCP。


8. 边界:本章不讲、以及这个骨架的代价

本章刻意不讲的(在别章):

这个骨架自身的代价,三条:

  1. 迁移债明面化。 新旧两代事件模型、两代 projector、EventV2Bridge 桥接层、InstanceRuntime 的 Promise 兜底,全部并存。好处是能小步走,代价是同一件事有两个地方要看。
  2. 单文件 SQLite 的并发上限。 多个编辑器窗口各起一个进程,却写同一个 kilo.db,靠 WAL + 5 秒 busy_timeout 硬扛(storage/db.ts:110-112)。这是“够用就好”的取舍,不是分布式方案。
  3. IDE 握手靠 stdout 正则。 VS Code / JetBrains 与后端唯一的启动同步点是一行日志文本(cli/cmd/serve.ts:24server-utils.ts:7)。改了那句 print 就断两个前端——脆,但简单到不会出别的错。(TUI 不受影响,它根本不解析端口。)

还有一个“看不出来”的诚实说明:事件表 event / event_sequence 的写入被 experimentalWorkspaces 开关关着(sync/index.ts:337),所以当前默认形态下 event sourcing 并没有真正在攒事件流,只是投影管道复用了那套写法。


9. 代码地图

主题文件路径关键符号
服务器启动packages/opencode/src/server/server.tsListenerlistenDefaultstartWithPortFallbackserverLayer
路由装配packages/opencode/src/server/routes/instance/httpapi/server.tscreateRoutescreateListenerRouteswebHandler
端点契约 / 实现.../httpapi/groups/.../httpapi/handlers/EventApieventHandlerssessionHandlers
请求 → 实例.../httpapi/middleware/workspace-routing.tsdefaultDirectoryWorkspaceRouteContext
实例注入.../httpapi/middleware/instance-context.tsInstanceContextMiddlewareinstanceContextLayer
实例上下文packages/opencode/src/project/instance-context.tsInstanceContextcontainsPath
实例缓存与引导packages/opencode/src/project/instance-store.tslayerloadreloadbootdisposeAll
实例服务图入口packages/opencode/src/project/bootstrap.tsbootstrap-service.tsInstanceBootstrap(9 行 tag 切断循环依赖)
实例引导顺序packages/opencode/src/project/bootstrap.tsrun
按实例惰性状态packages/opencode/src/effect/instance-state.tsmakegetcontextinvalidate
实例销毁登记packages/opencode/src/effect/instance-registry.tsregisterDisposerdisposeInstance
CLI 侧 Effect 运行时packages/opencode/src/effect/app-runtime.tsAppLayerAppRuntime
服务模板范例(权限)packages/opencode/src/permission/index.tsInterfaceServicelayerdefaultLayer
服务模板范例(agent)packages/opencode/src/agent/agent.tsInterfaceServiceuselayerdefaultLayer
实例内事件总线packages/opencode/src/bus/index.tsServicelayerpublishInstanceDisposed
全局事件总线packages/opencode/src/bus/global.tsGlobalBus
SSE 端点.../httpapi/handlers/event.ts.../handlers/global.tseventHandlerseventResponse
DB 连接与迁移packages/opencode/src/storage/db.tsClientgetChannelPathusetransactioneffect
运行时驱动切换packages/opencode/src/storage/db.bun.ts / db.node.tsinit(经 package.json#db 条件导入)
表定义packages/core/src/session/sql.tsSessionTableMessageTablePartTableSessionMessageTable
项目表 / 时间戳 mixinpackages/core/src/project/sql.tspackages/core/src/database/schema.sql.tsProjectTableTimestamps
事件→投影框架packages/opencode/src/sync/index.tsinitprojectprocessdefine
投影表(旧/新)packages/core/src/session/projector.tsSessionV1.Event.*SessionEvent.*update
投影注册入口packages/core/src/session/projector.ts:231packages/opencode/src/server/init-projectors.tsSessionProjector.nodeinitProjectors(空壳)
新旧事件桥packages/opencode/src/event-v2-bridge.tstoSyncDefinitionlayer
kilo serve 命令packages/opencode/src/cli/cmd/serve.tsServeCommand(注意 instance: false
ACP 外壳packages/opencode/src/cli/cmd/acp.tssrc/acp/AcpCommandACP
TUI 主线程 ↔ Workerpackages/opencode/src/cli/cmd/tui.tsTuiThreadCommandcreateWorkerFetchcreateEventSource
TUI 进程内后端packages/opencode/src/cli/tui/worker.tsrpc.fetchServer.Default().app.fetch)、rpc.server
VS Code 起服务器packages/kilo-vscode/src/services/cli-backend/server-manager.tsspawn serve --port 0parseServerPort
VS Code 连回来.../cli-backend/connection-service.tssdk-sse-adapter.tsConnectionServiceSdkSSEAdapterconsumeLoop
JetBrains 起服务器packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.ktPORT_REGEX、CLI 启动命令