zen-gitsync 2.17.23 → 2.17.25

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 (80) hide show
  1. package/README.md +10 -4
  2. package/package.json +8 -1
  3. package/scripts/run-tests.cjs +5 -0
  4. package/src/cli/ai/agent.js +20 -0
  5. package/src/cli/ai/termui.js +73 -1
  6. package/src/cli/ai/termui.test.js +51 -0
  7. package/src/cli/ai/tools.js +114 -0
  8. package/src/cli/ai/tools.test.js +65 -1
  9. package/src/cli/ai/turn.js +3 -0
  10. package/src/config.js +34 -3
  11. package/src/memory/library.js +273 -0
  12. package/src/memory/library.test.js +187 -0
  13. package/src/memory/store.js +199 -0
  14. package/src/memory/store.test.js +137 -0
  15. package/src/memory/templates.js +113 -0
  16. package/src/paths.js +18 -0
  17. package/src/ui/public/assets/{AgentEngineSelector-DLJ8He83.js → AgentEngineSelector-BArUIELz.js} +1 -1
  18. package/src/ui/public/assets/{AgentView-DnN-vCMi.css → AgentView-B3LhhRsy.css} +1 -1
  19. package/src/ui/public/assets/{AgentView-CQ18nw02.js → AgentView-LpT8A9Fo.js} +1 -1
  20. package/src/ui/public/assets/{AppVersionBadge-1pyUzRer.js → AppVersionBadge-DNXQm_Si.js} +2 -2
  21. package/src/ui/public/assets/{BranchSelector-DrsrZiWJ.js → BranchSelector-DZPTz5Ql.js} +1 -1
  22. package/src/ui/public/assets/{CommitForm-B5p-7P7m.js → CommitForm-6avyu1Kv.js} +1 -1
  23. package/src/ui/public/assets/{CommonDialog-BEm2cK1X.js → CommonDialog-CRXyMlp5.js} +1 -1
  24. package/src/ui/public/assets/EditorView-BIHL8I9g.js +1 -0
  25. package/src/ui/public/assets/{EditorView-C6HFYeEM.css → EditorView-DqxBkn6c.css} +1 -1
  26. package/src/ui/public/assets/{FileDiffViewer-CYxcLw10.js → FileDiffViewer-BEYcCYs5.js} +1 -1
  27. package/src/ui/public/assets/{FlowExecutionViewer-DTmlBLXQ.js → FlowExecutionViewer-DKRHDqbG.js} +1 -1
  28. package/src/ui/public/assets/{FlowOrchestrationWorkspace-Da_UmTRT.js → FlowOrchestrationWorkspace-BnCsF4z4.js} +1 -1
  29. package/src/ui/public/assets/{IconButton-BShiC2AP.js → IconButton-D4df4t9A.js} +1 -1
  30. package/src/ui/public/assets/{LogList-5M5JBhlB.js → LogList-DXqB4JJv.js} +1 -1
  31. package/src/ui/public/assets/{MindmapView-BUV_w14M.js → MindmapView-Ddc55wzp.js} +1 -1
  32. package/src/ui/public/assets/{MonitorView-D9N4QH5Y.js → MonitorView-DeERavq-.js} +1 -1
  33. package/src/ui/public/assets/{ProjectStartupButton-DYYQ-O7P.js → ProjectStartupButton-DaLU4iS4.js} +1 -1
  34. package/src/ui/public/assets/{RecentDirectoriesChat-BtWa_HMY.js → RecentDirectoriesChat-DVtsf7lW.js} +1 -1
  35. package/src/ui/public/assets/RecentDirectoriesChat-o4xK1Koc.css +1 -0
  36. package/src/ui/public/assets/{RemoteManagerDialog-C4QIJK_F.js → RemoteManagerDialog-C3cfDsZi.js} +1 -1
  37. package/src/ui/public/assets/{RemoteRepoCard-BmDh538v.js → RemoteRepoCard-D1Hso5Fq.js} +1 -1
  38. package/src/ui/public/assets/{SourceMapView-Ceamya-N.js → SourceMapView-Dwn5wy1o.js} +1 -1
  39. package/src/ui/public/assets/{SvgIcon-xtz4vvc9.js → SvgIcon-diKLLWK9.js} +1 -1
  40. package/src/ui/public/assets/{TemplateManager-DhKTZ4EM.js → TemplateManager-D9gyZbHP.js} +1 -1
  41. package/src/ui/public/assets/{UserInputNode-DfeCGlEO.js → UserInputNode-JNtVWcbE.js} +1 -1
  42. package/src/ui/public/assets/WorkbenchView-CJCxYK-K.css +1 -0
  43. package/src/ui/public/assets/WorkbenchView-DxaAjZAA.js +6 -0
  44. package/src/ui/public/assets/_plugin-vue_export-helper-C7IvbEC3.js +19 -0
  45. package/src/ui/public/assets/agentConversations-nN-yDz8q.js +5 -0
  46. package/src/ui/public/assets/configStore-C-htWfWm.js +1 -0
  47. package/src/ui/public/assets/{gitStore-ByP9lpuT.js → gitStore-DYpd8vZK.js} +1 -1
  48. package/src/ui/public/assets/index-BZFKDSGv.css +1 -0
  49. package/src/ui/public/assets/index-eq1OUcdk.js +29 -0
  50. package/src/ui/public/assets/{previewSandbox-DSzZ_Rky.js → previewSandbox-Z707CZD9.js} +1 -1
  51. package/src/ui/public/assets/toolsStore-DFu4Alwg.js +1 -0
  52. package/src/ui/public/assets/vendor-BY5ZDlW1.css +1 -0
  53. package/src/ui/public/assets/{vendor-CyWsJd6S.js → vendor-BjomZKSS.js} +28 -120
  54. package/src/ui/public/index.html +14 -14
  55. package/src/ui/server/index.js +4 -0
  56. package/src/ui/server/routes/config.js +11 -2
  57. package/src/ui/server/routes/fileOpen.js +78 -13
  58. package/src/ui/server/routes/memory.js +125 -0
  59. package/src/ui/server/routes/workbench/agentChat.js +35 -3
  60. package/src/ui/server/routes/workbench/agentChatShared.test.js +49 -0
  61. package/src/ui/server/routes/workbench/executorModels.js +329 -0
  62. package/src/ui/server/routes/workbench/executorModels.test.js +326 -0
  63. package/src/ui/server/routes/workbench/index.js +45 -2
  64. package/src/ui/server/routes/workbench/jobActivity.js +6 -0
  65. package/src/ui/server/routes/workbench/memoryContext.js +125 -0
  66. package/src/ui/server/routes/workbench/memoryContext.test.js +79 -0
  67. package/src/ui/server/routes/workbench/orchestratorStore.js +4 -1
  68. package/src/ui/server/routes/workbench/runExternalTurn.js +6 -1
  69. package/src/ui/server/routes/workbench/taskRunner.js +48 -5
  70. package/src/ui/public/assets/EditorView-fuMrS9P7.js +0 -1
  71. package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +0 -1
  72. package/src/ui/public/assets/WorkbenchView-D9l3i4Qj.css +0 -1
  73. package/src/ui/public/assets/WorkbenchView-DX2epPD9.js +0 -6
  74. package/src/ui/public/assets/_plugin-vue_export-helper-C_bQVzeb.js +0 -19
  75. package/src/ui/public/assets/agentConversations-BUPj8vzH.js +0 -5
  76. package/src/ui/public/assets/configStore-DJH7OlB_.js +0 -1
  77. package/src/ui/public/assets/index-CFX8sK4w.js +0 -29
  78. package/src/ui/public/assets/index-DVJITdth.css +0 -1
  79. package/src/ui/public/assets/toolsStore-B49mCaQ_.js +0 -1
  80. package/src/ui/public/assets/vendor-BzMymUBi.css +0 -1
