@harmonyos-arkts/opencode-acp 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ # VSCode → ACP → OpenCode Provider 配置全链路
2
+
3
+ ## 核心概念:OpenCode 的 Provider 可见性机制
4
+
5
+ OpenCode 有两个层面的 provider 数据:
6
+
7
+ | 数据源 | 接口 | 内容 | 用途 |
8
+ |--------|------|------|------|
9
+ | **数据库全量** | `GET /provider` | models.dev 中的所有 provider(70+) | 发现哪些 provider 可配置 |
10
+ | **运行时可用** | `GET /config/providers` | 有凭证或 autoload 的 provider + 模型列表 | 模型选择下拉框 |
11
+
12
+ **关键**:`GET /config/providers` 返回的不是"有 API Key 的 provider",而是 `s.providers` 这个内部状态。进入 `s.providers` 的条件(`provider/provider.ts:1076-1285`):
13
+
14
+ | 条件 | 代码位置 | 说明 |
15
+ |------|---------|------|
16
+ | 配置文件声明了 provider | 1124 行 | `opencode.json` 中 `provider.<id>` 有配置 |
17
+ | 环境变量有 key | 1211 行 | 如 `ANTHROPIC_API_KEY=xxx` |
18
+ | auth.json 有 key | 1224 行 | 通过 `PUT /auth/:providerID` 存入的 |
19
+ | Plugin auth loader | 1237 行 | Plugin 提供的认证加载器 |
20
+ | **Custom loader autoload** | 1258 行 | **`opencode` provider 始终 autoload** |
21
+
22
+ ### `opencode` Provider 的特殊性
23
+
24
+ `opencode` provider 有一个特殊的 custom loader(`provider/provider.ts:152-174`):
25
+
26
+ ```typescript
27
+ // 即使没有任何 key,只要还有免费模型,就 autoload
28
+ if (!ok) {
29
+ // 删除付费模型,保留免费模型(cost.input === 0)
30
+ for (const [key, value] of Object.entries(input.models)) {
31
+ if (value.cost.input === 0) continue
32
+ delete input.models[key]
33
+ }
34
+ }
35
+ return {
36
+ autoload: Object.keys(input.models).length > 0, // 有免费模型就 autoload
37
+ options: ok ? {} : { apiKey: "public" }, // 无 key 用 "public"
38
+ }
39
+ ```
40
+
41
+ **这意味着**:即使零配置,`GET /config/providers` 也会返回 `opencode` provider,包含所有免费模型(如 `glm-4.6`、`big-pickle` 等)。TUI 的 `/models` 能看到 GLM 模型就是这个原因。
42
+
43
+ ### 所以:配置 GLM 的场景分两种
44
+
45
+ **场景 A:使用 OpenCode Zen 的免费 GLM 模型**
46
+
47
+ ```
48
+ opencode provider 下的 glm-4.6 是免费模型
49
+ → 无需任何配置,直接可用
50
+ → providerID: "opencode", modelID: "glm-4.6"
51
+ ```
52
+
53
+ **场景 B:使用智谱 AI 官方 API**
54
+
55
+ ```
56
+ 需要配置 zhipuai provider 的 API Key
57
+ → extMethod("provider/auth/set", { providerID: "zhipuai", auth: { type: "api", key: "xxx" } })
58
+ → 配置后 zhipuai provider 进入 s.providers
59
+ → 其下所有 GLM 模型可用
60
+ ```
61
+
62
+ ## 阶段一:启动 & 发现
63
+
64
+ ```
65
+ VSCode 启动 harmony-acp 进程
66
+
67
+ ├─ harmony-acp 连接 OpenCode server (http://localhost:4096)
68
+ │ └─ sdk.global.health() 验证连通
69
+
70
+ └─ VSCode 调用 initialize()
71
+
72
+ └─ harmony-acp 返回:
73
+ {
74
+ authMethods: [{ id: "opencode-login", name: "Login with opencode" }],
75
+ _meta: {
76
+ providers: [
77
+ { id: "opencode", name: "OpenCode", connected: true }, ← 始终连接(免费模型)
78
+ { id: "anthropic", name: "Anthropic", connected: false },
79
+ { id: "openai", name: "OpenAI", connected: false },
80
+ { id: "zhipuai", name: "ZhipuAI", connected: false },
81
+ ]
82
+ }
83
+ }
84
+ ```
85
+
86
+ **VSCode 此时知道**:哪些 provider 可用、哪些已连接。
87
+
88
+ ## 阶段二:创建 Session & 发现可用模型
89
+
90
+ ```
91
+ VSCode 调用 newSession({ cwd })
92
+
93
+ ├─ harmony-acp 调用 sdk.config.providers()
94
+ │ → 返回 [opencode provider] ← 因为 opencode 始终 autoload
95
+ │ → opencode.models = { "glm-4.6", "big-pickle", ... } ← 免费模型
96
+
97
+ ├─ harmony-acp 调用 defaultModel()
98
+ │ → 找到 opencode provider → 选择第一个模型
99
+
100
+ ├─ sdk.session.create() → 成功创建 session
101
+
102
+ └─ 返回给 VSCode:
103
+ {
104
+ sessionId: "xxx",
105
+ models: {
106
+ currentModelId: "opencode/glm-4.6",
107
+ availableModels: [
108
+ { modelId: "opencode/glm-4.6", name: "OpenCode/GLM-4.6" },
109
+ { modelId: "opencode/big-pickle", name: "OpenCode/Big Pickle" },
110
+ ]
111
+ },
112
+ configOptions: [
113
+ { id: "model", currentValue: "opencode/glm-4.6", options: [...] },
114
+ { id: "mode", currentValue: "build", options: [...] }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ **此时**:VSCode 可以用免费模型直接对话,但只有 `opencode` provider 的免费模型可选。
120
+
121
+ ## 阶段三:用户配置新 Provider 的 API Key
122
+
123
+ 用户想使用 Claude、GPT 等付费模型,需要配置对应 provider 的 API Key。
124
+
125
+ ```
126
+ 用户在 VSCode 中选择 "配置 Provider"
127
+
128
+ ├─① extMethod("provider/list", { sessionId })
129
+ │ │
130
+ │ └─ harmony-acp → sdk.provider.list()
131
+ │ → GET /provider → 返回全量 provider 列表
132
+ │ → { all: [opencode, anthropic, openai, zhipuai, ...], connected: ["opencode"] }
133
+ │ → VSCode 展示 "未连接" 的 provider 列表让用户选择
134
+
135
+ ├─② 用户选择 anthropic,VSCode 查询认证方式:
136
+ │ extMethod("provider/auth/methods", { sessionId })
137
+ │ │
138
+ │ └─ harmony-acp → sdk.provider.auth()
139
+ │ → GET /provider/auth
140
+ │ → { anthropic: [{ type: "api", label: "API Key" }] }
141
+ │ → VSCode 知道需要输入 API Key,展示输入框
142
+
143
+ ├─③ 用户输入 API Key,VSCode 调用:
144
+ │ extMethod("provider/auth/set", {
145
+ │ providerID: "anthropic",
146
+ │ auth: { type: "api", key: "sk-ant-xxx" }
147
+ │ })
148
+ │ │
149
+ │ └─ harmony-acp → sdk.auth.set()
150
+ │ → PUT /auth/anthropic → 写入 ~/.opencode/data/auth.json
151
+ │ → 返回 { success: true }
152
+
153
+ └─④ 验证是否生效(可选):
154
+ extMethod("provider/list", { sessionId })
155
+
156
+ └─ harmony-acp → sdk.provider.list()
157
+ → 返回: { connected: ["opencode", "anthropic"] } ← anthropic 已连接
158
+ ```
159
+
160
+ ## 阶段四:切换到新模型
161
+
162
+ 配置 API Key 后,**不需要重新创建 session**,直接通过 `setSessionConfigOption` 切换模型:
163
+
164
+ ```
165
+ VSCode 调用 setSessionConfigOption({
166
+ sessionId, configId: "model", value: "anthropic/claude-sonnet-4"
167
+ })
168
+
169
+ ├─ harmony-acp → sdk.config.providers()
170
+ │ → 现在返回 [opencode, anthropic] ← anthropic 有 key 了
171
+ │ → anthropic.models = { "claude-sonnet-4", "claude-opus-4", ... }
172
+
173
+ ├─ parseModelSelection("anthropic/claude-sonnet-4")
174
+ │ → { providerID: "anthropic", modelID: "claude-sonnet-4" }
175
+
176
+ └─ 更新 session model → 返回新的 configOptions(包含 anthropic 的所有模型)
177
+ ```
178
+
179
+ 或者用 `setSessionModel`:
180
+
181
+ ```
182
+ VSCode 调用 unstable_setSessionModel({
183
+ sessionId, modelId: "anthropic/claude-sonnet-4/high"
184
+ })
185
+
186
+ └─ 同样效果,支持 variant 选择
187
+ ```
188
+
189
+ ## 阶段五:开始对话
190
+
191
+ ```
192
+ VSCode 调用 prompt({ sessionId, prompt: [...] })
193
+
194
+ └─ harmony-acp → sdk.session.prompt({
195
+ sessionID,
196
+ model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
197
+ parts: [...]
198
+ })
199
+
200
+ └─ OpenCode 用该模型发起 API 调用
201
+ → 如果 key 无效,此时才报错 → harmony-acp 捕获 → RequestError.authRequired()
202
+ → 如果 key 有效,正常流式返回
203
+ ```
204
+
205
+ ## 完整数据流图
206
+
207
+ ```
208
+ VSCode harmony-acp OpenCode Server
209
+ │ │ │
210
+ │── initialize() ──────────>│ │
211
+ │ │── sdk.provider.list() ───────>│ GET /provider
212
+ │ │<── {all, connected} ──────────│
213
+ │<── {authMethods, _meta} ──│ │
214
+ │ │ │
215
+ │── newSession() ──────────>│ │
216
+ │ │── sdk.config.providers() ────>│ GET /config/providers
217
+ │ │<── [opencode + 免费模型] ─────│ (opencode 始终 autoload)
218
+ │ │── sdk.session.create() ──────>│ POST /session
219
+ │ │<── { id } ────────────────────│
220
+ │<── { sessionId, models } ─│ │
221
+ │ │ │
222
+ │── prompt(免费模型) ───────>│ │
223
+ │ │── sdk.session.prompt() ──────>│ POST /session/xxx/prompt
224
+ │ │<── SSE events ────────────────│
225
+ │<── streaming response ────│ │
226
+ │ │ │
227
+ │== 用户想用 Claude =======│ │
228
+ │ │ │
229
+ │── extMethod("provider/list") ─────────>│ │
230
+ │ │── sdk.provider.list() ───────>│ GET /provider
231
+ │ │<── {all, connected} ──────────│
232
+ │<── 全量 provider 列表 ────│ │
233
+ │ │ │
234
+ │── extMethod("provider/auth/methods") ──>│ │
235
+ │ │── sdk.provider.auth() ───────>│ GET /provider/auth
236
+ │ │<── {anthropic: [{type:"api"}]}│
237
+ │<── 认证方式 ──────────────│ │
238
+ │ │ │
239
+ │── extMethod("provider/auth/set") ──────>│ │
240
+ │ { providerID, auth } │── sdk.auth.set() ────────────>│ PUT /auth/anthropic
241
+ │ │ │ → 写入 auth.json
242
+ │ │<── true ──────────────────────│
243
+ │<── { success: true } ─────│ │
244
+ │ │ │
245
+ │── setSessionConfigOption ─>│ │
246
+ │ { "anthropic/claude-sonnet-4" } │
247
+ │ │── sdk.config.providers() ────>│ GET /config/providers
248
+ │ │<── [opencode, anthropic] ────│
249
+ │<── 更新后的模型列表 ──────│ │
250
+ │ │ │
251
+ │── prompt(claude) ────────>│ │
252
+ │ │── sdk.session.prompt() ──────>│ POST /session/xxx/prompt
253
+ │ │<── SSE events ────────────────│
254
+ │<── streaming response ────│ │
255
+ ```
256
+
257
+ ## 涉及的 OpenCode 接口
258
+
259
+ ### 控制面路由(`routes/control/index.ts`)
260
+
261
+ | 方法 | 路径 | 用途 |
262
+ |------|------|------|
263
+ | `PUT` | `/auth/:providerID` | 设置 API Key,写入 `~/.opencode/data/auth.json` |
264
+ | `DELETE` | `/auth/:providerID` | 移除凭证 |
265
+
266
+ ### 实例面路由(`routes/instance/provider.ts`)
267
+
268
+ | 方法 | 路径 | 用途 |
269
+ |------|------|------|
270
+ | `GET` | `/provider` | 返回全量 provider 列表 + connected 状态 |
271
+ | `GET` | `/provider/auth` | 返回每个 provider 的认证方式(API Key / OAuth) |
272
+ | `POST` | `/:providerID/oauth/authorize` | OAuth 授权(仅 Plugin 注册了 OAuth hook 的 provider) |
273
+ | `POST` | `/:providerID/oauth/callback` | OAuth 回调 |
274
+
275
+ ### 实例面路由(`routes/instance/config.ts`)
276
+
277
+ | 方法 | 路径 | 用途 |
278
+ |------|------|------|
279
+ | `GET` | `/config/providers` | 返回 `s.providers`(有凭证或 autoload 的 provider + 模型列表) |
280
+
281
+ ## 两个 Provider 接口的区别
282
+
283
+ | | `GET /provider` | `GET /config/providers` |
284
+ |---|---|---|
285
+ | 数据源 | models.dev 全量数据库 + connected 状态 | `s.providers` 内部状态(有凭证/autoload) |
286
+ | 返回范围 | **所有** provider(70+) | 仅"可用"的 provider |
287
+ | 用途 | 发现可配置的 provider、展示配置入口 | 模型选择下拉框的数据源 |
288
+ | 返回格式 | `{ all, default, connected }` | `{ providers, default }` |
289
+ | ACP extMethod | `provider/list` | 无(harmony-acp 内部调用) |
290
+ | TUI 使用 | `store.provider_next` | `store.provider`(`/models` 下拉框) |
291
+
292
+ ## API Key 有效性判定
293
+
294
+ - **配置阶段**:只判断"存不存在",不判断"对不对"
295
+ - **使用阶段**:首次 `prompt()` 调用时才真正验证,key 无效则抛 `AI_LoadAPIKeyError`
296
+ - harmony-acp 在 `newSession`/`loadSession`/`forkSession`/`resumeSession` 中捕获该错误,返回 `RequestError.authRequired()`
297
+
298
+ ## OAuth 接口的调用条件
299
+
300
+ OAuth 流程**不是通用的**,只有 Plugin 注册了 `auth.provider` hook 的 provider 才支持。
301
+
302
+ 流程(参考 `packages/app/src/components/dialog-connect-provider.tsx`):
303
+
304
+ 1. `GET /provider/auth` 获取认证方式列表
305
+ 2. 如果 `type === "oauth"` 且有 `prompts` → 展示表单让用户填写
306
+ 3. `POST /:providerID/oauth/authorize` 获取授权 URL
307
+ 4. 根据 `method` 字段:
308
+ - `"code"` → 用户从浏览器复制 code → `POST /:providerID/oauth/callback({ code })`
309
+ - `"auto"` → 自动回调 → `POST /:providerID/oauth/callback`(无 code)
310
+ 5. 回调成功后 OpenCode 自动写入 auth.json
311
+
312
+ 绝大多数用户(Anthropic、OpenAI 等)只需 API Key 方式,不走 OAuth。
@@ -3,11 +3,28 @@
3
3
  ## 背景
4
4
 
5
5
  OpenCode 的 `UserQuestionTool` 允许 agent 向用户提问并阻塞等待回答。典型场景:
6
+
6
7
  - agent 需要用户确认技术选型(React vs Vue)
7
8
  - agent 需要用户提供额外信息
8
9
  - agent 遇到歧义需要用户裁决
9
10
 
10
- **问题**:OpenCode 内置 ACP Harmony-ACP 原本都忽略了 `question.asked` SSE 事件,导致 agent 调用 UserQuestionTool 时 Deferred promise 永远不 resolve,agent 挂起。
11
+ **问题**:OpenCode 内置 ACP 忽略了 `question.asked` SSE 事件,导致 agent 调用 UserQuestionTool 时 Deferred promise 永远不 resolve,agent 挂起。
12
+
13
+ opencode question.asked流程:
14
+
15
+ 1. Agent 调用 QuestionTool → Question.ask() → 创建 deferred,发布 question.asked 事件到 bus → await deferred
16
+ 2. TUI 通过 SSE 接收 question.asked 事件,向用户显示问题
17
+ 3. 用户回答 → TUI 调用 HTTP POST /question/:requestID/reply → Question.reply() → 解析 deferred
18
+ 4. Agent 继续
19
+
20
+ opencode acp 缺少对 "question.asked" 的处理。 当 agent 通过 UserQuestionTool (即 QuestionTool 在 src/tool/question.ts 中) 调用 Question.ask() 时:
21
+
22
+ 1. Question.ask() 创建一个 Deferred,发布 question.asked 到总线,并 await 延迟处理
23
+ 2. runEventSubscription() 通过全局 SSE 流接收到事件
24
+ 3. handleEvent() 调用并使用 switch(event.type) — 没有 case "question.asked" 分支
25
+ 4. 该事件被静默丢弃
26
+ 5. ACP 客户端 (例如 Claude Code、Copilot 等) 从未收到关于问题的通知,无法显示 UI,也无法调用 POST /question/:requestID/reply
27
+ 6. Deferred 永远不会解决 → agent 永久挂起
11
28
 
12
29
  **解决方案**:Harmony-ACP 拦截 `question.asked` SSE 事件,通过 ACP 扩展机制 `extMethod("questionAsked")` 转发给客户端,用户回答后通过 OpenCode SDK 回复,解除 agent 阻塞。
13
30
 
@@ -15,12 +32,12 @@ OpenCode 的 `UserQuestionTool` 允许 agent 向用户提问并阻塞等待回
15
32
 
16
33
  ACP 协议唯一的 agent→client request-response 机制是 `requestPermission`,但它只支持预定义选项(optionId),不支持自由文本。`extMethod` 的参数和返回值都是 `Record<string, unknown>`,天然支持任意结构。
17
34
 
18
- | 方面 | requestPermission | extMethod("questionAsked") |
19
- |------|-------------------|---------------------------|
20
- | 返回值 | 只有 optionId | 自由结构,直接返回 answers 数组 |
21
- | 自由文本 | 不支持 | 天然支持 |
22
- | 多问题 | 需 hack 合并 | 一次性传递完整问题列表 |
23
- | 语义 | 扭曲 permission 语义 | 干净的 question 语义 |
35
+ | 方面 | requestPermission | extMethod("questionAsked") |
36
+ | -------- | -------------------- | ------------------------------- |
37
+ | 返回值 | 只有 optionId | 自由结构,直接返回 answers 数组 |
38
+ | 自由文本 | 不支持 | 天然支持 |
39
+ | 多问题 | 需 hack 合并 | 一次性传递完整问题列表 |
40
+ | 语义 | 扭曲 permission 语义 | 干净的 question 语义 |
24
41
 
25
42
  ## 完整流程
26
43
 
@@ -244,14 +261,14 @@ OpenCode → Question.Service.reject()
244
261
  await sdk.question.reply({
245
262
  requestID: "q_1",
246
263
  directory: "/path/to/project",
247
- answers: [["React"]] // QuestionAnswer = Array<string>
248
- })
264
+ answers: [["React"]], // QuestionAnswer = Array<string>
265
+ });
249
266
 
250
267
  // 拒绝
251
268
  await sdk.question.reject({
252
269
  requestID: "q_1",
253
- directory: "/path/to/project"
254
- })
270
+ directory: "/path/to/project",
271
+ });
255
272
  ```
