@ljwei-stak/dsh-model-router 0.13.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 (37) hide show
  1. package/.dsh-plugin/client.js +3092 -0
  2. package/.dsh-plugin/index.mjs +1651 -0
  3. package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
  4. package/.dsh-plugin/shared/harness-plan.mjs +179 -0
  5. package/.dsh-plugin/shared/livebench.mjs +264 -0
  6. package/.dsh-plugin/shared/model-profiles.mjs +142 -0
  7. package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
  8. package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
  9. package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
  10. package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
  11. package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
  12. package/.dsh-plugin/shared/router-state.mjs +207 -0
  13. package/.dsh-plugin/shared/router.mjs +1134 -0
  14. package/.dsh-plugin/shared/routing-presets.mjs +49 -0
  15. package/.dsh-plugin/shared/run-ledger.mjs +348 -0
  16. package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
  17. package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
  18. package/.dsh-plugin/shared/task-executors.mjs +1154 -0
  19. package/.dsh-plugin/shared/tool-health.mjs +311 -0
  20. package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
  21. package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
  22. package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
  23. package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
  24. package/CHANGELOG.md +36 -0
  25. package/INSTALLATION_GUIDE.zh.md +134 -0
  26. package/LICENSE +21 -0
  27. package/MIGRATION.md +53 -0
  28. package/README.i18n.yaml +3 -0
  29. package/README.md +424 -0
  30. package/README.zh.md +413 -0
  31. package/cordis.patch.yml +12 -0
  32. package/docs/assets/candidate-pruning.svg +80 -0
  33. package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
  34. package/docs/assets/router-only-0.12.0.png +0 -0
  35. package/docs/assets/routing-workflow.svg +96 -0
  36. package/docs/assets/workbench-usage.svg +119 -0
  37. package/package.json +161 -0