@@ -0,0 +1,329 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 「这个执行器现在实际在用什么模型」的**只读探测**。
16
+ //
17
+ // ── 为什么要做这件事 ────────────────────────────────────────────────────────
18
+ //
19
+ // 工作台派任务时一律**不传模型参数**:claude / opencode / codex 的 spawn argv 里
20
+ // 都没有 --model(见 taskRunner.js 的注释与 README 的 argv 清单),模型跟随各自
21
+ // CLI 的配置。这条设计是对的 —— 但副作用是界面上**一个执行器的模型都看不到**:
22
+ // 底栏那个「默认模型 xxx」是**内置 g ai 引擎**用的 config.models,跟三个执行器
23
+ // 毫无关系(agentRoutes.js 里对外部引擎刻意把 session.model 置空也是同一个原因)。
24
+ // 于是用户想确认"这活到底是哪个模型跑的"只能去翻三个不同格式的配置文件。
25
+ //
26
+ // 这里把「读三个配置文件」这件事收口成一个模块,让前端能在选择器里直接显示。
27
+ //
28
+ // ── 三家配置文件的形状(这是本模块存在的全部理由)────────────────────────
29
+ //
30
+ // claude ~/.claude/settings.json JSON。模型在 env 里,且有**别名两层**:
31
+ // ANTHROPIC_DEFAULT_SONNET_MODEL 是 CLI 认识的名字
32
+ // (claude-sonnet-5[1M]),..._MODEL_NAME 才是它背后真正的模型
33
+ // (本机经代理指向 deepseek-v4.1-flash)。只报前者等于没报 ——
34
+ // 用户看到"claude-sonnet-5"会以为在用官方模型,实际是代理换过的。
35
+ // 所以两条都返回,display 优先取 _NAME。
36
+ //
37
+ // codex ~/.codex/config.toml TOML。顶层 `model = "..."`。
38
+ // ⚠️ 文件里还有 [projects.*] / [mcp_servers.*] 等几十个段,
39
+ // 段内也可能出现 model 键 —— 只认**任何 [表头] 之前**的顶层赋值。
40
+ // 同段的 model_providers.<id>.name 给出服务商名,一并带出。
41
+ //
42
+ // opencode ~/.config/opencode/opencode.json / .jsonc
43
+ // JSONC(带注释与尾逗号)。顶层 `model` 字段,可以是字符串
44
+ // "provider/model-id",也可以是对象 { model, variant }。
45
+ // ⚠️ **但它经常是空的** —— opencode 的 TUI 允许直接选模型,而选择存在
46
+ // 自己的 state 里、不回写配置。所以还要读第二处:
47
+ // ~/.local/state/opencode/model.json 的 recent[0](TUI 最近使用),
48
+ // variant(截图里那个蓝色的 max)在同文件的顶层 variant 表里。
49
+ // 两处都没有才算"没指定",返回值里用 source 区分(config / state)。
50
+ //
51
+ // ── 三条必须守住的底线 ──────────────────────────────────────────────────────
52
+ //
53
+ // 1. **纯只读**:只 fs.readFile,一个字节都不写。改用户 CLI 的配置是绝对禁区。
54
+ // 2. **不泄露密钥**:codex 的 config.toml 里有 experimental_bearer_token,
55
+ // claude 的 env 里有 ANTHROPIC_API_KEY —— 本模块只取模型名/服务商名,
56
+ // 任何 key 字段都不许出现在返回值里(readTomlTopLevel 只取显式白名单里的键)。
57
+ // 3. **读不到就是 null**:不猜、不回落成"看起来对"的字符串。前端照实显示
58
+ // 「未在配置中指定」,这比显示一个假模型名有用得多。
59
+
60
+ import fs from 'fs/promises';
61
+ import os from 'os';
62
+ import path from 'path';
63
+
64
+ /**
65
+ * 从 TOML 文本里取**顶层**(任何 [表头] 之前)的标量键。
66
+ *
67
+ * 为什么不用 TOML 解析库:codex 的 config.toml 结构简单(顶层几个标量 + 一堆段),
68
+ * 而为一个"读一行"引入解析器依赖不划算;本仓库也没有现成的 TOML 依赖。
69
+ * 代价是**只支持顶层标量**——这正是本模块需要的,段内的键一律不取。
70
+ *
71
+ * ⚠️ 刻意不解析行尾注释之外的任何语法:支持单/双引号值、数组(取不到就跳过)、
72
+ * 布尔与数字原样返回字符串。够用且不会把 `[projects.'c:\path']` 这种
73
+ * 引号里带方括号的段头误判成值。
74
+ *
75
+ * ⚠️ 注释行与空行必须 **continue 而不是 break**:真实的 codex config.toml 顶部
76
+ * 常带几行 `#` 说明、键与键之间也常空行;早先这三类一起 break,导致"文件第一行
77
+ * 是注释"时顶层 model 整个读空(返回 {}),表现为界面上 codex 永远显示未配置。
78
+ */
79
+ export function readTomlTopLevel(text, keys) {
80
+ const out = {};
81
+ for (const raw of String(text).split(/\r?\n/)) {
82
+ const line = raw.trim();
83
+ // 注释 / 空行在顶层键之间是合法的,跳过继续找
84
+ if (!line || line.startsWith('#')) continue;
85
+ // 只有段头才代表"顶层到此结束":后面的键都属于某个段
86
+ if (line.startsWith('[')) break;
87
+ const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
88
+ if (!m) continue;
89
+ if (!keys.includes(m[1])) continue;
90
+ const value = m[2].trim();
91
+ // 只收字符串值。数组/内联表一律不取 —— 本模块用不到,硬解只会解错。
92
+ const quoted = value.match(/^(["'])([\s\S]*?)\1\s*(?:#.*)?$/);
93
+ if (quoted) out[m[1]] = quoted[2];
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * 剥掉 JSONC 的注释与尾逗号,让 JSON.parse 能吃下 opencode.jsonc。
100
+ *
101
+ * 不引依赖的原因同上(只为读一个字段)。实现要点是**必须逐字符走**而不是
102
+ * 正则全局替换 —— 字符串字面量里的 `//`(比如 base_url 带 `https://`)会被误伤,
103
+ * 而本机的 codex/opencode 配置里 URL 是常见值。扫描时记住"在不在字符串里",
104
+ * 同时处理转义引号 `\"`。
105
+ */
106
+ export function stripJsonComments(text) {
107
+ let out = '';
108
+ let inString = false;
109
+ let escaped = false;
110
+ for (let i = 0; i < text.length; i += 1) {
111
+ const ch = text[i];
112
+ if (inString) {
113
+ out += ch;
114
+ if (escaped) escaped = false;
115
+ else if (ch === '\\') escaped = true;
116
+ else if (ch === '"') inString = false;
117
+ continue;
118
+ }
119
+ if (ch === '"') { inString = true; out += ch; continue; }
120
+ // 行注释
121
+ if (ch === '/' && text[i + 1] === '/') {
122
+ while (i < text.length && text[i] !== '\n') i += 1;
123
+ out += '\n';
124
+ continue;
125
+ }
126
+ // 块注释
127
+ if (ch === '/' && text[i + 1] === '*') {
128
+ i += 2;
129
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
130
+ i += 1;
131
+ continue;
132
+ }
133
+ out += ch;
134
+ }
135
+ // 尾逗号:`{"a":1,}` 与数组里的 `1,]`。这里只处理对象/数组结尾前的那一个逗号。
136
+ return out.replace(/,(\s*[}\]])/g, '$1');
137
+ }
138
+
139
+ async function readTextOrNull(filePath) {
140
+ try {
141
+ return await fs.readFile(filePath, 'utf8');
142
+ } catch {
143
+ // 文件不存在 / 无权限 / 是目录 —— 一律当"没配",不抛
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * claude:~/.claude/settings.json 的 env 段。
150
+ *
151
+ * 返回两条名字是有意的:
152
+ * model = ANTHROPIC_MODEL(若显式指定),否则取 sonnet 档的别名
153
+ * display= ..._MODEL_NAME,即别名背后真正的模型名
154
+ * 都取不到时返回 null。
155
+ *
156
+ * 为什么按 sonnet → opus → haiku 的顺序找:claude 在没有显式 ANTHROPIC_MODEL 时
157
+ * 会按档位选模型,日常跑任务默认落在 sonnet 档,所以它是最可能生效的那一档。
158
+ */
159
+ function detectClaude(env) {
160
+ const pick = (tier) => ({
161
+ alias: env[`ANTHROPIC_DEFAULT_${tier}_MODEL`],
162
+ display: env[`ANTHROPIC_DEFAULT_${tier}_MODEL_NAME`],
163
+ });
164
+
165
+ const explicit = env.ANTHROPIC_MODEL;
166
+ if (typeof explicit === 'string' && explicit.trim()) {
167
+ // 显式指定时没有"别名背后"的第二层,但 base_url 仍然要带出来 ——
168
+ // 用户看到 ANTHROPIC_MODEL 是官方名、实际请求打到自建代理,这才是关键信息。
169
+ return { model: explicit.trim(), display: null, provider: env.ANTHROPIC_BASE_URL || null };
170
+ }
171
+
172
+ for (const tier of ['SONNET', 'OPUS', 'HAIKU', 'FABLE']) {
173
+ const hit = pick(tier);
174
+ if (!hit.alias && !hit.display) continue;
175
+ return {
176
+ model: hit.alias || null,
177
+ // display 优先:别名背后那个才是用户真正在付钱/发请求的模型
178
+ display: hit.display || null,
179
+ provider: env.ANTHROPIC_BASE_URL || null,
180
+ };
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /** codex:顶层 model + model_providers.<model_provider>.name 的服务商名 */
186
+ function detectCodex(toml) {
187
+ const top = readTomlTopLevel(toml, ['model', 'model_provider']);
188
+ if (!top.model) return null;
189
+ const providerId = top.model_provider;
190
+ // 服务商名要进 [model_providers.x] 段里找,段头格式:`[model_providers."custom"]`
191
+ // 或 `[model_providers.custom]`。这里只取「段内第一个 name = 」,够用且不误伤
192
+ // (其他段也有 name 键,但只认 model_providers.* 段)。
193
+ let provider = null;
194
+ if (providerId) {
195
+ const sectionRe = new RegExp(`^\\[model_providers\\.${escapeRe(providerId)}\\]\\s*$`, 'm');
196
+ const m = sectionRe.exec(toml);
197
+ if (m) {
198
+ const rest = toml.slice(m.index + m[0].length);
199
+ const end = rest.search(/^\s*\[/m);
200
+ const body = end >= 0 ? rest.slice(0, end) : rest;
201
+ const name = readTomlTopLevel(body, ['name']).name;
202
+ if (name) provider = name;
203
+ }
204
+ }
205
+ return { model: top.model, display: null, provider };
206
+ }
207
+
208
+ /** opencode:顶层 model,字符串或 { model, variant } 都吃;顺便记 variant */
209
+ function detectOpencodeConfig(json) {
210
+ const raw = json && json.model;
211
+ if (typeof raw === 'string' && raw.trim()) {
212
+ return { model: raw.trim(), display: null, provider: null, source: 'config' };
213
+ }
214
+ if (raw && typeof raw === 'object' && typeof raw.model === 'string' && raw.model.trim()) {
215
+ const variant = typeof raw.variant === 'string' && raw.variant.trim() ? ` (${raw.variant.trim()})` : '';
216
+ return { model: raw.model.trim() + variant, display: null, provider: null, source: 'config' };
217
+ }
218
+ return null;
219
+ }
220
+
221
+ /**
222
+ * opencode 当前用哪个模型:**配置文件优先,没有就退回 TUI 的「最近使用」**。
223
+ *
224
+ * 为什么要读两处 —— opencode 的 TUI 允许直接选模型,而它把选择存在**自己的 state 里
225
+ * 而不是回写配置文件**。本机就是活例子:`~/.config/opencode/opencode.jsonc` 里只有
226
+ * `$schema`(真的没配 model),但 statusline 明确在用 `opencode-go/space-bunny-free`;
227
+ * 那个选择躺在 `~/.local/state/opencode/model.json` 的 `recent[0]`。
228
+ * 只读配置文件的话界面上会显示「未在配置中指定」—— 对一个**正在跑的模型**说"没指定",
229
+ * 比不说还糟:用户会去翻配置,而配置里确实没有,于是白折腾一场。
230
+ *
231
+ * 两种来源语义不同,用 `source` 区分,前端会照实标出来(同一条模型名,来源不同含义不同):
232
+ * config —— 配置文件里写死了,这是"默认模型"
233
+ * state —— TUI 上次选的,opencode 下次启动默认还用它
234
+ *
235
+ * variant(截图里那个蓝色的 `max`)在 state 顶层 `variant` 表里,键是 `provider/model`;
236
+ * 也兼容某些版本直接写在 recent 项上的写法。
237
+ */
238
+ function detectOpencodeState(json) {
239
+ const recent = json && Array.isArray(json.recent) ? json.recent[0] : null;
240
+ if (!recent || typeof recent.providerID !== 'string' || typeof recent.modelID !== 'string') return null;
241
+ const id = `${recent.providerID}/${recent.modelID}`;
242
+ const own = typeof recent.variant === 'string' ? recent.variant.trim() : '';
243
+ const mapped = typeof json.variant?.[id] === 'string' ? json.variant[id].trim() : '';
244
+ const variant = own || mapped;
245
+ return {
246
+ model: variant ? `${id} (${variant})` : id,
247
+ display: null,
248
+ // provider 不再单列:模型名本身就是 `provider/model` 形态,再报一次只是噪音
249
+ // (`opencode-go/space-bunny-free (max) (… · opencode-go)`)
250
+ provider: null,
251
+ source: 'state',
252
+ };
253
+ }
254
+
255
+ /** 配置里有就用配置的,否则看 TUI 状态;两处都没有才是真的"没指定" */
256
+ function detectOpencode(cfgJson, stateJson) {
257
+ return detectOpencodeConfig(cfgJson) || detectOpencodeState(stateJson);
258
+ }
259
+
260
+ function escapeRe(s) {
261
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
262
+ }
263
+
264
+ /**
265
+ * 探测三个执行器当前配置的模型。
266
+ *
267
+ * @param {object} [opts]
268
+ * @param {string} [opts.homeDir] 测试沙箱用(默认 os.homedir())
269
+ * @returns {Promise<{ claude: object|null, codex: object|null, opencode: object|null }>}
270
+ * 每项 `{ model, display, provider, source }`;该执行器没配模型时为 null。
271
+ * source = 'config'(配置文件里写死的)| 'state'(CLI 自己记的"上次用的")。
272
+ *
273
+ * 三路并发、互不阻塞:任何一个配置文件读失败都只让**它自己**变成 null。
274
+ */
275
+ export async function detectExecutorModels({ homeDir } = {}) {
276
+ const home = homeDir || os.homedir();
277
+
278
+ const [claudeRaw, codexRaw, opencodeJsonRaw, opencodeJsoncRaw, opencodeStateRaw] = await Promise.all([
279
+ readTextOrNull(path.join(home, '.claude', 'settings.json')),
280
+ readTextOrNull(path.join(home, '.codex', 'config.toml')),
281
+ // .json 优先(标准名),.jsonc 兜底。opencode 两个都认。
282
+ readTextOrNull(path.join(home, '.config', 'opencode', 'opencode.json')),
283
+ readTextOrNull(path.join(home, '.config', 'opencode', 'opencode.jsonc')),
284
+ // TUI 的"最近使用"(不写回配置文件,见 detectOpencodeState 的注释)
285
+ readTextOrNull(path.join(home, '.local', 'state', 'opencode', 'model.json')),
286
+ ]);
287
+
288
+ const pickJson = (raw) => {
289
+ if (!raw) return null;
290
+ try { return JSON.parse(stripJsonComments(raw)); } catch { return null; }
291
+ };
292
+
293
+ const claudeCfg = pickJson(claudeRaw);
294
+ // ⚠️ 两个文件都要接住:早先这一行只吃了 .json(另一个 promise 被解构丢掉),
295
+ // 于是注释里写的"jsonc 兜底"是死代码 —— 只写了 .jsonc 的机器永远显示未配置。
296
+ const opencodeCfg = pickJson(opencodeJsonRaw) || pickJson(opencodeJsoncRaw);
297
+ const opencodeState = pickJson(opencodeStateRaw);
298
+
299
+ return {
300
+ claude: claudeCfg && typeof claudeCfg.env === 'object' && claudeCfg.env
301
+ ? detectClaude(claudeCfg.env)
302
+ : null,
303
+ codex: codexRaw ? detectCodex(codexRaw) : null,
304
+ opencode: detectOpencode(opencodeCfg, opencodeState),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * 探测结果的展示格式化:给出**给人看**的那一个名字。
310
+ *
311
+ * 为什么 display 优先:claude 场景下 model 是 CLI 认识的别名(claude-sonnet-5[1M]),
312
+ * display 才是背后真实的模型(deepseek-v4.1-flash)。给用户看别名等于没回答问题。
313
+ * 两者都有且不同时用 `display` 并把别名放 detail(前端 title 用)。
314
+ */
315
+ export function formatExecutorModel(info) {
316
+ // 两个名字全空 = 没探测到,返回 null 让前端走"未在配置中指定"
317
+ if (!info || (!info.model && !info.display)) return null;
318
+ const name = info.display || info.model;
319
+ const detail = info.display && info.model && info.display !== info.model
320
+ ? info.model
321
+ : null;
322
+ return {
323
+ name,
324
+ detail: detail || null,
325
+ provider: info.provider || null,
326
+ // 来源照实带出去:同一个模型名,"配置里写死的"与"CLI 上次记下的"含义不同
327
+ source: info.source || null,
328
+ };
329
+ }
@@ -0,0 +1,326 @@
1
+ // 执行器模型探测的单测。
2
+ //
3
+ // 为什么这些用例值得写:这个模块的全部价值是"读三种格式的配置文件",
4
+ // 而三种格式的解析都是**手写的**(不引 TOML / JSONC 依赖,理由见模块注释)——
5
+ // 手写解析最容易在两个地方悄悄错:注释/空行的截断、以及多文件兜底被丢掉。
6
+ // 这两处都已经真出过 bug(见下面标 [回归] 的用例),所以它们必须钉住。
7
+ //
8
+ // 所有用例都在**临时沙箱 home** 里跑,绝不读真实 ~/ —— 否则用例会随开发机
9
+ // 装没装 claude、配没配模型而时红时绿。
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import fs from 'fs/promises'
13
+ import os from 'os'
14
+ import path from 'path'
15
+ import {
16
+ readTomlTopLevel,
17
+ stripJsonComments,
18
+ detectExecutorModels,
19
+ formatExecutorModel,
20
+ } from './executorModels.js'
21
+
22
+ const sandboxes = []
23
+
24
+ /** 造一个只属于本用例的 home 目录,按 {相对路径: 内容} 落文件 */
25
+ async function makeHome(files = {}) {
26
+ const home = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-executor-models-'))
27
+ sandboxes.push(home)
28
+ for (const [rel, content] of Object.entries(files)) {
29
+ const full = path.join(home, rel)
30
+ await fs.mkdir(path.dirname(full), { recursive: true })
31
+ await fs.writeFile(full, content, 'utf8')
32
+ }
33
+ return home
34
+ }
35
+
36
+ test.after(async () => {
37
+ for (const home of sandboxes) await fs.rm(home, { recursive: true, force: true })
38
+ })
39
+
40
+ // ── readTomlTopLevel ─────────────────────────────────────────────────────────
41
+
42
+ test('readTomlTopLevel: 注释行与空行不截断顶层读取 [回归]', () => {
43
+ // 老实现把「空行 / 注释行 / 段头」三类一起 break,于是文件第一行是注释时
44
+ // 顶层 model 整个读空 —— 表现为界面上 codex 永远显示"未配置"。
45
+ const toml = [
46
+ '# codex 配置说明',
47
+ '# 第二行注释',
48
+ 'model = "gpt-6-astra"',
49
+ '',
50
+ 'model_provider = "custom"',
51
+ '',
52
+ '[model_providers.custom]',
53
+ 'name = "kakouai"',
54
+ ].join('\n')
55
+ assert.deepEqual(readTomlTopLevel(toml, ['model', 'model_provider']), {
56
+ model: 'gpt-6-astra',
57
+ model_provider: 'custom',
58
+ })
59
+ })
60
+
61
+ test('readTomlTopLevel: 段内的同名键不会被当成顶层值', () => {
62
+ const toml = 'model = "top"\n\n[projects."c:/x"]\nmodel = "inner"\n'
63
+ assert.equal(readTomlTopLevel(toml, ['model']).model, 'top')
64
+ })
65
+
66
+ test('readTomlTopLevel: 只取白名单里的键(其余含密钥的键一律不取)', () => {
67
+ const toml = 'model = "m"\nexperimental_bearer_token = "tok"\napi_key = "k"\n'
68
+ assert.deepEqual(readTomlTopLevel(toml, ['model']), { model: 'm' })
69
+ })
70
+
71
+ test('readTomlTopLevel: 行尾注释不污染值;单引号值也认', () => {
72
+ const toml = "model = 'm-1' # 备注\nmodel_provider = \"custom\"#紧贴的注释\n"
73
+ assert.deepEqual(readTomlTopLevel(toml, ['model', 'model_provider']), {
74
+ model: 'm-1',
75
+ model_provider: 'custom',
76
+ })
77
+ })
78
+
79
+ test('readTomlTopLevel: 段头里带方括号的路径不被误判成值', () => {
80
+ const toml = 'model = "m"\n\n[projects.\'c:\\weird[path]\']\nmodel = "inner"\n'
81
+ assert.equal(readTomlTopLevel(toml, ['model']).model, 'm')
82
+ })
83
+
84
+ // ── stripJsonComments ────────────────────────────────────────────────────────
85
+
86
+ test('stripJsonComments: 字符串里的 // 不被当注释(base_url 场景)', () => {
87
+ const jsonc = '{ "url": "https://api.example.com/v1", // 注释\n "n": 1 }'
88
+ assert.equal(JSON.parse(stripJsonComments(jsonc)).url, 'https://api.example.com/v1')
89
+ })
90
+
91
+ test('stripJsonComments: 剥块注释与尾逗号后仍能 JSON.parse', () => {
92
+ const jsonc = '{\n /* 块\n 注释 */\n "model": "a/b",\n}'
93
+ assert.deepEqual(JSON.parse(stripJsonComments(jsonc)), { model: 'a/b' })
94
+ })
95
+
96
+ test('stripJsonComments: 转义引号不影响"在不在字符串里"的判断', () => {
97
+ const jsonc = '{ "s": "a\\"//b", "n": 1 } // 尾注释'
98
+ assert.deepEqual(JSON.parse(stripJsonComments(jsonc)), { s: 'a"//b', n: 1 })
99
+ })
100
+
101
+ // ── detectExecutorModels(沙箱 home)─────────────────────────────────────────
102
+
103
+ const CLAUDE_JSON = JSON.stringify({
104
+ env: {
105
+ ANTHROPIC_BASE_URL: 'http://127.0.0.1:15721',
106
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-5[1M]',
107
+ ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'deepseek-v4.1-flash',
108
+ ANTHROPIC_API_KEY: 'sk-should-never-leak',
109
+ },
110
+ })
111
+ const CODEX_TOML = [
112
+ '# 顶层注释',
113
+ 'model = "gpt-6-astra"',
114
+ 'model_provider = "custom"',
115
+ '',
116
+ '[model_providers.custom]',
117
+ 'name = "kakouai"',
118
+ 'experimental_bearer_token = "tok-should-never-leak"',
119
+ '',
120
+ '[projects."c:/x"]',
121
+ 'model = "inner-model"',
122
+ ].join('\n')
123
+ const OPENCODE_JSONC = '{\n // 注释\n "model": "anthropic/claude-x",\n}'
124
+
125
+ test('detectExecutorModels: 三家配置齐全时各自读出模型', async () => {
126
+ const home = await makeHome({
127
+ '.claude/settings.json': CLAUDE_JSON,
128
+ '.codex/config.toml': CODEX_TOML,
129
+ '.config/opencode/opencode.jsonc': OPENCODE_JSONC,
130
+ })
131
+ const out = await detectExecutorModels({ homeDir: home })
132
+
133
+ assert.deepEqual(out.claude, {
134
+ model: 'claude-sonnet-5[1M]',
135
+ display: 'deepseek-v4.1-flash',
136
+ provider: 'http://127.0.0.1:15721',
137
+ })
138
+ // 顶层注释不再截断 → model_provider 读得到 → 服务商名也能查出来
139
+ assert.deepEqual(out.codex, { model: 'gpt-6-astra', display: null, provider: 'kakouai' })
140
+ // 只有 .jsonc(没有 .json)时也要读到:早先那个 promise 被解构丢掉,这里是死代码
141
+ assert.deepEqual(out.opencode, {
142
+ model: 'anthropic/claude-x', display: null, provider: null, source: 'config',
143
+ })
144
+ })
145
+
146
+ test('detectExecutorModels: 只有 .jsonc 时 opencode 仍读得到 [回归]', async () => {
147
+ const home = await makeHome({ '.config/opencode/opencode.jsonc': OPENCODE_JSONC })
148
+ const out = await detectExecutorModels({ homeDir: home })
149
+ assert.equal(out.opencode?.model, 'anthropic/claude-x')
150
+ })
151
+
152
+ test('detectExecutorModels: .json 与 .jsonc 同时存在时 .json 优先', async () => {
153
+ const home = await makeHome({
154
+ '.config/opencode/opencode.json': '{"model":"from-json"}',
155
+ '.config/opencode/opencode.jsonc': '{"model":"from-jsonc"}',
156
+ })
157
+ const out = await detectExecutorModels({ homeDir: home })
158
+ assert.equal(out.opencode?.model, 'from-json')
159
+ })
160
+
161
+ test('detectExecutorModels: opencode 的 model 是对象时带上 variant', async () => {
162
+ const home = await makeHome({
163
+ '.config/opencode/opencode.json': '{"model":{"model":"openai/gpt-y","variant":"high"}}',
164
+ })
165
+ const out = await detectExecutorModels({ homeDir: home })
166
+ assert.equal(out.opencode?.model, 'openai/gpt-y (high)')
167
+ })
168
+
169
+ // ── opencode 的第二处来源:TUI 状态 ──────────────────────────────────────────
170
+ //
171
+ // opencode 的 TUI 允许直接选模型,而它把选择**存在自己的 state 里、不回写配置文件**。
172
+ // 只读配置文件的话,对一个明明在跑的模型显示"未在配置中指定" —— 用户会去翻配置,
173
+ // 配置里确实没有,白折腾一场。这组用例钉住"配置文件优先、没有则退回 state"。
174
+
175
+ const OPENCODE_STATE = JSON.stringify({
176
+ recent: [
177
+ { providerID: 'opencode-go', modelID: 'space-bunny-free' },
178
+ { providerID: 'opencode-go', modelID: 'deepseek-v4.1-flash' },
179
+ ],
180
+ favorite: [],
181
+ variant: { 'opencode-go/space-bunny-free': 'max', 'opencode-go/deepseek-v4.1-flash': 'max' },
182
+ })
183
+
184
+ test('detectExecutorModels: 配置里没写 model 时,退回 TUI 状态里的「最近使用」', async () => {
185
+ const home = await makeHome({
186
+ // 本机真实情形:配置文件里只有一个 $schema
187
+ '.config/opencode/opencode.jsonc': '{\n "$schema": "https://opencode.ai/config.json"\n}',
188
+ '.local/state/opencode/model.json': OPENCODE_STATE,
189
+ })
190
+ const out = await detectExecutorModels({ homeDir: home })
191
+ assert.deepEqual(out.opencode, {
192
+ model: 'opencode-go/space-bunny-free (max)', // recent[0] + variant 表里的 max
193
+ display: null,
194
+ provider: null, // 模型名本身已是 provider/model,不再重复报一次
195
+ source: 'state',
196
+ })
197
+ })
198
+
199
+ test('detectExecutorModels: 配置文件里写了 model 就以它为准(state 只是兜底)', async () => {
200
+ const home = await makeHome({
201
+ '.config/opencode/opencode.json': '{"model":"anthropic/claude-x"}',
202
+ '.local/state/opencode/model.json': OPENCODE_STATE,
203
+ })
204
+ const out = await detectExecutorModels({ homeDir: home })
205
+ assert.deepEqual(out.opencode, {
206
+ model: 'anthropic/claude-x', display: null, provider: null, source: 'config',
207
+ })
208
+ })
209
+
210
+ test('detectExecutorModels: recent 项自带 variant 时优先于顶层 variant 表', async () => {
211
+ const home = await makeHome({
212
+ '.config/opencode/opencode.jsonc': '',
213
+ '.local/state/opencode/model.json': JSON.stringify({
214
+ recent: [{ providerID: 'p', modelID: 'm', variant: 'own' }],
215
+ variant: { 'p/m': 'mapped' },
216
+ }),
217
+ })
218
+ const out = await detectExecutorModels({ homeDir: home })
219
+ assert.equal(out.opencode?.model, 'p/m (own)')
220
+ })
221
+
222
+ test('detectExecutorModels: state 里没有 recent / 缺字段时仍是 null(不猜)', async () => {
223
+ const empty = await makeHome({ '.local/state/opencode/model.json': '{"recent":[],"favorite":[]}' })
224
+ assert.equal((await detectExecutorModels({ homeDir: empty })).opencode, null)
225
+
226
+ const partial = await makeHome({
227
+ '.local/state/opencode/model.json': '{"recent":[{"providerID":"p"}]}',
228
+ })
229
+ assert.equal((await detectExecutorModels({ homeDir: partial })).opencode, null)
230
+ })
231
+
232
+ test('detectExecutorModels: 两处都没有 model 才是真的"未在配置中指定"', async () => {
233
+ const home = await makeHome({ '.config/opencode/opencode.json': '{"model":""}' })
234
+ assert.equal((await detectExecutorModels({ homeDir: home })).opencode, null)
235
+ })
236
+
237
+ test('detectExecutorModels: claude 显式 ANTHROPIC_MODEL 优先于档位别名', async () => {
238
+ const home = await makeHome({
239
+ '.claude/settings.json': JSON.stringify({
240
+ env: {
241
+ ANTHROPIC_MODEL: 'my-explicit-model',
242
+ ANTHROPIC_BASE_URL: 'http://proxy.local',
243
+ ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-alias',
244
+ ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'sonnet-real',
245
+ },
246
+ }),
247
+ })
248
+ const out = await detectExecutorModels({ homeDir: home })
249
+ // 显式指定时没有"别名背后"的第二层,但 base_url 仍要带出来
250
+ assert.deepEqual(out.claude, {
251
+ model: 'my-explicit-model',
252
+ display: null,
253
+ provider: 'http://proxy.local',
254
+ })
255
+ })
256
+
257
+ test('detectExecutorModels: 一个配置文件都没有时三项全 null 且不抛', async () => {
258
+ const home = await makeHome()
259
+ const out = await detectExecutorModels({ homeDir: home })
260
+ assert.deepEqual(out, { claude: null, codex: null, opencode: null })
261
+ })
262
+
263
+ test('detectExecutorModels: 配置残缺/是目录/内容不是 JSON 时只让自己变 null', async () => {
264
+ const home = await makeHome({
265
+ '.claude/settings.json': '{ 这不是 JSON', // 解析失败 → null
266
+ '.codex/config.toml': '# 只有注释,没有 model\n', // 无 model → null
267
+ '.config/opencode/opencode.json': '{"model":""}', // 空串 → null
268
+ })
269
+ const out = await detectExecutorModels({ homeDir: home })
270
+ assert.deepEqual(out, { claude: null, codex: null, opencode: null })
271
+ })
272
+
273
+ test('detectExecutorModels: 返回值里绝不出现任何密钥', async () => {
274
+ const home = await makeHome({
275
+ '.claude/settings.json': CLAUDE_JSON,
276
+ '.codex/config.toml': CODEX_TOML,
277
+ })
278
+ const serialized = JSON.stringify(await detectExecutorModels({ homeDir: home }))
279
+ assert.equal(serialized.includes('sk-should-never-leak'), false)
280
+ assert.equal(serialized.includes('tok-should-never-leak'), false)
281
+ })
282
+
283
+ // ── formatExecutorModel ──────────────────────────────────────────────────────
284
+
285
+ test('formatExecutorModel: display 优先,别名落到 detail', () => {
286
+ assert.deepEqual(
287
+ formatExecutorModel({
288
+ model: 'claude-sonnet-5[1M]',
289
+ display: 'deepseek-v4.1-flash',
290
+ provider: 'http://127.0.0.1:15721',
291
+ }),
292
+ { name: 'deepseek-v4.1-flash', detail: 'claude-sonnet-5[1M]', provider: 'http://127.0.0.1:15721', source: null },
293
+ )
294
+ })
295
+
296
+ test('formatExecutorModel: 两个名字相同则不重复进 detail', () => {
297
+ assert.deepEqual(
298
+ formatExecutorModel({ model: 'same', display: 'same', provider: null }),
299
+ { name: 'same', detail: null, provider: null, source: null },
300
+ )
301
+ })
302
+
303
+ test('formatExecutorModel: 只有别名时就用别名,detail 为空', () => {
304
+ assert.deepEqual(
305
+ formatExecutorModel({ model: 'only-alias', display: null, provider: null }),
306
+ { name: 'only-alias', detail: null, provider: null, source: null },
307
+ )
308
+ })
309
+
310
+ test('formatExecutorModel: 来源(config / state)照实透传出去', () => {
311
+ assert.equal(
312
+ formatExecutorModel({ model: 'm', display: null, provider: null, source: 'state' }).source,
313
+ 'state',
314
+ )
315
+ assert.equal(
316
+ formatExecutorModel({ model: 'm', display: null, provider: null, source: 'config' }).source,
317
+ 'config',
318
+ )
319
+ })
320
+
321
+ test('formatExecutorModel: null / 空对象一律 null(前端据此走"未在配置中指定")', () => {
322
+ assert.equal(formatExecutorModel(null), null)
323
+ assert.equal(formatExecutorModel(undefined), null)
324
+ assert.equal(formatExecutorModel({ model: null, display: null, provider: null }), null)
325
+ assert.equal(formatExecutorModel({ model: '', display: '', provider: null }), null)
326
+ })