跳转到内容

SDK / API / 对外扩展点

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度中等
预计阅读40 分钟
  • 章节 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
  • packages/opencode/src/server/server.ts
  • packages/opencode/src/server/routes/instance/httpapi/api.ts
  • packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
  • packages/sdk/js/src/client.ts
  • packages/sdk/js/src/server.ts
  • packages/opencode/src/plugin/index.ts
  • packages/plugin/src/index.ts

源码基线:eec0843ce422。本章示例是基于源码组合出的典型请求,不是一次实际抓包记录。

读完本章,你应该能:

  • 画出 HTTP API、handler、runtime service、generated SDK 和 plugin hook 的关系。
  • 沿一次 session.prompt 请求,从 typed endpoint 追到 SessionPrompt.Service
  • 解释 SDK wrapper 为什么不只是把 URL 包成函数。
  • 区分“从外部调用已有能力”和“在内部生命周期中扩展行为”。
  • 识别异步 prompt、插件顺序执行和进程启动 helper 的失败边界。

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.paramstool.execute.beforeshell.env 等时点调整数据。

把两者混在一起会产生危险设计:客户端为了改一个 header 侵入 runtime,或者插件绕过 API 自己创建第二套 session。

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 output

OpenCode 的 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    })
机制主要用户方向稳定边界典型风险
HTTP API任意语言/进程外部调用 runtimemethod/path/schema网络、认证、版本
JS SDKTS/JS 客户端类型化调用 APIgenerated 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 调用。

  1. 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])
  1. 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")对外暴露模块成员。
  1. 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          }),
  1. 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    })
  1. 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))返回给上一层。
  1. 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 })返回给上一层。
  1. 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) {对外暴露模块成员。
  1. 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()读取运行配置。
  1. 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    })
  1. 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 生成和类型检查。

SessionApiInstanceHttpApi 的一个 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:

  1. requireSession(sessionID)
  2. 把 path 中的 sessionID 与 payload 合并;
  3. 调用 promptSvc.prompt(...)
  4. 把错误映射为 BadRequest
  5. 以 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”会被误解成任务成功。

Server.Default() 暴露一个可直接 fetch(Request) 的 app handler,见 packages/opencode/src/server/server.ts:58-63Server.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 包括:

  • eventconfigtoolauthprovider
  • chat.messagechat.paramschat.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>

当 runtime 触发 tool.execute.before 时,把 { tool, sessionID, callID } 作为 input,把 { args } 作为可修改 output。插件按加载顺序逐个执行,后一个插件会看到前一个已修改的 output。

这是从 Hooks 类型和 Plugin.trigger 控制流得到的明确结论,见 packages/plugin/src/index.ts:265-268packages/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 调用点,不能一概宣称“插件错误都被隔离”。

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。

选择好处代价
typed API groupcontract、server、SDK 更一致schema 演进成本前置
generated client + 小 wrapper大部分自动生成,产品语义集中补充生成流程必须纳入 CI
同时支持 in-process fetch本地客户端复用 API 且开销低仍需真实网络测试
同步与异步 prompt 分开调用者可选择等待或事件驱动两种完成语义必须写清楚
顺序 plugin hooks简单、可组合、容易理解顺序依赖和延迟会累积

方法一:让 contract 成为生成与校验的共同输入

Section titled “方法一:让 contract 成为生成与校验的共同输入”

不要分别维护 server DTO、OpenAPI 文档和 SDK 类型。

验证问题:改一个 payload 字段时,类型检查能否同时暴露 handler 与 client 的不一致?

generated client 负责协议机械细节,薄 wrapper 负责 directory、认证、错误等稳定横切语义。

验证问题:wrapper 是否开始手写每个 endpoint?如果是,生成边界已经失效。

{ input, output } 明确什么是上下文、什么允许改变,并记录顺序与异常策略。

验证问题:两个插件同时修改 args 时,结果是否可预测、可测试?

请回答:

  1. API、SDK 与 plugin 分别解决什么问题?
  2. 为什么异步 prompt 的 NoContent 不能代表成功?
  3. 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:33
    33export 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:289
    289        .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))Effect 异步工作流。
  • 迁移:设计一个 tool.execute.before hook,并写出两个插件冲突时的测试。

最后复盘:扩展首先是边界设计

Section titled “最后复盘:扩展首先是边界设计”
typed contract
-> handler 适配
-> runtime service
-> generated SDK 调用
runtime checkpoint
-> ordered hooks
-> controlled output mutation

有了边界还不够:generated SDK 是否及时更新?核心包修改后该跑哪些检查?下一章会把 OpenCode 的 monorepo 任务图还原成一条最小但可信的交付链。