@@ -0,0 +1,642 @@
1
+ /**
2
+ * Host-side probe and install runtime for the official tool registry.
3
+ *
4
+ * The DeepSeek Harness plugin host runs as a full Node module, so probing and
5
+ * installing official CLIs uses node:child_process directly. Every command is
6
+ * built exclusively from the fixed registry: callers supply a tool id, never
7
+ * a package name, executable or argument. Installs are serialized (npm global
8
+ * state must not interleave), output is kept as a bounded tail, and success is
9
+ * always confirmed by a fresh probe rather than the installer's exit code.
10
+ */
11
+ import { spawn } from 'node:child_process'
12
+ import { createHash } from 'node:crypto'
13
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
14
+ import { isAbsolute, join, relative, sep } from 'node:path'
15
+ import { getOfficialTool, installCommandLine, OFFICIAL_TOOLS } from './official-tool-registry.mjs'
16
+ import { ZCODE_SUPPORTED_VERSION, discoverZCodeBundle, unverifiedZCodeInstalls } from './zcode-bundle.mjs'
17
+ import { openZCodeInstaller } from './zcode-installer.mjs'
18
+
19
+ const PROBE_TIMEOUT_MS = 8_000
20
+ const INSTALL_TIMEOUT_MS = 900_000
21
+ const OUTPUT_LINE_CAP = 120
22
+ const IS_WINDOWS = process.platform === 'win32'
23
+ const CREATE_NO_WINDOW = 0x0800_0000
24
+ const MINIMAX_INSTALLER_URL = 'https://filecdn.minimax.chat/public/install.ps1'
25
+ const MINIMAX_INSTALLER_SHA256 = '96508631f5874b0ab1519c8a6a463ebc0a80833698346a549c2e03f538680236'
26
+ const MAX_INSTALLER_BYTES = 200_000
27
+ /** Windows ships where.exe in System32; POSIX which is ubiquitous. */
28
+ const LOCATOR = IS_WINDOWS ? 'where' : 'which'
29
+ /** Cached probe results keep plan generation fast; installs invalidate them. */
30
+ const probeCache = new Map()
31
+ const PROBE_CACHE_MS = 60_000
32
+ const installJobs = new Map()
33
+ let installChain = Promise.resolve()
34
+
35
+ async function trustedExecutionReadiness(toolId) {
36
+ // Loaded only during an install to avoid coupling fast version probes to
37
+ // the executor. The executor itself uses this runtime for PATH discovery.
38
+ const { officialToolReadiness } = await import('./official-tool-executor.mjs')
39
+ return officialToolReadiness(toolId)
40
+ }
41
+
42
+ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, signal, env } = {}) {
43
+ return new Promise(resolve => {
44
+ if (signal?.aborted) {
45
+ resolve({ ok: false, code: null, stdout: '', stderr: '', timedOut: false, cancelled: true })
46
+ return
47
+ }
48
+ let stdout = ''
49
+ let stderr = ''
50
+ let settled = false
51
+ let stopReason = null
52
+ let child
53
+ try {
54
+ child = spawn(executable, args, {
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ windowsHide: true,
57
+ // Windows npm CLIs install as .cmd shims; only cmd.exe can execute
58
+ // them. Safe here because every executable and argument comes from
59
+ // the fixed registry — caller input never reaches this boundary.
60
+ shell: useShell,
61
+ ...(env ? { env } : {}),
62
+ })
63
+ } catch (error) {
64
+ resolve({ ok: false, code: null, stdout: '', stderr: String(error?.message ?? error), timedOut: false, cancelled: false })
65
+ return
66
+ }
67
+ let graceTimer
68
+ const finish = result => {
69
+ if (settled) return
70
+ settled = true
71
+ clearTimeout(timer)
72
+ clearTimeout(graceTimer)
73
+ signal?.removeEventListener('abort', onAbort)
74
+ resolve(result)
75
+ }
76
+ const stop = reason => {
77
+ if (settled || stopReason) return
78
+ stopReason = reason
79
+ if (IS_WINDOWS && useShell && Number.isInteger(child.pid)) {
80
+ try {
81
+ const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
82
+ windowsHide: true,
83
+ stdio: 'ignore',
84
+ })
85
+ killer.on('error', () => { try { child.kill('SIGTERM') } catch { /* already gone */ } })
86
+ killer.on('close', code => { if (code !== 0) try { child.kill('SIGTERM') } catch { /* already gone */ } })
87
+ } catch { try { child.kill('SIGTERM') } catch { /* already gone */ } }
88
+ } else {
89
+ try { child.kill('SIGTERM') } catch { /* already gone */ }
90
+ }
91
+ graceTimer = setTimeout(() => finish({ ok: false, code: null, stdout, stderr,
92
+ timedOut: reason === 'timeout', cancelled: reason === 'cancel' }), 5_000)
93
+ graceTimer.unref?.()
94
+ }
95
+ const onAbort = () => stop('cancel')
96
+ const timer = setTimeout(() => stop('timeout'), timeoutMs)
97
+ signal?.addEventListener('abort', onAbort, { once: true })
98
+ if (signal?.aborted) onAbort()
99
+ child.stdout?.on('data', data => {
100
+ if (settled || stopReason) return
101
+ stdout = appendBounded(stdout, String(data))
102
+ onOutput?.(String(data))
103
+ })
104
+ child.stderr?.on('data', data => {
105
+ if (settled || stopReason) return
106
+ stderr = appendBounded(stderr, String(data))
107
+ onOutput?.(String(data))
108
+ })
109
+ child.on('error', error => {
110
+ finish({ ok: false, code: null, stdout, stderr: `${stderr}${error.message}`.trim(),
111
+ timedOut: stopReason === 'timeout', cancelled: stopReason === 'cancel' })
112
+ })
113
+ child.on('close', (code, signal) => {
114
+ finish({ ok: !stopReason && code === 0, code, stdout, stderr,
115
+ timedOut: stopReason === 'timeout', cancelled: stopReason === 'cancel', signal })
116
+ })
117
+ })
118
+ }
119
+
120
+ /** Preserve the reviewed upstream installer bytes and patch only npm's version selector. */
121
+ export function pinnedMiniMaxInstaller(source, expectedHash = MINIMAX_INSTALLER_SHA256) {
122
+ const bytes = Buffer.isBuffer(source) ? source : Buffer.from(source)
123
+ if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) {
124
+ throw new Error('MiniMax 官方 Windows 安装脚本哈希与审核版本不符,已拒绝执行。')
125
+ }
126
+ const marker = '"$PackageName@latest"'
127
+ const text = bytes.toString('utf8')
128
+ if (text.split(marker).length !== 2) {
129
+ throw new Error('MiniMax 官方安装脚本的版本选择结构已变化,已拒绝执行。')
130
+ }
131
+ return text.replace(marker, '"$PackageName@0.5.5"')
132
+ }
133
+
134
+ async function fetchPinnedMiniMaxInstaller(signal) {
135
+ const response = await fetch(MINIMAX_INSTALLER_URL, {
136
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
137
+ })
138
+ if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
139
+ const chunks = []
140
+ let length = 0
141
+ for await (const chunk of response.body) {
142
+ length += chunk.length
143
+ if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
144
+ chunks.push(chunk)
145
+ }
146
+ return pinnedMiniMaxInstaller(Buffer.concat(chunks))
147
+ }
148
+
149
+ async function runPinnedMiniMaxInstaller(job) {
150
+ let staging = null
151
+ let prefixRoot = null
152
+ try {
153
+ job.finishedAt = null
154
+ job.error = null
155
+ job.phase = 'downloading-and-verifying'
156
+ pushLine(job, '— npm 原生依赖未就绪;校验 MiniMax 官方 Windows 安装脚本并锁定 0.5.5 —')
157
+ const prefixResult = await runCapture('npm', ['config', 'get', 'prefix'], {
158
+ timeoutMs: 5_000, useShell: true, signal: job.controller.signal,
159
+ })
160
+ const rawPrefix = prefixResult.stdout.trim().replace(/^\\\\\?\\/, '')
161
+ if (!prefixResult.ok || !isAbsolute(rawPrefix)) throw new Error('无法确定 npm 全局安装目录。')
162
+ await mkdir(rawPrefix, { recursive: true })
163
+ prefixRoot = await realpath(rawPrefix)
164
+ const script = await fetchPinnedMiniMaxInstaller(job.controller.signal)
165
+ staging = await mkdtemp(join(prefixRoot, '.model-router-minimax-installer-'))
166
+ const scriptPath = join(staging, 'install.ps1')
167
+ const tempPath = join(staging, 'temp')
168
+ await mkdir(tempPath)
169
+ // Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page.
170
+ // The upstream installer contains Unicode art, so write a UTF-8 BOM.
171
+ await writeFile(scriptPath, `\uFEFF${script}`, 'utf8')
172
+ const installDir = join(prefixRoot, '.minimax-code')
173
+ const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
174
+ 'WindowsPowerShell', 'v1.0', 'powershell.exe')
175
+ const utilityModule = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
176
+ 'WindowsPowerShell', 'v1.0', 'Modules', 'Microsoft.PowerShell.Utility',
177
+ 'Microsoft.PowerShell.Utility.psd1')
178
+ const command = '$ErrorActionPreference="Stop"; '
179
+ + 'Import-Module -Name $env:MODEL_ROUTER_UTILITY_MODULE -ErrorAction Stop; '
180
+ + '& $env:MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT'
181
+ job.phase = 'installing'
182
+ const outcome = await runCapture(powershell,
183
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
184
+ timeoutMs: INSTALL_TIMEOUT_MS, signal: job.controller.signal,
185
+ env: { ...process.env, MCODE_INSTALL_DIR: installDir,
186
+ MCODE_NO_MODIFY_PATH: '1', MCODE_DOWNLOAD_MIRROR: 'global',
187
+ MCODE_NPM_REGISTRY: 'https://registry.npmjs.org/',
188
+ npm_config_cache: join(prefixRoot, '.model-router-npm-cache'),
189
+ TEMP: tempPath, TMP: tempPath,
190
+ MODEL_ROUTER_UTILITY_MODULE: utilityModule,
191
+ MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT: scriptPath },
192
+ onOutput: chunk => {
193
+ for (const line of chunk.split(/\r?\n/)) if (line.trim()) pushLine(job, line.slice(0, 500))
194
+ },
195
+ })
196
+ if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
197
+ job.exitCode = outcome.code
198
+ job.timedOut = outcome.timedOut
199
+ if (!outcome.ok) throw new Error(outcome.timedOut
200
+ ? '官方安装脚本超时并已终止。'
201
+ : `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 180) ?? ''}`)
202
+ const currentPath = process.env.PATH ?? ''
203
+ if (!currentPath.split(';').includes(installDir)) process.env.PATH = `${currentPath};${installDir}`
204
+ job.phase = 'verifying'
205
+ probeCache.delete(job.tool.id)
206
+ const probe = await probeToolWith(job.tool, defaultRunner)
207
+ job.postInstallProbe = probe
208
+ const readiness = await trustedExecutionReadiness(job.tool.id)
209
+ if (!probe.installed || probe.version !== job.tool.version || !readiness.ready) {
210
+ throw new Error(`官方安装器完成,但入口未通过固定版本与哈希验收:${readiness.reason ?? probe.detail}`)
211
+ }
212
+ job.status = 'succeeded'
213
+ job.finishedAt = new Date().toISOString()
214
+ pushLine(job, '— 官方安装器、固定版本和执行入口均已核验 —')
215
+ } catch (error) {
216
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
217
+ job.status = 'failed'
218
+ job.error = `MiniMax 官方安装兜底失败:${String(error?.message ?? error).slice(0, 300)}`
219
+ job.finishedAt = new Date().toISOString()
220
+ } finally {
221
+ if (staging && prefixRoot) {
222
+ const part = relative(prefixRoot, staging)
223
+ if (part.startsWith('.model-router-minimax-installer-') && !part.includes(sep)) {
224
+ await rm(staging, { recursive: true, force: true }).catch(() => {})
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ function appendBounded(current, addition) {
231
+ const merged = current + addition
232
+ return merged.length > 64_000 ? merged.slice(merged.length - 32_000) : merged
233
+ }
234
+
235
+ function compareStableVersions(left, right) {
236
+ const parse = value => /^([0-9]+)\.([0-9]+)\.([0-9]+)$/.exec(String(value ?? ''))
237
+ const a = parse(left)
238
+ const b = parse(right)
239
+ if (!a || !b) return null
240
+ for (let index = 1; index <= 3; index += 1) {
241
+ const difference = Number(a[index]) - Number(b[index])
242
+ if (difference) return Math.sign(difference)
243
+ }
244
+ return 0
245
+ }
246
+
247
+ /** Extract the first version-looking token from a CLI banner. */
248
+ export function versionFromBanner(banner) {
249
+ const match = String(banner ?? '').match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)
250
+ return match ? match[0] : null
251
+ }
252
+
253
+ /**
254
+ * Probe one registry tool. `runner` is injectable for offline tests and must
255
+ * return { ok, code, stdout, stderr, timedOut }.
256
+ */
257
+ export async function probeToolWith(tool, runner, { cache = probeCache, cacheMs = PROBE_CACHE_MS, now = Date.now } = {}) {
258
+ const cached = cache.get(tool.id)
259
+ if (cached && now() - cached.at < cacheMs) return cached.value
260
+ const probe = await probeUncached(tool, runner)
261
+ cache.set(tool.id, { at: now(), value: probe })
262
+ return probe
263
+ }
264
+
265
+ async function probeUncached(tool, runner) {
266
+ if (tool.unsupported) {
267
+ return { id: tool.id, installed: false, version: null, status: 'unsupported', detail: tool.unsupportedReason }
268
+ }
269
+ if (tool.manager === 'signed-windows-installer') {
270
+ const bundle = await discoverZCodeBundle()
271
+ if (bundle) return { id: tool.id, installed: true, version: bundle.version, status: 'installed',
272
+ detail: `${tool.probeNote} 安装目录:${bundle.root}`, bannerLine: `${tool.label} ${bundle.buildVersion}` }
273
+ const other = (await unverifiedZCodeInstalls().catch(() => []))[0]
274
+ return { id: tool.id, installed: false, version: null, status: 'not-installed',
275
+ detail: other
276
+ ? `检测到已安装的 ZCode ${other.version ?? '(版本未知)'}${other.root ? `(${other.root})` : ''},但插件只启用已核验签名与 CLI 哈希的 ${ZCODE_SUPPORTED_VERSION} 版,因此暂不通过插件调用;可以继续直接使用 ZCode 桌面版,或等待插件更新核验信息。`
277
+ : '未找到官方签名、版本和 CLI 脚本哈希均匹配的 ZCode 桌面版。' }
278
+ }
279
+ if (tool.id === 'minimax-code' && IS_WINDOWS) {
280
+ // The official Windows installer keeps mcode under a versioned releases
281
+ // directory. A partial npm install can leave a broken shim earlier on PATH;
282
+ // prefer the checksum-verified installer release in that case.
283
+ const { findManagedMiniMaxEntry } = await import('./official-tool-executor.mjs')
284
+ const managed = await findManagedMiniMaxEntry(process.cwd())
285
+ if (managed) return { id: tool.id, installed: true, version: managed.version,
286
+ status: 'installed', detail: '已核验 MiniMax 官方 Windows 安装器版本及 CLI 哈希。',
287
+ bannerLine: `MiniMax Code ${managed.version}` }
288
+ }
289
+ for (const executable of tool.probeExecutables) {
290
+ // Existence check first: a missing command is indistinguishable from a
291
+ // broken one by exit code alone once a shell is involved.
292
+ const located = await runner(LOCATOR, [executable], { timeoutMs: PROBE_TIMEOUT_MS })
293
+ if (located.timedOut) {
294
+ return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `定位 ${executable} 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
295
+ }
296
+ if (!located.ok || !located.stdout.trim()) {
297
+ continue
298
+ }
299
+ const attempt = await runner(executable, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS })
300
+ if (attempt.timedOut) {
301
+ return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `${executable} --version 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
302
+ }
303
+ if (attempt.ok) {
304
+ const banner = `${attempt.stdout}\n${attempt.stderr}`.trim()
305
+ return {
306
+ id: tool.id,
307
+ installed: true,
308
+ version: versionFromBanner(banner),
309
+ status: 'installed',
310
+ detail: tool.probeNote ?? '',
311
+ bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '',
312
+ }
313
+ }
314
+ return {
315
+ id: tool.id,
316
+ installed: false,
317
+ version: null,
318
+ status: 'probe-failed',
319
+ detail: `${executable} --version 退出码 ${attempt.code ?? '信号终止'};程序可能损坏或版本过旧。${attempt.stderr.split('\n').find(Boolean)?.slice(0, 120) ?? ''}`,
320
+ }
321
+ }
322
+ return { id: tool.id, installed: false, version: null, status: 'not-installed', detail: '未在 PATH 中找到官方命令。' }
323
+ }
324
+
325
+ /** Default runner: real spawn with the platform's shell for .cmd shims. */
326
+ export const defaultRunner = (executable, args, options = {}) =>
327
+ runCapture(executable, args, { ...options, useShell: IS_WINDOWS })
328
+
329
+ let prefixPromise = null
330
+
331
+ /**
332
+ * Users frequently move the npm global prefix off the default location (for
333
+ * example to save C-drive space), so globally installed CLIs are missing from
334
+ * PATH. Resolve the real prefix once and append it to this process's PATH so
335
+ * probes and installs see the user's actual tools. Best effort: any failure
336
+ * leaves the environment untouched.
337
+ */
338
+ export function ensureNpmPrefixOnPath() {
339
+ if (prefixPromise === null) {
340
+ prefixPromise = (async () => {
341
+ try {
342
+ const outcome = await runCapture('npm', ['config', 'get', 'prefix'], {
343
+ timeoutMs: 5_000,
344
+ useShell: IS_WINDOWS,
345
+ })
346
+ if (!outcome.ok) return
347
+ let prefix = outcome.stdout.trim()
348
+ if (!prefix) return
349
+ if (prefix.startsWith('\\\\?\\')) prefix = prefix.slice(4)
350
+ if (IS_WINDOWS && !process.env.GROK_HOME && /^[D-Z]:[\\/]/i.test(prefix)) {
351
+ // Grok's npm bootstrap otherwise expands its native binary under
352
+ // the Windows user profile on C. Keep plugin-managed downloads on
353
+ // the same non-C drive as this user's npm global prefix.
354
+ process.env.GROK_HOME = join(prefix, '.model-router-grok')
355
+ }
356
+ const current = process.env.PATH ?? ''
357
+ if (current.split(IS_WINDOWS ? ';' : ':').includes(prefix)) return
358
+ process.env.PATH = current ? `${current}${IS_WINDOWS ? ';' : ':'}${prefix}` : prefix
359
+ } catch { /* environment stays untouched */ }
360
+ })()
361
+ }
362
+ return prefixPromise
363
+ }
364
+
365
+ /** Probe every registry tool and return the ids whose CLI is installed. */
366
+ export async function installedToolIds() {
367
+ await ensureNpmPrefixOnPath()
368
+ const probes = await probeAllTools()
369
+ return probes.filter(probe => probe.installed).map(probe => probe.id)
370
+ }
371
+
372
+ /**
373
+ * Probe every registry tool in parallel (the slowest single probe bounds the
374
+ * wall time; the cache keeps repeat calls instant).
375
+ */
376
+ export async function probeAllTools({ fresh = false } = {}) {
377
+ await ensureNpmPrefixOnPath()
378
+ return Promise.all(OFFICIAL_TOOLS.map(tool => probeToolWith(tool, defaultRunner,
379
+ fresh ? { cacheMs: 0 } : {})))
380
+ }
381
+
382
+ export function probeSnapshot() {
383
+ return [...probeCache.values()].map(entry => entry.value)
384
+ }
385
+
386
+ function jobView(job) {
387
+ if (!job) return null
388
+ return {
389
+ tool: job.tool.id,
390
+ command: job.command,
391
+ status: job.status,
392
+ phase: job.phase,
393
+ cancelRequested: job.cancelRequested,
394
+ startedAt: job.startedAt,
395
+ finishedAt: job.finishedAt,
396
+ exitCode: job.exitCode,
397
+ timedOut: job.timedOut,
398
+ error: job.error,
399
+ outputTail: [...job.outputTail],
400
+ outputTruncated: job.outputTruncated,
401
+ postInstallProbe: job.postInstallProbe,
402
+ }
403
+ }
404
+
405
+ function pushLine(job, line) {
406
+ if (job.outputTail.length >= OUTPUT_LINE_CAP) {
407
+ job.outputTail.shift()
408
+ job.outputTruncated = true
409
+ }
410
+ job.outputTail.push(line)
411
+ }
412
+
413
+ /**
414
+ * Start the fixed install for one registry tool id. Returns the job snapshot
415
+ * synchronously; poll installStatus until status is succeeded/failed.
416
+ */
417
+ export function startInstall(toolId) {
418
+ const tool = getOfficialTool(toolId)
419
+ if (!tool) throw new Error(`未知工具:${toolId}。只允许安装注册表中的官方工具。`)
420
+ if (tool.unsupported) throw new Error(tool.unsupportedReason)
421
+ const existing = installJobs.get(tool.id)
422
+ if (existing?.status === 'running') throw new Error(`${tool.label} 已有安装任务正在执行。`)
423
+ const command = installCommandLine(tool)
424
+ if (!command) throw new Error(tool.unsupportedReason)
425
+ // With shell mode on Windows the bare name resolves npm.cmd/uv.exe.
426
+ const manager = tool.manager === 'npm' ? 'npm' : tool.manager === 'signed-windows-installer' ? 'signed-windows-installer' : 'uv'
427
+ const job = {
428
+ tool,
429
+ command,
430
+ status: 'running',
431
+ phase: 'queued',
432
+ cancelRequested: false,
433
+ controller: new AbortController(),
434
+ startedAt: new Date().toISOString(),
435
+ finishedAt: null,
436
+ exitCode: null,
437
+ timedOut: false,
438
+ error: null,
439
+ outputTail: [`$ ${command}`],
440
+ outputTruncated: false,
441
+ postInstallProbe: null,
442
+ }
443
+ installJobs.set(tool.id, job)
444
+ probeCache.delete(tool.id)
445
+ installChain = installChain.then(() => manager === 'signed-windows-installer'
446
+ ? runZCodeInstallJob(job) : runInstallJob(manager, tool.installArgs, job)).catch(error => {
447
+ if (job.status === 'cancelled') return
448
+ job.status = 'failed'
449
+ job.finishedAt = new Date().toISOString()
450
+ job.error = `安装流程失败:${String(error?.message ?? error).slice(0, 200)}`
451
+ })
452
+ return jobView(job)
453
+ }
454
+
455
+ async function runInstallJob(manager, args, job) {
456
+ if (job.status !== 'running') return
457
+ job.phase = 'preflight'
458
+ await ensureNpmPrefixOnPath()
459
+ if (job.cancelRequested) { markCancelled(job); return }
460
+ probeCache.delete(job.tool.id)
461
+ const before = await probeToolWith(job.tool, defaultRunner)
462
+ if (job.cancelRequested) { markCancelled(job); return }
463
+ if (before.installed && job.tool.version) {
464
+ const order = compareStableVersions(before.version, job.tool.version)
465
+ if (order === null || order > 0) {
466
+ job.status = 'failed'
467
+ job.finishedAt = new Date().toISOString()
468
+ job.postInstallProbe = before
469
+ job.error = `已安装 ${before.version ?? '未知版本'},无法证明升级到 ${job.tool.version} 不会降级;请先人工核对。`
470
+ return
471
+ }
472
+ if (order === 0) {
473
+ const readiness = await trustedExecutionReadiness(job.tool.id)
474
+ if (job.cancelRequested) { markCancelled(job); return }
475
+ if (readiness.ready) {
476
+ job.status = 'succeeded'
477
+ job.finishedAt = new Date().toISOString()
478
+ job.postInstallProbe = before
479
+ pushLine(job, `— ${job.tool.label} ${before.version} 的官方执行入口已核验,无需重复安装 —`)
480
+ return
481
+ }
482
+ pushLine(job, `— 版本横幅已是 ${before.version},但执行入口未就绪:${readiness.reason};按固定来源修复 —`)
483
+ }
484
+ }
485
+ job.phase = 'installing'
486
+ const outcome = await runCapture(manager, args, {
487
+ timeoutMs: INSTALL_TIMEOUT_MS,
488
+ useShell: IS_WINDOWS,
489
+ signal: job.controller.signal,
490
+ onOutput: chunk => {
491
+ for (const line of chunk.split(/\r?\n/)) {
492
+ if (line.trim()) pushLine(job, line.slice(0, 500))
493
+ }
494
+ },
495
+ })
496
+ if (job.status !== 'running') return
497
+ if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
498
+ job.finishedAt = new Date().toISOString()
499
+ job.exitCode = outcome.code
500
+ job.timedOut = outcome.timedOut
501
+ if (outcome.timedOut) {
502
+ job.status = 'failed'
503
+ job.error = `安装超时(${INSTALL_TIMEOUT_MS / 1000}s),已终止。`
504
+ } else if (outcome.ok) {
505
+ job.phase = 'verifying'
506
+ await ensureNpmPrefixOnPath()
507
+ probeCache.delete(job.tool.id)
508
+ const probe = await probeToolWith(job.tool, defaultRunner)
509
+ if (job.cancelRequested) { markCancelled(job); return }
510
+ job.postInstallProbe = probe
511
+ if (!probe.installed || (job.tool.version && probe.version !== job.tool.version)) {
512
+ job.status = 'failed'
513
+ job.error = probe.installed
514
+ ? `安装命令已完成,但 PATH 上读到 ${probe.version ?? '未知版本'};预期 ${job.tool.version}。请检查重复安装。`
515
+ : `安装命令已完成,但重探测失败:${probe.detail}`
516
+ } else {
517
+ const readiness = await trustedExecutionReadiness(job.tool.id)
518
+ if (job.cancelRequested) { markCancelled(job); return }
519
+ if (!readiness.ready) {
520
+ if (IS_WINDOWS && job.tool.id === 'minimax-code') await runPinnedMiniMaxInstaller(job)
521
+ else {
522
+ job.status = 'failed'
523
+ job.error = `安装命令和版本探测已成功,但官方执行入口未就绪:${readiness.reason}`
524
+ }
525
+ } else {
526
+ job.status = 'succeeded'
527
+ pushLine(job, '— 安装、版本和官方执行入口均已核验 —')
528
+ }
529
+ }
530
+ } else if (IS_WINDOWS && job.tool.id === 'minimax-code') {
531
+ await runPinnedMiniMaxInstaller(job)
532
+ } else {
533
+ job.status = 'failed'
534
+ job.error = `安装命令失败(退出码 ${outcome.code ?? '信号终止'})。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 200) ?? ''}`
535
+ }
536
+ }
537
+
538
+ async function runZCodeInstallJob(job) {
539
+ if (job.status !== 'running') return
540
+ job.phase = 'preflight'
541
+ probeCache.delete(job.tool.id)
542
+ const before = await probeToolWith(job.tool, defaultRunner)
543
+ if (job.cancelRequested) { markCancelled(job); return }
544
+ if (before.installed && before.version === job.tool.version) {
545
+ job.status = 'succeeded'
546
+ job.phase = 'done'
547
+ job.finishedAt = new Date().toISOString()
548
+ job.postInstallProbe = before
549
+ pushLine(job, `— 已找到 ZCode ${before.version},无需重复打开安装器 —`)
550
+ return
551
+ }
552
+ const prefix = await runCapture('npm', ['config', 'get', 'prefix'], {
553
+ timeoutMs: 5_000, useShell: IS_WINDOWS, signal: job.controller.signal,
554
+ })
555
+ if (job.cancelRequested) { markCancelled(job); return }
556
+ job.phase = 'downloading-and-verifying'
557
+ let lastProgress = 0
558
+ try {
559
+ const opened = await openZCodeInstaller({
560
+ npmPrefix: prefix.ok ? prefix.stdout.trim() : undefined,
561
+ signal: job.controller.signal,
562
+ onProgress({ receivedBytes, declaredBytes }) {
563
+ const now = Date.now()
564
+ if (now - lastProgress < 3_000) return
565
+ lastProgress = now
566
+ pushLine(job, `已下载 ${(receivedBytes / 1_000_000).toFixed(1)} MB${declaredBytes ? ` / ${(declaredBytes / 1_000_000).toFixed(1)} MB` : ''}`)
567
+ },
568
+ })
569
+ if (job.cancelRequested) { markCancelled(job); return }
570
+ job.status = opened.status
571
+ job.phase = 'installer-opened'
572
+ job.finishedAt = new Date().toISOString()
573
+ probeCache.delete(job.tool.id)
574
+ pushLine(job, `已验证并打开官方安装器:${opened.installerPath}`)
575
+ pushLine(job, '请在原厂安装界面选择非 C 盘目录;完成后点击“重新检测”。')
576
+ } catch (error) {
577
+ if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
578
+ job.status = 'failed'
579
+ job.phase = 'done'
580
+ job.finishedAt = new Date().toISOString()
581
+ job.error = `ZCode 安装器未打开:${String(error?.message ?? error).slice(0, 300)}`
582
+ }
583
+ }
584
+
585
+ function markCancelled(job) {
586
+ job.status = 'cancelled'
587
+ job.phase = 'done'
588
+ job.finishedAt = new Date().toISOString()
589
+ job.error = '已取消安装。若 npm 已开始写入,可能需要重新检测或修复该工具。'
590
+ probeCache.delete(job.tool.id)
591
+ pushLine(job, '— 安装已取消;请重新检测实际状态 —')
592
+ }
593
+
594
+ /** Cancel a queued or running install; cancellation is reflected after its process exits. */
595
+ export function cancelInstall(toolId) {
596
+ const tool = getOfficialTool(toolId)
597
+ if (!tool) throw new Error(`未知工具:${toolId}。`)
598
+ const job = installJobs.get(tool.id)
599
+ if (!job || job.status !== 'running') throw new Error(`${tool.label} 当前没有进行中的安装任务。`)
600
+ if (!job.cancelRequested) {
601
+ job.cancelRequested = true
602
+ job.controller.abort()
603
+ if (job.phase === 'queued') markCancelled(job)
604
+ }
605
+ return jobView(job)
606
+ }
607
+
608
+ export function installStatus(toolId) {
609
+ const job = installJobs.get(String(toolId ?? '').trim())
610
+ return job ? jobView(job) : null
611
+ }
612
+
613
+ /** Plan-side projection: whether each provider can run through an installed CLI. */
614
+ export async function executionChannels(providers) {
615
+ const unique = [...new Set(providers.filter(Boolean))]
616
+ await Promise.all(unique.map(provider => {
617
+ const tool = OFFICIAL_TOOLS.find(entry => !entry.unsupported
618
+ && entry.providerHints.some(hint => provider.toLowerCase().includes(hint)))
619
+ return tool ? probeToolWith(tool, defaultRunner) : Promise.resolve(null)
620
+ }))
621
+ const resolved = new Map()
622
+ for (const provider of unique) {
623
+ const tool = OFFICIAL_TOOLS.find(entry => !entry.unsupported
624
+ && entry.providerHints.some(hint => provider.toLowerCase().includes(hint)))
625
+ if (!tool) {
626
+ resolved.set(provider, { kind: 'harness-llm', detail: '通过官方模型目录 API 调用。' })
627
+ continue
628
+ }
629
+ const probe = await probeToolWith(tool, defaultRunner)
630
+ resolved.set(provider, probe.installed
631
+ ? { kind: 'harness-llm', tool: tool.id, label: tool.label, version: probe.version, detail: 'CLI 已安装;执行入口仍须单独核验,当前使用官方模型目录 API。' }
632
+ : { kind: 'harness-llm', tool: tool.id, label: tool.label, detail: `${tool.label} 未安装;将使用官方模型目录 API 调用。可在会话中运行 /tools install ${tool.id} 安装。` })
633
+ }
634
+ return resolved
635
+ }
636
+
637
+ /** Test-only: drop caches and jobs. */
638
+ export function resetForTests() {
639
+ probeCache.clear()
640
+ installJobs.clear()
641
+ installChain = Promise.resolve()
642
+ }