magic-agent 1.0.0

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.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,1044 @@
1
+ package agent
2
+
3
+ // codebuddy.go - CodeBuddy Code CLI 引擎(codebuddy / codebuddy-ai 两个账号)。
4
+ //
5
+ // 两个引擎共用本文件的实现核心(codebuddyCore),差异只有三处:引擎名 / 账号 / 默认模型。
6
+ // 二者跑的是**同一个独立安装的 CLI**(npm i -g @tencent-ai/codebuddy-code),
7
+ // 不再使用桌面 App(WorkBuddy / WorkBuddy AI)内置的那一份 —— 那份的凭据由 App
8
+ // 通过 sidecar 通道用受管密钥静态加密,独立进程解不开(at-rest 登记表里
9
+ // read/write 全是 missing-key),引擎只能拿到空输出;见 engine_base.go 的
10
+ // codebuddyBase 注释。
11
+ //
12
+ // 账号怎么分开(一个 CLI 挂两个账号):
13
+ // CLI 的票据路径是 sharedDataPath/auth/<authentication.id>.info,而 authentication.id
14
+ // 的取值链里 **ACC_PRODUCT_CONFIG_V3(环境变量)优先于 CLI 包内的 product.json**。
15
+ // 给两个子进程注入不同的 id,就等于让它们各用各的账号、互不顶号(2026-09-28 实测)。
16
+ //
17
+ // codebuddy 账号 id codebuddy → auth/codebuddy.info
18
+ // codebuddy-ai 账号 id codebuddy-ai → auth/codebuddy-ai.info
19
+ //
20
+ // 非交互协议与 claude 同源(CodeBuddy Code v2.x)、flag 面一致;模型清单与积分倍率
21
+ // 走同一条扩展链(见 listModels)。
22
+ //
23
+ // 默认模型:codebuddy = hy3;codebuddy-ai 故意留空(交 CLI 自身默认,旧静态清单里的
24
+ // kimi-k3-1 等实测被国际后端 400 拒绝)。
25
+ //
26
+ // 非交互模式输出 envelope 实测有两种形态,解析需兼容:
27
+ //
28
+ // 1. 单对象:{"type":"result","result":"<md>",...}
29
+ // 2. 数组:[{...stream messages...}],扫描 type=="result" 或
30
+ // 逐条拼 assistant text content
31
+ //
32
+ // 关键 flags:
33
+ //
34
+ // --print --output-format json 单结果 envelope
35
+ // --model <m> 裸模型名(hy3 / glm-5.3 / ...)
36
+ // --tools "" 禁用全部工具
37
+ // -y 启用工具(--dangerously-skip-permissions)
38
+ // -r, --resume <id> 续接指定会话(Request.SessionID 非空时传)。
39
+ // -c, --continue 续接最近一次会话(Request.Continue)。
40
+ // 默认新会话不传 --no-session-persistence,
41
+ // 否则首轮会话不落盘、session_id 无法续接
42
+ // (与 claude 同款 "cannot be resumed" 语义)
43
+ // --append-system-prompt <s> 注入 system prompt(仅 off 模式附 noToolSuffix)
44
+ // --settings <json> 注入 {"env":{"CLAUDE_CODE_MAX_OUTPUT_TOKENS":"<n>"}}
45
+ // 实现 MaxTokens(codebuddy 无原生 max-tokens flag,
46
+ // 与 claude 同款 --settings 通道,见矩阵①)
47
+ //
48
+ // CLI 路径解析:显式 BinPath → MAGIC_AGENT_CODEBUDDY_BIN / MAGIC_AGENT_CODEBUDDY_AI_BIN
49
+ // → PATH 上的 `codebuddy` → npm 全局 bin 目录(探测链统一收敛在 engine_base.go 的
50
+ // cliBase)。**已不再探测桌面 App 包内的路径** —— 那是导致 missing-key 空输出的那份,
51
+ // 原因见 codebuddyBase 注释。
52
+ //
53
+ // ⚠️ 在 WorkBuddy 会话内调用时,父进程注入的 SERVER__PORT 会让 CLI 抢
54
+ // 父会话已监听的端口 → EADDRINUSE → 永久挂起。子进程环境由 env.go
55
+ // 的 denylist 统一剔除该类变量。
56
+
57
+ import (
58
+ "context"
59
+ "encoding/json"
60
+ "fmt"
61
+ "io"
62
+ "os"
63
+ "path/filepath"
64
+ "strings"
65
+ "time"
66
+ )
67
+
68
+ // codebuddyCore 两个 codebuddy 后端共享的实现核心。
69
+ // CodeBuddyEngine / CodeBuddyAIEngine 都是它的薄壳(见文件头)。
70
+ type codebuddyCore struct {
71
+ // name 引擎名:进错误消息、Response.Engine、agentSettingsPayload 的引擎键。
72
+ name string
73
+ // base 可执行文件探测链(engine_base.go 的 cliBase)。
74
+ base cliBase
75
+ // binPath 显式指定 CLI 路径(空 = 走 base 探测链)。测试注入用。
76
+ binPath string
77
+ // model 默认模型(空 = 不传 --model,交 CLI 自身默认)。
78
+ model string
79
+ // extraEnv 追加给子进程的环境变量(Go exec:重复 key 取最后 → 覆盖继承值)。
80
+ // 两个引擎都用它注入「配置目录 + 账号标识(ACC_PRODUCT_CONFIG_V3)」,
81
+ // 从而实现「同一个 CLI、两个账号」,见 codebuddyAccountEnv。
82
+ extraEnv []string
83
+ // extendedModelSources 启用扩展模型清单来源(两个 codebuddy 引擎均为 true):
84
+ // ① 桌面客户端合并配置缓存 acc-product-config(客户端模型选择器同源,
85
+ // 见 codebuddyAIAccConfigPath / codebuddyAccConfigPath)→ ② 远程配置缓存
86
+ // ∪ App 包 product.json → ③ --help。
87
+ extendedModelSources bool
88
+ // modelCacheDirs 远程配置缓存的 local_storage 目录(按优先级),两个用途:
89
+ // extendedModelSources=true 时是扩展清单链 ②;一律用于 modelCredits 的兜底 ②
90
+ // (见 codebuddyAIModelCacheDirs / codebuddyModelCacheDirs)。
91
+ modelCacheDirs []string
92
+ // accConfigPath 该引擎对应桌面客户端的合并产品配置缓存路径(构造时算好:
93
+ // codebuddyAIAccConfigPath / codebuddyAccConfigPath)。三个用途:
94
+ // extendedModelSources=true 时是扩展清单链 ①;一律用于 modelCredits 的首选 ①;
95
+ // 以及**自定义模型端点同步**的来源(见 custom_models.go)。
96
+ accConfigPath string
97
+ // configDir 该引擎自己的配置目录(codebuddyDir / codebuddyAIDir),
98
+ // 即 CLI 读 models.json 的地方 —— 自定义模型端点同步的落点。
99
+ // 用户显式设了 CODEBUDDY_CONFIG_DIR 时以那个为准(见 effectiveConfigDir)。
100
+ configDir string
101
+ }
102
+
103
+ // bin 探测 CLI 路径(委托 cliBase 统一探测链)。
104
+ func (c codebuddyCore) bin() string {
105
+ return c.base.resolve(c.binPath)
106
+ }
107
+
108
+ // detect 返回(是否可用,说明)。
109
+ func (c codebuddyCore) detect() (bool, string) {
110
+ p := c.base.resolve(c.binPath)
111
+ if p == "" {
112
+ return false, c.base.notFound
113
+ }
114
+ if c.model == "" {
115
+ return true, p
116
+ }
117
+ return true, p + " (default model: " + c.model + ")"
118
+ }
119
+
120
+ // selectorModels 返回客户端模型选择器的显式清单(agents[].models),取自优先级最高的
121
+ // 远程配置缓存目录;没有则返回 nil(**无权威清单** → acc 缓存不过滤,宁可多不可少)。
122
+ func (c codebuddyCore) selectorModels() []string {
123
+ for _, dir := range c.modelCacheDirs {
124
+ if ids, ok := agentSelectorModels(dir); ok {
125
+ return ids
126
+ }
127
+ }
128
+ return nil
129
+ }
130
+
131
+ // extendedModelCatalog 扩展来源链(两个 codebuddy 引擎均启用):返回(模型清单, 积分倍率表, 是否命中)。
132
+ //
133
+ // ① 客户端合并配置缓存 acc-product-config(注册表),按**客户端选择器清单**
134
+ //
135
+ // agents[].models 过滤(见 agentSelectorModels / readAccCatalog)
136
+ //
137
+ // ② 远程配置缓存 ∪ App 包 product.json(超集近似,含国内后端条目)
138
+ //
139
+ // 命中 ① 时 ② 不再叠加 —— ① 就是客户端所见,别无二义。倍率表与清单同源同链。
140
+ func (c codebuddyCore) extendedModelCatalog(bin string) ([]string, map[string]string, bool) {
141
+ if ids, credits := readAccCatalog(c.accConfigPath, c.selectorModels()); len(ids) > 0 {
142
+ return ids, credits, true
143
+ }
144
+ var ids []string
145
+ credits := map[string]string{}
146
+ for _, dir := range c.modelCacheDirs {
147
+ i, cr := remoteConfigCacheCatalog(dir)
148
+ ids = append(ids, i...)
149
+ for k, v := range cr {
150
+ if _, dup := credits[k]; !dup {
151
+ credits[k] = v
152
+ }
153
+ }
154
+ }
155
+ i, cr := readProductJSONCatalog(productJSONPath(bin))
156
+ ids = append(ids, i...)
157
+ for k, v := range cr {
158
+ if _, dup := credits[k]; !dup {
159
+ credits[k] = v
160
+ }
161
+ }
162
+ ids = dedupeModels(ids)
163
+ if len(ids) == 0 {
164
+ return nil, nil, false
165
+ }
166
+ return ids, credits, true
167
+ }
168
+
169
+ // modelCredits 返回各模型的**积分倍率表**(model → 规范化数字字符串,如 "0.34",
170
+ // 源自客户端 "x0.34 credits")。来源链与 extendedModelCatalog 完全一致
171
+ // (① acc 缓存 → ② 远程配置缓存 ∪ ③ App 包 product.json),但**只取倍率、
172
+ // 不改变模型清单来源**:两个引擎的清单都走同一条扩展链(见 listModels),
173
+ // 倍率表与清单同源同链、按 model id 对上。
174
+ func (c codebuddyCore) modelCredits(bin string) map[string]string {
175
+ // ① 客户端合并配置缓存(与 extendedModelCatalog 同源同语义:**有模型即钉死**,
176
+ // 哪怕一条倍率都没有也不穿透 ②③ 去混别处的倍率 —— 客户端不给就是不显示)
177
+ if ids, credits := readAccCatalog(c.accConfigPath, c.selectorModels()); len(ids) > 0 {
178
+ if len(credits) == 0 {
179
+ return nil
180
+ }
181
+ return credits
182
+ }
183
+ // ② 远程配置缓存(按优先级,先到先得)∪ ③ App 包 product.json
184
+ credits := map[string]string{}
185
+ for _, dir := range c.modelCacheDirs {
186
+ _, cr := remoteConfigCacheCatalog(dir)
187
+ for k, v := range cr {
188
+ if _, dup := credits[k]; !dup {
189
+ credits[k] = v
190
+ }
191
+ }
192
+ }
193
+ _, cr := readProductJSONCatalog(productJSONPath(bin))
194
+ for k, v := range cr {
195
+ if _, dup := credits[k]; !dup {
196
+ credits[k] = v
197
+ }
198
+ }
199
+ if len(credits) == 0 {
200
+ return nil
201
+ }
202
+ return credits
203
+ }
204
+
205
+ // listModels 实现 ModelLister 的共享逻辑。
206
+ //
207
+ // 两个引擎**同一条三级来源链**:
208
+ //
209
+ // ① 客户端合并配置缓存 acc-product-config(客户端模型选择器同源)
210
+ // ② 远程配置缓存 ∪ App 包 product.json(超集近似)
211
+ // ③ --help 里 --model 描述自带的 "Currently supported: (...)" 清单
212
+ //
213
+ // 命中 ① 时 ② 不再叠加 —— ① 就是客户端所见,别无二义。
214
+ //
215
+ // 实测(2026-09-21 / 2026-09-28):
216
+ //
217
+ // - AI 端 acc 缓存 27 条(23 预制 + 4 custom-local,含 deepseek-v4.1-flash /
218
+ // gpt-5.6-*);远程配置缓存(52 条)混着国内后端条目但没有 gpt;product.json
219
+ // (26 条)没有 deepseek-v4.1-flash;--help 只有 4 个分层别名。
220
+ // - WorkBuddy 端 acc 缓存 61 条(53 预制 + 8 custom-local)。**--help 只有 17 条**
221
+ // (9 预制 + 全部 8 个 custom-local)—— 官方预制只放行 9 个、且恰好不是客户端
222
+ // 主力模型(hy3 / hy4-preview / deepseek-v4.1-flash / glm-5.3 全部不在内)。
223
+ // 故 2026-09-28 起两端一致走扩展链:此前 codebuddy 只按 --help 出清单、倍率却按
224
+ // acc 缓存取,同一引擎两个字段读自两个文件,`model_credits` 里能查到倍率而
225
+ // `models` 里查不到该模型。
226
+ //
227
+ // ⚠️ --help 路径必须带 extraEnv:清单由登录后端下发,跟随配置目录。不带隔离
228
+ // 环境时 codebuddy-ai 会读到共享 ~/.codebuddy 里 WorkBuddy 后端的清单。
229
+ func (c codebuddyCore) listModels(ctx context.Context) ([]string, error) {
230
+ bin := c.bin()
231
+ if bin == "" {
232
+ return nil, fmt.Errorf("%s CLI not found", c.name)
233
+ }
234
+ // 顺带补自定义模型端点(幂等,见 syncCustomModelEndpoints):
235
+ // 清单与端点同源,只列清单不同步就会「选得出来、调用报没有 url」。
236
+ c.syncCustomModelEndpoints()
237
+ if c.extendedModelSources {
238
+ // ① 客户端合并配置缓存(客户端模型选择器同源)→ ② 远程配置缓存 ∪
239
+ // App 包 product.json(超集近似)→ 都没有时落 ③ --help(见函数头)。
240
+ if ids, _, ok := c.extendedModelCatalog(bin); ok {
241
+ return ids, nil
242
+ }
243
+ }
244
+ stdout, stderr, err := runCLIEnvIn(ctx, "", c.extraEnv, bin, "--help")
245
+ if err != nil {
246
+ return nil, wrapCliError(c.name, stdout, stderr, err)
247
+ }
248
+ models := parseCodebuddyHelpModels(stdout)
249
+ if len(models) == 0 {
250
+ return nil, fmt.Errorf("%w: --help has no \"Currently supported\" list", ErrNoModelSource)
251
+ }
252
+ return models, nil
253
+ }
254
+
255
+ // syncCustomModelEndpoints 顺带把 App 里配好的自定义模型端点补进 CLI 的 models.json。
256
+ //
257
+ // 为什么放在 listModels:清单本身来自同一份 acc 缓存,**只列清单不同步端点**就会出现
258
+ // 「模型选择器里列得出来、选了就报 has no endpoint url configured」。
259
+ // best-effort(错误忽略):同步失败不该让模型探测失败 —— 官方模型根本不读 models.json。
260
+ func (c codebuddyCore) syncCustomModelEndpoints() {
261
+ _, _ = syncCustomModelsFromApp(c.accConfigPath, c.effectiveConfigDir())
262
+ }
263
+
264
+ // complete 单次调用的共享逻辑。
265
+ func (c codebuddyCore) complete(ctx context.Context, req Request) (Response, error) {
266
+ start := time.Now()
267
+ bin := c.bin()
268
+ if bin == "" {
269
+ return Response{}, fmt.Errorf("%s CLI not found; set %s", c.name, c.base.envVar)
270
+ }
271
+ // 自定义模型才补端点(用户可能刚在 App 里配好就直接 -m 调用,没打开过选择器)。
272
+ c.ensureCustomModelEndpoint(req.Model)
273
+
274
+ // 附件(截图):与 claude 同族,--print 文本模式收不了图,走
275
+ // --input-format stream-json(图片 content block 经 stdin)。
276
+ if hasImageAttachment(req.Attachments) {
277
+ return c.completeWithAttachments(ctx, req, start)
278
+ }
279
+
280
+ args := c.buildArgs(req)
281
+
282
+ // workspace:codebuddy 与 claude 同族(无工作目录 flag,--add-dir 只加额外目录)
283
+ stdout, stderr, err := runCLIEnvIn(ctx, req.Workspace, c.extraEnv, bin, args...)
284
+ if err != nil {
285
+ return Response{}, wrapCliError(c.name, stdout, stderr, err)
286
+ }
287
+
288
+ raw := strings.TrimSpace(stdout)
289
+ if raw == "" {
290
+ return Response{}, fmt.Errorf("%s CLI returned empty output", c.name)
291
+ }
292
+
293
+ text := extractCodeBuddyResult(raw)
294
+
295
+ // 兜底:个别版本会把 user 请求回显(<user_query>…</user_query>)当正文。
296
+ if cleaned := stripUserQueryEcho(text); cleaned != text {
297
+ text = cleaned
298
+ }
299
+ if strings.TrimSpace(text) == "" {
300
+ return Response{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
301
+ }
302
+
303
+ return Response{
304
+ Text: text,
305
+ Model: req.Model,
306
+ SessionID: extractCodeBuddySession(raw),
307
+ Latency: time.Since(start),
308
+ }, nil
309
+ }
310
+
311
+ // completeWithAttachments 走 stream-json 输入通道跑一次带附件的调用
312
+ // (codebuddy 与 claude 同族协议),把事件归约成单次 Response。
313
+ func (c codebuddyCore) completeWithAttachments(ctx context.Context, req Request, start time.Time) (Response, error) {
314
+ bin := c.bin()
315
+ prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
316
+ if prompt == "" {
317
+ return Response{}, fmt.Errorf("%s: empty prompt", c.name)
318
+ }
319
+ args := streamJSONArgs(c.buildArgsBase(req), prompt, false)
320
+ stdin, err := streamJSONUserLine(prompt, req.Attachments)
321
+ if err != nil {
322
+ return Response{}, err
323
+ }
324
+ acc := &streamAccumulator{Engine: c.name, OnSessionID: req.OnSessionID}
325
+ var fin streamJSONResult
326
+ seen, err := runStreamJSONIn(ctx, req.Workspace, c.extraEnv, bin, args, strings.NewReader(stdin), acc, &fin)
327
+ if err != nil {
328
+ return Response{}, wrapCliError(c.name, "", "", err)
329
+ }
330
+ if !seen {
331
+ return Response{}, fmt.Errorf("%s CLI stream ended without result line", c.name)
332
+ }
333
+ if fin.IsError {
334
+ return Response{}, fmt.Errorf("%s CLI error (subtype=%s): %s", c.name, fin.Subtype, truncateStr(fin.Result, 500))
335
+ }
336
+ text := stripUserQueryEcho(finalizeStreamText(fin, acc))
337
+ if strings.TrimSpace(text) == "" {
338
+ return Response{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
339
+ }
340
+ return Response{
341
+ Text: text,
342
+ Model: fin.Model,
343
+ SessionID: fin.SessionID,
344
+ Latency: time.Since(start),
345
+ }, nil
346
+ }
347
+
348
+ // buildArgs 构造 codebuddy CLI 参数(Complete 与 Stream 共用)。
349
+ func (c codebuddyCore) buildArgs(req Request) []string {
350
+ return append(c.buildArgsBase(req), FlattenPrompt(req.SystemPrompt, req.Messages, false))
351
+ }
352
+
353
+ // buildArgsBase 构造参数(不含末尾的位置参数 prompt)——附件场景复用:
354
+ // 那条路提示词走 stdin,不能再作为命令行参数传。
355
+ func (c codebuddyCore) buildArgsBase(req Request) []string {
356
+ args := []string{"--print", "--output-format", "json"}
357
+ switch {
358
+ case req.SessionID != "":
359
+ // 续接指定会话(commander 风格空格传值)。默认新会话不传
360
+ // --no-session-persistence:不落盘的会话无法续接。
361
+ args = append(args, "--resume", req.SessionID)
362
+ case req.Continue:
363
+ // 续接最近一次会话(无需 id)。
364
+ args = append(args, "--continue")
365
+ }
366
+ // 工具模式决定两件事:CLI 侧的工具白名单,以及 system prompt 是否
367
+ // 追加 noToolSuffix。
368
+ //
369
+ // ⚠️ noToolSuffix 只在 off 模式注入。它明文写着「严禁使用任何工具」,
370
+ // 与 on / 白名单模式的目标直接冲突 —— 之前无条件注入,导致
371
+ // `--tools on` 表面开了工具、system prompt 却在压制模型调用,
372
+ // 表现为「工具启用无效」。enable 模式下必须让 system prompt 干净。
373
+ tools := toolsOrDefault(req.Tools)
374
+ switch {
375
+ case tools.IsOff():
376
+ // 关工具:不仅 CLI 侧禁掉,system prompt 也要堵住模型「伪工具调用」
377
+ // (实测 off 且不注入时,模型会输出 <tool_calls:xxxx> 这类假标签)。
378
+ args = append(args, "--tools", "")
379
+ args = append(args, "--append-system-prompt", noToolSuffix)
380
+ case tools.IsOn():
381
+ // 四档权限档位:codebuddy 与 claude 同族,--permission-mode 取值完全一致
382
+ //(default / acceptEdits / auto / bypassPermissions),故复用同一映射。
383
+ // 改造前这里恒传 -y(= --dangerously-skip-permissions,第 4 档)。
384
+ args = append(args, "--permission-mode", claudePermissionMode(req.Permission))
385
+ default:
386
+ args = append(args, "--tools", strings.Join(tools.Allowlist(), ","),
387
+ "--permission-mode", claudePermissionMode(req.Permission))
388
+ }
389
+ if m := stripModelPrefix(req.Model); m != "" {
390
+ args = append(args, "--model", m)
391
+ } else if c.model != "" {
392
+ // 默认模型(codebuddy=hy3;codebuddy-ai 故意留空 = CLI 自身默认,
393
+ // 见文件头的模型注册表漂移说明);空串 = 不传 --model。
394
+ args = append(args, "--model", c.model)
395
+ }
396
+ // 统一参数矩阵:`--settings` 只接受一份载荷,故 MaxTokens 的 env 注入与
397
+ // 四档模型的 sandbox / autoMode / permissions 合并进同一个 JSON。
398
+ // 注意 codebuddy 的沙箱键集与 claude 略有差异(文档未列 failIfUnavailable),
399
+ // 由 sandboxSettings 按引擎区分(codebuddy / codebuddy-ai 同族同语义)。
400
+ if payload, ok := agentSettingsPayload(req, c.name); ok {
401
+ args = append(args, "--settings", payload)
402
+ }
403
+ return args
404
+ }
405
+
406
+ // stream 流式调用的共享逻辑。
407
+ // 协议与 claude 同源(CodeBuddy Code 系 stream-json)。
408
+ func (c codebuddyCore) stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
409
+ start := time.Now()
410
+ bin := c.bin()
411
+ if bin == "" {
412
+ return StreamResult{}, fmt.Errorf("%s CLI not found; set %s", c.name, c.base.envVar)
413
+ }
414
+ // 自定义模型才补端点(同 complete,见 custom_models.go)。
415
+ c.ensureCustomModelEndpoint(req.Model)
416
+
417
+ args := c.buildArgs(req)
418
+ if req.Append != nil || hasImageAttachment(req.Attachments) {
419
+ // 附件 / 常驻会话 → stream-json 输入通道:提示词改走 stdin(见 streamjson.go)。
420
+ prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
421
+ var stdin io.Reader
422
+ if req.Append != nil {
423
+ pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
424
+ if perr != nil {
425
+ return StreamResult{}, perr
426
+ }
427
+ stdin = pipe
428
+ } else {
429
+ line, serr := streamJSONUserLine(prompt, req.Attachments)
430
+ if serr != nil {
431
+ return StreamResult{}, serr
432
+ }
433
+ stdin = strings.NewReader(line)
434
+ }
435
+ args = streamJSONArgs(c.buildArgsBase(req), prompt, true)
436
+ acc := &streamAccumulator{Engine: c.name, OnEvent: onEvent, OnSessionID: req.OnSessionID}
437
+ var fin streamJSONResult
438
+ seen, err := runStreamJSONIn(ctx, req.Workspace, c.extraEnv, bin, args, stdin, acc, &fin)
439
+ if err != nil {
440
+ return StreamResult{}, err
441
+ }
442
+ if !seen {
443
+ return StreamResult{}, fmt.Errorf("%s CLI stream ended without result line", c.name)
444
+ }
445
+ if fin.IsError {
446
+ return StreamResult{}, fmt.Errorf("%s CLI error (subtype=%s): %s", c.name, fin.Subtype, truncateStr(fin.Result, 500))
447
+ }
448
+ text := stripUserQueryEcho(finalizeStreamText(fin, acc))
449
+ if strings.TrimSpace(text) == "" {
450
+ return StreamResult{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
451
+ }
452
+ return StreamResult{
453
+ Response: Response{
454
+ Engine: c.name,
455
+ Text: text,
456
+ Model: fin.Model,
457
+ SessionID: fin.SessionID,
458
+ Latency: time.Since(start),
459
+ },
460
+ Thinking: acc.Thinking.String(),
461
+ Tools: acc.Tools,
462
+ }, nil
463
+ }
464
+ for i := range args {
465
+ if args[i] == "--output-format" {
466
+ args[i+1] = "stream-json"
467
+ break
468
+ }
469
+ }
470
+ args = append(args, "--include-partial-messages", "--verbose")
471
+
472
+ acc := &streamAccumulator{Engine: c.name, OnEvent: onEvent}
473
+ var fin struct {
474
+ Type string `json:"type"`
475
+ Subtype string `json:"subtype"`
476
+ IsError bool `json:"is_error"`
477
+ Result string `json:"result"`
478
+ SessionID string `json:"session_id"`
479
+ Model string `json:"model"`
480
+ }
481
+ seenResult := false
482
+
483
+ err := runStreamCLIEnvIn(ctx, req.Workspace, c.extraEnv, bin, args, func(line string) error {
484
+ isResult, perr := acc.handleNDJSONLine(line)
485
+ if perr != nil {
486
+ return perr
487
+ }
488
+ if isResult {
489
+ _ = json.Unmarshal([]byte(line), &fin)
490
+ seenResult = true
491
+ }
492
+ return nil
493
+ })
494
+ if err != nil {
495
+ return StreamResult{}, err
496
+ }
497
+ if !seenResult {
498
+ return StreamResult{}, fmt.Errorf("%s CLI stream ended without result line", c.name)
499
+ }
500
+ if fin.IsError {
501
+ return StreamResult{}, fmt.Errorf("%s CLI error (subtype=%s): %s", c.name, fin.Subtype, truncateStr(fin.Result, 500))
502
+ }
503
+
504
+ text := stripUserQueryEcho(fin.Result)
505
+ if strings.TrimSpace(text) == "" {
506
+ text = strings.TrimSpace(acc.Text.String())
507
+ }
508
+ if strings.TrimSpace(text) == "" {
509
+ return StreamResult{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
510
+ }
511
+ return StreamResult{
512
+ Response: Response{
513
+ Engine: c.name,
514
+ Text: text,
515
+ Model: fin.Model,
516
+ SessionID: fin.SessionID,
517
+ Latency: time.Since(start),
518
+ },
519
+ Thinking: acc.Thinking.String(),
520
+ Tools: acc.Tools,
521
+ }, nil
522
+ }
523
+
524
+ // ── codebuddy:主账号 ────────────────────────────────────────
525
+
526
+ // CodeBuddyEngine 通过**独立安装**的 CodeBuddy Code CLI 实现 Engine。
527
+ // 账号标识 "codebuddy" → 票据 sharedDataPath/auth/codebuddy.info。
528
+ type CodeBuddyEngine struct {
529
+ // BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
530
+ BinPath string
531
+ }
532
+
533
+ // Name 实现 Engine。
534
+ func (e *CodeBuddyEngine) Name() string { return "codebuddy" }
535
+
536
+ // DefaultCodeBuddyTimeout 单次尝试默认超时(codebuddy / codebuddy-ai 共用)。
537
+ const DefaultCodeBuddyTimeout = 5 * time.Minute
538
+
539
+ // DefaultCodeBuddyModel codebuddy 引擎的默认模型。
540
+ // 未显式 -m 指定时使用 hy3(与 magic-video 的 DefaultCreativeModel 一致)。
541
+ const DefaultCodeBuddyModel = "hy3"
542
+
543
+ // core 返回共享实现核心。
544
+ // 扩展清单链(extendedModelSources)启用:与 codebuddy-ai 同源同链,清单按
545
+ // acc 缓存(客户端模型选择器真实数据源)→ 远程配置缓存 ∪ product.json → --help;
546
+ // 积分倍率走 modelCredits(同一条链,见 codebuddyAccConfigPath)。
547
+ func (e *CodeBuddyEngine) core() codebuddyCore {
548
+ return codebuddyCore{
549
+ name: "codebuddy",
550
+ base: codebuddyBase,
551
+ binPath: e.BinPath,
552
+ model: DefaultCodeBuddyModel,
553
+ extraEnv: codebuddyAccountEnv(codebuddyDir(), codebuddyAuthID, codebuddyAuthIDEnv),
554
+ extendedModelSources: true,
555
+ accConfigPath: codebuddyAccConfigPath(),
556
+ modelCacheDirs: codebuddyModelCacheDirs(),
557
+ configDir: codebuddyDir(),
558
+ }
559
+ }
560
+
561
+ // bin 探测 codebuddy CLI 路径。
562
+ func (e *CodeBuddyEngine) bin() string { return e.core().bin() }
563
+
564
+ // Detect 实现 Engine。
565
+ func (e *CodeBuddyEngine) Detect() (bool, string) { return e.core().detect() }
566
+
567
+ // LoginCommand 实现 LoginRunner:拉起交互式会话(进去执行 /login)。
568
+ // 带上本引擎的账号环境,确保登录结果落进本引擎用的票据 auth/codebuddy.info。
569
+ func (e *CodeBuddyEngine) LoginCommand() (string, []string, []string, error) {
570
+ bin := e.bin()
571
+ if bin == "" {
572
+ return "", nil, nil, fmt.Errorf("%s CLI not found; set %s", e.Name(), codebuddyBase.envVar)
573
+ }
574
+ return bin, nil, codebuddyAccountEnv(codebuddyDir(), codebuddyAuthID, codebuddyAuthIDEnv), nil
575
+ }
576
+
577
+ // ListModels 实现 ModelLister。
578
+ func (e *CodeBuddyEngine) ListModels(ctx context.Context) ([]string, error) {
579
+ return e.core().listModels(ctx)
580
+ }
581
+
582
+ // ModelCredits 实现 ModelCreditLister:各模型的积分倍率(规范化数字字符串,
583
+ // 如 "0.34",源自客户端 "x0.34 credits")。**只取倍率表,清单不动** ——
584
+ // codebuddy 的模型清单仍按 --help(见 listModels);倍率来源链与 codebuddy-ai
585
+ // 一致:acc 缓存(~/.workbuddy)→ 远程配置缓存 ∪ product.json。清单里某模型
586
+ // 没有倍率数据时自然查不到(UI 不显示);完全无数据返回 nil(CLI 端省略字段)。
587
+ func (e *CodeBuddyEngine) ModelCredits(ctx context.Context) map[string]string {
588
+ bin := e.bin()
589
+ if bin == "" {
590
+ return nil
591
+ }
592
+ return e.core().modelCredits(bin)
593
+ }
594
+
595
+ // Complete 实现 Engine:单次调用 codebuddy CLI。
596
+ func (e *CodeBuddyEngine) Complete(ctx context.Context, req Request) (Response, error) {
597
+ return e.core().complete(ctx, req)
598
+ }
599
+
600
+ // Stream 实现 Streamer。
601
+ func (e *CodeBuddyEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
602
+ return e.core().stream(ctx, req, onEvent)
603
+ }
604
+
605
+ // buildArgs 构造 codebuddy CLI 参数。
606
+ func (e *CodeBuddyEngine) buildArgs(req Request) []string { return e.core().buildArgs(req) }
607
+
608
+ // buildArgsBase 构造参数(不含末尾的位置参数 prompt)。
609
+ func (e *CodeBuddyEngine) buildArgsBase(req Request) []string { return e.core().buildArgsBase(req) }
610
+
611
+ // ── codebuddy-ai:第二个账号 ─────────────────────────────────
612
+
613
+ // DefaultCodeBuddyAIModel codebuddy-ai 引擎的默认模型。
614
+ //
615
+ // 故意留空:国际站账号的模型注册表与静态 --help 清单有漂移(--help 里的
616
+ // kimi-k3-1 / deepseek-v4-pro 实测被后端 400 拒绝),强制指定 hy3 有同款风险,
617
+ // 故默认不传 --model、交 CLI 自身默认;-m 仍可显式指定。
618
+ const DefaultCodeBuddyAIModel = ""
619
+
620
+ // CodeBuddyAIEngine 与 CodeBuddyEngine 用**同一个**(独立安装的)CodeBuddy Code CLI,
621
+ // 只是账号不同:account id "codebuddy-ai" → 票据 sharedDataPath/auth/codebuddy-ai.info。
622
+ // 协议 / flag 面完全一致。
623
+ type CodeBuddyAIEngine struct {
624
+ // BinPath 显式指定 CLI 路径(空 = 自动探测)。测试注入用。
625
+ BinPath string
626
+ }
627
+
628
+ // Name 实现 Engine。
629
+ func (e *CodeBuddyAIEngine) Name() string { return "codebuddy-ai" }
630
+
631
+ // core 返回共享实现核心。
632
+ func (e *CodeBuddyAIEngine) core() codebuddyCore {
633
+ return codebuddyCore{
634
+ name: "codebuddy-ai",
635
+ base: codebuddyAIBase,
636
+ binPath: e.BinPath,
637
+ model: DefaultCodeBuddyAIModel,
638
+ extraEnv: codebuddyAIExtraEnv(),
639
+ extendedModelSources: true,
640
+ modelCacheDirs: codebuddyAIModelCacheDirs(),
641
+ accConfigPath: codebuddyAIAccConfigPath(),
642
+ configDir: codebuddyAIDir(),
643
+ }
644
+ }
645
+
646
+ // workbuddyAccConfigPath 返回 WorkBuddy 系桌面客户端的合并产品配置缓存路径
647
+ // (appHome = 客户端数据目录名:".workbuddy-ai" / ".workbuddy")。
648
+ //
649
+ // 桌面客户端把「product.json ∪ 网关远程配置 ∪ 用户自定义模型」合并后的运行时
650
+ // 配置缓存在 <数据目录>/cache/acc-product-config-v*.json,与 product.json 同构
651
+ // (models[].id + models[].credits)—— 这是客户端模型选择器的真实数据源。
652
+ // 文件名带版本号(当前 v3):glob 全部版本取 mtime 最新;客户端从未运行过时
653
+ // 文件不存在,返回 ""(调用方回退各自的下一条来源)。
654
+ //
655
+ // 两端同构不同目录:codebuddy-ai → ~/.workbuddy-ai(WorkBuddy AI App);
656
+ // codebuddy → ~/.workbuddy(WorkBuddy App,实测 2026-09-22 同样有 v3 缓存:
657
+ // 58 个模型 33 条倍率,含 hy3 x0.00 —— codebuddy 倍率的首选来源)。
658
+ func workbuddyAccConfigPath(appHome string) string {
659
+ home, err := os.UserHomeDir()
660
+ if err != nil || home == "" {
661
+ home = os.Getenv("HOME")
662
+ }
663
+ hits, err := filepath.Glob(filepath.Join(home, appHome, "cache", "acc-product-config-v*.json"))
664
+ if err != nil || len(hits) == 0 {
665
+ return ""
666
+ }
667
+ best := ""
668
+ var bestMod time.Time
669
+ for _, p := range hits {
670
+ fi, statErr := os.Stat(p)
671
+ if statErr != nil {
672
+ continue
673
+ }
674
+ if best == "" || fi.ModTime().After(bestMod) {
675
+ best, bestMod = p, fi.ModTime()
676
+ }
677
+ }
678
+ return best
679
+ }
680
+
681
+ // codebuddyAIAccConfigPath 返回 AI 桌面客户端(~/.workbuddy-ai)的合并产品配置
682
+ // 缓存路径。实测 2026-09-21:27 条 = 23 预制 + 4 custom-local,含
683
+ // deepseek-v4.1-flash 与 gpt-5.6-sol/terra/luna、gpt-6-astra 等静态 product.json
684
+ // 里还没有的新模型;endpoint=www.workbuddy.ai、daemon 持续刷新。
685
+ func codebuddyAIAccConfigPath() string {
686
+ return workbuddyAccConfigPath(".workbuddy-ai")
687
+ }
688
+
689
+ // codebuddyAccConfigPath 返回 WorkBuddy 桌面客户端(非 AI,~/.workbuddy)的
690
+ // 合并产品配置缓存路径 —— codebuddy 引擎积分倍率的首选来源。
691
+ func codebuddyAccConfigPath() string {
692
+ return workbuddyAccConfigPath(".workbuddy")
693
+ }
694
+
695
+ // codebuddyAIModelCacheDirs 返回 codebuddy-ai 模型清单的远程配置缓存目录(按优先级)。
696
+ //
697
+ // 隔离目录优先;共享 ~/.codebuddy 兜底 —— 桌面 App daemon 仍在共享目录运行,
698
+ // 会把网关下发的远程配置持续刷在那里(实测 2026-09-21:隔离 CLI 自身拉
699
+ // /v3/config 全部 400,deepseek-v4.1-flash 所在的远程配置缓存只存在于共享目录)。
700
+ // 只读缓存文件,不触碰凭据。用户显式设置 CODEBUDDY_CONFIG_DIR 时只信该目录。
701
+ func codebuddyAIModelCacheDirs() []string {
702
+ dirs := []string{filepath.Join(codebuddyAIDir(), "local_storage")}
703
+ if os.Getenv("CODEBUDDY_CONFIG_DIR") != "" {
704
+ return dirs
705
+ }
706
+ home, err := os.UserHomeDir()
707
+ if err != nil || home == "" {
708
+ home = os.Getenv("HOME")
709
+ }
710
+ shared := filepath.Join(home, ".codebuddy", "local_storage")
711
+ if shared != dirs[0] {
712
+ dirs = append(dirs, shared)
713
+ }
714
+ return dirs
715
+ }
716
+
717
+ // codebuddyModelCacheDirs 返回 codebuddy(非 ai)远程配置缓存目录,**按可信度排序**。
718
+ // 引擎自身不注入 CODEBUDDY_CONFIG_DIR;用户显式设置时只信该目录(与 CLI 的实际
719
+ // 读取一致)。只读缓存文件,不触碰凭据。
720
+ //
721
+ // ⚠️ 首选 `~/.workbuddy/local_storage`(**与 acc 缓存同源**,客户端当前账号正在
722
+ // 持续刷它,实测 10:48 更新),共享 `~/.codebuddy/local_storage` 只作兜底:那里
723
+ // 可能还留着**别的账号**的旧条目(实测同一份 d43e… 缓存里 userId=f231e9af 的
724
+ // 清单是 hy4-preview-f、ed6c16d4 的是 hy4-preview,mtime 相同)。选择器清单按本
725
+ // 目录优先级取「mtime 最新那一条」,所以目录顺序 = 账号可信度顺序,错了会把
726
+ // 上一账号的型号混进 -m 清单。
727
+ func codebuddyModelCacheDirs() []string {
728
+ if dir := os.Getenv("CODEBUDDY_CONFIG_DIR"); dir != "" {
729
+ return []string{filepath.Join(dir, "local_storage")}
730
+ }
731
+ home, err := os.UserHomeDir()
732
+ if err != nil || home == "" {
733
+ home = os.Getenv("HOME")
734
+ }
735
+ own := filepath.Join(home, ".workbuddy", "local_storage")
736
+ shared := filepath.Join(home, ".codebuddy", "local_storage")
737
+ if shared != own {
738
+ return []string{own, shared}
739
+ }
740
+ return []string{own}
741
+ }
742
+
743
+ // codebuddyAIDir codebuddy-ai 引擎的专用配置目录(~/.codebuddy-ai)。
744
+ //
745
+ // 注意:**账号**的隔离靠 authentication.id(见 codebuddyAuthID / codebuddyAIAuthID),
746
+ // 与配置目录无关 —— 票据文件是共享的 sharedDataPath/auth/<id>.info。
747
+ // 这里分目录是为了让两个引擎的 settings / keyblob / 会话 / 插件缓存各归各的,
748
+ // 避免互相覆盖(历史上共享目录时出现过登录态被顶)。
749
+ func codebuddyAIDir() string {
750
+ home, err := os.UserHomeDir()
751
+ if err != nil || home == "" {
752
+ home = os.Getenv("HOME")
753
+ }
754
+ return filepath.Join(home, ".codebuddy-ai")
755
+ }
756
+
757
+ // ── 账号隔离:一个 CLI,两个账号 ─────────────────────────────
758
+ //
759
+ // CLI 的票据路径由 authentication.id 决定:
760
+ //
761
+ // sharedDataPath/auth/<authentication.id>.info (sharedDataPath 与配置目录无关)
762
+ //
763
+ // 而 authentication.id 的取值链是:
764
+ //
765
+ // ① 合并后的产品配置 → ② ACC_PRODUCT_CONFIG_PATH 指向的文件
766
+ // → ③ ACC_PRODUCT_CONFIG_V3 / _V2(环境变量里的 JSON)→ ④ CLI 包内 product.json
767
+ //
768
+ // 实测(2026-09-28):独立运行时 ①② 为空,③ 生效 —— 给两个子进程注入不同的 id,
769
+ // CLI 就各读各的票据、各用各的账号,互不顶号(改 id 后立刻改读另一份 .info 并能正常出正文)。
770
+ //
771
+ // ⚠️ ACC_PRODUCT_CONFIG_PATH 必须**清空**:父会话(WorkBuddy App / 内嵌会话)会把它
772
+ // 指到自己的 acc-product-config,而它优先级**高于** ③,会把账号选回 App 的默认账号。
773
+ const (
774
+ // codebuddyAuthID codebuddy 引擎的账号标识(票据 auth/codebuddy.info)。
775
+ codebuddyAuthID = "codebuddy"
776
+ // codebuddyAIAuthID codebuddy-ai 引擎的账号标识(票据 auth/codebuddy-ai.info)。
777
+ codebuddyAIAuthID = "codebuddy-ai"
778
+
779
+ // codebuddyAuthIDEnv / codebuddyAIAuthIDEnv:各引擎自己的账号覆盖变量
780
+ //(临时切到别的账号用;不读通用的 ACC_PRODUCT_CONFIG_V3,理由见 codebuddyAccountEnv)。
781
+ codebuddyAuthIDEnv = "MAGIC_AGENT_CODEBUDDY_AUTH_ID"
782
+ codebuddyAIAuthIDEnv = "MAGIC_AGENT_CODEBUDDY_AI_AUTH_ID"
783
+ )
784
+
785
+ // codebuddyAccountEnv 构造「选定账号」的子进程环境变量。
786
+ //
787
+ // - ACC_PRODUCT_CONFIG_PATH 恒清空(见上)
788
+ // - ACC_PRODUCT_CONFIG_V3 只声明 authentication.id
789
+ // - CODEBUDDY_CONFIG_DIR 该引擎自己的配置目录(settings / keyblob / 会话);
790
+ // 用户已显式设置时尊重之
791
+ //
792
+ // 想临时换账号:设**各引擎自己的** authIDEnv(见 codebuddyAuthIDEnv /
793
+ // codebuddyAIAuthIDEnv)。刻意**不**读通用的 ACC_PRODUCT_CONFIG_V3 当覆盖 ——
794
+ // 那个变量一旦在全局会话里存在,两个引擎就会静默合并成同一个账号,
795
+ // 「一个 CLI 挂两个账号」的设计当场作废。
796
+ func codebuddyAccountEnv(configDir, authID, authIDEnv string) []string {
797
+ if v := strings.TrimSpace(os.Getenv(authIDEnv)); v != "" {
798
+ authID = v
799
+ }
800
+ env := []string{
801
+ "ACC_PRODUCT_CONFIG_PATH=",
802
+ `ACC_PRODUCT_CONFIG_V3={"authentication":{"id":"` + authID + `"}}`,
803
+ }
804
+ if os.Getenv("CODEBUDDY_CONFIG_DIR") == "" {
805
+ env = append(env, "CODEBUDDY_CONFIG_DIR="+configDir)
806
+ }
807
+ return env
808
+ }
809
+
810
+ // codebuddyDir codebuddy 引擎的配置目录(= CLI 默认目录 ~/.codebuddy)。
811
+ func codebuddyDir() string {
812
+ home, err := os.UserHomeDir()
813
+ if err != nil || home == "" {
814
+ home = os.Getenv("HOME")
815
+ }
816
+ return filepath.Join(home, ".codebuddy")
817
+ }
818
+
819
+ // codebuddyAIExtraEnv 返回 codebuddy-ai 子进程的账号环境变量。
820
+ // 与 codebuddy 的差异只在账号标识与配置目录,二进制是同一个。
821
+ func codebuddyAIExtraEnv() []string {
822
+ return codebuddyAccountEnv(codebuddyAIDir(), codebuddyAIAuthID, codebuddyAIAuthIDEnv)
823
+ }
824
+
825
+ // bin 探测 codebuddy-ai CLI 路径。
826
+ func (e *CodeBuddyAIEngine) bin() string { return e.core().bin() }
827
+
828
+ // Detect 实现 Engine。
829
+ func (e *CodeBuddyAIEngine) Detect() (bool, string) { return e.core().detect() }
830
+
831
+ // LoginCommand 实现 LoginRunner:拉起交互式会话(进去执行 /login)。
832
+ // 与 CodeBuddyEngine 用同一个二进制,账号环境不同(票据 auth/codebuddy-ai.info)。
833
+ func (e *CodeBuddyAIEngine) LoginCommand() (string, []string, []string, error) {
834
+ bin := e.bin()
835
+ if bin == "" {
836
+ return "", nil, nil, fmt.Errorf("%s CLI not found; set %s", e.Name(), codebuddyAIBase.envVar)
837
+ }
838
+ return bin, nil, codebuddyAIExtraEnv(), nil
839
+ }
840
+
841
+ // ListModels 实现 ModelLister。
842
+ func (e *CodeBuddyAIEngine) ListModels(ctx context.Context) ([]string, error) {
843
+ return e.core().listModels(ctx)
844
+ }
845
+
846
+ // ModelCredits 实现 ModelCreditLister:各模型的积分倍率(规范化数字字符串,
847
+ // 如 "0.34",源自客户端 "x0.34 credits")。与 ListModels 同源同链
848
+ // (core().modelCredits,即 extendedModelCatalog 的倍率半边);客户端不给倍率
849
+ // 的模型(custom-local)不在返回值里;引擎不可用 / 无倍率数据时返回 nil。
850
+ func (e *CodeBuddyAIEngine) ModelCredits(ctx context.Context) map[string]string {
851
+ bin := e.bin()
852
+ if bin == "" {
853
+ return nil
854
+ }
855
+ return e.core().modelCredits(bin)
856
+ }
857
+
858
+ // Complete 实现 Engine:单次调用 codebuddy-ai CLI。
859
+ func (e *CodeBuddyAIEngine) Complete(ctx context.Context, req Request) (Response, error) {
860
+ return e.core().complete(ctx, req)
861
+ }
862
+
863
+ // Stream 实现 Streamer。
864
+ func (e *CodeBuddyAIEngine) Stream(ctx context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
865
+ return e.core().stream(ctx, req, onEvent)
866
+ }
867
+
868
+ // buildArgs 构造 codebuddy-ai CLI 参数。
869
+ func (e *CodeBuddyAIEngine) buildArgs(req Request) []string { return e.core().buildArgs(req) }
870
+
871
+ // buildArgsBase 构造参数(不含末尾的位置参数 prompt)。
872
+ func (e *CodeBuddyAIEngine) buildArgsBase(req Request) []string { return e.core().buildArgsBase(req) }
873
+
874
+ // ── 输出解析(两个后端共用)──────────────────────────────────
875
+
876
+ // stripUserQueryEcho 剥离 <user_query>…</user_query> 请求回显。
877
+ func stripUserQueryEcho(s string) string {
878
+ t := strings.TrimSpace(s)
879
+ if !strings.HasPrefix(t, "<user_query>") {
880
+ return s
881
+ }
882
+ end := strings.Index(t, "</user_query>")
883
+ if end < 0 {
884
+ return ""
885
+ }
886
+ return strings.TrimSpace(t[end+len("</user_query>"):])
887
+ }
888
+
889
+ // extractCodeBuddyResult 解析 codebuddy 输出,兼容三种形态:
890
+ //
891
+ // 1. 单对象 envelope {"type":"result","result":"..."}
892
+ // 2. 数组:优先 type=="result",否则拼接全部 assistant text
893
+ // 3. 解析失败原样返回
894
+ func extractCodeBuddyResult(raw string) string {
895
+ trimmed := strings.TrimSpace(raw)
896
+ if trimmed == "" {
897
+ return raw
898
+ }
899
+
900
+ // Shape 1: 单对象。
901
+ var single struct {
902
+ Type string `json:"type"`
903
+ IsError bool `json:"is_error"`
904
+ Result string `json:"result"`
905
+ }
906
+ if trimmed[0] == '{' {
907
+ if err := json.Unmarshal([]byte(trimmed), &single); err == nil {
908
+ if single.Result != "" {
909
+ return single.Result
910
+ }
911
+ }
912
+ }
913
+
914
+ // Shape 2: stream 消息数组。
915
+ if trimmed[0] == '[' {
916
+ var msgs []json.RawMessage
917
+ if err := json.Unmarshal([]byte(trimmed), &msgs); err == nil {
918
+ for _, raw := range msgs {
919
+ var m struct {
920
+ Type string `json:"type"`
921
+ Result string `json:"result"`
922
+ }
923
+ if json.Unmarshal(raw, &m) == nil && m.Type == "result" && m.Result != "" {
924
+ return m.Result
925
+ }
926
+ }
927
+ var sb strings.Builder
928
+ for _, raw := range msgs {
929
+ var probe map[string]json.RawMessage
930
+ if json.Unmarshal(raw, &probe) != nil {
931
+ continue
932
+ }
933
+ if r, ok := probe["role"]; ok && string(r) == `"assistant"` {
934
+ if text := extractAssistantText(probe); text != "" {
935
+ if sb.Len() > 0 {
936
+ sb.WriteString("\n")
937
+ }
938
+ sb.WriteString(text)
939
+ }
940
+ }
941
+ if r, ok := probe["type"]; ok && string(r) == `"assistant"` {
942
+ if text := extractAssistantText(probe); text != "" {
943
+ if sb.Len() > 0 {
944
+ sb.WriteString("\n")
945
+ }
946
+ sb.WriteString(text)
947
+ }
948
+ }
949
+ }
950
+ if sb.Len() > 0 {
951
+ return sb.String()
952
+ }
953
+ }
954
+ }
955
+
956
+ // Shape 3: 原样返回。
957
+ return raw
958
+ }
959
+
960
+ // extractCodeBuddySession 从 codebuddy 输出里挖 session_id(尽力而为),
961
+ // 兼容单对象 envelope 与 stream 消息数组两种形态;找不到返回空串。
962
+ func extractCodeBuddySession(raw string) string {
963
+ trimmed := strings.TrimSpace(raw)
964
+ if trimmed == "" {
965
+ return ""
966
+ }
967
+ // Shape 1: 单对象 envelope。
968
+ if trimmed[0] == '{' {
969
+ var single struct {
970
+ SessionID string `json:"session_id"`
971
+ }
972
+ if json.Unmarshal([]byte(trimmed), &single) == nil {
973
+ return single.SessionID
974
+ }
975
+ return ""
976
+ }
977
+ // Shape 2: stream 消息数组 —— 优先 type=="result" 行,其次任意带
978
+ // session_id 的行。
979
+ if trimmed[0] == '[' {
980
+ var msgs []json.RawMessage
981
+ if json.Unmarshal([]byte(trimmed), &msgs) != nil {
982
+ return ""
983
+ }
984
+ fallback := ""
985
+ for _, m := range msgs {
986
+ var probe struct {
987
+ Type string `json:"type"`
988
+ SessionID string `json:"session_id"`
989
+ }
990
+ if json.Unmarshal(m, &probe) != nil || probe.SessionID == "" {
991
+ continue
992
+ }
993
+ if probe.Type == "result" {
994
+ return probe.SessionID
995
+ }
996
+ if fallback == "" {
997
+ fallback = probe.SessionID
998
+ }
999
+ }
1000
+ return fallback
1001
+ }
1002
+ return ""
1003
+ }
1004
+
1005
+ // extractAssistantText 从单条 stream 消息里挖 assistant 可见文本。
1006
+ func extractAssistantText(msg map[string]json.RawMessage) string {
1007
+ var sb strings.Builder
1008
+ var containers []json.RawMessage
1009
+ // "message" 可能是 {"content":[...]}(包一层),也可能直接是 [...]。
1010
+ if v, ok := msg["message"]; ok {
1011
+ var inner map[string]json.RawMessage
1012
+ if err := json.Unmarshal(v, &inner); err == nil {
1013
+ if c, ok := inner["content"]; ok {
1014
+ containers = append(containers, c)
1015
+ }
1016
+ } else {
1017
+ containers = append(containers, v)
1018
+ }
1019
+ }
1020
+ if v, ok := msg["content"]; ok {
1021
+ containers = append(containers, v)
1022
+ }
1023
+ for _, c := range containers {
1024
+ var blocks []struct {
1025
+ Type string `json:"type"`
1026
+ Text string `json:"text"`
1027
+ }
1028
+ if err := json.Unmarshal(c, &blocks); err != nil {
1029
+ continue
1030
+ }
1031
+ for _, b := range blocks {
1032
+ t := b.Type
1033
+ if t == "" || t == "text" || t == "output_text" {
1034
+ if b.Text != "" {
1035
+ if sb.Len() > 0 {
1036
+ sb.WriteString("\n")
1037
+ }
1038
+ sb.WriteString(b.Text)
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+ return sb.String()
1044
+ }