@ljwei-stak/dsh-model-router 0.18.2 → 0.20.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.
@@ -23,6 +23,7 @@ import { routeBoundaries } from './shared/security-boundaries.mjs'
23
23
  import { createPlanFromRoutes } from './shared/harness-plan.mjs'
24
24
  import { applyModelProfiles, parseModelProfilesJson } from './shared/model-profiles.mjs'
25
25
  import { registerOfficialToolsRemote } from './official-tools-remote-service.mjs'
26
+ import { parseRunRequest } from './shared/official-tools-remote.mjs'
26
27
  import { createTerminalManager } from './shared/cli-terminal.mjs'
27
28
  import {
28
29
  parseTerminalRead,
@@ -42,7 +43,7 @@ import {
42
43
  } from './shared/official-tool-registry.mjs'
43
44
  import { parseInstallPreferences, resolveInstallPlan } from './shared/tool-install-preferences.mjs'
44
45
  import { officialToolExecutionCapabilities, officialToolReadiness, runOfficialTool } from './shared/official-tool-executor.mjs'
45
- import { runOfficialTask, runOfficialTeam, sessionWorkspace } from './shared/official-team-runtime.mjs'
46
+ import { preflightOfficialWorkspace, runOfficialTask, runOfficialTeam, sessionWorkspace } from './shared/official-team-runtime.mjs'
46
47
  import { downstreamPackageIds, executeAssignmentPlan, rerunAssignmentPackage, taskTextProblem } from './shared/task-executors.mjs'
47
48
  import {
48
49
  probeAllTools,
@@ -95,6 +96,7 @@ export const Config = z.object({
95
96
  toolInstallMethodsJson: z.string().max(8_000).default('{}').volatile(),
96
97
  toolScriptUrlsJson: z.string().max(8_000).default('{}').volatile(),
97
98
  toolAllowScriptInstall: z.boolean().default(true).volatile(),
99
+ zcodeInstallDir: z.string().max(4_096).default('').volatile(),
98
100
  })
99
101
 
100
102
  const JSON_OUTPUT = {
@@ -386,9 +388,21 @@ export function billingForResult(result) {
386
388
  })
387
389
  }
388
390
 
389
- function routePricing(routes, item) {
390
- const route = routes.find(route => route.provider === item?.provider && route.model === item?.model)
391
- return route?.pricing ? { ...route.pricing, source: route.pricingSource ?? 'route', version: route.pricingVersion ?? null, asOf: route.snapshotAsOf ?? null } : null
391
+ export function routePricing(routes, item) {
392
+ const matches = routes.filter(route => {
393
+ if (route.provider !== item?.provider) return false
394
+ if (item?.channel !== 'official-cli') return route.model === item?.model
395
+ // A routing recommendation cannot establish which model the CLI billed.
396
+ return typeof item.actualModel === 'string' && [route.model, route.cliModel, `${route.provider}/${route.model}`].includes(item.actualModel)
397
+ })
398
+ // Different catalog routes can share a CLI alias. Conflicting prices are
399
+ // not evidence of what the CLI actually billed.
400
+ const prices = new Set(matches.map(route => JSON.stringify(route.pricing ?? null)))
401
+ if (prices.size > 1) return null
402
+ const route = matches[0]
403
+ return route?.pricing ? { ...route.pricing, provider: route.provider, model: route.model,
404
+ ...(route.cliModel ? { cliModel: route.cliModel } : {}),
405
+ source: route.pricingSource ?? 'route', version: route.pricingVersion ?? null, asOf: route.snapshotAsOf ?? null } : null
392
406
  }
393
407
 
394
408
  function feedbackOptions(config) {
@@ -462,7 +476,7 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
462
476
  const readiness = options.skipToolProbe === true ? []
463
477
  : await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
464
478
  const executable = new Set(Array.isArray(options.runnableToolIds)
465
- ? options.runnableToolIds : readiness.filter(item => item.ready).map(item => item.id))
479
+ ? options.runnableToolIds : readiness.filter(item => item.ready && (item.readyModes ?? item.modes ?? []).includes('read-only')).map(item => item.id))
466
480
  const loggedOut = options.skipToolProbe === true
467
481
  ? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
468
482
  : loggedOutIds(await currentHealth())
@@ -698,7 +712,7 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
698
712
  : await Promise.all(installed.map(id => officialToolReadiness(id, options.workspace ?? process.cwd())))
699
713
  const runnableToolIds = Array.isArray(options.runnableToolIds)
700
714
  ? options.runnableToolIds
701
- : readiness.filter(item => item.ready).map(item => item.id)
715
+ : readiness.filter(item => item.ready && (item.readyModes ?? item.modes ?? []).includes('read-only')).map(item => item.id)
702
716
  const loggedOut = options.skipToolProbe === true
703
717
  ? (Array.isArray(options.loggedOutToolIds) ? options.loggedOutToolIds : [])
704
718
  : loggedOutIds(await currentHealth())
@@ -822,7 +836,7 @@ export function rerunSupport(run) {
822
836
  return { ok: true, kind: run.kind === 'team' ? 'team' : 'assign', writes: false }
823
837
  }
824
838
 
825
- export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, provider, model, confirmOverBudget = false, confirmWrite = false, subscriptionChoice = null, workspace, root, signal, ...options } = {}) {
839
+ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, provider, model, confirmOverBudget = false, confirmWrite = false, confirmUnsandboxed = false, subscriptionChoice = null, workspace, root, signal, ...options } = {}) {
826
840
  const { run, saved } = await storedRun(runId)
827
841
  const target = run.packages.find(item => item.id === text(packageId))
828
842
  if (!target) throw new Error(`运行记录中没有工作包 ${text(packageId)}`)
@@ -853,9 +867,12 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
853
867
  if (support.kind === 'team-write' && target.ok) {
854
868
  throw new Error('可编辑团队运行只能续跑失败或未完成的步骤:已成功步骤的改动已在原独立工作区中,重跑会重复套用。如需重做请重新调用 model_router_team_execute。')
855
869
  }
856
- if (support.writes && confirmWrite !== true) {
857
- return { paused: true, needsConfirmation: ['workspace-write'], run,
858
- budget: null, message: '续跑可编辑团队步骤会在新的独立 Git 工作树中套用之前的改动并修改文件,需要先确认。' }
870
+ const missingConfirmations = []
871
+ if (support.writes && confirmWrite !== true) missingConfirmations.push('workspace-write')
872
+ if (run.kind === 'team' && run.executionEnvironment === 'cli-native' && confirmUnsandboxed !== true) missingConfirmations.push('unsandboxed')
873
+ if (missingConfirmations.length) {
874
+ return { paused: true, needsConfirmation: missingConfirmations, run,
875
+ budget: null, message: '请确认续跑所需的文件修改与执行环境权限后继续;厂商 CLI 正常环境以当前用户权限运行,独立 Git 工作树不是操作系统读写边界。' }
859
876
  }
860
877
  const sameRoute = !override || (override.provider === target.recommendedProvider && override.model === target.recommendedModel)
861
878
  const budget = budgetFor(config, saved, sameRoute ? target.estimatedCost : null)
@@ -929,16 +946,21 @@ function teamPlanFromRun(run, ids, override, targetId) {
929
946
  */
930
947
  async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd, root, signal, options, config = {} }) {
931
948
  const mode = run.executionMode === 'workspace-write' ? 'workspace-write' : 'read-only'
949
+ const executionEnvironment = teamExecutionEnvironment(run.executionEnvironment, mode)
932
950
  const downstream = downstreamPackageIds(run.packages, target.id)
933
951
  .filter(id => !run.packages.find(item => item.id === id)?.ok)
934
952
  const ids = [target.id, ...downstream]
935
953
  const plan = teamPlanFromRun(run, ids, override, target.id)
936
954
  const cliModels = Object.fromEntries(Object.entries(run.cliModels ?? {}).filter(([key]) => ids.includes(key) && !(override && key === target.id)))
955
+ if (override) {
956
+ const reassignedPlan = { team: { workPackages: plan.team.workPackages.filter(item => item.id === target.id) } }
957
+ Object.assign(cliModels, resolveTeamCliModelBindings(reassignedPlan, routes))
958
+ }
937
959
  const previous = run.packages.filter(item => item.ok && !ids.includes(item.id))
938
960
  .map(item => ({ id: item.id, name: item.name, finalText: item.answer }))
939
961
  const runTeam = options.runTeam ?? runOfficialTeam
940
962
  const installed = Array.isArray(options.installedToolIds) ? options.installedToolIds : await installedToolIds()
941
- const execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode,
963
+ const execution = await runTeam({ plan, task: run.task, workspace: cwd, allowedRoot: root ?? cwd, mode, executionEnvironment,
942
964
  installedIds: installed, cliModels, signal, sandbox: ctx.sandbox, previous, onlyIds: ids,
943
965
  ...(mode === 'workspace-write' ? { seedFrom: { workspace: run.isolatedWorkspace, baseCommit: run.baseCommit } } : {}),
944
966
  ...(options.runtime ? { runtime: options.runtime } : {}) })
@@ -960,6 +982,7 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd,
960
982
  if (stored) stored.billingSwitch = note
961
983
  }
962
984
  current.status = execution?.status ?? current.status
985
+ current.executionEnvironment = executionEnvironment
963
986
  if (mode === 'workspace-write' && execution?.workspace && execution.status !== 'blocked') {
964
987
  current.isolatedWorkspace = String(execution.workspace)
965
988
  if (typeof execution.baseCommit === 'string') current.baseCommit = execution.baseCommit
@@ -968,6 +991,10 @@ async function rerunTeamStep(ctx, { run, target, override, routes, budget, cwd,
968
991
  if (override) {
969
992
  const stored = current.packages.find(item => item.id === target.id)
970
993
  if (stored) { stored.recommendedProvider = override.provider; stored.recommendedModel = override.model }
994
+ const bindings = { ...(current.cliModels ?? {}) }
995
+ delete bindings[target.id]
996
+ if (cliModels[target.id]) bindings[target.id] = cliModels[target.id]
997
+ current.cliModels = bindings
971
998
  }
972
999
  })
973
1000
  return { run: updated, execution: { ...converted, ranIds: ids, raw: execution }, budget }
@@ -1010,13 +1037,14 @@ async function annotateCliQuota(run, config, failures) {
1010
1037
  }
1011
1038
  }
1012
1039
 
1013
- export async function recordTeamRun({ task, plan, execution, mode, workspace, routes, cliModels, budget, startedAt, preset, config = {} }) {
1040
+ export async function recordTeamRun({ task, plan, execution, mode, executionEnvironment = 'harness', workspace, routes, cliModels, budget, startedAt, preset, config = {} }) {
1014
1041
  const run = buildTeamRunRecord({
1015
1042
  id: randomUUID(), createdAt: startedAt, finishedAt: Date.now(), task, plan, execution, mode, workspace,
1016
1043
  preset: plan?.preset ?? preset ?? DEFAULT_ROUTING_PRESET,
1017
1044
  pricingFor: item => routePricing(routes, item), billingFor: billingForResult,
1018
1045
  budget: budget ? { estimateUsd: budget.estimateUsd, exceeded: budget.exceeded, downgraded: false, confirmed: budget.exceeded ? true : undefined } : null,
1019
1046
  })
1047
+ run.executionEnvironment = teamExecutionEnvironment(executionEnvironment, mode ?? 'read-only')
1020
1048
  const bindings = Object.fromEntries(Object.entries(cliModels ?? {}).filter(([, value]) => typeof value === 'string' && value))
1021
1049
  if (Object.keys(bindings).length) run.cliModels = bindings
1022
1050
  await annotateCliQuota(run, config, (Array.isArray(execution?.results) ? execution.results : [])
@@ -1048,9 +1076,7 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
1048
1076
  routes = await routesWithLearning(ctx, config, signal, refreshed.runs, refreshed.dynamicData, refreshed.routingLearning)
1049
1077
  route = routes.find(item => item.provider === text(args.provider) && item.model === text(args.model)) ?? null
1050
1078
  if (!route) throw new Error('所选 provider/model 不在官方模型目录中')
1051
- modelId = route.cliModel && toolId !== 'zcode'
1052
- ? route.cliModel
1053
- : toolId === 'claude-code' || toolId === 'codex' ? route.model : null
1079
+ modelId = route.cliModel && toolId !== 'zcode' ? route.cliModel : null
1054
1080
  }
1055
1081
  if (text(args.cliModel)) {
1056
1082
  if (!route) throw new Error('cliModel 需要同时提供已配置的 provider 和 model 路线')
@@ -1060,7 +1086,7 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
1060
1086
  const estimate = route ? createPlanFromRoutes(taskText, routes, { mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost : null
1061
1087
  const budget = budgetFor(config, await savedState(), Number.isFinite(estimate) ? estimate : null)
1062
1088
  return { toolId, tool, taskText, mode, modelId, route, routes, estimate: Number.isFinite(estimate) ? estimate : null, budget,
1063
- provider: route?.provider ?? '', model: route?.model ?? '', cliModel: text(args.cliModel) || null }
1089
+ provider: route?.provider ?? '', model: route?.model ?? '', cliModel: modelId }
1064
1090
  }
1065
1091
 
1066
1092
  /** Run a planned tool call and record it; `rerunOf` links a rerun to the original run. */
@@ -1199,6 +1225,103 @@ export function resolveTeamCliModelBindings(plan, routes, requested = {}) {
1199
1225
  .filter(([key]) => !Object.hasOwn(bindings, key))) }
1200
1226
  }
1201
1227
 
1228
+ /** Only plan team packages on an installed CLI that is ready in this mode. */
1229
+ export function executableTeamRoutes(routes, installed, readiness, mode, capabilities = officialToolExecutionCapabilities()) {
1230
+ const available = new Set(installed)
1231
+ const ready = new Map(readiness.map(item => [item.id, item]))
1232
+ const supported = new Map(capabilities.map(item => [item.id, item]))
1233
+ return routes.filter(route => {
1234
+ const id = toolForProvider(route.provider)?.id
1235
+ const state = ready.get(id)
1236
+ const capability = supported.get(id)
1237
+ const readyModes = state?.readyModes ?? state?.modes ?? capability?.modes ?? []
1238
+ return available.has(id) && state?.ready === true && capability?.modes?.includes(mode)
1239
+ && readyModes.includes(mode)
1240
+ })
1241
+ }
1242
+
1243
+ function teamExecutionEnvironment(value = 'harness', mode = 'read-only') {
1244
+ if (!['harness', 'cli-native'].includes(value)) throw new Error('团队执行环境必须是 harness 或 cli-native')
1245
+ if (value === 'cli-native' && mode !== 'workspace-write') throw new Error('厂商 CLI 正常环境仅支持官方团队的 workspace-write 模式')
1246
+ return value
1247
+ }
1248
+
1249
+ /** Plan a CLI-only team without starting a provider request or changing files. */
1250
+ export async function planOfficialTeam(ctx, config = {}, args = {}, options = {}) {
1251
+ const taskText = text(args.task)
1252
+ const problem = taskTextProblem(taskText)
1253
+ if (problem) throw new Error(problem)
1254
+ const mode = args.mode ?? args.executionMode ?? 'read-only'
1255
+ if (!['read-only', 'workspace-write'].includes(mode)) throw new Error('团队执行模式必须是 read-only 或 workspace-write')
1256
+ const executionEnvironment = teamExecutionEnvironment(args.executionEnvironment, mode)
1257
+ const workspace = options.workspace ?? process.cwd()
1258
+ throwIfAborted(options.signal)
1259
+ const saved = await routingSavedState(config)
1260
+ const [routes, installed] = await Promise.all([
1261
+ routesWithLearning(ctx, config, options.signal, saved.runs, saved.dynamicData, saved.routingLearning),
1262
+ Array.isArray(options.installedToolIds) ? options.installedToolIds : installedToolIds(),
1263
+ ])
1264
+ const readiness = Array.isArray(options.readiness) ? options.readiness
1265
+ : await Promise.all(installed.map(id => (options.readinessFor ?? officialToolReadiness)(id, workspace, { executionEnvironment })))
1266
+ const capabilities = options.executionCapabilities ?? (options.capabilitiesFor ?? officialToolExecutionCapabilities)({ executionEnvironment })
1267
+ const executableRoutes = executableTeamRoutes(routes, installed, readiness, mode, capabilities)
1268
+ const base = { taskText, workspace, mode, executionEnvironment, plan: null, routes: executableRoutes, installed, readiness,
1269
+ executionCapabilities: capabilities, executionReadiness: readiness, cliModels: {}, budget: null }
1270
+ if (executionEnvironment === 'harness' && typeof ctx.sandbox?.confine !== 'function') {
1271
+ return { ...base, status: 'blocked', reason: '官方 Harness 进程沙箱不可用,不能执行官方 CLI 团队。' }
1272
+ }
1273
+ if (executableRoutes.length === 0) return { ...base, status: 'blocked',
1274
+ reason: `官方模型目录中没有同时满足已配置路线、已安装 CLI、托管执行适配器与 ${mode} 模式的供应商。请检查该模式下的 CLI 执行就绪状态。` }
1275
+ const supported = new Set(executableRoutes.map(route => toolForProvider(route.provider).id))
1276
+ const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1277
+ const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
1278
+ const plan = createPlanFromRoutes(taskText, executableRoutes, {
1279
+ ...planDataOptions(saved, config), mode: 'team', budgetUsd, installedToolIds: installed,
1280
+ runnableToolIds: installed.filter(id => supported.has(id)), preset: routingPresetOf(config, args.preset),
1281
+ })
1282
+ const bindingsText = text(args.cliModelsJson)
1283
+ if (bindingsText.length > 4_000) throw new Error('cliModelsJson 超过 4000 字符上限')
1284
+ let requestedModels = {}
1285
+ if (bindingsText) {
1286
+ try { requestedModels = JSON.parse(bindingsText) }
1287
+ catch { throw new Error('cliModelsJson 不是有效的 JSON 对象') }
1288
+ }
1289
+ const cliModels = resolveTeamCliModelBindings(plan, executableRoutes, requestedModels)
1290
+ const budget = budgetFor(config, await savedState(), plan.estimatedCost)
1291
+ return { ...base, status: 'planned', plan, cliModels, budget }
1292
+ }
1293
+
1294
+ /** Execute and record a planned team through official CLIs only. */
1295
+ export async function executeOfficialTeamPlan(ctx, config = {}, planned, options = {}) {
1296
+ if (planned?.status === 'blocked') return { ...planned }
1297
+ if (!planned?.plan || !['read-only', 'workspace-write'].includes(planned.mode)) throw new Error('缺少有效的官方 CLI 团队计划')
1298
+ const executionEnvironment = teamExecutionEnvironment(planned.executionEnvironment, planned.mode)
1299
+ const workspace = options.workspace ?? planned.workspace
1300
+ const budget = budgetFor(config, await savedState(), planned.plan.estimatedCost)
1301
+ const common = { plan: planned.plan, workspace, mode: planned.mode, executionEnvironment, budget }
1302
+ if (budget.exceeded && options.confirmOverBudget !== true) return { ...common, status: 'paused-budget',
1303
+ notice: `${budget.message} 已暂停团队执行。请向用户确认后再执行。` }
1304
+ if (executionEnvironment === 'cli-native' && options.confirmUnsandboxed !== true) return { ...common,
1305
+ status: 'needs-confirmation', missing: ['unsandboxed'],
1306
+ reasons: confirmationReasons(['unsandboxed'], { executionEnvironment }),
1307
+ notice: '厂商 CLI 正常环境必须明确确认后才会启动。' }
1308
+ if (executionEnvironment === 'harness' && typeof ctx.sandbox?.confine !== 'function') return { ...common, status: 'blocked',
1309
+ reason: '官方 Harness 进程沙箱不可用,不能执行官方 CLI 团队。' }
1310
+ throwIfAborted(options.signal)
1311
+ const startedAt = Date.now()
1312
+ const execution = await (options.runTeam ?? runOfficialTeam)({ plan: planned.plan, task: planned.taskText, workspace,
1313
+ allowedRoot: options.root ?? workspace, mode: planned.mode, executionEnvironment, installedIds: planned.installed,
1314
+ cliModels: planned.cliModels, signal: options.signal, sandbox: ctx.sandbox })
1315
+ const run = await recordTeamRun({ task: planned.taskText, plan: planned.plan, execution, mode: planned.mode, executionEnvironment, workspace,
1316
+ routes: planned.routes, cliModels: planned.cliModels, budget, startedAt, config })
1317
+ return { ...common, status: execution.status, execution, run, runId: run?.id ?? null,
1318
+ rerunNotice: planned.mode === 'read-only'
1319
+ ? '失败的工作包可用 model_router_rerun_step 单步重跑(只重跑该步及其未完成的下游)。'
1320
+ : '若在某个步骤失败而停止,可用 model_router_rerun_step 在新的独立工作树中套用之前的改动后续跑(需审批);已整合或待人工整合的运行不能单步重跑。',
1321
+ modelNotice: '已配置 cliModel 的路线按工作包传给官方 CLI;所有厂商无映射时使用 CLI 默认模型。请求模型、Harness 推荐模型与 CLI 回报的实际模型分别记录;未核验实际模型时不按推荐路线价格计算实际费用。',
1322
+ billingNotice: 'budgetUsd 仅影响估算与路由,无法限制官方 CLI 账号实际费用。' }
1323
+ }
1324
+
1202
1325
  /** What was assigned and why, per package: route, difficulty, estimated cost, channel. */
1203
1326
  export function decisionSummary(plan) {
1204
1327
  const packages = plan?.routingBypassed
@@ -1291,12 +1414,28 @@ export function approvalReasonCodes(name, args = {}, config = {}, { rerunRun = n
1291
1414
  if (name === 'model_router_rerun_step' && args.subscriptionChoice === 'api') codes.push('subscription-api')
1292
1415
  if (args.confirmOverBudget === true) codes.push('over-budget')
1293
1416
  const directRun = name === 'model_router_execute' || (name === 'model_router_rerun_step' && (!rerunRun || rerunSupport(rerunRun).kind === 'assign'))
1417
+ if ((name === 'model_router_team_execute' && args.executionEnvironment === 'cli-native')
1418
+ || (name === 'model_router_rerun_step' && rerunRun?.executionEnvironment === 'cli-native')) codes.push('unsandboxed')
1294
1419
  if (directRun && valueOf(config, 'confirmUnsandboxedCli', true) !== false && mayLaunchDirectCli(args)) codes.push('unsandboxed')
1295
1420
  return codes
1296
1421
  }
1297
1422
 
1423
+ function executionApprovalText(code, sandboxAvailable, executionEnvironment) {
1424
+ if (executionEnvironment === 'cli-native' && ['workspace-write', 'unsandboxed'].includes(code)) {
1425
+ return { reason: 'Run verified official CLIs with the current user permissions, outside the Harness process sandbox',
1426
+ zh: code === 'workspace-write'
1427
+ ? '修改文件:CLI 在独立 Git 工作树执行,成功后核对并整合补丁。工作树用于改动回收,不构成操作系统读写边界。'
1428
+ : '厂商 CLI 正常环境:使用原登录、配置、技能及 MCP,以当前用户权限运行,不经过 Harness 进程沙箱。工具可能访问工作树外当前用户可访问的资源;不会自动切换执行环境。',
1429
+ en: code === 'workspace-write'
1430
+ ? 'Edit in an isolated Git worktree and integrate the verified patch. The worktree collects changes; it is not an OS file-access boundary.'
1431
+ : 'Run the verified vendor CLI with its original login, configuration, skills and MCP under the current user permissions, outside the Harness process sandbox. Tools can access resources outside the worktree. This environment is never selected automatically.' }
1432
+ }
1433
+ return APPROVAL_TEXT[code](sandboxAvailable)
1434
+ }
1435
+
1298
1436
  function approvalReasons(name, args, config, { sandboxAvailable = false, rerunRun = null } = {}) {
1299
- return approvalReasonCodes(name, args, config, { rerunRun }).map(code => ({ code, ...APPROVAL_TEXT[code](sandboxAvailable) }))
1437
+ return approvalReasonCodes(name, args, config, { rerunRun }).map(code => ({ code,
1438
+ ...executionApprovalText(code, sandboxAvailable, rerunRun?.executionEnvironment ?? args.executionEnvironment) }))
1300
1439
  }
1301
1440
 
1302
1441
  /** One approval prompt listing every reason, so the user is asked once. */
@@ -1317,8 +1456,8 @@ export function combinedAsk(reasons) {
1317
1456
  }
1318
1457
 
1319
1458
  /** Reasons in the workbench's shape: code plus the Chinese and English text. */
1320
- export function confirmationReasons(codes, { sandboxAvailable = false } = {}) {
1321
- return codes.map(code => ({ code, zh: APPROVAL_TEXT[code](sandboxAvailable).zh, en: APPROVAL_TEXT[code](sandboxAvailable).en }))
1459
+ export function confirmationReasons(codes, { sandboxAvailable = false, executionEnvironment = 'harness' } = {}) {
1460
+ return codes.map(code => ({ code, ...executionApprovalText(code, sandboxAvailable, executionEnvironment) }))
1322
1461
  }
1323
1462
 
1324
1463
  /**
@@ -1336,10 +1475,39 @@ export async function workbenchWorkspace(requested, runs = []) {
1336
1475
  return chosen
1337
1476
  }
1338
1477
 
1339
- /** Plan preview for the workbench: decision, estimate, budget and what needs confirming. */
1340
- export async function previewWorkbenchRun(ctx, config, request = {}, options = {}) {
1478
+ /** Resolve one fresh preview and retain its exact CLI plan for the confirmed run. */
1479
+ async function prepareWorkbenchPreview(ctx, config, supplied = {}, options = {}) {
1480
+ const request = parseRunRequest(supplied)
1341
1481
  const saved = await savedState()
1342
1482
  const workspace = await workbenchWorkspace(request.workspace, saved.runs)
1483
+ if (request.executor === 'official-team') {
1484
+ const mode = request.executionMode
1485
+ let planned
1486
+ try {
1487
+ await (options.preflightWorkspace ?? preflightOfficialWorkspace)(workspace, mode, options.signal)
1488
+ planned = await planOfficialTeam(ctx, config, { ...request, mode }, {
1489
+ workspace, signal: options.signal, ...options.planOptions,
1490
+ })
1491
+ } catch (error) {
1492
+ return { request, preview: { executor: 'official-team', executionMode: mode, executionEnvironment: request.executionEnvironment ?? 'harness', workspace,
1493
+ status: 'blocked', reason: errorText(error), decision: { packages: [] }, selected: null,
1494
+ estimatedCost: null, budget: null, reasons: [], readOnly: mode === 'read-only' } }
1495
+ }
1496
+ const decision = decisionSummary(planned.plan)
1497
+ decision.packages = decision.packages.map(item => ({ ...item, channel: 'official-cli',
1498
+ channelDetail: `官方 CLI;${mode === 'workspace-write' ? '在隔离 Git 工作树中使用自己的工具' : '只读检查工程,工具集按各 CLI 只读适配限制;Claude 禁用 shell、技能及既有 MCP,并忽略用户设置'};未配置映射时使用 CLI 默认模型;失败停止,不自动转 API。` }))
1499
+ const codes = planned.status === 'blocked' ? [] : approvalReasonCodes('model_router_team_execute', {
1500
+ mode, executionEnvironment: planned.executionEnvironment, confirmOverBudget: Boolean(planned.budget?.exceeded),
1501
+ }, config)
1502
+ return { request, planned, preview: {
1503
+ executor: 'official-team', executionMode: mode, executionEnvironment: planned.executionEnvironment, workspace, status: planned.status,
1504
+ ...(planned.reason ? { reason: planned.reason } : {}),
1505
+ decision, selected: planned.plan?.selected ?? null, routingBypassed: false,
1506
+ estimatedCost: planned.plan?.estimatedCost ?? null, budget: planned.budget,
1507
+ reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function', executionEnvironment: planned.executionEnvironment }),
1508
+ readOnly: mode === 'read-only',
1509
+ } }
1510
+ }
1343
1511
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1344
1512
  const planned = await planAssignment(ctx, request.task, config, {
1345
1513
  provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
@@ -1347,7 +1515,7 @@ export async function previewWorkbenchRun(ctx, config, request = {}, options = {
1347
1515
  })
1348
1516
  await hydrateHealth()
1349
1517
  const codes = approvalReasonCodes('model_router_execute', { provider: request.provider, confirmOverBudget: Boolean(planned.budget.exceeded) }, config)
1350
- return {
1518
+ return { request, preview: {
1351
1519
  workspace,
1352
1520
  decision: decisionSummary(planned.plan),
1353
1521
  selected: planned.plan.selected ?? null,
@@ -1356,15 +1524,32 @@ export async function previewWorkbenchRun(ctx, config, request = {}, options = {
1356
1524
  budget: planned.budget,
1357
1525
  reasons: confirmationReasons(codes, { sandboxAvailable: typeof ctx.sandbox?.confine === 'function' }),
1358
1526
  readOnly: true,
1359
- }
1527
+ } }
1528
+ }
1529
+
1530
+ /** Plan preview: no model calls, workspace changes or worktree creation. */
1531
+ export async function previewWorkbenchRun(ctx, config, request = {}, options = {}) {
1532
+ return (await prepareWorkbenchPreview(ctx, config, request, options)).preview
1360
1533
  }
1361
1534
 
1362
1535
  /** Execute from the workbench; refuses until every current reason is in confirmedReasons. */
1363
- export async function startWorkbenchRun(ctx, config, request = {}, options = {}) {
1364
- const preview = await previewWorkbenchRun(ctx, config, request, options)
1536
+ export async function startWorkbenchRun(ctx, config, supplied = {}, options = {}) {
1537
+ const prepared = await prepareWorkbenchPreview(ctx, config, supplied, options)
1538
+ const { preview, request } = prepared
1539
+ if (preview.status === 'blocked') return preview
1365
1540
  const confirmed = new Set(Array.isArray(request.confirmedReasons) ? request.confirmedReasons : [])
1366
1541
  const missing = preview.reasons.filter(item => !confirmed.has(item.code))
1367
- if (missing.length) return { status: 'needs-confirmation', ...preview, reasons: preview.reasons, missing: missing.map(item => item.code) }
1542
+ if (missing.length) return { ...preview, status: 'needs-confirmation', reasons: preview.reasons, missing: missing.map(item => item.code) }
1543
+ if (request.executor === 'official-team') {
1544
+ const result = await (options.executeOfficialTeam ?? executeOfficialTeamPlan)(ctx, config, prepared.planned, {
1545
+ workspace: preview.workspace, root: preview.workspace, signal: options.signal,
1546
+ confirmOverBudget: confirmed.has('over-budget'), confirmUnsandboxed: confirmed.has('unsandboxed'), ...(options.teamOptions ?? {}),
1547
+ })
1548
+ return { status: result.status, runId: result.runId ?? null, workspace: preview.workspace,
1549
+ executor: 'official-team', executionMode: request.executionMode, executionEnvironment: preview.executionEnvironment, decision: preview.decision,
1550
+ budget: result.budget ?? preview.budget, ...(result.reason ? { reason: result.reason } : {}),
1551
+ ...(result.notice ? { notice: result.notice } : {}) }
1552
+ }
1368
1553
  const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1369
1554
  const result = await (options.execute ?? executeConfiguredAssignment)(ctx, request.task, config, {
1370
1555
  provider: request.provider, model: request.model, planMode: request.planMode, preset: request.preset,
@@ -1387,6 +1572,8 @@ export async function startWorkbenchRun(ctx, config, request = {}, options = {})
1387
1572
  export function createRouterTerminals(options = {}) {
1388
1573
  return createTerminalManager({
1389
1574
  prepare: () => ensureNpmPrefixOnPath(),
1575
+ preferences: () => installPreferences(),
1576
+ launchOptions: () => currentInstallOptions(),
1390
1577
  onSessionEnd: record => { void routerStateStore().appendTerminalSession(record).catch(() => {}) },
1391
1578
  ...options,
1392
1579
  })
@@ -1397,11 +1584,14 @@ async function terminalHistory(limit = 20) {
1397
1584
  return (saved.terminalSessions ?? []).slice(-limit).reverse()
1398
1585
  }
1399
1586
 
1400
- function terminalServices(terminals) {
1587
+ function terminalServices(terminals, assertPreferences = () => {}) {
1401
1588
  if (!terminals) return {}
1402
1589
  return {
1403
- terminalInfo: async () => ({ ...terminals.info(), history: await terminalHistory() }),
1404
- terminalStart: async request => terminals.start(parseTerminalStart(request)),
1590
+ terminalInfo: async () => {
1591
+ const tools = await probeAllTools({ fresh: true })
1592
+ return { ...terminals.info(), tools, toolsCheckedAt: Date.now(), history: await terminalHistory() }
1593
+ },
1594
+ terminalStart: async request => { assertPreferences(); return terminals.start(parseTerminalStart(request)) },
1405
1595
  terminalRead: async request => terminals.read(parseTerminalRead(request)),
1406
1596
  terminalWrite: async request => terminals.write(parseTerminalWrite(request)),
1407
1597
  terminalResize: async request => terminals.resize(parseTerminalResize(request)),
@@ -1409,9 +1599,9 @@ function terminalServices(terminals) {
1409
1599
  }
1410
1600
  }
1411
1601
 
1412
- export function routerRemoteServices(ctx, config, { terminals = null } = {}) {
1602
+ export function routerRemoteServices(ctx, config, { terminals = null, assertPreferences } = {}) {
1413
1603
  return {
1414
- ...terminalServices(terminals),
1604
+ ...terminalServices(terminals, assertPreferences),
1415
1605
  health: async fresh => {
1416
1606
  const report = await toolHealthReport({ fresh: fresh === true })
1417
1607
  const saved = await savedState()
@@ -1444,7 +1634,7 @@ export function apply(ctx, config = {}) {
1444
1634
  installPreferenceError = null
1445
1635
  } catch (error) { installPreferenceError = errorText(error) }
1446
1636
  }
1447
- const installFields = new Set(['toolInstallDir', 'toolNpmRegistry', 'toolInstallMethodsJson', 'toolScriptUrlsJson', 'toolAllowScriptInstall'])
1637
+ const installFields = new Set(['toolInstallDir', 'toolNpmRegistry', 'toolInstallMethodsJson', 'toolScriptUrlsJson', 'toolAllowScriptInstall', 'zcodeInstallDir'])
1448
1638
  syncInstallPreferences()
1449
1639
  // Loader scopes this event to the fiber whose refs were committed.
1450
1640
  ctx.on('loader/volatile-update', paths => {
@@ -1464,9 +1654,10 @@ export function apply(ctx, config = {}) {
1464
1654
  return () => { process.off('exit', onExit); terminals.disposeAll('dispose') }
1465
1655
  }, 'model-router-galgame: interactive terminals')
1466
1656
  registerOfficialToolsRemote(ctx, {
1467
- ...routerRemoteServices(ctx, config, { terminals }),
1657
+ ...routerRemoteServices(ctx, config, { terminals, assertPreferences: assertInstallPreferences }),
1468
1658
  installSettingsError: () => installPreferenceError,
1469
1659
  installRequestedDirectory: () => valueOf(config, 'toolInstallDir', ''),
1660
+ zcodeRequestedDirectory: () => valueOf(config, 'zcodeInstallDir', ''),
1470
1661
  })
1471
1662
  }
1472
1663
  ctx.on('tools/pre-execute', async (exec, next) => {
@@ -1759,7 +1950,7 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
1759
1950
  }))
1760
1951
  ctx.tools.register(defineTool({
1761
1952
  name: 'model_router_tool_run',
1762
- description: 'Run one supported official model CLI in the current Harness session workspace. Claude, Codex, MiMo and Grok support read-only; Kimi, MiniMax and ZCode require workspace-write. Write mode needs approval and a clean Git repository. Optional provider/model must match a configured Harness route and selected vendor; cliModel can specify that vendor CLI’s own configured model name. Without cliModel, Kimi, MiniMax, MiMo, Grok and ZCode use the CLI default.',
1953
+ description: 'Run one supported official model CLI in the current Harness session workspace. Claude, Codex, MiMo and Grok support read-only; Kimi, MiniMax and ZCode require workspace-write. Write mode needs approval and a clean Git repository. Optional provider/model must match a configured Harness route and selected vendor; cliModel can specify that vendor CLI’s own configured model name. Every CLI uses its own default unless an explicit cliModel or saved route mapping is configured.',
1763
1954
  parameters: {
1764
1955
  tool: { type: 'string', required: true, description: 'Fixed registry tool id, e.g. claude-code or codex.' },
1765
1956
  task: { type: 'string', required: true, description: 'Concrete task for the official CLI model.' },
@@ -1860,7 +2051,7 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
1860
2051
  // The pre-execute hook already asked the user about file edits for editable reruns.
1861
2052
  const result = await rerunRecordedStep(ctx, config, {
1862
2053
  runId: args.runId, packageId: args.packageId, provider: args.provider, model: args.model,
1863
- confirmOverBudget: args.confirmOverBudget === true, confirmWrite: true, workspace: cwd, root, signal: exec.signal,
2054
+ confirmOverBudget: args.confirmOverBudget === true, confirmWrite: true, confirmUnsandboxed: stored?.executionEnvironment === 'cli-native', workspace: cwd, root, signal: exec.signal,
1864
2055
  ...(text(args.subscriptionChoice) ? { subscriptionChoice: text(args.subscriptionChoice) } : {}),
1865
2056
  })
1866
2057
  return jsonValue({
@@ -1887,10 +2078,11 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
1887
2078
  }))
1888
2079
  ctx.tools.register(defineTool({
1889
2080
  name: 'model_router_team_execute',
1890
- description: 'Plan a complex task into dependent work packages, route among configured providers with ready official CLIs, and run each package sequentially. Claude/Codex request the planned model ID; other CLIs use their configured default unless cliModelsJson supplies exact CLI names. Editable runs use one isolated Git worktree and integrate source changes after CLI success. Confirm the actual model from vendor records.',
2081
+ description: 'Plan a complex task into dependent work packages, route among configured providers with ready official CLIs, and run each package sequentially. Every CLI uses its own default unless a saved cliModel mapping or cliModelsJson supplies exact CLI names. Editable runs use one isolated Git worktree and integrate source changes after CLI success. Confirm the actual model from vendor records.',
1891
2082
  parameters: {
1892
2083
  task: { type: 'string', required: true, description: 'Full task to plan, distribute and execute.' },
1893
2084
  mode: { type: 'string', enum: ['read-only', 'workspace-write'], description: 'Default read-only; workspace-write needs a clean Git repository and approval.' },
2085
+ executionEnvironment: { type: 'string', enum: ['harness', 'cli-native'], description: 'Default harness. cli-native requires workspace-write and separate approval: normal vendor tools run with current user permissions outside Harness sandbox; Git isolation only collects changes.' },
1894
2086
  budgetUsd: { type: 'number', description: 'Estimated planning ceiling only, not a vendor billing limit.' },
1895
2087
  cliModelsJson: { type: 'string', description: 'Optional JSON object mapping official tool IDs or work package IDs to exact model names configured in those CLIs. MiniMax/MiMo require provider/model; ZCode cannot switch per call.' },
1896
2088
  confirmOverBudget: { type: 'boolean', description: 'Set only after the user agreed to exceed the daily/monthly budget. Triggers an approval prompt.' },
@@ -1900,51 +2092,11 @@ function registerOfficialToolModels(ctx, config, assertInstallPreferences) {
1900
2092
  const { cwd, root, sandboxMode } = await sessionWorkspace(ctx, exec)
1901
2093
  const mode = args.mode === 'workspace-write' ? 'workspace-write' : 'read-only'
1902
2094
  if (mode === 'workspace-write' && sandboxMode === 'read-only') throw new Error('当前 Harness 会话为只读模式,不能请求可编辑团队执行')
1903
- const saved = await routingSavedState(config)
1904
- const [routes, installed] = await Promise.all([routesWithLearning(ctx, config, exec.signal, saved.runs, saved.dynamicData, saved.routingLearning), installedToolIds()])
1905
- const readiness = await Promise.all(installed.map(id => officialToolReadiness(id, cwd)))
1906
- const supported = new Set(readiness.filter(item => item.ready).map(item => item.id))
1907
- const capabilities = new Map(officialToolExecutionCapabilities().map(item => [item.id, item]))
1908
- const executableRoutes = routes.filter(route => {
1909
- const tool = toolForProvider(route.provider)
1910
- return tool && installed.includes(tool.id) && supported.has(tool.id)
1911
- && capabilities.get(tool.id)?.modes?.includes(mode)
1912
- })
1913
- if (executableRoutes.length === 0) return jsonValue({ status: 'blocked',
1914
- reason: `官方模型目录中没有同时满足已配置路线、已安装 CLI、托管执行适配器与 ${mode} 模式的供应商;Kimi、MiniMax、ZCode 仅支持经审批的 workspace-write。`,
1915
- installed, executionCapabilities: officialToolExecutionCapabilities(), executionReadiness: readiness })
1916
- const configuredBudget = valueOf(config, 'budgetUsd', DEFAULT_ROUTER_SETTINGS.budgetUsd)
1917
- const budgetUsd = Math.max(0, finiteNumber(args.budgetUsd, finiteNumber(configuredBudget, 0)))
1918
- const plan = createPlanFromRoutes(args.task, executableRoutes, {
1919
- ...planDataOptions(saved, config),
1920
- mode: 'team', budgetUsd, installedToolIds: installed,
1921
- runnableToolIds: installed.filter(id => supported.has(id)),
1922
- preset: routingPresetOf(config),
1923
- })
1924
- const budget = budgetFor(config, await savedState(), plan.estimatedCost)
1925
- if (budget.exceeded && args.confirmOverBudget !== true) {
1926
- return jsonValue({ status: 'paused-budget', budget, plan,
1927
- notice: `${budget.message} 已暂停团队执行。请向用户确认后以 confirmOverBudget: true 重新调用。` })
1928
- }
1929
- const bindingsText = text(args.cliModelsJson)
1930
- if (bindingsText.length > 4_000) throw new Error('cliModelsJson 超过 4000 字符上限')
1931
- let cliModels = null
1932
- if (bindingsText) {
1933
- try { cliModels = JSON.parse(bindingsText) }
1934
- catch { throw new Error('cliModelsJson 不是有效的 JSON 对象') }
1935
- }
1936
- cliModels = resolveTeamCliModelBindings(plan, executableRoutes, cliModels ?? {})
1937
- const startedAt = Date.now()
1938
- const execution = await runOfficialTeam({ plan, task: args.task, workspace: cwd,
1939
- allowedRoot: root, mode, installedIds: installed, cliModels, signal: exec.signal, sandbox: ctx.sandbox })
1940
- const recorded = await recordTeamRun({ task: args.task, plan, execution, mode, workspace: cwd,
1941
- routes: executableRoutes, cliModels, budget, startedAt, config })
1942
- return jsonValue({ plan, execution, runId: recorded?.id ?? null,
1943
- rerunNotice: mode === 'read-only'
1944
- ? '失败的工作包可用 model_router_rerun_step 单步重跑(只重跑该步及其未完成的下游)。'
1945
- : '若在某个步骤失败而停止,可用 model_router_rerun_step 在新的独立工作树中套用之前的改动后续跑(需审批);已整合或待人工整合的运行不能单步重跑。',
1946
- modelNotice: '已配置 cliModel 的路线按工作包传给官方 CLI;Claude/Codex 在未配置映射时请求 Harness 模型 ID。其他厂商无映射时使用 CLI 默认模型。多数 CLI 尚不返回可核验的实际模型 ID,须以厂商运行记录核对。',
1947
- billingNotice: 'budgetUsd 仅影响估算与路由,无法限制官方 CLI 账号实际费用。' })
2095
+ const planned = await planOfficialTeam(ctx, config, { ...args, mode }, { workspace: cwd, signal: exec.signal })
2096
+ return jsonValue(await executeOfficialTeamPlan(ctx, config, planned, {
2097
+ workspace: cwd, root, signal: exec.signal, confirmOverBudget: args.confirmOverBudget === true,
2098
+ confirmUnsandboxed: args.executionEnvironment === 'cli-native',
2099
+ }))
1948
2100
  },
1949
2101
  }))
1950
2102
  }
@@ -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