SDK / API / 对外扩展点
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
12-sdk-api-extension - 章节摘要:沿一次 session prompt API 调用,理解 typed HTTP contract、generated SDK、SSE 事件与 plugin hook 如何组成受控扩展边界。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/server/server.tspackages/opencode/src/server/routes/instance/httpapi/api.tspackages/opencode/src/server/routes/instance/httpapi/groups/session.tspackages/opencode/src/server/routes/instance/httpapi/handlers/session.tspackages/sdk/js/src/client.tspackages/sdk/js/src/server.tspackages/opencode/src/plugin/index.tspackages/plugin/src/index.ts
源码基线:
eec0843ce422。本章示例是基于源码组合出的典型请求,不是一次实际抓包记录。
0. 本章学习目标
Section titled “0. 本章学习目标”读完本章,你应该能:
- 画出 HTTP API、handler、runtime service、generated SDK 和 plugin hook 的关系。
- 沿一次
session.prompt请求,从 typed endpoint 追到SessionPrompt.Service。 - 解释 SDK wrapper 为什么不只是把 URL 包成函数。
- 区分“从外部调用已有能力”和“在内部生命周期中扩展行为”。
- 识别异步 prompt、插件顺序执行和进程启动 helper 的失败边界。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 用 typed HTTP API 定义稳定能力边界,用 generated SDK 把边界变成客户端方法,再用 plugin hooks 在明确时点修改输入或输出;API 是门,SDK 是门卡,hook 是预留插槽。
中心问题是:外部程序要让 agent 做事,为什么既需要 API/SDK,又需要 plugin,而不能只开放几个内部函数?
2. 先画地图:调用与扩展是两条路
Section titled “2. 先画地图:调用与扩展是两条路”外部客户端 / UI -> generated SDK + wrapper -> typed HTTP endpoint -> handler -> Session / Provider / Tool / Permission service
插件模块 -> Plugin loader -> Hooks[] -> Plugin.trigger(name, input, output) -> 在受控时点观察事件或修改 output第一条路是调用能力:发 prompt、查 session、回复权限。
第二条路是参与能力:在 chat.params、tool.execute.before、shell.env 等时点调整数据。
把两者混在一起会产生危险设计:客户端为了改一个 header 侵入 runtime,或者插件绕过 API 自己创建第二套 session。
3. 最小机制
Section titled “3. 最小机制”API/SDK 的通用内核可以缩成:
contract = endpoint(method, path, params, payload, result, errors)handler = bind(contract, applicationService)client = generate(contract)
client.call(input) -> validate/encode request -> handler(input) -> applicationService(input) -> encode result插件内核则是:
hooks = loadPlugins(context)
trigger(name, input, output): for hook in hooks: if hook[name]: await hook[name](input, output) return outputOpenCode 的 trigger 正是顺序遍历 hooks 并返回被修改的 output,见 packages/opencode/src/plugin/index.ts:261-274。
packages/opencode/src/plugin/index.ts
packages/opencode/src/plugin/index.ts:261-274
261 const trigger = Effect.fn("Plugin.trigger")(function* <调用插件扩展点。262 Name extends TriggerName,263 Input = Parameters<Required<Hooks>[Name]>[0],264 Output = Parameters<Required<Hooks>[Name]>[1],265 >(name: Name, input: Input, output: Output) {266 if (!name) return output按条件进入分支。267 const s = yield* InstanceState.get(state)等待 Effect 结果。268 for (const hook of s.hooks) {调用插件扩展点。269 const fn = hook[name] as any调用插件扩展点。270 if (!fn) continue按条件进入分支。271 yield* Effect.promise(async () => fn(input, output))Effect 异步工作流。272 }273 return output返回给上一层。274 })
4. API、SDK、Plugin 到底差在哪
Section titled “4. API、SDK、Plugin 到底差在哪”| 机制 | 主要用户 | 方向 | 稳定边界 | 典型风险 |
|---|---|---|---|---|
| HTTP API | 任意语言/进程 | 外部调用 runtime | method/path/schema | 网络、认证、版本 |
| JS SDK | TS/JS 客户端 | 类型化调用 API | generated types + wrapper | 生成物与服务不一致 |
| Server helper | 自动化程序 | 管理 opencode 子进程 | stdout/port/lifecycle | 启动超时、进程泄漏 |
| Plugin hook | 受信任扩展代码 | 参与 runtime 内部时点 | Hooks 接口 | 顺序副作用、异常隔离 |
Java 类比:API 像 Controller 契约,SDK 像 OpenAPI client,plugin 像 SPI + interceptor。类比的边界是:OpenCode 的 Effect service 装配、in-process fetch 和 hooks 的可变 output 并不等同于 Spring bean 调用。
5. 最小源码路径
Section titled “5. 最小源码路径”packages/opencode/src/server/routes/instance/httpapi/api.ts:30-59:API group 总装图。
packages/opencode/src/server/routes/instance/httpapi/api.ts
packages/opencode/src/server/routes/instance/httpapi/api.ts:30-59
30export const RootHttpApi = HttpApi.make("opencode-root")对外暴露模块成员。31 .addHttpApi(ControlApi)32 .addHttpApi(GlobalApi)读写本地文件。33 .middleware(SchemaErrorMiddleware)执行前先处理中间件。34 .middleware(Authorization)执行前先处理中间件。3536export const InstanceHttpApi = HttpApi.make("opencode-instance")对外暴露模块成员。37 .addHttpApi(ConfigApi)38 .addHttpApi(ExperimentalApi)39 .addHttpApi(FileApi)40 .addHttpApi(InstanceApi)41 .addHttpApi(McpApi)42 .addHttpApi(ProjectApi)43 .addHttpApi(PtyApi)44 .addHttpApi(QuestionApi)45 .addHttpApi(PermissionApi)46 .addHttpApi(ProviderApi)选择模型或 provider。47 .addHttpApi(SessionApi)48 .addHttpApi(SyncApi)49 .addHttpApi(V2Api)50 .addHttpApi(TuiApi)51 .addHttpApi(WorkspaceApi)52 .middleware(SchemaErrorMiddleware)执行前先处理中间件。5354export const OpenCodeHttpApi = HttpApi.make("opencode")对外暴露模块成员。55 .addHttpApi(RootHttpApi)56 .addHttpApi(EventApi)57 .addHttpApi(InstanceHttpApi)58 .addHttpApi(PtyConnectApi)59 .annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:66-103:payload 与 path。
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:66-103
66export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))定义并校验数据形状。67export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))定义并校验数据形状。68export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))定义并校验数据形状。69export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))定义并校验数据形状。70export const PermissionResponsePayload = Schema.Struct({定义并校验数据形状。71 response: Permission.Reply,72})7374export const SessionPaths = {对外暴露模块成员。75 list: root,76 status: `${root}/status`,77 get: `${root}/:sessionID`,78 children: `${root}/:sessionID/children`,79 todo: `${root}/:sessionID/todo`,80 diff: `${root}/:sessionID/diff`,81 messages: `${root}/:sessionID/message`,82 message: `${root}/:sessionID/message/:messageID`,83 create: root,84 remove: `${root}/:sessionID`,85 update: `${root}/:sessionID`,86 fork: `${root}/:sessionID/fork`,87 abort: `${root}/:sessionID/abort`,88 share: `${root}/:sessionID/share`,89 init: `${root}/:sessionID/init`,90 summarize: `${root}/:sessionID/summarize`,91 prompt: `${root}/:sessionID/message`,92 promptAsync: `${root}/:sessionID/prompt_async`,93 command: `${root}/:sessionID/command`,处理命令执行。94 shell: `${root}/:sessionID/shell`,处理命令执行。95 revert: `${root}/:sessionID/revert`,96 unrevert: `${root}/:sessionID/unrevert`,97 permissions: `${root}/:sessionID/permissions/:permissionID`,98 deleteMessage: `${root}/:sessionID/message/:messageID`,99 deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,100 updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,101} as const102103export const SessionApi = HttpApi.make("session")对外暴露模块成员。
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-363:prompt/command/shell 契约。
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-363
312 HttpApiEndpoint.post("prompt", SessionPaths.prompt, {313 params: { sessionID: SessionID },314 query: WorkspaceRoutingQuery,315 payload: PromptPayload,316 success: described(MessageV2.WithParts, "Created message"),会话消息片段结构。317 error: [HttpApiError.BadRequest, ApiNotFoundError],318 }).annotateMerge(319 OpenApi.annotations({320 identifier: "session.prompt",把输入交给会话主流程。321 summary: "Send message",322 description: "Create and send a new message to a session, streaming the AI response.",323 }),324 ),325 HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {326 params: { sessionID: SessionID },327 query: WorkspaceRoutingQuery,328 payload: PromptPayload,329 success: described(HttpApiSchema.NoContent, "Prompt accepted"),定义并校验数据形状。330 error: [HttpApiError.BadRequest, ApiNotFoundError],331 }).annotateMerge(332 OpenApi.annotations({333 identifier: "session.prompt_async",把输入交给会话主流程。334 summary: "Send async message",335 description:336 "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",337 }),338 ),339 HttpApiEndpoint.post("command", SessionPaths.command, {处理命令执行。340 params: { sessionID: SessionID },341 query: WorkspaceRoutingQuery,342 payload: CommandPayload,343 success: described(MessageV2.WithParts, "Created message"),会话消息片段结构。344 error: [HttpApiError.BadRequest, ApiNotFoundError],345 }).annotateMerge(346 OpenApi.annotations({347 identifier: "session.command",执行内置 session 命令。348 summary: "Send command",处理命令执行。349 description: "Send a new command to a session for execution by the AI assistant.",处理命令执行。350 }),351 ),352 HttpApiEndpoint.post("shell", SessionPaths.shell, {处理命令执行。353 params: { sessionID: SessionID },354 query: WorkspaceRoutingQuery,355 payload: ShellPayload,356 success: described(MessageV2.WithParts, "Created message"),会话消息片段结构。357 error: [HttpApiError.BadRequest, ApiNotFoundError],358 }).annotateMerge(359 OpenApi.annotations({360 identifier: "session.shell",处理命令执行。361 summary: "Run shell command",处理命令执行。362 description: "Execute a shell command within the session context and return the AI's response.",处理命令执行。363 }),
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:279-315:同步与异步 prompt handler。
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:279-315
279 const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {Effect 异步工作流。280 params: { sessionID: SessionID }281 payload: typeof PromptPayload.Type282 }) {283 yield* requireSession(ctx.params.sessionID)等待 Effect 结果。284 const message = yield* promptSvc等待 Effect 结果。285 .prompt({286 ...ctx.payload,287 sessionID: ctx.params.sessionID,288 })289 .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))Effect 异步工作流。290 return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {返回给上一层。291 contentType: "application/json",292 })293 })294295 const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {Effect 异步工作流。296 params: { sessionID: SessionID }297 payload: typeof PromptPayload.Type298 }) {299 yield* requireSession(ctx.params.sessionID)等待 Effect 结果。300 yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(等待 Effect 结果。301 Effect.catchCause((cause) =>Effect 异步工作流。302 Effect.gen(function* () {Effect 异步工作流。303 yield* Effect.logError("prompt_async failed").pipe(Effect 异步工作流。304 Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),Effect 异步工作流。305 )306 yield* bus.publish(Session.Event.Error, {广播状态变化。307 sessionID: ctx.params.sessionID,308 error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),309 })310 }),311 ),312 Effect.forkIn(scope, { startImmediately: true }),Effect 异步工作流。313 )314 return HttpApiSchema.NoContent.make()定义并校验数据形状。315 })
packages/opencode/src/server/server.ts:58-63、:75-103:in-process app 与真实 listener。
packages/opencode/src/server/server.ts
packages/opencode/src/server/server.ts:58-63
58export const Default = lazy(() => {对外暴露模块成员。59 const handler = HttpApiApp.webHandler().handler60 const app: ServerApp = {61 fetch: (request: Request) => handler(request, HttpApiApp.context),62 request(input, init) {63 return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))返回给上一层。
packages/sdk/js/src/client.ts:17-56:SDK wrapper。
packages/sdk/js/src/client.ts
packages/sdk/js/src/client.ts:17-56
17function rewrite(request: Request, directory?: string) {定义一段可复用逻辑。18 if (request.method !== "GET" && request.method !== "HEAD") return request按条件进入分支。1920 const value = pick(request.headers.get("x-opencode-directory"), directory)21 if (!value) return request按条件进入分支。2223 const url = new URL(request.url)24 if (!url.searchParams.has("directory")) {按条件进入分支。25 url.searchParams.set("directory", value)26 }2728 const next = new Request(url, request)29 next.headers.delete("x-opencode-directory")30 return next返回给上一层。31}3233export function createOpencodeClient(config?: Config & { directory?: string }) {创建 SDK 客户端。34 if (!config?.fetch) {按条件进入分支。35 const customFetch: any = (req: any) => {36 // @ts-ignore37 req.timeout = false38 return fetch(req)返回给上一层。39 }40 config = {41 ...config,42 fetch: customFetch,43 }44 }4546 if (config?.directory) {按条件进入分支。47 config.headers = {读取运行配置。48 ...config.headers,读取运行配置。49 "x-opencode-directory": encodeURIComponent(config.directory),读取运行配置。50 }51 }5253 const client = createClient(config)创建 SDK 客户端。54 client.interceptors.request.use((request) => rewrite(request, config?.directory))55 client.interceptors.error.use(wrapClientError)56 return new OpencodeClient({ client })返回给上一层。
packages/sdk/js/src/server.ts:22-102:server/TUI 进程 helper。
packages/sdk/js/src/server.ts
packages/sdk/js/src/server.ts:22-102
22export async function createOpencodeServer(options?: ServerOptions) {对外暴露模块成员。23 options = Object.assign(24 {25 hostname: "127.0.0.1",26 port: 4096,27 timeout: 5000,28 },29 options ?? {},30 )3132 const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]33 if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)读取运行配置。3435 const proc = launch(`opencode`, args, {36 env: {37 ...process.env,38 OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),39 },40 })41 let clear = () => {}4243 const url = await new Promise<string>((resolve, reject) => {44 const id = setTimeout(() => {45 clear()46 stop(proc)47 reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))48 }, options.timeout)49 let output = ""50 let resolved = false51 proc.stdout?.on("data", (chunk) => {52 if (resolved) return按条件进入分支。53 output += chunk.toString()54 const lines = output.split("\n")55 for (const line of lines) {遍历集合。56 if (line.startsWith("opencode server listening")) {按条件进入分支。57 const match = line.match(/on\s+(https?:\/\/[^\s]+)/)58 if (!match) {按条件进入分支。59 clear()60 stop(proc)61 clearTimeout(id)62 reject(new Error(`Failed to parse server url from output: ${line}`))63 return返回给上一层。64 }65 clearTimeout(id)66 resolved = true67 resolve(match[1]!)68 return返回给上一层。69 }70 }71 })72 proc.stderr?.on("data", (chunk) => {73 output += chunk.toString()74 })75 proc.on("exit", (code) => {76 clearTimeout(id)77 let msg = `Server exited with code ${code}`78 if (output.trim()) {按条件进入分支。79 msg += `\nServer output: ${output}`80 }81 reject(new Error(msg))82 })83 proc.on("error", (error) => {84 clearTimeout(id)85 reject(error)86 })87 clear = bindAbort(proc, options.signal, () => {88 clearTimeout(id)89 reject(options.signal?.reason)90 })91 })9293 return {返回给上一层。94 url,95 close() {96 clear()97 stop(proc)98 },99 }100}101102export function createOpencodeTui(options?: TuiOptions) {对外暴露模块成员。
packages/opencode/src/plugin/index.ts:126-167:插件输入与加载起点。
packages/opencode/src/plugin/index.ts
packages/opencode/src/plugin/index.ts:126-167
126 const { Server } = yield* Effect.promise(() => import("../server/server"))Effect 异步工作流。127128 const client = createOpencodeClient({创建 SDK 客户端。129 baseUrl: "http://localhost:4096",130 directory: ctx.directory,131 headers: ServerAuth.headers(),132 fetch: async (...args) => Server.Default().app.fetch(...args),复用后端请求入口。133 })134 const cfg = yield* config.get()读取运行配置。135 const input: PluginInput = {调用插件扩展点。136 client,137 project: ctx.project,138 worktree: ctx.worktree,139 directory: ctx.directory,140 experimental_workspace: {141 register(type: string, adapter: PluginWorkspaceAdapter) {调用插件扩展点。142 registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter)143 },144 },145 get serverUrl(): URL {146 return Server.url ?? new URL("http://localhost:4096")返回给上一层。147 },148 // @ts-expect-error149 $: typeof Bun === "undefined" ? undefined : Bun.$,150 }151152 for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {调用插件扩展点。153 log.info("loading internal plugin", { name: plugin.name })154 const init = yield* Effect.tryPromise({Effect 异步工作流。155 try: () => plugin(input),156 catch: (err) => {集中处理异常。157 log.error("failed to load internal plugin", { name: plugin.name, error: err })158 },159 }).pipe(Effect.option)Effect 异步工作流。160 if (init._tag === "Some") hooks.push(init.value)调用插件扩展点。161 }162163 const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])164 if (flags.pure && cfg.plugin_origins?.length) {按条件进入分支。165 log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })166 }167 if (plugins.length) yield* config.waitForDependencies()读取运行配置。
packages/opencode/src/plugin/index.ts:245-274:事件分发与 trigger。
packages/opencode/src/plugin/index.ts
packages/opencode/src/plugin/index.ts:245-274
245 // Subscribe to bus events, fiber interrupted when scope closes246 yield* (yield* bus.subscribeAll()).pipe(等待 Effect 结果。247 Stream.runForEach((input) =>248 Effect.sync(() => {Effect 异步工作流。249 for (const hook of hooks) {调用插件扩展点。250 void hook["event"]?.({ event: input as any })调用插件扩展点。251 }252 }),253 ),254 Effect.forkScoped,Effect 异步工作流。255 )256257 return { hooks }调用插件扩展点。258 }),259 )260261 const trigger = Effect.fn("Plugin.trigger")(function* <调用插件扩展点。262 Name extends TriggerName,263 Input = Parameters<Required<Hooks>[Name]>[0],264 Output = Parameters<Required<Hooks>[Name]>[1],265 >(name: Name, input: Input, output: Output) {266 if (!name) return output按条件进入分支。267 const s = yield* InstanceState.get(state)等待 Effect 结果。268 for (const hook of s.hooks) {调用插件扩展点。269 const fn = hook[name] as any调用插件扩展点。270 if (!fn) continue按条件进入分支。271 yield* Effect.promise(async () => fn(input, output))Effect 异步工作流。272 }273 return output返回给上一层。274 })
packages/plugin/src/index.ts:56-80、:222-280:公开 plugin 契约。
packages/plugin/src/index.ts
packages/plugin/src/index.ts:56-80
56export type PluginInput = {调用插件扩展点。57 client: ReturnType<typeof createOpencodeClient>创建 SDK 客户端。58 project: Project59 directory: string60 worktree: string61 experimental_workspace: {62 register(type: string, adapter: WorkspaceAdapter): void63 }64 serverUrl: URL65 $: BunShell66}6768export type PluginOptions = Record<string, unknown>调用插件扩展点。6970export type Config = Omit<SDKConfig, "plugin"> & {定义数据结构约束。71 plugin?: Array<string | [string, PluginOptions]>调用插件扩展点。72}7374export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>调用插件扩展点。7576export type PluginModule = {调用插件扩展点。77 id?: string78 server: Plugin调用插件扩展点。79 tui?: never80}
6. 一条具体源码旅程:外部客户端发送 prompt
Section titled “6. 一条具体源码旅程:外部客户端发送 prompt”假设一个 Node 客户端要向已有 session 发送“解释这个项目”。下面只追一条同步路径。
第一步:SDK 方法来自 typed contract
Section titled “第一步:SDK 方法来自 typed contract”Session group 把不含 sessionID 的 prompt body 定义为 PromptPayload;session id 来自 path param,见 packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:66-68。
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:66-68
66export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))定义并校验数据形状。67export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))定义并校验数据形状。68export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))定义并校验数据形状。
endpoint 再声明 POST path、payload、成功结果和错误:packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-337。
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:312-337
312 HttpApiEndpoint.post("prompt", SessionPaths.prompt, {313 params: { sessionID: SessionID },314 query: WorkspaceRoutingQuery,315 payload: PromptPayload,316 success: described(MessageV2.WithParts, "Created message"),会话消息片段结构。317 error: [HttpApiError.BadRequest, ApiNotFoundError],318 }).annotateMerge(319 OpenApi.annotations({320 identifier: "session.prompt",把输入交给会话主流程。321 summary: "Send message",322 description: "Create and send a new message to a session, streaming the AI response.",323 }),324 ),325 HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {326 params: { sessionID: SessionID },327 query: WorkspaceRoutingQuery,328 payload: PromptPayload,329 success: described(HttpApiSchema.NoContent, "Prompt accepted"),定义并校验数据形状。330 error: [HttpApiError.BadRequest, ApiNotFoundError],331 }).annotateMerge(332 OpenApi.annotations({333 identifier: "session.prompt_async",把输入交给会话主流程。334 summary: "Send async message",335 description:336 "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",337 }),
这比手写 Controller 的关键优势不是“少代码”,而是同一份契约能参与 server 校验、OpenAPI/SDK 生成和类型检查。
第二步:API group 被装进总 API
Section titled “第二步:API group 被装进总 API”SessionApi 是 InstanceHttpApi 的一个 group,而 InstanceHttpApi 又与 Root、Event、PtyConnect 一起组成 OpenCodeHttpApi,见 packages/opencode/src/server/routes/instance/httpapi/api.ts:30-59。
packages/opencode/src/server/routes/instance/httpapi/api.ts
packages/opencode/src/server/routes/instance/httpapi/api.ts:30-59
30export const RootHttpApi = HttpApi.make("opencode-root")对外暴露模块成员。31 .addHttpApi(ControlApi)32 .addHttpApi(GlobalApi)读写本地文件。33 .middleware(SchemaErrorMiddleware)执行前先处理中间件。34 .middleware(Authorization)执行前先处理中间件。3536export const InstanceHttpApi = HttpApi.make("opencode-instance")对外暴露模块成员。37 .addHttpApi(ConfigApi)38 .addHttpApi(ExperimentalApi)39 .addHttpApi(FileApi)40 .addHttpApi(InstanceApi)41 .addHttpApi(McpApi)42 .addHttpApi(ProjectApi)43 .addHttpApi(PtyApi)44 .addHttpApi(QuestionApi)45 .addHttpApi(PermissionApi)46 .addHttpApi(ProviderApi)选择模型或 provider。47 .addHttpApi(SessionApi)48 .addHttpApi(SyncApi)49 .addHttpApi(V2Api)50 .addHttpApi(TuiApi)51 .addHttpApi(WorkspaceApi)52 .middleware(SchemaErrorMiddleware)执行前先处理中间件。5354export const OpenCodeHttpApi = HttpApi.make("opencode")对外暴露模块成员。55 .addHttpApi(RootHttpApi)56 .addHttpApi(EventApi)57 .addHttpApi(InstanceHttpApi)58 .addHttpApi(PtyConnectApi)59 .annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
SessionApi -> InstanceHttpApi -> OpenCodeHttpApi目录不是架构本身;真正的总装关系由这些 .addHttpApi(...) 表达。
第三步:handler 做边界工作,再交给 service
Section titled “第三步:handler 做边界工作,再交给 service”同步 prompt handler:
requireSession(sessionID);- 把 path 中的
sessionID与 payload 合并; - 调用
promptSvc.prompt(...); - 把错误映射为
BadRequest; - 以 JSON stream 返回 message。
证据在 packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:279-293。
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:279-293
279 const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {Effect 异步工作流。280 params: { sessionID: SessionID }281 payload: typeof PromptPayload.Type282 }) {283 yield* requireSession(ctx.params.sessionID)等待 Effect 结果。284 const message = yield* promptSvc等待 Effect 结果。285 .prompt({286 ...ctx.payload,287 sessionID: ctx.params.sessionID,288 })289 .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))Effect 异步工作流。290 return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {返回给上一层。291 contentType: "application/json",292 })293 })
handler 没有重写 agent loop。HTTP 边界只做协议适配和错误映射,业务仍在 SessionPrompt.Service。
第四步:SDK wrapper 补上工作区语义
Section titled “第四步:SDK wrapper 补上工作区语义”createOpencodeClient 基于 generated createClient,但额外做三件事:
- 默认 fetch 关闭请求 timeout;
- 将
directory编码进x-opencode-directory; - 对 GET/HEAD 将 directory 改写到 query,并注册统一错误 interceptor。
见 packages/sdk/js/src/client.ts:17-56。
packages/sdk/js/src/client.ts
packages/sdk/js/src/client.ts:17-56
17function rewrite(request: Request, directory?: string) {定义一段可复用逻辑。18 if (request.method !== "GET" && request.method !== "HEAD") return request按条件进入分支。1920 const value = pick(request.headers.get("x-opencode-directory"), directory)21 if (!value) return request按条件进入分支。2223 const url = new URL(request.url)24 if (!url.searchParams.has("directory")) {按条件进入分支。25 url.searchParams.set("directory", value)26 }2728 const next = new Request(url, request)29 next.headers.delete("x-opencode-directory")30 return next返回给上一层。31}3233export function createOpencodeClient(config?: Config & { directory?: string }) {创建 SDK 客户端。34 if (!config?.fetch) {按条件进入分支。35 const customFetch: any = (req: any) => {36 // @ts-ignore37 req.timeout = false38 return fetch(req)返回给上一层。39 }40 config = {41 ...config,42 fetch: customFetch,43 }44 }4546 if (config?.directory) {按条件进入分支。47 config.headers = {读取运行配置。48 ...config.headers,读取运行配置。49 "x-opencode-directory": encodeURIComponent(config.directory),读取运行配置。50 }51 }5253 const client = createClient(config)创建 SDK 客户端。54 client.interceptors.request.use((request) => rewrite(request, config?.directory))55 client.interceptors.error.use(wrapClientError)56 return new OpencodeClient({ client })返回给上一层。
因此 SDK wrapper 不是重复 generated client,而是在“生成契约”和“OpenCode 工作区路由语义”之间做适配。
第五步:结果与事件不能混为一谈
Section titled “第五步:结果与事件不能混为一谈”同步 prompt 返回最终 message stream;长过程中的 part、tool、permission 等状态仍需要事件通道。一个请求返回值无法替代整个实时状态模型。
这也是上一章 UI 同时需要 command path 与 event path 的原因。
7. 同步 prompt 与异步 prompt 的失败语义
Section titled “7. 同步 prompt 与异步 prompt 的失败语义”异步 handler 在验证 session 后,把 promptSvc.prompt fork 到 scope,立即返回 NoContent,见 packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:295-315。
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:295-315
295 const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {Effect 异步工作流。296 params: { sessionID: SessionID }297 payload: typeof PromptPayload.Type298 }) {299 yield* requireSession(ctx.params.sessionID)等待 Effect 结果。300 yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(等待 Effect 结果。301 Effect.catchCause((cause) =>Effect 异步工作流。302 Effect.gen(function* () {Effect 异步工作流。303 yield* Effect.logError("prompt_async failed").pipe(Effect 异步工作流。304 Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),Effect 异步工作流。305 )306 yield* bus.publish(Session.Event.Error, {广播状态变化。307 sessionID: ctx.params.sessionID,308 error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),309 })310 }),311 ),312 Effect.forkIn(scope, { startImmediately: true }),Effect 异步工作流。313 )314 return HttpApiSchema.NoContent.make()定义并校验数据形状。315 })
后台失败不会再变成这个 HTTP 响应,而是:
- 记录
prompt_async failed; - 向 bus 发布
Session.Event.Error。
同步:调用者等待结果,错误映射到本次 response异步:调用者只知道已接收,后续成功/失败靠 event这是 API 设计的真实分叉。选择异步 endpoint,就必须保证客户端订阅事件,否则“202/NoContent”会被误解成任务成功。
8. in-process server 与真实 listener
Section titled “8. in-process server 与真实 listener”Server.Default() 暴露一个可直接 fetch(Request) 的 app handler,见 packages/opencode/src/server/server.ts:58-63。Server.listen(...) 则构造真实 listener 和 stop 生命周期,见 packages/opencode/src/server/server.ts:75-103。
packages/opencode/src/server/server.ts
packages/opencode/src/server/server.ts:58-63
58export const Default = lazy(() => {对外暴露模块成员。59 const handler = HttpApiApp.webHandler().handler60 const app: ServerApp = {61 fetch: (request: Request) => handler(request, HttpApiApp.context),62 request(input, init) {63 return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))返回给上一层。
packages/opencode/src/server/server.ts
packages/opencode/src/server/server.ts:75-103
75export async function listen(opts: ListenOptions): Promise<Listener> {对外暴露模块成员。76 const listener = await Effect.runPromise(listenEffect(opts))Effect 异步工作流。77 return {返回给上一层。78 hostname: listener.hostname,79 port: listener.port,80 url: listener.url,81 stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined),Effect 异步工作流。82 }83}8485const listenEffect: (opts: ListenOptions) => Effect.Effect<EffectListener, unknown> = Effect.fn("Server.listen")(Effect 异步工作流。86 function* (opts: ListenOptions) {定义一段可复用逻辑。87 const state = yield* startWithPortFallback(opts)等待 Effect 结果。88 const address = yield* tcpAddress(state)等待 Effect 结果。89 const listenerUrl = makeURL(opts.hostname, address.port)90 url = listenerUrl9192 const unpublishMdns = yield* setupMdns(opts, address.port, state.scope)等待 Effect 结果。9394 return {返回给上一层。95 hostname: opts.hostname,96 port: address.port,97 url: listenerUrl,98 stop: yield* makeStop(state, unpublishMdns),等待 Effect 结果。99 }100 },101)102103function listenerLayer(opts: ListenOptions, port: number) {定义一段可复用逻辑。
二者复用 API/handler,但并不等价:
- in-process 避开端口与网络;
- listener 才覆盖地址绑定、跨进程、真实认证/代理等问题。
测试 API handler 可以优先用 in-process fetch;交付 server 时仍需一组真实 listener 冒烟测试。
9. 插件怎样获得能力,又被限制在 hook 边界
Section titled “9. 插件怎样获得能力,又被限制在 hook 边界”插件初始化时,OpenCode 给出 PluginInput:SDK client、project、worktree、directory、workspace adapter 注册点、server URL 与 Bun shell,见 packages/opencode/src/plugin/index.ts:126-150。
packages/opencode/src/plugin/index.ts
packages/opencode/src/plugin/index.ts:126-150
126 const { Server } = yield* Effect.promise(() => import("../server/server"))Effect 异步工作流。127128 const client = createOpencodeClient({创建 SDK 客户端。129 baseUrl: "http://localhost:4096",130 directory: ctx.directory,131 headers: ServerAuth.headers(),132 fetch: async (...args) => Server.Default().app.fetch(...args),复用后端请求入口。133 })134 const cfg = yield* config.get()读取运行配置。135 const input: PluginInput = {调用插件扩展点。136 client,137 project: ctx.project,138 worktree: ctx.worktree,139 directory: ctx.directory,140 experimental_workspace: {141 register(type: string, adapter: PluginWorkspaceAdapter) {调用插件扩展点。142 registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter)143 },144 },145 get serverUrl(): URL {146 return Server.url ?? new URL("http://localhost:4096")返回给上一层。147 },148 // @ts-expect-error149 $: typeof Bun === "undefined" ? undefined : Bun.$,150 }
公开类型在 packages/plugin/src/index.ts:56-80。这是一份能力清单:插件能做什么,应从 input 与 hooks 判断,而不是因为它是 JS 就假设它“只能安全地做这些”。插件代码仍运行在进程信任边界内。
packages/plugin/src/index.ts
packages/plugin/src/index.ts:56-80
56export type PluginInput = {调用插件扩展点。57 client: ReturnType<typeof createOpencodeClient>创建 SDK 客户端。58 project: Project59 directory: string60 worktree: string61 experimental_workspace: {62 register(type: string, adapter: WorkspaceAdapter): void63 }64 serverUrl: URL65 $: BunShell66}6768export type PluginOptions = Record<string, unknown>调用插件扩展点。6970export type Config = Omit<SDKConfig, "plugin"> & {定义数据结构约束。71 plugin?: Array<string | [string, PluginOptions]>调用插件扩展点。72}7374export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>调用插件扩展点。7576export type PluginModule = {调用插件扩展点。77 id?: string78 server: Plugin调用插件扩展点。79 tui?: never80}
公开 Hooks 包括:
event、config、tool、auth、provider;chat.message、chat.params、chat.headers;permission.ask;command.execute.before;tool.execute.before/after;shell.env。
见 packages/plugin/src/index.ts:222-280。
packages/plugin/src/index.ts
packages/plugin/src/index.ts:222-280
222export interface Hooks {定义数据结构约束。223 event?: (input: { event: Event }) => Promise<void>224 config?: (input: Config) => Promise<void>225 tool?: {226 [key: string]: ToolDefinition227 }228 auth?: AuthHook229 provider?: ProviderHook选择模型或 provider。230 /**231 * Called when a new message is received232 */233 "chat.message"?: (234 input: {235 sessionID: string236 agent?: string237 model?: { providerID: string; modelID: string }选择模型或 provider。238 messageID?: string239 variant?: string240 },241 output: { message: UserMessage; parts: Part[] },242 ) => Promise<void>243 /**244 * Modify parameters sent to LLM245 */246 "chat.params"?: (247 input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },选择模型或 provider。248 output: {249 temperature: number250 topP: number251 topK: number252 maxOutputTokens: number | undefined253 options: Record<string, any>254 },255 ) => Promise<void>256 "chat.headers"?: (257 input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },选择模型或 provider。258 output: { headers: Record<string, string> },259 ) => Promise<void>260 "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>进入权限审批。261 "command.execute.before"?: (处理命令执行。262 input: { command: string; sessionID: string; arguments: string },处理命令执行。263 output: { parts: Part[] },264 ) => Promise<void>265 "tool.execute.before"?: (266 input: { tool: string; sessionID: string; callID: string },267 output: { args: any },268 ) => Promise<void>269 "shell.env"?: (处理命令执行。270 input: { cwd: string; sessionID?: string; callID?: string },271 output: { env: Record<string, string> },272 ) => Promise<void>273 "tool.execute.after"?: (274 input: { tool: string; sessionID: string; callID: string; args: any },275 output: {276 title: string277 output: string278 metadata: any279 },280 ) => Promise<void>
一个具体 hook:修改工具参数
Section titled “一个具体 hook:修改工具参数”当 runtime 触发 tool.execute.before 时,把 { tool, sessionID, callID } 作为 input,把 { args } 作为可修改 output。插件按加载顺序逐个执行,后一个插件会看到前一个已修改的 output。
这是从 Hooks 类型和 Plugin.trigger 控制流得到的明确结论,见 packages/plugin/src/index.ts:265-268、packages/opencode/src/plugin/index.ts:261-274。
packages/plugin/src/index.ts
packages/plugin/src/index.ts:265-268
265 "tool.execute.before"?: (266 input: { tool: string; sessionID: string; callID: string },267 output: { args: any },268 ) => Promise<void>
packages/opencode/src/plugin/index.ts
packages/opencode/src/plugin/index.ts:261-274
261 const trigger = Effect.fn("Plugin.trigger")(function* <调用插件扩展点。262 Name extends TriggerName,263 Input = Parameters<Required<Hooks>[Name]>[0],264 Output = Parameters<Required<Hooks>[Name]>[1],265 >(name: Name, input: Input, output: Output) {266 if (!name) return output按条件进入分支。267 const s = yield* InstanceState.get(state)等待 Effect 结果。268 for (const hook of s.hooks) {调用插件扩展点。269 const fn = hook[name] as any调用插件扩展点。270 if (!fn) continue按条件进入分支。271 yield* Effect.promise(async () => fn(input, output))Effect 异步工作流。272 }273 return output返回给上一层。274 })
风险也随之明确:hook 顺序可观察,慢插件会增加延迟,抛错是否被上层处理要看具体 trigger 调用点,不能一概宣称“插件错误都被隔离”。
event hook 与 trigger hook 不同
Section titled “event hook 与 trigger hook 不同”bus event 订阅处直接对每个 hook 调用 hook.event?.(...),见 packages/opencode/src/plugin/index.ts:245-255;二参数变换型 hook 才走通用 trigger。
packages/opencode/src/plugin/index.ts
packages/opencode/src/plugin/index.ts:245-255
245 // Subscribe to bus events, fiber interrupted when scope closes246 yield* (yield* bus.subscribeAll()).pipe(等待 Effect 结果。247 Stream.runForEach((input) =>248 Effect.sync(() => {Effect 异步工作流。249 for (const hook of hooks) {调用插件扩展点。250 void hook["event"]?.({ event: input as any })调用插件扩展点。251 }252 }),253 ),254 Effect.forkScoped,Effect 异步工作流。255 )
这就是为什么读源码时要先看函数签名,不能把所有 hook 想象成同一种 middleware。
10. OpenCode 的选择
Section titled “10. OpenCode 的选择”| 选择 | 好处 | 代价 |
|---|---|---|
| typed API group | contract、server、SDK 更一致 | schema 演进成本前置 |
| generated client + 小 wrapper | 大部分自动生成,产品语义集中补充 | 生成流程必须纳入 CI |
| 同时支持 in-process fetch | 本地客户端复用 API 且开销低 | 仍需真实网络测试 |
| 同步与异步 prompt 分开 | 调用者可选择等待或事件驱动 | 两种完成语义必须写清楚 |
| 顺序 plugin hooks | 简单、可组合、容易理解 | 顺序依赖和延迟会累积 |
11. 可以带走的方法
Section titled “11. 可以带走的方法”方法一:让 contract 成为生成与校验的共同输入
Section titled “方法一:让 contract 成为生成与校验的共同输入”不要分别维护 server DTO、OpenAPI 文档和 SDK 类型。
验证问题:改一个 payload 字段时,类型检查能否同时暴露 handler 与 client 的不一致?
方法二:wrapper 只补产品语义
Section titled “方法二:wrapper 只补产品语义”generated client 负责协议机械细节,薄 wrapper 负责 directory、认证、错误等稳定横切语义。
验证问题:wrapper 是否开始手写每个 endpoint?如果是,生成边界已经失效。
方法三:hook 必须声明可修改面
Section titled “方法三:hook 必须声明可修改面”用 { input, output } 明确什么是上下文、什么允许改变,并记录顺序与异常策略。
验证问题:两个插件同时修改 args 时,结果是否可预测、可测试?
12. 费曼复述与练习
Section titled “12. 费曼复述与练习”请回答:
- API、SDK 与 plugin 分别解决什么问题?
- 为什么异步 prompt 的 NoContent 不能代表成功?
- SDK wrapper 为什么处理 directory,而不是让每个调用者自己拼 query?
练习阶梯:
-
入门:画出
SessionApi -> handler -> SessionPrompt.Service。 -
进阶:为 mini agent 定义同步
/prompt与异步/prompt_async的完成语义。 -
源码追踪:从
packages/sdk/js/src/client.ts:33追到packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:289。packages/sdk/js/src/client.ts
packages/sdk/js/src/client.ts:3333export function createOpencodeClient(config?: Config & { directory?: string }) {创建 SDK 客户端。packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:289289 .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))Effect 异步工作流。 -
迁移:设计一个
tool.execute.beforehook,并写出两个插件冲突时的测试。
最后复盘:扩展首先是边界设计
Section titled “最后复盘:扩展首先是边界设计”typed contract -> handler 适配 -> runtime service -> generated SDK 调用
runtime checkpoint -> ordered hooks -> controlled output mutation有了边界还不够:generated SDK 是否及时更新?核心包修改后该跑哪些检查?下一章会把 OpenCode 的 monorepo 任务图还原成一条最小但可信的交付链。