@ljwei-stak/dsh-model-router 0.18.2 → 0.19.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.
@@ -44,6 +44,7 @@ const unavailable = () => { throw new Error('模型路由工作台服务尚未
44
44
  export function installSummary(preferences = installPreferences(), options = currentInstallOptions()) {
45
45
  return {
46
46
  installDir: preferences.installDir ?? '',
47
+ zcodeInstallDir: preferences.zcodeInstallDir ?? '',
47
48
  registry: preferences.registry ?? '',
48
49
  allowScriptInstall: preferences.allowScriptInstall !== false,
49
50
  scriptUrls: { ...(preferences.scriptUrls ?? {}) },
@@ -61,12 +62,14 @@ export function installSummary(preferences = installPreferences(), options = cur
61
62
  }
62
63
  if (tool.manager === 'signed-windows-installer') {
63
64
  entry.methodId = 'signed-windows-installer'
65
+ entry.installDir = preferences.zcodeInstallDir ?? ''
64
66
  if (options.platform === 'win32') {
65
67
  entry.command = installCommandLine(tool)
66
68
  entry.updateCommand = entry.command
67
- entry.notices = [preferences.installDir
68
- ? `请在官方安装窗口手动选择 ${preferences.installDir},完成后重新体检。`
69
- : '请在官方安装窗口选择目录并完成安装,然后重新体检。']
69
+ entry.notices = [preferences.zcodeInstallDir
70
+ ? `TUI 使用已保存的 ZCode 桌面目录 ${preferences.zcodeInstallDir}。更新时请在官方安装窗口选择同一目录,完成后重新体检。`
71
+ : '请在下方单独配置 ZCode 桌面目录,或留空自动检测。内置 CLI 随桌面版更新,TUI 的登录、模型和插件在终端中自行配置。']
72
+ entry.notices.push('ZCode 桌面目录独立于统一 npm 安装根;不会把桌面资源放进其他工具的共享目录。')
70
73
  } else entry.installError = entry.error = 'ZCode 签名安装器仅支持 Windows。'
71
74
  entry.uninstallError = 'ZCode 是桌面安装版,请在系统“应用”中卸载它。'
72
75
  return entry
@@ -191,6 +194,7 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
191
194
  // The saved input can contain Host-only home/environment expansions. Keep
192
195
  // it separate from the resolved directory used by the actual command.
193
196
  install.requestedInstallDir = this.services?.installRequestedDirectory?.() ?? install.installDir
197
+ install.requestedZCodeInstallDir = this.services?.zcodeRequestedDirectory?.() ?? install.zcodeInstallDir
194
198
  return { install, hostVersion: HOST_PLUGIN_VERSION }
195
199
  }
196
200
 
@@ -9,10 +9,12 @@
9
9
  * into that interactive process, exactly as in their own terminal, so a
10
10
  * session starts only after an explicit confirmation.
11
11
  */
12
- import { getOfficialTool } from './official-tool-registry.mjs'
12
+ import { getOfficialTool, OFFICIAL_TOOLS } from './official-tool-registry.mjs'
13
13
 
14
- /** Registry CLIs that have an interactive terminal UI (ZCode is a desktop app). */
15
- export const TERMINAL_TOOL_IDS = Object.freeze(['codex', 'claude-code', 'kimi-code', 'minimax-code', 'mimo-code', 'grok-build', 'gemini'])
14
+ /** Follow the tool registry, including the TUI bundled with ZCode desktop. */
15
+ export const TERMINAL_TOOL_IDS = Object.freeze(OFFICIAL_TOOLS
16
+ .filter(tool => !tool.unsupported && (tool.probeExecutables?.length || tool.manager === 'signed-windows-installer'))
17
+ .map(tool => tool.id))
16
18
  export const TERMINAL_SHELL_ID = 'shell'
17
19
  export const TERMINAL_TARGETS = Object.freeze([TERMINAL_SHELL_ID, ...TERMINAL_TOOL_IDS])
18
20
  export const TERMINAL_MODES = Object.freeze(['interactive', 'login'])
@@ -46,8 +48,16 @@ export const TERMINAL_LOGIN_ARGS = Object.freeze({
46
48
  'mimo-code': Object.freeze(['auth', 'login']),
47
49
  'grok-build': Object.freeze(['login']),
48
50
  gemini: Object.freeze([]),
51
+ opencode: Object.freeze(['auth', 'login']),
52
+ stepcode: Object.freeze(['login']),
53
+ zcode: Object.freeze([]),
49
54
  })
50
55
 
56
+ /** Tools without a separate login command authenticate from their TUI. */
57
+ export function terminalCanLogin(target) {
58
+ return Boolean(TERMINAL_LOGIN_ARGS[target]?.length)
59
+ }
60
+
51
61
  const SESSION_ID = /^term-[a-z0-9]{6,40}$/
52
62
 
53
63
  function plainObject(value, subject) {
@@ -82,7 +92,7 @@ export function terminalTargetLabel(target) {
82
92
  export function terminalCommandPreview(target, mode = 'interactive', platform = 'win32') {
83
93
  if (target === TERMINAL_SHELL_ID) return platform === 'win32' ? 'powershell.exe -NoLogo' : '$SHELL -l'
84
94
  const tool = getOfficialTool(target)
85
- const executable = tool?.probeExecutables?.[0] ?? String(target)
95
+ const executable = target === 'zcode' ? 'ZCode 桌面版内置 TUI' : tool?.probeExecutables?.[0] ?? String(target)
86
96
  const args = mode === 'login' ? TERMINAL_LOGIN_ARGS[target] ?? [] : []
87
97
  return [executable, ...args].join(' ')
88
98
  }
@@ -92,7 +102,7 @@ export function parseTerminalStart(value) {
92
102
  if (!TERMINAL_TARGETS.includes(request.target)) throw new TypeError('target must be the shell or a fixed official CLI')
93
103
  const mode = request.mode === undefined ? 'interactive' : request.mode
94
104
  if (!TERMINAL_MODES.includes(mode)) throw new TypeError('mode must be interactive or login')
95
- if (mode === 'login' && request.target === TERMINAL_SHELL_ID) throw new TypeError('the shell has no login mode')
105
+ if (mode === 'login' && !terminalCanLogin(request.target)) throw new TypeError('this target has no login mode')
96
106
  if (!isAbsoluteDirectoryText(request.cwd)) throw new TypeError('cwd must be an absolute directory path')
97
107
  if (request.confirmed !== true) throw new TypeError('starting a terminal requires the user\'s confirmation')
98
108
  return {
@@ -13,11 +13,14 @@
13
13
  */
14
14
  import { spawn } from 'node:child_process'
15
15
  import { randomBytes } from 'node:crypto'
16
- import { stat } from 'node:fs/promises'
16
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises'
17
17
  import { createRequire } from 'node:module'
18
18
  import { StringDecoder } from 'node:string_decoder'
19
- import { extname } from 'node:path'
20
- import { getOfficialTool } from './official-tool-registry.mjs'
19
+ import { homedir } from 'node:os'
20
+ import { extname, isAbsolute, join, relative, sep } from 'node:path'
21
+ import { getOfficialTool, installMethodFor } from './official-tool-registry.mjs'
22
+ import { resolveInstallPlan } from './tool-install-preferences.mjs'
23
+ import { discoverZCodeBundle } from './zcode-bundle.mjs'
21
24
  import {
22
25
  TERMINAL_LIMITS,
23
26
  TERMINAL_LOGIN_ARGS,
@@ -84,6 +87,75 @@ export function pickWindowsExecutable(candidates) {
84
87
 
85
88
  const quoteWindows = path => `"${String(path).replace(/"/g, '')}"`
86
89
 
90
+ function within(root, child) {
91
+ const part = relative(root, child)
92
+ return Boolean(part) && part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
93
+ }
94
+
95
+ /** Bind a configured npm/pnpm tool to its declared package entry, not a shared shim. */
96
+ export async function findTerminalPackageEntry(tool, plan, { platform = process.platform } = {}) {
97
+ let roots = plan.packageModuleRoots
98
+ let boundary
99
+ if (!Array.isArray(roots)) {
100
+ if (!plan.installDir) return null
101
+ if (plan.manager === 'pnpm') {
102
+ const global = join(plan.installDir, '.pnpm', 'global')
103
+ boundary = await realpath(global).catch(() => null)
104
+ const versions = await readdir(global, { withFileTypes: true }).catch(() => [])
105
+ roots = versions.filter(item => item.isDirectory() && /^\d+$/.test(item.name))
106
+ .sort((left, right) => Number(left.name) - Number(right.name))
107
+ .map(item => join(global, item.name, 'node_modules'))
108
+ } else {
109
+ const modules = platform === 'win32' ? join(plan.installDir, 'node_modules') : join(plan.installDir, 'lib', 'node_modules')
110
+ boundary = await realpath(modules).catch(() => null)
111
+ roots = [modules]
112
+ }
113
+ } else {
114
+ boundary = await realpath(plan.globalDir || plan.installDir || roots[0]).catch(() => null)
115
+ }
116
+ if (!boundary) return null
117
+ const entries = []
118
+ for (const modules of roots) {
119
+ try {
120
+ const packageRoot = await realpath(join(modules, ...tool.package.split('/')))
121
+ if (!within(boundary, packageRoot)) continue
122
+ const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
123
+ if (manifest.name !== tool.package) continue
124
+ for (const executable of tool.probeExecutables) {
125
+ const raw = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[executable]
126
+ const bin = typeof raw === 'string' ? raw.replace(/\\/g, '/').replace(/^\.\//, '') : ''
127
+ if (!bin || bin.split('/').some(part => !part || part === '..') || isAbsolute(bin)) continue
128
+ const entry = await realpath(join(packageRoot, ...bin.split('/')))
129
+ if (!within(packageRoot, entry) || !(await stat(entry)).isFile()) continue
130
+ entries.push({ entry, packageRoot, version: String(manifest.version ?? '') })
131
+ }
132
+ } catch { /* not the selected tool's usable package */ }
133
+ }
134
+ // pnpm normally has one active global tree. Prefer the highest numbered
135
+ // layout when multiple layouts survive an upgrade, following its directories.
136
+ return entries.at(-1) ?? null
137
+ }
138
+
139
+ async function terminalNode(workspace, minimum, acceptedVersion) {
140
+ const { findNodeExecutable } = await import('./official-tool-executor.mjs')
141
+ return findNodeExecutable(workspace, minimum, acceptedVersion)
142
+ }
143
+
144
+ function executableLaunch(file, args, display, { platform, env }) {
145
+ if (platform !== 'win32') return { file, args, verbatim: false, display }
146
+ const extension = extname(file).toLowerCase()
147
+ if (extension === '.cmd' || extension === '.bat') {
148
+ const comspec = env.ComSpec || env.COMSPEC || 'cmd.exe'
149
+ const tail = `/d /s /c "${[quoteWindows(file), ...args].join(' ')}"`
150
+ return { file: comspec, args: tail, verbatim: true, display }
151
+ }
152
+ if (extension === '.ps1') {
153
+ const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
154
+ return { file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, args: ['-NoLogo', '-NoProfile', '-File', file, ...args], verbatim: false, display }
155
+ }
156
+ return { file, args, verbatim: false, display }
157
+ }
158
+
87
159
  /**
88
160
  * Build the fixed launch for one target. `locate(executable)` returns the
89
161
  * candidate paths `where`/`which` printed. Returns
@@ -92,6 +164,9 @@ const quoteWindows = path => `"${String(path).replace(/"/g, '')}"`
92
164
  */
93
165
  export async function resolveTerminalLaunch(target, mode = 'interactive', {
94
166
  platform = process.platform, env = process.env, locate = defaultLocate,
167
+ preferences = {}, home = homedir(), cwd = process.cwd(),
168
+ packageEntry = findTerminalPackageEntry, fileExists = async path => (await stat(path).catch(() => null))?.isFile() === true,
169
+ discoverBundle = discoverZCodeBundle, findNode = terminalNode,
95
170
  } = {}) {
96
171
  if (target === TERMINAL_SHELL_ID) {
97
172
  if (platform === 'win32') {
@@ -104,9 +179,47 @@ export async function resolveTerminalLaunch(target, mode = 'interactive', {
104
179
  }
105
180
  if (!TERMINAL_TOOL_IDS.includes(target)) throw new Error('只能启动系统终端或固定注册表中的官方 CLI。')
106
181
  const tool = getOfficialTool(target)
182
+ if (target === 'zcode') {
183
+ if (platform !== 'win32') throw new Error('当前仅支持 Windows ZCode 桌面版内置 TUI。')
184
+ const bundle = await discoverBundle({ installDir: preferences.zcodeInstallDir, env })
185
+ if (!bundle) throw new Error('未找到通过智谱签名与组件核验的 ZCode 桌面版;请先配置 ZCode 独立安装目录并重新体检。')
186
+ const node = await findNode(cwd, [24, 14, 0], version => Number(String(version).split('.')[0]) === 24)
187
+ // The verified desktop's electron-node entry is usable independently of
188
+ // its GUI. It supplies its own runtime when external Node 24 is absent.
189
+ return { file: node || bundle.executable, args: [bundle.cliEntry], verbatim: false,
190
+ display: `ZCode TUI · ${bundle.cliEntry}`, ...(!node ? { env: { ELECTRON_RUN_AS_NODE: '1' } } : {}) }
191
+ }
107
192
  const executable = tool.probeExecutables[0]
108
193
  const args = mode === 'login' ? [...(TERMINAL_LOGIN_ARGS[target] ?? [])] : []
109
194
  const display = [executable, ...args].join(' ')
195
+ const selectedScript = installMethodFor(tool, preferences.methods?.[target])?.kind === 'script'
196
+ const selectedPreferences = selectedScript ? { ...preferences, allowScriptInstall: true } : preferences
197
+ const plan = resolveInstallPlan(tool, undefined, selectedPreferences, { platform, env, home })
198
+ const configured = Boolean(preferences.installDir || preferences.methods?.[target])
199
+ if (plan.kind === 'package-manager' && configured) {
200
+ const boundPlan = plan.installDir ? plan : await (await import('./official-tools-runtime.mjs')).resolvePackageJobPlan(tool, plan, selectedPreferences, { options: { platform, env, home } })
201
+ const found = await packageEntry(tool, boundPlan, { platform })
202
+ if (!found) throw new Error(`未在所选 ${boundPlan.manager} 安装目录中找到 ${tool.label} 的真实命令入口;请重新体检或修复。`)
203
+ const extension = extname(found.entry).toLowerCase()
204
+ if (['.js', '.mjs', '.cjs'].includes(extension) || (platform === 'win32' && !extension)) {
205
+ const node = await findNode(cwd)
206
+ if (!node) throw new Error(`未找到可核验的 Node.js 运行时,无法启动 ${tool.label}。`)
207
+ return { file: node, args: [found.entry, ...args], verbatim: false, display }
208
+ }
209
+ return executableLaunch(found.entry, args, display, { platform, env })
210
+ }
211
+ if (plan.kind === 'script' && plan.binDir) {
212
+ for (const name of tool.probeExecutables) {
213
+ const filenames = platform === 'win32' ? WINDOWS_PREFERENCE.map(extension => `${name}${extension}`) : [name]
214
+ for (const filename of filenames) {
215
+ const file = join(plan.binDir, filename)
216
+ if (await fileExists(file)) return executableLaunch(file, args, display, { platform, env })
217
+ }
218
+ }
219
+ // A saved method or directory is authoritative: an older PATH copy cannot
220
+ // satisfy a missing install in the user's selected tool directory.
221
+ if (configured || tool.manager === 'script-installer') throw new Error(`未在所选安装目录 ${plan.binDir} 中找到 ${tool.label};请重新体检或安装。`)
222
+ }
110
223
  const candidates = await locate(executable, { platform, env })
111
224
  if (platform !== 'win32') {
112
225
  const file = candidates.map(item => String(item).trim()).find(item => item.startsWith('/'))
@@ -115,18 +228,7 @@ export async function resolveTerminalLaunch(target, mode = 'interactive', {
115
228
  }
116
229
  const file = pickWindowsExecutable(candidates)
117
230
  if (!file) throw new Error(`未在 PATH 中找到 ${executable};请先在“官方工具 · 体检”中安装 ${tool.label}。`)
118
- const extension = extname(file).toLowerCase()
119
- if (extension === '.cmd' || extension === '.bat') {
120
- // npm shims only run under cmd.exe. Every token is fixed by the registry.
121
- const comspec = env.ComSpec || env.COMSPEC || 'cmd.exe'
122
- const tail = `/d /s /c "${[quoteWindows(file), ...args].join(' ')}"`
123
- return { file: comspec, args: tail, verbatim: true, display }
124
- }
125
- if (extension === '.ps1') {
126
- const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
127
- return { file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, args: ['-NoLogo', '-NoProfile', '-File', file, ...args], verbatim: false, display }
128
- }
129
- return { file, args, verbatim: false, display }
231
+ return executableLaunch(file, args, display, { platform, env })
130
232
  }
131
233
 
132
234
  /** Locate an executable with where.exe / which (fixed registry names only). */
@@ -161,7 +263,10 @@ function spawnPtyProcess(pty, launch, { cwd, cols, rows, env }) {
161
263
 
162
264
  /** Pipe fallback: no TTY; the Host echoes input and maps Enter/Ctrl+C. */
163
265
  export function spawnPipeProcess(launch, { cwd, env, platform = process.platform, spawnImpl = spawn }) {
164
- const child = spawnImpl(launch.file, launch.args, {
266
+ // node-pty accepts a Windows command-line string; node:child_process needs
267
+ // an array even with windowsVerbatimArguments. Keep the prepared tail intact.
268
+ const args = typeof launch.args === 'string' ? [launch.args] : launch.args
269
+ const child = spawnImpl(launch.file, args, {
165
270
  cwd, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false,
166
271
  windowsVerbatimArguments: launch.verbatim === true,
167
272
  })
@@ -220,6 +325,8 @@ export function createTerminalManager({
220
325
  statPath = stat,
221
326
  prepare = async () => {},
222
327
  environment = () => terminalEnvironment(process.env),
328
+ preferences = () => ({}),
329
+ launchOptions = () => ({}),
223
330
  onSessionEnd = () => {},
224
331
  now = Date.now,
225
332
  platform = process.platform,
@@ -329,10 +436,11 @@ export function createTerminalManager({
329
436
  try { info = await statPath(request.cwd) } catch { info = null }
330
437
  if (!info?.isDirectory?.()) throw new Error(`工作目录不存在或不是目录:${request.cwd}`)
331
438
  await prepare()
332
- const launch = await resolveLaunch(request.target, request.mode, { platform })
333
439
  const env = environment()
440
+ const launch = await resolveLaunch(request.target, request.mode, { ...launchOptions(), platform, env,
441
+ preferences: preferences(), cwd: request.cwd })
334
442
  const backend = backendInfo()
335
- const options = { cwd: request.cwd, cols: request.cols, rows: request.rows, env, platform }
443
+ const options = { cwd: request.cwd, cols: request.cols, rows: request.rows, env: { ...env, ...launch.env }, platform }
336
444
  let proc
337
445
  try {
338
446
  proc = backend.backend === 'pty'
@@ -586,7 +586,7 @@ async function launchSpec(toolId, workspace, mode, modelId, task = 'Check readin
586
586
  }
587
587
  }
588
588
  if (toolId === 'zcode') {
589
- const found = await discoverZCodeBundle()
589
+ const found = await discoverZCodeBundle({ installDir: (selectionOptions?.preferences ?? installPreferences()).zcodeInstallDir })
590
590
  if (!found) return { unsupported: '未找到具有智谱有效签名、且 CLI 脚本与首次核验记录一致的 ZCode 桌面版。' }
591
591
  const nodeExecutable = await findNodeExecutable(workspace, [24, 14, 0], version =>
592
592
  Number(String(version).split('.')[0]) === 24)
@@ -405,12 +405,12 @@ export const OFFICIAL_TOOLS = Object.freeze([
405
405
  id: 'zcode',
406
406
  label: 'ZCode',
407
407
  vendor: 'Z.ai',
408
- purpose: '智谱官方 ZCode 桌面版,内含 GLM 编程代理。Windows 安装器可选择 D 盘目录。',
408
+ purpose: '智谱官方 ZCode 桌面版,内含可独立运行的 GLM CLI/TUI。可单独配置桌面目录,内置 CLI 随桌面版更新。',
409
409
  manager: 'signed-windows-installer',
410
410
  installArgs: [],
411
411
  uninstallArgs: [],
412
412
  probeExecutables: [],
413
- probeNote: '检测经过有效签名的 ZCode.exe 和同目录 GLM 资源;桌面安装器需人工选择安装位置。',
413
+ probeNote: '检测经过有效签名的 ZCode.exe 和同目录 GLM CLI/TUI 资源;桌面安装器需人工选择安装位置。',
414
414
  providerHints: ['zai', 'z.ai', 'zcode', 'glm', 'zhipu', 'bigmodel'],
415
415
  }),
416
416
  ])
@@ -60,7 +60,7 @@ export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
60
60
  * and script source). Refreshed on apply and live config updates, so a client
61
61
  * can only ever pick a method id — these addresses never arrive over the wire.
62
62
  */
63
- let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
63
+ let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true, zcodeInstallDir: '' }
64
64
 
65
65
  export function setInstallPreferences(next) {
66
66
  if (next && typeof next === 'object') {
@@ -868,10 +868,13 @@ async function probeUncached(tool, runner, { selectedPreferences, plan, fileExis
868
868
  }
869
869
  if (tool.manager === 'signed-windows-installer') {
870
870
  let rejected = null
871
- const bundle = await discoverZCodeBundle({ onRejected: value => { rejected ??= value } })
871
+ const bundle = await discoverZCodeBundle({ installDir: (selectedPreferences ?? preferences).zcodeInstallDir,
872
+ onRejected: value => { rejected ??= value } })
872
873
  if (bundle) return { id: tool.id, installed: true, version: bundle.version, status: 'installed',
873
- detail: `${tool.probeNote} 安装目录:${bundle.root}`, bannerLine: `${tool.label} ${bundle.buildVersion}` }
874
- if (rejected) return { id: tool.id, installed: false, version: null, status: 'not-installed', detail: `检测到已安装的 ZCode ${rejected.version}(${rejected.root}):${rejected.rejected}` }
874
+ detail: `${tool.probeNote} 安装目录:${bundle.root}`, bannerLine: `${tool.label} 桌面版 ${bundle.buildVersion}`,
875
+ installDir: bundle.root, cliEntry: bundle.cliEntry, source: bundle.source }
876
+ if (rejected) return { id: tool.id, installed: false, version: null, status: 'not-installed',
877
+ detail: rejected.version ? `检测到 ZCode ${rejected.version}(${rejected.root}):${rejected.rejected}` : rejected.rejected }
875
878
  const other = (await unverifiedZCodeInstalls().catch(() => []))[0]
876
879
  return { id: tool.id, installed: false, version: null, status: 'not-installed',
877
880
  detail: other
@@ -1589,30 +1592,43 @@ async function runInstallJob(installPlan, job) {
1589
1592
  await finishVerifiedInstall(job, probe)
1590
1593
  }
1591
1594
 
1592
- async function runZCodeInstallJob(job) {
1595
+ export async function runZCodeInstallJob(job, {
1596
+ probe = probeToolWith, lookup = (...args) => latestVersions.lookup(...args),
1597
+ openInstaller = openZCodeInstaller, capture = runCapture,
1598
+ } = {}) {
1593
1599
  if (job.status !== 'running') return
1594
1600
  job.phase = 'preflight'
1595
1601
  probeCache.delete(job.tool.id)
1596
- const before = await probeToolWith(job.tool, defaultRunner)
1602
+ const before = await probe(job.tool, defaultRunner, { preferences: job.preferences, cacheMs: 0 })
1597
1603
  if (job.cancelRequested) { markCancelled(job); return }
1598
- const latest = await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null)
1604
+ const latest = await lookup(job.tool, { fresh: true }).catch(() => null)
1599
1605
  if (job.cancelRequested) { markCancelled(job); return }
1600
- if (before.installed && latest?.version && (compareReleaseVersions(before.version, latest.version) ?? -1) >= 0) {
1606
+ const desktopOrder = before.installed && latest?.version ? compareReleaseVersions(before.version, latest.version) : null
1607
+ if (before.installed && desktopOrder === null) {
1608
+ job.status = 'failed'
1609
+ job.phase = 'done'
1610
+ job.finishedAt = new Date().toISOString()
1611
+ job.error = '无法核实 ZCode 最新桌面版本;保留当前安装,网络恢复后重试检查更新。'
1612
+ return
1613
+ }
1614
+ if (desktopOrder === 1 || (job.operation !== 'repair' && desktopOrder === 0)) {
1601
1615
  job.status = 'succeeded'
1602
1616
  job.phase = 'done'
1603
1617
  job.finishedAt = new Date().toISOString()
1604
1618
  job.postInstallProbe = before
1605
- pushLine(job, `— 已找到 ZCode ${before.version}(最新 ${latest.version}),无需重复打开安装器 —`)
1619
+ pushLine(job, desktopOrder === 1
1620
+ ? `— ZCode ${before.version} 高于下载页的 ${latest.version},不会打开旧版安装器降级;TUI 使用当前桌面内置入口 —`
1621
+ : `— 已找到 ZCode ${before.version}(最新 ${latest.version}),无需重复打开安装器;TUI 使用当前桌面内置入口 —`)
1606
1622
  return
1607
1623
  }
1608
- const prefix = await runCapture('npm', ['config', 'get', 'prefix'], {
1624
+ const prefix = await capture('npm', ['config', 'get', 'prefix'], {
1609
1625
  timeoutMs: 5_000, useShell: IS_WINDOWS, signal: job.controller.signal,
1610
1626
  })
1611
1627
  if (job.cancelRequested) { markCancelled(job); return }
1612
1628
  job.phase = 'downloading-and-verifying'
1613
1629
  let lastProgress = 0
1614
1630
  try {
1615
- const opened = await openZCodeInstaller({
1631
+ const opened = await openInstaller({
1616
1632
  latest,
1617
1633
  npmPrefix: prefix.ok ? prefix.stdout.trim() : undefined,
1618
1634
  signal: job.controller.signal,
@@ -1629,7 +1645,11 @@ async function runZCodeInstallJob(job) {
1629
1645
  job.finishedAt = new Date().toISOString()
1630
1646
  probeCache.delete(job.tool.id)
1631
1647
  pushLine(job, `已验证发布者签名并打开 ZCode ${opened.version} 官方安装器:${opened.installerPath}`)
1632
- pushLine(job, '请在原厂安装界面选择非 C 盘目录;完成后点击“重新检测”。')
1648
+ const desktopDirectory = before.installDir || (/[\\/]ZCode\.exe$/i.test(job.preferences.zcodeInstallDir ?? '')
1649
+ ? dirname(job.preferences.zcodeInstallDir) : job.preferences.zcodeInstallDir)
1650
+ pushLine(job, desktopDirectory
1651
+ ? `请在原厂安装界面选择原桌面安装目录 ${desktopDirectory};完成后重新体检,内置 TUI 随桌面版更新。`
1652
+ : '请在原厂安装界面选择独立的 ZCode 桌面目录;完成后保存该目录并重新体检,内置 TUI 随桌面版更新。')
1633
1653
  } catch (error) {
1634
1654
  if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
1635
1655
  job.status = 'failed'
@@ -1700,5 +1720,5 @@ export function resetForTests() {
1700
1720
  installJobs.clear()
1701
1721
  installChain = Promise.resolve()
1702
1722
  prefixPromise = null
1703
- preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
1723
+ preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true, zcodeInstallDir: '' }
1704
1724
  }
@@ -70,6 +70,26 @@ export function expandInstallDir(value, { home = '', env = {} } = {}) {
70
70
  return expanded
71
71
  }
72
72
 
73
+ /** ZCode's existing desktop bundle is selected independently of npm's prefix. */
74
+ export function normalizeZCodeInstallDir(value, options = {}) {
75
+ const input = String(value ?? '')
76
+ if (/[\x00-\x1f]/.test(input)) throw new Error('ZCode 安装位置包含无效字符。')
77
+ const raw = input.trim().replace(/^"(.*)"$/, '$1')
78
+ if (!raw) return ''
79
+ let path
80
+ try { path = expandInstallDir(raw, options) } catch (error) {
81
+ throw new Error(`ZCode 安装位置无效:${error.message}`)
82
+ }
83
+ // Discovery and Authenticode verification operate on local Windows files.
84
+ if (!/^[A-Za-z]:[\\/]/.test(path)) {
85
+ throw new Error('ZCode 安装位置必须是 Windows 本地绝对目录或 ZCode.exe 路径。')
86
+ }
87
+ if (/\.exe[\\/]*$/i.test(path) && !/[\\/]ZCode\.exe[\\/]*$/i.test(path)) {
88
+ throw new Error('ZCode 程序路径必须指向官方 ZCode.exe。')
89
+ }
90
+ return path.replace(/[\\/]+$/, '')
91
+ }
92
+
73
93
  /** Only https mirrors are accepted; a plain-http script source can be tampered with in transit. */
74
94
  export function normalizeSourceUrl(value, subject) {
75
95
  const raw = String(value ?? '').trim()
@@ -116,6 +136,7 @@ function keepKnownToolIds(raw, known, subject) {
116
136
  export function parseInstallPreferences(config = {}, { tools = [], home = '', env = {} } = {}) {
117
137
  const known = new Set(tools.map(tool => tool.id))
118
138
  const installDir = expandInstallDir(valueOf(config, 'toolInstallDir', ''), { home, env })
139
+ const zcodeInstallDir = normalizeZCodeInstallDir(valueOf(config, 'zcodeInstallDir', ''), { home, env })
119
140
  const registry = normalizeSourceUrl(valueOf(config, 'toolNpmRegistry', DEFAULT_NPM_REGISTRY), 'npm 源') || DEFAULT_NPM_REGISTRY
120
141
  const methodRaw = keepKnownToolIds(
121
142
  parseJsonObject(valueOf(config, 'toolInstallMethodsJson', '{}'), '安装方式覆盖'), known, '安装方式覆盖')
@@ -134,6 +155,7 @@ export function parseInstallPreferences(config = {}, { tools = [], home = '', en
134
155
  }
135
156
  return {
136
157
  installDir,
158
+ zcodeInstallDir,
137
159
  registry,
138
160
  methods,
139
161
  scriptUrls,
@@ -46,6 +46,14 @@ function powershellPath(env) {
46
46
  return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
47
47
  }
48
48
 
49
+ /** PowerShell 7's module path can prevent Windows PowerShell 5.1 loading Security. */
50
+ export function zcodeWindowsPowerShellEnvironment(env = process.env) {
51
+ return {
52
+ ...env,
53
+ PSModulePath: join(env.SystemRoot || env.windir || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'Modules'),
54
+ }
55
+ }
56
+
49
57
  async function powershellJson(script, env, extraEnv = {}) {
50
58
  if (process.platform !== 'win32') return null
51
59
  const command = Buffer.from(
@@ -62,7 +70,7 @@ async function powershellJson(script, env, extraEnv = {}) {
62
70
  encoding: 'utf8',
63
71
  timeout: 10_000,
64
72
  maxBuffer: 256_000,
65
- env: { ...env, ...extraEnv },
73
+ env: { ...zcodeWindowsPowerShellEnvironment(env), ...extraEnv },
66
74
  })
67
75
  const output = stdout.trim().replace(/^\uFEFF/, '')
68
76
  return output ? JSON.parse(output) : null
@@ -94,13 +102,21 @@ function rootFromCommand(value) {
94
102
  return executable && win32.isAbsolute(executable) ? dirname(executable) : null
95
103
  }
96
104
 
97
- function candidateRoots(env, records) {
105
+ /** Explicit selection is authoritative, even when the selected bundle is missing. */
106
+ export function zcodeDiscoveryCandidates({ installDir = '', env = process.env, records = [] } = {}) {
98
107
  const candidates = []
99
108
  const add = (path, source, registryVersion = null) => {
100
109
  if (typeof path !== 'string' || !path.trim()) return
101
110
  candidates.push({ path: path.trim().replace(/^"|"$/g, ''), source, registryVersion })
102
111
  }
103
- add(env[ZCODE_INSTALL_ENV_VAR], 'operator-env')
112
+ if (installDir != null && String(installDir).trim()) {
113
+ add(installDir, 'operator-config')
114
+ return candidates
115
+ }
116
+ if (env[ZCODE_INSTALL_ENV_VAR] != null && String(env[ZCODE_INSTALL_ENV_VAR]).trim()) {
117
+ add(env[ZCODE_INSTALL_ENV_VAR], 'operator-env')
118
+ return candidates
119
+ }
104
120
  for (const record of records) {
105
121
  const version = String(record.displayVersion || '') || null
106
122
  add(record.installLocation, 'uninstall-registry', version)
@@ -204,7 +220,7 @@ async function checkedBundle(candidate, env, trustPath) {
204
220
  const input = candidate.path
205
221
  if (!localWindowsPath(input)) return null
206
222
  try {
207
- const initial = input.toLowerCase().endsWith('\\zcode.exe') ? dirname(input) : input
223
+ const initial = /[\\/]zcode\.exe$/i.test(input) ? dirname(input) : input
208
224
  const root = await realpath(initial)
209
225
  if (!localWindowsPath(root) || !(await stat(root)).isDirectory()) return null
210
226
  const executable = await realpath(join(root, 'ZCode.exe'))
@@ -239,18 +255,23 @@ async function checkedBundle(candidate, env, trustPath) {
239
255
  }
240
256
  }
241
257
 
242
- /** Return the first verified local bundle (any version), or null when none is usable. */
243
- export async function discoverZCodeBundle({ env = process.env, trustPath = zcodeTrustPath(env), onRejected } = {}) {
258
+ /** A configured desktop location never falls back to a different installation. */
259
+ export async function discoverZCodeBundle({ installDir = '', env = process.env, trustPath = zcodeTrustPath(env), onRejected } = {}) {
244
260
  if (process.platform !== 'win32') return null
245
- const records = await uninstallRecords(env)
261
+ const explicit = Boolean(String(installDir || env[ZCODE_INSTALL_ENV_VAR] || '').trim())
262
+ const records = explicit ? [] : await uninstallRecords(env)
246
263
  const seen = new Set()
247
- for (const candidate of candidateRoots(env, records)) {
264
+ for (const candidate of zcodeDiscoveryCandidates({ installDir, env, records })) {
248
265
  const key = win32.normalize(candidate.path).toLowerCase()
249
266
  if (seen.has(key)) continue
250
267
  seen.add(key)
251
268
  const bundle = await checkedBundle(candidate, env, trustPath)
252
269
  if (bundle?.rejected) { onRejected?.(bundle); continue }
253
270
  if (bundle) return bundle
271
+ if (explicit) onRejected?.({
272
+ root: candidate.path,
273
+ rejected: `指定的 ZCode 安装位置 ${candidate.path} 不存在,或未通过官方签名、桌面内置 CLI 和目录边界核验;请检查独立安装位置。`,
274
+ })
254
275
  }
255
276
  return null
256
277
  }
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, relative, sep } from 'node:path'
19
19
  import { Readable, Transform } from 'node:stream'
20
20
  import { pipeline } from 'node:stream/promises'
21
21
  import { promisify } from 'node:util'
22
- import { ZCODE_SIGNER } from './zcode-bundle.mjs'
22
+ import { ZCODE_SIGNER, zcodeWindowsPowerShellEnvironment } from './zcode-bundle.mjs'
23
23
  import { getOfficialTool } from './official-tool-registry.mjs'
24
24
  import { latestVersions } from './latest-versions.mjs'
25
25
 
@@ -118,7 +118,7 @@ async function verifyInstallerSignature(path, env) {
118
118
  encoding: 'utf8',
119
119
  timeout: 120_000,
120
120
  maxBuffer: 16_384,
121
- env: { ...env, MODEL_ROUTER_ZCODE_INSTALLER_PATH: path },
121
+ env: { ...zcodeWindowsPowerShellEnvironment(env), MODEL_ROUTER_ZCODE_INSTALLER_PATH: path },
122
122
  })
123
123
  const signature = JSON.parse(stdout.trim().replace(/^\uFEFF/, ''))
124
124
  return signature.status === 'Valid'
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0 — 2026-10-12
4
+
5
+ Stable minor under the npm latest tag. Fully quit Harness (including its tray or WebUI Host process) and restart after upgrading.
6
+
7
+ - Synchronize interactive terminal targets with the complete official-tool registry, adding OpenCode, Step Code and the verified Windows ZCode desktop-bundled TUI. Terminal refresh discovers current installations; older requests and health snapshots cannot restore stale target lists.
8
+ - Resolve terminals from the saved package manager, package entry or private script directory. Refuse to launch a different PATH copy when the selected installation is missing. Fix Windows pipe fallback argument handling for command shims.
9
+ - Add independent ZCode desktop-directory configuration in its tool card and plugin settings. Explicit selection is authoritative; missing, unsigned or modified bundles cannot silently fall back to another desktop. The built-in CLI follows desktop updates, while users configure login, models and plugins in TUI.
10
+ - Offer separate desktop update checks and reinstall controls, preserve reviewed queued-job settings and refuse known downgrades or an unverified latest version. Opening an installer remains distinct from completed installation.
11
+ - Fix ZCode discovery/installer signature checks under inherited PowerShell 7 module paths by using the Windows PowerShell system module directory. Prefer signed external Node 24 for TUI; a verified desktop Electron runtime can run the same bundled entry when Node is unavailable.
12
+ - Add directory, refresh-race, desktop lifecycle and terminal launch regressions. Release notes record full validation and version-only probes of all ten installed CLI entries; no model requests were made.
13
+
3
14
  ## 0.18.2 — 2026-10-12
4
15
 
5
16
  Stable patch under the npm latest tag. Upgrade and fully restart Harness before using Step install, repair or update.
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # Model Router · DeepSeek Harness Desktop
2
2
 
3
- Version: **0.18.2**. See [routing research](docs/ROUTING_RESEARCH.zh.md), [mathematical derivation and experiments](docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md), and the [LiveBench evidence audit and validation protocol](docs/ROUTING_LIVEBENCH_VALIDATION.zh.md). The latest public 2026-06-25 score/cost tables are aggregates; its paired question-quality/cost matrix has not been obtained, and no real routing gain is claimed.
3
+ Version: **0.19.0**. See [routing research](docs/ROUTING_RESEARCH.zh.md), [mathematical derivation and experiments](docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md), and the [LiveBench evidence audit and validation protocol](docs/ROUTING_LIVEBENCH_VALIDATION.zh.md). The latest public 2026-06-25 score/cost tables are aggregates; its paired question-quality/cost matrix has not been obtained, and no real routing gain is claimed.
4
4
 
5
5
  A cost-aware model-routing workbench for **DeepSeek Harness Desktop**. Describe a task, compare your configured models, inspect the plan, and explicitly choose when to execute it.
6
6
 
7
+ **0.19.0:** interactive terminals follow the full registry and saved installation, including OpenCode, Step and the ZCode desktop-bundled TUI. ZCode has independent desktop-directory configuration and desktop update/reinstall controls; its bundled CLI follows desktop updates. Fresh terminal discovery discards stale responses.
8
+
7
9
  **0.18.2:** isolates configurable vendor scripts beneath the shared install root, preventing Step from replacing other tools’ npm packages. Gemini/OpenCode installation success is separate from restricted execution support; Windows Gemini uses its verified package entry through Node. Upgrade and restart Harness before updating Step.
8
10
 
9
11
  **0.18.1:** fixes install-method selection stuck on saving. Saved volatile settings now update Host install preferences immediately; command previews refresh separately from tool inspection.
@@ -20,7 +22,7 @@ A cost-aware model-routing workbench for **DeepSeek Harness Desktop**. Describe
20
22
 
21
23
  **0.16.0:** [dynamic data and adaptive feedback](docs/ROUTING_ADAPTIVE_LEARNING.zh.md) adds opt-in public benchmark/curated fixed-USD pricing refresh and task-specific, time-decayed, shrinkage-regularized subjective utility. Objective proxy quality floors remain separate. Public sources default off; preferences are shared within one local DSH_HOME and limited to the latest 200 runs. This is not a universal vendor-price scraper or evidence of real quality–cost gains.
22
24
 
23
- Current version: **0.18.2** (`@ljwei-stak/dsh-model-router`). Its declared host compatibility is **DeepSeek Harness Desktop 0.2.0-rc.1 / 0.2.0-rc.2**. The router and [GAL](https://github.com/Alice-Marx/deepseek-harness-galgame) have been independent plugins since 0.12.0.
25
+ Current version: **0.19.0** (`@ljwei-stak/dsh-model-router`). Its declared host compatibility is **DeepSeek Harness Desktop 0.2.0-rc.1 / 0.2.0-rc.2**. The router and [GAL](https://github.com/Alice-Marx/deepseek-harness-galgame) have been independent plugins since 0.12.0.
24
26
 
25
27
  **0.15.0 changes:** a four-tab workbench with routing advice directly below task planning, protection against stale execution previews, preserved spending totals across history pruning and retries, and a standalone mock UI preview for source development.
26
28
 
@@ -38,7 +40,7 @@ For self-testing, follow the [desktop acceptance and quality–cost experiment s
38
40
  | Read-only execution | Preview and confirm a routed run from the workbench, or invoke it in an official Harness session. Supported headless CLIs and model-directory API calls return recorded results. |
39
41
  | Editable CLI execution | One supported official CLI or a sequential CLI team, using approved isolated Git worktrees and conditional patch integration. |
40
42
  | Official tools | Detect, install/update, check launch readiness and inspect login status for ten registry tools; installation jobs have logs and cancellation. |
41
- | Interactive terminal | PowerShell/system shell and seven CLI targets, including fixed login commands, live output and multiple sessions. |
43
+ | Interactive terminal | PowerShell/system shell and all ten registry CLI targets (ZCode TUI on verified Windows desktop installs), including fixed login commands, live output and multiple sessions. |
42
44
  | Costs and subscriptions | Daily/monthly budget checks, economy downgrade or pause, subscription-first routing, quota cooldown and explicit handling of other subscription failures. |
43
45
  | History and quality | Per-package results, dependency views, failure details, rerun/reassignment, ratings and optional stronger-model review. |
44
46
  | Execution boundaries | Shows what each route can read/write and whether its process uses the Harness sandbox. |
@@ -52,16 +54,16 @@ Generating a route recommendation is local: it does not start a vendor CLI or ma
52
54
  Open **Plugins → Add plugin** and enter:
53
55
 
54
56
  ```text
55
- @ljwei-stak/dsh-model-router@0.18.2
57
+ @ljwei-stak/dsh-model-router@0.19.0
56
58
  ```
57
59
 
58
- Use the official HTTPS npm source `https://registry.npmjs.org/` when a mirror lacks that exact version. Enable the plugin, then fully quit Harness (including its tray process) and restart it. Plugin details should show **0.18.2** and the sidebar should contain **模型路由** (Model Router). A global `npm install -g` alone does not register a Desktop plugin.
60
+ Use the official HTTPS npm source `https://registry.npmjs.org/` when a mirror lacks that exact version. Enable the plugin, then fully quit Harness (including its tray process) and restart it. Plugin details should show **0.19.0** and the sidebar should contain **模型路由** (Model Router). A global `npm install -g` alone does not register a Desktop plugin.
59
61
 
60
62
  You can instead install a local `.tgz` archive or its extracted inner `package` directory through the plugin manager. Both must contain `package.json` and `.dsh-plugin`. See [releases](https://github.com/Alice-Marx/dsh-model-router/releases) for archived builds, or build your own checkout below.
61
63
 
62
64
  ### Upgrade from the old package name
63
65
 
64
- The old name `@ljwei-stak/model-router-galgame` ended at 0.13.0. Remove that plugin entry while retaining your profile/application data, then add `@ljwei-stak/dsh-model-router@0.18.2`. Do not keep both entries installed: they share the internal plugin id `model-router-galgame` and tool names. The retained id lets existing router settings and history carry over.
66
+ The old name `@ljwei-stak/model-router-galgame` ended at 0.13.0. Remove that plugin entry while retaining your profile/application data, then add `@ljwei-stak/dsh-model-router@0.19.0`. Do not keep both entries installed: they share the internal plugin id `model-router-galgame` and tool names. The retained id lets existing router settings and history carry over.
65
67
 
66
68
  For the combined 0.11.x package, back up the profile and export the GAL saves you want to preserve before upgrading. Story playback, artwork, music and saves now belong to the independent `@ljwei-stak/dsh-galgame` package. The detailed save limitations are in [MIGRATION.md](docs/archive/MIGRATION.md).
67
69