256
273
 
257
274
  ## 序列化设计
@@ -278,10 +295,10 @@ question 2 到达 → questionQueues["ses_1"] = Promise<处理 Q1>.then(() =>
278
295
 
279
296
  ## 涉及的文件
280
297
 
281
- | 文件 | 改动 | 作用 |
282
- |------|------|------|
283
- | `src/event-handler.ts` | 新增 `question.asked` case + `questionQueues` | 拦截 SSE 事件、调用 extMethod、回复 OpenCode |
284
- | `scripts/view-log.mjs` | 新增 `question.asked/reply/rejected` 格式化 | 日志查看器展示问题流程 |
285
- | `vscode-acp/src/handlers/QuestionHandler.ts` | 新建 | VSCode 客户端问题 UI(QuickPick / InputBox) |
286
- | `vscode-acp/src/core/AcpClientImpl.ts` | 新增 `extMethod()` | 客户端 extMethod 路由 |
287
- | `vscode-acp/src/core/ConnectionManager.ts` | 创建 QuestionHandler | 注入 handler |
298
+ | 文件 | 改动 | 作用 |
299
+ | -------------------------------------------- | --------------------------------------------- | -------------------------------------------- |
300
+ | `src/event-handler.ts` | 新增 `question.asked` case + `questionQueues` | 拦截 SSE 事件、调用 extMethod、回复 OpenCode |
301
+ | `scripts/view-log.mjs` | 新增 `question.asked/reply/rejected` 格式化 | 日志查看器展示问题流程 |
302
+ | `vscode-acp/src/handlers/QuestionHandler.ts` | 新建 | VSCode 客户端问题 UI(QuickPick / InputBox) |
303
+ | `vscode-acp/src/core/AcpClientImpl.ts` | 新增 `extMethod()` | 客户端 extMethod 路由 |
304
+ | `vscode-acp/src/core/ConnectionManager.ts` | 创建 QuestionHandler | 注入 handler |
@@ -0,0 +1,217 @@
1
+ # 方案:将 Session 统计信息暴露给 harmony-acp 并呈现到 VSCode
2
+
3
+ > 日期:2026-04-29
4
+ > 状态:方案设计
5
+
6
+ ---
7
+
8
+ ## Context
9
+
10
+ OpenCode session 表已持久化 `summary_additions`/`summary_deletions`/`summary_files`/`summary_diffs`,SDK 类型已包含 `summary` 字段。但 harmony-acp 目前只通过 `usage_update` 发送 token/cost,未暴露代码行变更信息。
11
+
12
+ **目标:** VSCode 能实时/加载时看到 session 的代码修改统计(additions/deletions/files)。
13
+
14
+ ---
15
+
16
+ ## 架构决策:`usage_update._meta` 扩展
17
+
18
+ 选择 `usage_update._meta` 而非 `session_info_update`,理由:
19
+
20
+ 1. **语义一致** — 代码行变更与 token/cost 同属"资源消耗"维度,`usage_update` 即"这次消耗了什么"
21
+ 2. **减少事件流** — 不额外增加通知,VSCode 一个回调拿到所有统计,避免 UI 抖动
22
+ 3. **`_meta` 为扩展设计** — ACP 协议文档明确 `_meta` 用于附加数据
23
+ 4. **时序对齐** — token 和代码变更在同一事件中,无中间态
24
+ 5. **文件级 diff 已有通道** — edit tool 的 tool_call_update 已返回 diff,无需重复传输
25
+
26
+ **数据格式:**
27
+ ```typescript
28
+ usage_update._meta.codeChange = {
29
+ additions: number
30
+ deletions: number
31
+ files: number
32
+ }
33
+ ```
34
+
35
+ ---
36
+
37
+ ## 修改范围
38
+
39
+ 仅修改 harmony-acp,**不改 OpenCode、不改 ACP 协议**:
40
+
41
+ - OpenCode 已有 `session.diff` SSE 事件 + `EventSessionDiff` SDK 类型
42
+ - ACP `UsageUpdate._meta` 是 `{ [key: string]: unknown }` 扩展字段
43
+ - harmony-acp 已在 `_meta` 传递 `parentCost`/`childCost`,模式一致
44
+
45
+ ---
46
+
47
+ ## 数据流
48
+
49
+ ```
50
+ ┌─────────────────────────────────────────────────────────────────┐
51
+ │ OpenCode Server │
52
+ │ │
53
+ │ Session 完成时 → SSE 事件 session.diff {sessionID, diff[]} │
54
+ │ Session 加载时 → API session.get() 返回 summary 字段 │
55
+ └───────────────────────────┬─────────────────────────────────────┘
56
+ │ SSE / HTTP
57
+
58
+ ┌─────────────────────────────────────────────────────────────────┐
59
+ │ harmony-acp (EventHandler) │
60
+ │ │
61
+ │ 1. 监听 session.diff → 聚合 additions/deletions/files │
62
+ │ 2. 存入 diffStats Map<sessionID, CodeChangeStats> │
63
+ │ 3. loadSession 时从 session.summary 回填 │
64
+ │ 4. sendUsageUpdate() 时读取并附加到 _meta.codeChange │
65
+ └───────────────────────────┬─────────────────────────────────────┘
66
+ │ ACP JSON-RPC (usage_update)
67
+
68
+ ┌─────────────────────────────────────────────────────────────────┐
69
+ │ VSCode Extension │
70
+ │ │
71
+ │ usage_update._meta.codeChange = { additions, deletions, files }│
72
+ │ → 状态栏展示 "+123 -45 (3 files)" │
73
+ │ → Session 面板展示详细统计 │
74
+ └─────────────────────────────────────────────────────────────────┘
75
+ ```
76
+
77
+ ---
78
+
79
+ ## 实现步骤
80
+
81
+ ### Step 1: 新增 CodeChangeStats 类型
82
+
83
+ **文件:** `src/types.ts`
84
+
85
+ ```typescript
86
+ export interface CodeChangeStats {
87
+ additions: number
88
+ deletions: number
89
+ files: number
90
+ }
91
+ ```
92
+
93
+ ### Step 2: EventHandler 监听 `session.diff` 事件
94
+
95
+ **文件:** `src/event-handler.ts`
96
+
97
+ 在 `handleEvent()` 的 switch 中新增 `session.diff` 分支:
98
+
99
+ ```typescript
100
+ case "session.diff": {
101
+ const { sessionID, diff } = event.properties
102
+ const stats = diff.reduce(
103
+ (acc, d) => ({
104
+ additions: acc.additions + d.additions,
105
+ deletions: acc.deletions + d.deletions,
106
+ files: acc.files + 1,
107
+ }),
108
+ { additions: 0, deletions: 0, files: 0 },
109
+ )
110
+ this.diffStats.set(sessionID, stats)
111
+ return
112
+ }
113
+ ```
114
+
115
+ 新增实例变量和方法:
116
+ - `diffStats: Map<string, CodeChangeStats>` — 存储 session 级 diff 聚合
117
+ - `getDiffStats(sessionId)` — 供 agent.ts 读取
118
+ - `setDiffStats(sessionId, stats)` — 供 agent.ts 回填历史数据
119
+ - `clearDiffStats(sessionId)` — session 结束时清理
120
+
121
+ ### Step 3: `sendUsageUpdate()` 附加 codeChange
122
+
123
+ **文件:** `src/agent.ts` 的 `sendUsageUpdate()` 方法
124
+
125
+ 在现有 `_meta` 构造中增加 `codeChange` 字段:
126
+
127
+ ```typescript
128
+ const stats = this.eventHandler.getDiffStats(sessionId)
129
+ // 聚合子 session 的 diff
130
+ let childStats: CodeChangeStats | undefined
131
+ for (const childId of children) {
132
+ const cs = this.eventHandler.getDiffStats(childId)
133
+ if (cs) {
134
+ childStats = childStats
135
+ ? { additions: childStats.additions + cs.additions, deletions: childStats.deletions + cs.deletions, files: childStats.files + cs.files }
136
+ : cs
137
+ }
138
+ }
139
+
140
+ const totalStats = stats || childStats
141
+ ? {
142
+ additions: (stats?.additions ?? 0) + (childStats?.additions ?? 0),
143
+ deletions: (stats?.deletions ?? 0) + (childStats?.deletions ?? 0),
144
+ files: (stats?.files ?? 0) + (childStats?.files ?? 0),
145
+ }
146
+ : undefined
147
+
148
+ // 在 _meta 中附加
149
+ _meta: {
150
+ ...existingMeta,
151
+ ...totalStats && { codeChange: totalStats },
152
+ ...childStats && {
153
+ childCodeChange: childStats,
154
+ },
155
+ }
156
+ ```
157
+
158
+ ### Step 4: 加载历史 session 时读取 summary
159
+
160
+ **文件:** `src/agent.ts`
161
+
162
+ 在 `loadSession()` 和 `unstable_resumeSession()` 中:
163
+
164
+ 这两个方法已通过 `sdk.session.get()` 获取 session 数据。SDK 返回的 `Session` 类型包含 `summary?: { additions, deletions, files, diffs }`。
165
+
166
+ 从返回数据中提取 summary,存入 EventHandler 的 `diffStats` map:
167
+
168
+ ```typescript
169
+ const session = await this.sdk.session.get({ sessionID, directory })
170
+ if (session.data?.summary) {
171
+ this.eventHandler.setDiffStats(sessionID, {
172
+ additions: session.data.summary.additions,
173
+ deletions: session.data.summary.deletions,
174
+ files: session.data.summary.files,
175
+ })
176
+ }
177
+ ```
178
+
179
+ 这样已有的 `sendUsageUpdate()` 调用会自动携带历史统计数据。
180
+
181
+ ### Step 5: Session 清理
182
+
183
+ **文件:** `src/event-handler.ts`
184
+
185
+ 在子 session 完成(`sendChildSessionCompleted`)和 session 关闭时,清理 `diffStats` 中对应条目避免内存泄漏。
186
+
187
+ ---
188
+
189
+ ## 涉及文件
190
+
191
+ | 文件 | 修改内容 |
192
+ |------|---------|
193
+ | `src/types.ts` | 新增 `CodeChangeStats` 接口 |
194
+ | `src/event-handler.ts` | 新增 `session.diff` 事件处理 + `diffStats` map + get/set/clear 方法 |
195
+ | `src/agent.ts` | `sendUsageUpdate()` 附加 `_meta.codeChange`;`loadSession()`/`resumeSession()` 读取 `session.summary` |
196
+
197
+ ---
198
+
199
+ ## 依赖的 OpenCode 现有能力
200
+
201
+ | 能力 | 来源 | 说明 |
202
+ |------|------|------|
203
+ | `session.diff` SSE 事件 | OpenCode SDK `EventSessionDiff` | session 代码变更时实时推送 `SnapshotFileDiff[]` |
204
+ | `Session.summary` 字段 | OpenCode SDK `Session` 类型 | 含 `additions`/`deletions`/`files`/`diffs` |
205
+ | `UsageUpdate._meta` | ACP 协议 | `{ [key: string]: unknown }` 扩展字段 |
206
+ | 已有 `_meta` 扩展模式 | harmony-acp `sendUsageUpdate()` | 已传递 `parentCost`/`childCost`/`childSessionCount` |
207
+
208
+ ---
209
+
210
+ ## 验证
211
+
212
+ 1. 启动 OpenCode server(`bun dev serve`)
213
+ 2. 启动 harmony-acp 连接 VSCode
214
+ 3. 创建 session,让 agent 修改几个文件
215
+ 4. 检查 VSCode 收到的 `usage_update._meta.codeChange` 是否正确
216
+ 5. `loadSession()` 加载已有 session,验证历史 summary 也正确传递
217
+ 6. 创建含子 session 的任务,验证子 session 的 diff 也被聚合