opencode 项目级 API 设计解析:让单实例同时驾驭多项目与 Git Worktree
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
在 opencode 的 V2 架构设计中,specs/project.md 定义了一个关键目标:让单个 OpenCode 实例同时为多个项目、以及每个项目的不同 worktree 运行会话(Session)。本文围绕该设计文档展开,完整继承其 API 规划,并结合 core 项目服务、项目数据表 与 worktree 服务 等源码,说明这一设计的落地方式、当前实现状态与关键约束,帮助读者理解 opencode 如何用一套 API 支撑"多项目 + 多 worktree"的工作负载。
设计目标:单实例,多项目,多 worktree
specs/project.md 开篇即给出核心诉求:
The goal is to let a single instance of OpenCode run sessions for multiple projects and different worktrees per project.
这意味着:
- 不再以"一个进程对应一个项目目录"为前提。同一 OpenCode 进程需要维护多个项目的上下文,并为每个项目持有独立的项目级状态(如 VCS 仓库位置、沙箱目录、启动命令等)。
- worktree 成为一等概念。一个项目(例如一个 Git 仓库)可以同时存在多个 worktree 检出,每个 worktree 都可以承载独立的会话;项目 ID 必须在这些 worktree 之间保持稳定。
理解后续所有 API 的设计动机,都要回到这两点:项目是跨 worktree 的稳定标识,而 session 挂在具体目录(可能是某个 worktree)上运行。
完整 API 规划:project 与 session 两级资源
设计文档中的 API 规划将资源组织成两级:项目级端点与嵌套在/project/:projectID/session/...下的会话级端点。下面按文档原文完整保留并分组说明。
项目级端点
GET /project -> Project[] POST /project/init -> ProjectGET /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的请求体:directory是必填字段,即会话绑定到一个具体目录(可以是主检出,也可以是某个 worktree);id与parentID可选,支持调用方指定会话 ID 和父子会话关系。这正对应"每个项目下不同 worktree 各自开会话"的目标。
会话控制端点
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 POST /project/:projectID/session/:sessionID/revert -> Session POST /project/:projectID/session/:sessionID/unrevert -> Session POST /project/:projectID/session/:sessionID/permission/:permissionID -> Session这一组端点覆盖了会话的初始化、中止、共享开关、上下文压缩、撤销/恢复撤销,以及对具体权限请求(permissionID)的裁决。
消息端点
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[] }消息以{ info: Message, parts: Part[] }的形态传输,即"消息元信息 + 若干片段(文本、工具调用、文件变更等)"。
文件检索端点
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[]其他与"awkward"端点
文档还保留了两个全局端点,并明确标注了一批作者自己认为是"awkward(别扭)"的端点:
POST /log // These are awkward GET /provider?directory=<resolve path> -> Provider GET /config?directory=<resolve path> -> Config // think only tui uses this? GET /project/:projectID/agent?directory=<resolve path> -> Agent GET /project/:projectID/find/file?directory=<resolve path> -> File这些端点需要显式传directory查询参数来定位项目/工作区上下文。文档作者用注释点出了设计上的不满足感(例如config端点"似乎只有 TUI 在用"),这属于设计阶段的自我审视,也为后续演进(如统一的路由中间件)留下了动机。
项目 ID 的解析:从源码看项目如何被识别与保持唯一
API 中反复出现的:projectID,其背后是 packages/core/src/project.ts 中ProjectV2服务的resolve逻辑。从源码结构看,解析优先级如下(见 resolve 实现):
- 发现 Git 仓库:对输入目录执行
git.repo.discover;若找不到仓库,则返回全局 ID(ID.global),目录回退到文件系统根。 - git remote 归一化哈希:读取仓库
originremote,把 URL(含user@host:path的 SCP 形式)归一化为host/pathname(小写、去.git后缀、file:协议视为无效),再用ID.make(Hash.fast(git-remote:${normalized}))生成 ID。这样同一远程仓库的所有 worktree/克隆会解析出相同的项目 ID——这正是"不同 worktree 归属同一项目"的关键。 - 仓库本地缓存:读取
.git公共目录下的opencode文件(cached),这是旧版项目服务持久化的 ID;resolve会同时返回previous(缓存值)与新id,便于旧数据迁移。 - 根提交兜底:若既无 remote 也无缓存,则取仓库最早一次提交的 commit hash 作为 ID。
此外,Interface 还声明了一个commit方法,源码注释明确说明它是"临时桥接":由 core 解析 ID,由旧项目服务负责落库;注释还提到旧服务应在新迁移完成后调用commit把 ID 写回仓库本地缓存(即.git/opencode文件),这与上面第 3 步的cached读取形成闭环。
项目 ID 与 VCS 的 Schema 定义在 packages/core/src/project/schema.ts:Vcs目前是一个只含git变体的联合类型({ type: "git", store: AbsolutePath }),store指向 Git 公共目录。
项目数据模型:worktree 是一级字段
packages/core/src/project/sql.ts 中的 SQLite 表结构印证了设计目标中的 worktree 语义:
export const ProjectTable = sqliteTable("project", { id: text().$type<ProjectSchema.ID>().primaryKey(), worktree: DatabasePath.absoluteColumn().notNull(), // 项目"主" worktree 绝对路径 vcs: text(), name: text(), ... sandboxes: DatabasePath.absoluteArrayColumn().notNull(), // 沙箱目录数组 commands: text({ mode: "json" }).$type<{ start?: string }>(), // 启动命令 }) export const ProjectDirectoryTable = sqliteTable( "project_directory", { project_id: ..., // 级联删除外键 directory: DatabasePath.absoluteColumn().notNull(), type: text().$type<"main" | "root" | "git_worktree">(), // 目录角色 strategy: text(), time_created: ..., }, (table) => [primaryKey({ columns: [table.project_id, table.directory] })], )两个要点:
project.worktree是 NOT NULL 的绝对路径,代表该项目的主检出;project_directory表以(project_id, directory)为主键记录项目下的每个已知目录,并用type区分main(主检出)、root与git_worktree(派生 worktree)。
这与 API 设计中"一个 projectID 下可存在多个会话目录"完全对应:worktree 不是另一个项目,而是同一项目下的一类目录。
当前实现:project 路由与 worktree 实验端点
设计文档描述的是 V2 目标形态,而当前仓库中已存在与之对应的部分实现,可据此判断落地进度。
已实现的 project 路由
packages/opencode/src/server/routes/instance/httpapi/groups/project.ts 定义了以下端点:
| 方法 | 路径 | OpenAPI identifier | 说明 |
|---|---|---|---|
| GET | /project | project.list | 列出用 OpenCode 打开过的项目 |
| GET | /project/current | project.current | 获取当前活动项目 |
| POST | /project/git/init | project.initGit | 为当前项目创建 git 仓库 |
| PATCH | /project/:projectID | project.update | 更新 name / icon / commands |
| GET | /project/:projectID/directories | project.directories | 列出项目的已知本地绝对目录 |
可以看到,设计中的GET /project已实现,而POST /project/init在当前形态下拆成了更具体的POST /project/git/init;/:projectID/directories端点则直接对应project_directory表的查询能力(经由 ProjectV2.directories)。该路由组还挂了三类中间件:InstanceContextMiddleware、WorkspaceRoutingMiddleware与Authorization(见 路由组定义),其中 workspace 路由中间件正是"多项目/多工作区"场景下把请求分发到正确实例上下文的机制。
worktree 服务与实验端点
packages/opencode/src/worktree/index.ts 实现了 worktree 的完整生命周期服务:
Info结构为{ name, branch?, directory }(定义见 L23-L28);create接受可选name与startCommand("在项目启动命令之后追加运行的启动脚本"),非 Git 目录、名称生成失败、启动命令失败等均有独立错误类型(NotGitError、NameGenerationFailedError、StartCommandFailedError等);remove按目录移除 worktree,reset将 worktree 分支重置回主默认分支。
对应的 HTTP 端点位于 实验路由组:
GET /experimental/worktree # 列出项目所有 sandbox worktree POST /experimental/worktree # 创建 git worktree 并运行配置的启动脚本 DELETE /experimental/worktree # 移除 worktree 并删除其分支 POST /experimental/worktree/reset # 将 worktree 分支重置回主默认分支注意其 OpenAPI 描述措辞:"List allsandbox worktreesfor thecurrent project"——这与project表中的sandboxes字段和project_directory表的git_worktree类型相互印证。worktree 相关能力目前仍在experimental命名空间下,说明该部分属于渐进开放中。
会话执行如何支撑"多项目单实例"
项目 API 的最终服务对象是会话。specs/v2/session.md 中给出的执行路由,解释了单实例如何为不同项目的会话正确分发执行:
SessionExecution.resume(sessionID) -> SessionStore.get(sessionID) -> LocationServiceMap.get(session.location) -> SessionRunner.run({ sessionID, force? })从源码结构看,SessionExecution与读侧SessionStore是进程全局的,而SessionRunner、catalog、模型解析器、工具注册表、权限状态和文件系统都按Location(即会话所属的项目位置/目录)缓存;任何一层都不接收 Session ID 作为入口参数。这意味着:同一个进程内,不同项目、不同 worktree 的会话可以并发运行,而它们各自命中自己 Location 的服务实例,互不串扰。会话创建时的directory(如前文POST .../session请求体所要求)就是会话定位到具体 worktree 的依据。
设计状态小结:哪些已落地,哪些是规划
综合 specs/project.md 与当前源码,可以这样把握该设计的成熟度:
- 已落地:
GET /project列表、/project/current、/project/git/init、PATCH /project/:projectID、/project/:projectID/directories;项目 ID 的多级解析(remote 哈希 → 本地缓存 → 根提交);project/project_directory数据表及 worktree 类型枚举;worktree 的创建/删除/重置服务与实验端点;基于 Location 的多会话执行路由。 - 规划/部分落地:文档中
POST /project/init、/project/:projectID/session/...这一整套嵌套会话端点,与当前仓库中以 Location/Session 独立路由为主的形态存在差异,属于设计文档描述的目标 API 形态;作者自注"awkward"的?directory=<resolve path>端点体现了该层 API 仍在收敛过程中。 - 适用前提:worktree 能力仅对 Git 仓库项目有效(非 Git 目录会触发
WorktreeNotGitError);remote 归一化依赖originremote 可解析(file:协议 remote 会退化为缓存或根提交策略);experimental命名空间下的端点语义可能随迭代调整。
通过这份设计文档与其源码实现的对照,可以看到 opencode 用"稳定的项目 ID + 目录角色表 + Location 级服务缓存 + worktree 生命周期端点"这条链路,把"一个实例服务多个项目、每个项目多 worktree"从一句目标拆解成了可验证的 API 契约与数据结构。
【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考