KiloCode 多项目 API 设计:单实例如何同时服务多个项目与 Worktree
【免费下载链接】kilocodeKilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/ki/kilocode
本文基于仓库中的设计规范 specs/project.md,讲解 Kilo(opencode 包)如何设计 HTTP API,让同一个服务端实例为多个项目、每个项目的多个 worktree 并发运行会话。读完本篇,你会理解规范中列出的完整端点清单、请求通过directory参数路由到正确项目实例的中间件机制,以及Project服务在解析、落库与迁移上的源码级实现。
一、设计目标:一个实例,多项目,多 worktree
specs/project.md 开篇即给出核心目标:
The goal is to let a single instance of OpenCode run sessions for multiple projects and different worktrees per project.
即:启动一个 Kilo/opencode 服务进程后,客户端不必为每个项目分别拉起服务,而是通过 API 参数声明“当前要操作哪个项目、哪个工作目录”,由服务端在内存中维护每个目录对应的运行时实例(实例上下文)。这带来两个关键概念:
- 项目(Project):以版本库(主要是 git 仓库)为边界的逻辑单元,拥有唯一
id、主worktree路径、VCS 类型、图标、自定义命令等属性; - Worktree / Sandbox:同一个项目的多个检出目录。规范标题里 “different worktrees per project” 就是指:一个项目可以同时有多个 checkout,会话可以落在其中任意一个目录。
从源码结构看,这两个概念直接对应Project服务的接口定义(见 project.ts):
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> readonly discover: (input: Info) => Effect.Effect<void> readonly list: () => Effect.Effect<Info[]> readonly get: (id: ProjectV2.ID) => Effect.Effect<Info | undefined> readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError> readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info> readonly sandboxes: (id: ProjectV2.ID) => Effect.Effect<string[]> readonly addSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect<void> readonly removeSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect<void>其中fromDirectory是核心入口:给定任意一个目录,返回它所属的project以及该目录下实际生效的沙箱目录sandbox。sandboxes字段(持久化在ProjectTable.sandboxes)就是“一个项目多个 worktree”的落地形式——凡是归属于该项目、但不等于主 worktree 的检出目录,都会被追加进sandboxes列表(project.ts)。
二、规范中的完整端点清单
specs/project.md 给出的 API 草案如下(原样继承,后文逐一说明其在当前仓库中的演进状态):
GET /project -> Project[] POST /project/init -> Project GET /project/:projectID/session -> Session[] GET /project/:projectID/session/:sessionID -> Session POST /project/:projectID/session -> Session { id?: string parentID?: string directory: string } DELETE /project/:projectID/session/:sessionID POST /project/:projectID/session/:sessionID/init POST /project/:projectID/session/:sessionID/abort POST /project/:projectID/session/:sessionID/share DELETE /project/:projectID/session/:sessionID/share POST /project/:projectID/session/:sessionID/compact GET /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] }[] GET /project/:projectID/session/:sessionID/message/:messageID -> { info: Message, parts: Part[] } POST /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] } POST /project/:projectID/session/:sessionID/revert -> Session POST /project/:projectID/session/:sessionID/unrevert -> Session POST /project/:projectID/session/:sessionID/permission/:permissionID -> Session GET /project/:projectID/session/:sessionID/find/file -> string[] GET /project/:projectID/session/:sessionID/file -> { type: "raw" | "patch", content: string } GET /project/:projectID/session/:sessionID/file/status -> File[] POST /log // These are awkward GET /provider?directory=<resolve path> -> Provider GET /config?directory=<resolve path> -> Config GET /project/:projectID/agent?directory=<resolve path> -> Agent GET /project/:projectID/find/file?directory=<resolve path> -> File规范的设计意图很清晰:会话及其一切子资源(消息、文件、权限、共享、压缩)都挂在projectID路径下,用 URL 显式表达“会话属于哪个项目”;创建会话的 body 里还带有directory字段,用于指定会话落在该项目的哪个 worktree。
规范作者自己也标注了尾部四个?directory=<resolve path>端点 “are awkward”——因为directory是路径参数之外的查询参数,语义上不如直接放在 URL 里自然。当前仓库的最终取舍(下文第三节详述)恰恰是反过来的:把directory统一提升为全局查询参数,会话则收敛到独立的/session根路径。
三、directory参数:请求如何被路由到正确的项目
当前实现中,所有需要“知道自己在哪个目录”的端点都继承同一套查询参数。定义见 workspace-routing.ts:
export const WorkspaceRoutingQueryFields = { directory: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), } export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields)WorkspaceRoutingMiddleware是路由的第一层。它先为请求计算“计划”(RequestPlan:本地执行还是代理到远端 workspace),本地场景下工作目录的解析优先级为(workspace-routing.ts):
- URL 查询参数
?directory=; - 请求头
x-kilo-directory; - 进程当前工作目录
process.cwd()兜底。
此外还有两个与“多项目/多工作区”直接相关的机制:
workspace参数与远端代理:请求携带workspace参数且对应 workspace 的目标是 remote 时,中间件不会本地执行,而是把请求(含 WebSocket)代理到远端目标地址,并在同步栅栏(Fence)上等待数据一致后才放行,见 workspace-routing.ts;- fork 目录覆盖(Kilo 的定制,源码中以
kilocode_change注释标记):当请求是 fork 会话且带有显式目标目录(例如 fork 到某个 worktree)时,目标目录优先于源会话的目录继承,见 workspace-routing.ts。
也就是说,规范里被批评为 awkward 的?directory=<resolve path>模式被保留了下来,但不再零散地附着在个别端点上,而是成为整个实例路由的统一约定:项目类端点挂WorkspaceRoutingQuery,会话类端点(ListQuery、MessagesQuery等,见 session.ts)同样展开这套字段。规范中的GET /provider?directory=...、GET /config?directory=...对应的 provider、config 端点也依旧存在于同一 HttpApi 分组目录下(groups/provider.ts、groups/config.ts)。
四、InstanceContextMiddleware 与 InstanceStore:按目录维护实例生命周期
目录解析完成后,instance-context.ts 负责把目录变成完整的运行时上下文:
const route = yield* WorkspaceRouteContext const ctx = yield* store.load({ directory: decode(route.directory) }) return yield* effect.pipe( Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, route.workspaceID), )这里InstanceStore(instance-store.ts)对外暴露load / reload / dispose / disposeDirectory / disposeAll / provide六个操作,内部用Map<directory, Entry>做实例缓存。load未命中时执行boot(instance-store.ts):
- 若调用方已显式给出
project与worktree(例如 reload 场景),直接使用; - 否则调用
project.fromDirectory(input.directory)(见第五节),用返回的sandbox作为该实例的 worktree; - 随后在
InstanceRef作用域内运行InstanceBootstrap(Kilo 定制:把 bootstrap 放进 Instance ALS 中执行,保证 fork 出的子逻辑能看到Instance.directory); - 整个 boot 用
Deferred收敛:同一目录的并发请求共享同一次启动;且无论成功还是失败(含 fiber 被中断),缓存项都会被清理或正确完成,避免“卡死在启动中导致后续请求永远挂起”的问题(源码注释明确说明了这一动机)。
实例被回收时,InstanceStore会运行该目录注册的 disposer 并广播server.instance.disposed事件(instance-store.ts)。至此,“单实例多项目”的内存模型完整成立:一个进程 = 一个 InstanceStore = 若干以目录为键的实例上下文,每个上下文内持有该目录的项目信息与各类服务状态。
实例路由的中间件链顺序在 groups/project.ts 中固定为:
.middleware(InstanceContextMiddleware) .middleware(WorkspaceRoutingMiddleware) .middleware(Authorization)即先解析 workspace/目录,再装载实例上下文,最后做鉴权。
五、Project 服务:目录解析、落库与 ID 迁移
Project.fromDirectory是“任意目录 → 项目”的核心实现(project.ts),流程如下:
- 解析:
projectV2.resolve(absolutePath)向上查找版本库根,得到{ id, directory, vcs, previous };全局(无 VCS)且非项目 id 时 worktree 取"/"; - ID 迁移:若解析出新的项目 id 与历史 id 不同(典型场景:仓库根变化或首次识别到 VCS),
migrateProjectId在单个数据库事务中复制项目行、清空ProjectDirectoryTable旧目录列表、把SessionTable与WorkspaceTable中挂旧 id 的记录整体改挂新 id,最后删除旧行(project.ts)。这保证了“同一项目 ID 变化时,历史会话与工作区不会丢失归属”; - 沙箱归集:当前目录若不属于
ProjectV2.ID.global、不等于主 worktree、且不在sandboxes中,就追加进去;随后过滤掉磁盘上已不存在的沙箱路径,再upsert回ProjectTable; - 会话归属修正:将
project_id为global且directory匹配当前目录的会话批量改挂到新项目(project.ts); - 目录登记与事件:
saveProjectDirectory写入项目-目录关联表(global 项目跳过);emitUpdated通过GlobalBus广播project.updated事件(project.ts),前端/插件即可感知项目信息变化; - VCS 快照:git 项目还会调用
projectV2.commit更新 VCS 存储(project.ts)。
返回的{ project, sandbox }中sandbox的选择规则值得注意:有 VCS 时取实际检出目录,无 VCS 时取 worktree(project.ts)。
其余接口要点:
initGit(project.ts):先确认系统装有 git(which("git")),执行git init --quiet,再重新走一遍fromDirectory返回刷新后的项目信息。这对应规范中的POST /project/init(当前 HttpApi 中端点名为project.initGit,路径POST /project/git/init);update:修改name、icon、commands,未命中返回Project.NotFoundError(project.ts);- 图标发现:开启
experimentalIconDiscovery标志后,discover会在 worktree 内 glob**/favicon.{ico,png,svg,jpg,jpeg,webp},取路径最短的一个转成 data URL 写回项目图标(project.ts)。
六、当前实验性 HttpApi 中的落地形态
规范草案按“项目挂会话”的方式组织路由;当前仓库的实验性 HttpApi(packages/opencode/src/server/routes/instance/httpapi/)落地时做了结构调整:
项目端点(groups/project.ts):
| 端点 | 规范对应 | 说明 |
|---|---|---|
GET /project→Project[] | GET /project | 列出所有已打开的项目(project.list) |
GET /project/current | 无(演进新增) | 返回当前实例上下文中的项目(project.current) |
POST /project/git/init | POST /project/init | 初始化 git 并返回刷新后的项目 |
PATCH /project/:projectID | 演进新增 | 更新name/icon/commands |
GET /project/:projectID/directories | 演进新增 | 列出该项目已知的所有本地绝对目录 |
处理逻辑在 handlers/project.ts 中。其中initGit有一个细节值得注意:当 git 初始化导致项目id、vcs或worktree发生变化时,handler 会调用markInstanceForReload标记当前实例需要重载(handlers/project.ts),让后续请求拿到刷新后的实例上下文。
会话端点则从/project/:projectID/session/*收敛为独立的/session根路径(session.ts),规范中列出的会话子资源大多可以找到对应项:
| 规范端点 | 当前对应 |
|---|---|
POST /project/:projectID/session(body 含directory) | POST /session,目录由WorkspaceRoutingQuery携带 |
GET/DELETE .../session/:sessionID | GET /session/:sessionID、DELETE /session/:sessionID |
.../abort、.../share(POST/DELETE) | POST /session/:sessionID/abort、POST /session/:sessionID/share |
.../message(GET 列表 / GET 单条 / POST 提交) | GET /session/:sessionID/message、GET .../message/:messageID、POST /session/:sessionID/message |
.../revert、.../unrevert | POST /session/:sessionID/revert、POST /session/:sessionID/unrevert |
.../permission/:permissionID | POST /session/:sessionID/permissions/:permissionID |
| 会话列表 | GET /session,查询参数支持scope: "project"按项目过滤(session.ts) |
规范里没有、当前实现中额外提供的相关端点包括fork、summarize(对应规范compact语义的演进)、command、shell、todo、diff、status等;abort还新增了scope: session | tree参数,可选择只停当前会话还是连同子代理整树停止(session.ts)。文件类端点(规范中的find/file、file、file/status)由同一 HttpApi 分组目录下的 file 端点组承担(groups/file.ts)。
七、实践要点:如何用 API 操作多项目
结合前述机制,调用方在操作不同项目时的实际用法是:
- 对需要区分项目的请求,统一携带
?directory=<absolute path>(或x-kilo-directory请求头);服务端据此解析项目并装载对应实例上下文,目录缺省时回退到进程 cwd; - 需要跨 workspace(含远端同步的工作区)操作时追加
?workspace=<workspaceID>;远端目标由中间件自动代理; - 会话相关操作走
/session/*端点,同一 URL 模板可服务任意项目,区分维度完全由directory参数表达;按项目筛选会话时用scope=project; - 项目元数据(名称、图标、命令)用
PATCH /project/:projectID更新;目录与项目的关联关系可用GET /project/:projectID/directories查询; - 所有事件广播(
project.updated、server.instance.disposed等)都带有project与directory维度,客户端可按项目订阅过滤。
需要注意的适用前提:以上HttpApi路由在源码中明确标注为 Experimental 表面(groups/project.ts 的 OpenAPI 描述为 “Experimental HttpApi surface for selected instance routes”),端点名称与路径可能随版本演进调整,集成时建议以仓库中当前的分组定义与 OpenAPI 注解为准。
参考文件
- 设计规范:specs/project.md
- 项目端点定义与 OpenAPI 注解:groups/project.ts
- 项目端点处理逻辑:handlers/project.ts
- 目录/workspace 路由中间件:middleware/workspace-routing.ts
- 实例上下文中间件:middleware/instance-context.ts
- 项目服务(解析、迁移、initGit、图标发现):project/project.ts
- 实例缓存与生命周期:project/instance-store.ts
- 会话端点定义:groups/session.ts
【免费下载链接】kilocodeKilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/ki/kilocode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考