# SDK / API / 对外扩展点

> 源码基线：`v1.18.16`（提交 `a3647eb025c7`）。本章示例是基于源码组合出的典型请求，不是一次实际抓包记录。

## 0. 本章学习目标

读完本章，你应该能：

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

## 1. 一句话讲明白

OpenCode 用 typed HTTP API 定义稳定能力边界，用 generated SDK 把边界变成客户端方法，再用 plugin hooks 在明确时点修改输入或输出；API 是门，SDK 是门卡，hook 是预留插槽。

中心问题是：**外部程序要让 agent 做事，为什么既需要 API/SDK，又需要 plugin，而不能只开放几个内部函数？**

## v1.18.16 相比旧基线改变了什么

- HTTP API 继续拆成 root 与 instance 两组契约，并由 `OpenCodeHttpApi` 合并；handler 依赖 typed group，而不是手写字符串路由。
- JS SDK 的 `createOpencodeClient` 仍是 generated client 上的薄配置层，server/TUI helper 继续负责进程生命周期。
- plugin service 新增更多 runtime flag 与内部 plugin 分支，但 `trigger(name, input, output)` 仍是统一扩展合同。

<!-- source-ref path="packages/opencode/src/server/routes/instance/httpapi/api.ts" lines="48-85" title="Root、Instance 与 OpenCode API 组合" note="对外契约先组合，再由 handler 绑定 runtime service。" -->

<!-- source-ref path="packages/sdk/js/src/client.ts" lines="1-50" title="SDK client wrapper" note="directory 等公共参数在 wrapper 层注入 generated client。" -->

## 2. 先画地图：调用与扩展是两条路

```text
外部客户端 / 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. 最小机制

API/SDK 的通用内核可以缩成：

```text
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
```

插件内核则是：

```text
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`。

## 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. 最小源码路径

1. `packages/opencode/src/server/routes/instance/httpapi/api.ts`：API group 总装图。
2. `packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`：payload 与 path。
3. `packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`：prompt/command/shell 契约。
4. `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`：同步与异步 prompt handler。
5. `packages/opencode/src/server/server.ts`、`:75-103`：in-process app 与真实 listener。
6. `packages/sdk/js/src/client.ts`：SDK wrapper。
7. `packages/sdk/js/src/server.ts`：server/TUI 进程 helper。
8. `packages/opencode/src/plugin/index.ts`：插件输入与加载起点。
9. `packages/opencode/src/plugin/index.ts`：事件分发与 trigger。
10. `packages/plugin/src/index.ts`、`:222-280`：公开 plugin 契约。

## 6. 一条具体源码旅程：外部客户端发送 prompt

假设一个 Node 客户端要向已有 session 发送“解释这个项目”。下面只追一条同步路径。

### 第一步：SDK 方法来自 typed contract

Session group 把不含 `sessionID` 的 prompt body 定义为 `PromptPayload`；session id 来自 path param，见 `packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`。

endpoint 再声明 POST path、payload、成功结果和错误：`packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`。

这比手写 Controller 的关键优势不是“少代码”，而是同一份契约能参与 server 校验、OpenAPI/SDK 生成和类型检查。

### 第二步：API group 被装进总 API

`SessionApi` 是 `InstanceHttpApi` 的一个 group，而 `InstanceHttpApi` 又与 Root、Event、PtyConnect 一起组成 `OpenCodeHttpApi`，见 `packages/opencode/src/server/routes/instance/httpapi/api.ts`。

```text
SessionApi
  -> InstanceHttpApi
  -> OpenCodeHttpApi
```

目录不是架构本身；真正的总装关系由这些 `.addHttpApi(...)` 表达。

### 第三步：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`。

handler 没有重写 agent loop。HTTP 边界只做协议适配和错误映射，业务仍在 `SessionPrompt.Service`。

### 第四步：SDK wrapper 补上工作区语义

`createOpencodeClient` 基于 generated `createClient`，但额外做三件事：

- 默认 fetch 关闭请求 timeout；
- 将 `directory` 编码进 `x-opencode-directory`；
- 对 GET/HEAD 将 directory 改写到 query，并注册统一错误 interceptor。

见 `packages/sdk/js/src/client.ts`。

因此 SDK wrapper 不是重复 generated client，而是在“生成契约”和“OpenCode 工作区路由语义”之间做适配。

### 第五步：结果与事件不能混为一谈

同步 prompt 返回最终 message stream；长过程中的 part、tool、permission 等状态仍需要事件通道。一个请求返回值无法替代整个实时状态模型。

这也是上一章 UI 同时需要 command path 与 event path 的原因。

## 7. 同步 prompt 与异步 prompt 的失败语义

异步 handler 在验证 session 后，把 `promptSvc.prompt` fork 到 scope，立即返回 NoContent，见 `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`。

后台失败不会再变成这个 HTTP 响应，而是：

- 记录 `prompt_async failed`；
- 向 bus 发布 `Session.Event.Error`。

```text
同步：调用者等待结果，错误映射到本次 response
异步：调用者只知道已接收，后续成功/失败靠 event
```

这是 API 设计的真实分叉。选择异步 endpoint，就必须保证客户端订阅事件，否则“202/NoContent”会被误解成任务成功。

## 8. in-process server 与真实 listener

`Server.Default()` 暴露一个可直接 `fetch(Request)` 的 app handler，见 `packages/opencode/src/server/server.ts`。`Server.listen(...)` 则构造真实 listener 和 stop 生命周期，见 `packages/opencode/src/server/server.ts`。

二者复用 API/handler，但并不等价：

- in-process 避开端口与网络；
- listener 才覆盖地址绑定、跨进程、真实认证/代理等问题。

测试 API handler 可以优先用 in-process fetch；交付 server 时仍需一组真实 listener 冒烟测试。

## 9. 插件怎样获得能力，又被限制在 hook 边界

插件初始化时，OpenCode 给出 `PluginInput`：SDK client、project、worktree、directory、workspace adapter 注册点、server URL 与 Bun shell，见 `packages/opencode/src/plugin/index.ts`。

公开类型在 `packages/plugin/src/index.ts`。这是一份能力清单：插件能做什么，应从 input 与 hooks 判断，而不是因为它是 JS 就假设它“只能安全地做这些”。插件代码仍运行在进程信任边界内。

公开 `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`。

### 一个具体 hook：修改工具参数

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

这是从 `Hooks` 类型和 `Plugin.trigger` 控制流得到的明确结论，见 `packages/plugin/src/index.ts`、`packages/opencode/src/plugin/index.ts`。

风险也随之明确：hook 顺序可观察，慢插件会增加延迟，抛错是否被上层处理要看具体 trigger 调用点，不能一概宣称“插件错误都被隔离”。

### event hook 与 trigger hook 不同

bus event 订阅处直接对每个 hook 调用 `hook.event?.(...)`，见 `packages/opencode/src/plugin/index.ts`；二参数变换型 hook 才走通用 `trigger`。

这就是为什么读源码时要先看函数签名，不能把所有 hook 想象成同一种 middleware。

## 10. OpenCode 的选择

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

## 11. 可以带走的方法

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

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

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

### 方法二：wrapper 只补产品语义

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

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

### 方法三：hook 必须声明可修改面

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

验证问题：两个插件同时修改 `args` 时，结果是否可预测、可测试？

## 12. 费曼复述与练习

请回答：

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` 追到 `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`。
- **迁移**：设计一个 `tool.execute.before` hook，并写出两个插件冲突时的测试。

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

```text
typed contract
  -> handler 适配
  -> runtime service
  -> generated SDK 调用

runtime checkpoint
  -> ordered hooks
  -> controlled output mutation
```

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