@ljwei-stak/dsh-model-router 0.23.1 → 0.25.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.
@@ -43,6 +43,24 @@ const BASE_ENV_KEYS = Object.freeze([
43
43
  ])
44
44
 
45
45
  const READ_ONLY_INSTRUCTION = 'Read the task supplied on standard input. Answer in the task language. Do not modify files.'
46
+ const executionBoundaryFailure = error => ['ROUTER_CHECKPOINT_FAILED', 'ROUTER_BUDGET_CHANGED'].includes(error?.code)
47
+ const usageFields = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
48
+
49
+ /** Subscription evidence contains reported usage only, never prices or secrets. */
50
+ function subscriptionReceipt(result, { provider, model, toolId = null, channel = 'harness-llm', now = Date.now } = {}) {
51
+ const usage = result?.usage && typeof result.usage === 'object' && !Array.isArray(result.usage)
52
+ ? Object.fromEntries(usageFields.filter(key => typeof result.usage[key] === 'number'
53
+ && Number.isFinite(result.usage[key]) && result.usage[key] >= 0).map(key => [key, result.usage[key]])) : null
54
+ const notStarted = result?.notStarted === true || result?.blocked === true
55
+ const incomplete = result?.usageIncomplete === true || !usage
56
+ || !['inputTokens', 'outputTokens'].every(key => Object.hasOwn(usage, key))
57
+ || usageFields.some(key => result?.usage?.[key] !== undefined && !Object.hasOwn(usage ?? {}, key))
58
+ return { provider: String(provider ?? ''), model: String(reportedCliModel(result?.actualModel ?? result?.reportedModel) ?? model ?? ''),
59
+ toolId, channel, usage: usage && Object.keys(usage).length ? usage : null,
60
+ ...(incomplete ? { usageIncomplete: true } : {}), ...(notStarted ? { notStarted: true } : {}),
61
+ finishedAt: typeof result?.finishedAt === 'number' && Number.isFinite(result.finishedAt)
62
+ && result.finishedAt >= 0 ? result.finishedAt : now() }
63
+ }
46
64
 
47
65
  function additionalApiFields(value) {
48
66
  if (!value || typeof value !== 'object') return {}
@@ -409,6 +427,7 @@ export function looksLikeLoginFailure(detail) {
409
427
  }
410
428
 
411
429
  const count = value => Number.isFinite(value) && value >= 0 ? Math.round(value) : 0
430
+ const knownCount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
412
431
 
413
432
  /** Token usage reported by the CLI, in the Harness TokenUsage shape (disjoint cache counts). */
414
433
  export function usageFromOutput(format, stdout = '') {
@@ -422,11 +441,14 @@ export function usageFromOutput(format, stdout = '') {
422
441
  ? Object.keys(result.modelUsage) : []
423
442
  return {
424
443
  usage: {
425
- inputTokens: count(usage.input_tokens),
426
- outputTokens: count(usage.output_tokens),
444
+ ...(knownCount(usage.input_tokens) ? { inputTokens: count(usage.input_tokens) } : {}),
445
+ ...(knownCount(usage.output_tokens) ? { outputTokens: count(usage.output_tokens) } : {}),
427
446
  cacheReadTokens: count(usage.cache_read_input_tokens),
428
447
  cacheWriteTokens: count(usage.cache_creation_input_tokens),
429
448
  },
449
+ ...(!knownCount(usage.input_tokens) || !knownCount(usage.output_tokens)
450
+ || ['cache_read_input_tokens', 'cache_creation_input_tokens'].some(key => usage[key] !== undefined && !knownCount(usage[key]))
451
+ ? { usageIncomplete: true } : {}),
430
452
  ...(Number.isFinite(reported) && reported >= 0 ? { reportedCostUsd: reported } : {}),
431
453
  ...(models.length === 1 && /^[A-Za-z0-9][A-Za-z0-9._:/\[\]-]{0,199}$/.test(models[0]) ? { reportedModel: models[0] } : {}),
432
454
  }
@@ -436,24 +458,33 @@ export function usageFromOutput(format, stdout = '') {
436
458
  if (!completed) return null
437
459
  const cached = count(completed.usage.cached_input_tokens)
438
460
  return { usage: {
439
- inputTokens: Math.max(0, count(completed.usage.input_tokens) - cached),
440
- outputTokens: count(completed.usage.output_tokens),
461
+ ...(knownCount(completed.usage.input_tokens) ? { inputTokens: Math.max(0, count(completed.usage.input_tokens) - cached) } : {}),
462
+ ...(knownCount(completed.usage.output_tokens) ? { outputTokens: count(completed.usage.output_tokens) } : {}),
441
463
  cacheReadTokens: cached,
442
464
  cacheWriteTokens: 0,
443
- } }
465
+ }, ...(!knownCount(completed.usage.input_tokens) || !knownCount(completed.usage.output_tokens)
466
+ || completed.usage.cached_input_tokens !== undefined && (!knownCount(completed.usage.cached_input_tokens)
467
+ || completed.usage.cached_input_tokens > completed.usage.input_tokens) ? { usageIncomplete: true } : {}) }
444
468
  }
445
469
  if (format === 'gemini-json') {
446
470
  const models = JSON.parse(stdout)?.stats?.models
447
- if (!models || typeof models !== 'object') return null
471
+ if (!models || typeof models !== 'object' || Array.isArray(models) || !Object.keys(models).length) return null
448
472
  let input = 0
449
473
  let output = 0
450
474
  let cached = 0
475
+ let inputKnown = true, outputKnown = true, incomplete = false
451
476
  for (const entry of Object.values(models)) {
477
+ inputKnown &&= knownCount(entry?.tokens?.prompt)
478
+ outputKnown &&= knownCount(entry?.tokens?.candidates)
479
+ incomplete ||= entry?.tokens?.cached !== undefined && (!knownCount(entry.tokens.cached)
480
+ || entry.tokens.cached > entry.tokens.prompt)
452
481
  input += count(entry?.tokens?.prompt)
453
482
  output += count(entry?.tokens?.candidates)
454
483
  cached += count(entry?.tokens?.cached)
455
484
  }
456
- return { usage: { inputTokens: Math.max(0, input - cached), outputTokens: output, cacheReadTokens: cached, cacheWriteTokens: 0 } }
485
+ return { usage: { ...(inputKnown ? { inputTokens: Math.max(0, input - cached) } : {}),
486
+ ...(outputKnown ? { outputTokens: output } : {}), cacheReadTokens: cached, cacheWriteTokens: 0 },
487
+ ...(!inputKnown || !outputKnown || incomplete ? { usageIncomplete: true } : {}) }
457
488
  }
458
489
  } catch { /* usage is optional */ }
459
490
  return null
@@ -621,7 +652,7 @@ function captureProcess(spawnImpl, file, args, { cwd, env, stdin, timeoutMs, sig
621
652
  })
622
653
  }
623
654
 
624
- async function useApi(apiFallback, { route, task, signal, reason, adapter, preference, exitCode = null, timedOut = false, detail = '', raw = '', skipped = false, attempted = false }) {
655
+ async function useApi(apiFallback, { route, task, signal, reason, adapter, preference, exitCode = null, timedOut = false, detail = '', raw = '', skipped = false, attempted = false, subscriptionResult = null }) {
625
656
  const provider = String(route?.provider ?? '')
626
657
  const model = String(route?.model ?? '')
627
658
  const fallback = {
@@ -637,7 +668,7 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
637
668
  }
638
669
  }
639
670
  try {
640
- const api = await apiFallback({ route, task, signal, reason, detail, raw: String(raw ?? '').slice(-40_000), toolId: adapter?.id ?? null, attempted, timedOut, exitCode })
671
+ const api = await apiFallback({ route, task, signal, reason, detail, raw: String(raw ?? '').slice(-40_000), toolId: adapter?.id ?? null, attempted, timedOut, exitCode, subscriptionResult })
641
672
  // A billing-aware fallback can pause the step for the user's decision.
642
673
  if (api?.paused) {
643
674
  return {
@@ -646,6 +677,7 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
646
677
  pause: { ...api.pause, ...(detail ? { detail } : {}), ...(fallback.loginRequired ? { loginRequired: true } : {}) },
647
678
  ...reasoningExecutionFields(api),
648
679
  ...additionalApiFields(api),
680
+ ...(api?.billing ? { billing: api.billing } : {}),
649
681
  error: api.error,
650
682
  }
651
683
  }
@@ -674,6 +706,7 @@ async function useApi(apiFallback, { route, task, signal, reason, adapter, prefe
674
706
  error: api?.ok === true && answer.trim() ? undefined : (api?.error || reason),
675
707
  }
676
708
  } catch (error) {
709
+ if (executionBoundaryFailure(error)) throw error
677
710
  if (signal?.aborted) {
678
711
  return {
679
712
  ok: false, provider, model, preference, channel: 'harness-llm', toolId: adapter?.id ?? null,
@@ -833,6 +866,8 @@ export async function executeAssignedTask({
833
866
  return useApi(apiFallback, {
834
867
  route, task: prompt, signal, adapter, preference, exitCode: verified.exitCode ?? null,
835
868
  timedOut: verified.status === 'timed-out', attempted: true,
869
+ subscriptionResult: { usage: verified.usage, usageIncomplete: verified.usageIncomplete,
870
+ notStarted: verified.notStarted === true, actualModel: verified.actualModel, reportedModel: verified.reportedModel },
836
871
  reason: verified.error || verified.reason || '官方 CLI 执行失败,已回退模型目录 API。',
837
872
  detail: redactDiagnostic(verifiedDiagnostic(verified), childEnvironment(adapter, credentials, credentialMode).secret),
838
873
  raw: [verified.error, verified.reason, verified.detail, verified.stderrTail, verified.stdoutTail].filter(item => typeof item === 'string').join('\n'),
@@ -861,7 +896,7 @@ export async function executeAssignedTask({
861
896
  : probe.timedOut ? `${adapter.label} 检测超时,已回退模型目录 API。`
862
897
  : `${adapter.label} 无法运行,已回退模型目录 API。`
863
898
  if (probe.cancelled) {
864
- return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: null, error: reason, cancelled: true }
899
+ return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: null, error: reason, cancelled: true, notStarted: true }
865
900
  }
866
901
  return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, reason })
867
902
  }
@@ -871,11 +906,14 @@ export async function executeAssignedTask({
871
906
  ...(Number.isSafeInteger(stopGraceMs) && stopGraceMs >= 10 && stopGraceMs <= STOP_GRACE_MS ? { stopGraceMs } : {}),
872
907
  })
873
908
  if (run.cancelled) {
874
- return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: run.exitCode, error: '执行已取消。', cancelled: true }
909
+ const usage = usageFromOutput(adapter.format, run.stdout)
910
+ return { ok: false, provider, model, preference, channel: 'official-cli', toolId: adapter.id, answer: '', fallback: null, timedOut: false, exitCode: run.exitCode, error: '执行已取消。', cancelled: true,
911
+ credentialSource, ...(usage?.usage ? { usage: usage.usage } : {}), ...(usage?.usageIncomplete ? { usageIncomplete: true } : {}) }
875
912
  }
876
913
  if (!run.ok) {
877
914
  return useApi(apiFallback, {
878
915
  route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, timedOut: run.timedOut, attempted: true,
916
+ subscriptionResult: usageFromOutput(adapter.format, run.stdout),
879
917
  reason: run.timedOut ? `${adapter.label} 执行超时,已回退模型目录 API。`
880
918
  : run.outputLimit ? `${adapter.label} 输出超过上限,已回退模型目录 API。`
881
919
  : `${adapter.label} 执行失败,已回退模型目录 API。`,
@@ -889,6 +927,7 @@ export async function executeAssignedTask({
889
927
  if (!parsed.ok) {
890
928
  return useApi(apiFallback, {
891
929
  route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, attempted: true,
930
+ subscriptionResult: usageFromOutput(adapter.format, run.stdout),
892
931
  reason: `${parsed.error} 已回退模型目录 API。`,
893
932
  detail: failureDetail(adapter.format, run.stdout, run.stderr, secret),
894
933
  raw: `${run.stdout.slice(-20_000)}\n${run.stderr.slice(-20_000)}`,
@@ -896,7 +935,8 @@ export async function executeAssignedTask({
896
935
  }
897
936
  const answer = redact(parsed.answer, secret)
898
937
  if (!answer.trim()) {
899
- return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, attempted: true, reason: '官方 CLI 没有返回文本,已回退模型目录 API。' })
938
+ return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, exitCode: run.exitCode, attempted: true,
939
+ subscriptionResult: usageFromOutput(adapter.format, run.stdout), reason: '官方 CLI 没有返回文本,已回退模型目录 API。' })
900
940
  }
901
941
  return officialSuccess({
902
942
  route, adapter, preference, answer, exitCode: run.exitCode,
@@ -954,14 +994,26 @@ const failureSummary = text => `${String(text ?? '').replace(/,?已(?:回退
954
994
  * - subscription-only: never fall back to an API key.
955
995
  * - api-only: never use the subscription.
956
996
  */
957
- export async function executeRouteWithBilling({ route, billing = null, apiFallback, ...options } = {}) {
997
+ export async function executeRouteWithBilling({ route, billing = null, apiFallback, onSubscriptionResult, ...options } = {}) {
958
998
  const plan = billingPlan(route, billing?.routes ?? [])
999
+ const subscriptionAttempts = []
1000
+ const recordSubscription = async receipt => {
1001
+ // This executor attempts the subscription once, then may use its API
1002
+ // fallback. Keep a bounded vector so unrelated result fields cannot grow it.
1003
+ if (subscriptionAttempts.length >= 2) throw new Error('订阅尝试证据超过本步上限。')
1004
+ subscriptionAttempts.push(receipt)
1005
+ const event = { ...structuredClone(receipt), billing: 'subscription', subscriptionAttempts: structuredClone(subscriptionAttempts) }
1006
+ await awaitExecutionCheckpoint(onSubscriptionResult, event,
1007
+ () => ({ status: 'failed', packages: [{ ...event, ok: false, ran: !receipt.notStarted }] }), 'subscription-result')
1008
+ }
959
1009
  // Every result names the billing mode it ran under, whichever branch produced it.
960
- const result = await routeWithBilling(plan, { route, billing, apiFallback, ...options })
961
- return { ...result, billingMode: result?.billingMode ?? plan.mode }
1010
+ const result = await routeWithBilling(plan, { route, billing, apiFallback, ...options, onSubscriptionResult: recordSubscription })
1011
+ return { ...result, billingMode: result?.billingMode ?? plan.mode,
1012
+ ...(subscriptionAttempts.length ? { subscriptionAttempts,
1013
+ ...(result?.billing ? {} : { billing: 'subscription' }) } : {}) }
962
1014
  }
963
1015
 
964
- async function routeWithBilling(plan, { route, billing = null, apiFallback, ...options } = {}) {
1016
+ async function routeWithBilling(plan, { route, billing = null, apiFallback, onSubscriptionResult, ...options } = {}) {
965
1017
  if (!billing) return executeAssignedTask({ ...options, route, apiFallback })
966
1018
  const now = typeof billing.now === 'function' ? billing.now : Date.now
967
1019
  const sub = plan.subscription
@@ -972,7 +1024,7 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
972
1024
  const base = { provider, model, billingMode: plan.mode }
973
1025
  const refuse = (error, extra = {}) => ({
974
1026
  ok: false, ...base, preference: executionPreference(route), channel: sub?.kind === 'plan-key' ? 'harness-llm' : 'official-cli',
975
- toolId: sub?.toolId ?? null, answer: '', fallback: null, error, ...extra,
1027
+ toolId: sub?.toolId ?? null, answer: '', fallback: null, notStarted: true, error, ...extra,
976
1028
  })
977
1029
  const viaApi = async (reason, note, skipped = false) => {
978
1030
  if (!apiRoute || (sub?.kind === 'plan-key' && apiRoute.provider === sub.route.provider && apiRoute.model === sub.route.model)) {
@@ -1016,11 +1068,16 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
1016
1068
  }
1017
1069
 
1018
1070
  if (sub.kind === 'plan-key') {
1071
+ if (typeof apiFallback !== 'function') return refuse('编程套餐执行器不可用,此步骤未启动。', { notStarted: true, billing: 'subscription' })
1019
1072
  const reasoningRoute = routeForReasoningSwitch(route, sub.route)
1020
1073
  const originalEffort = typeof route?.reasoningEffort === 'string' && route.reasoningEffort ? route.reasoningEffort : null
1021
1074
  let attempt
1022
1075
  try { attempt = await apiFallback({ route: reasoningRoute, task: options.task, signal: options.signal, reason: '编程套餐(订阅)', subscription: true }) }
1023
- catch (error) { attempt = { ok: false, error: String(error?.message ?? error) } }
1076
+ catch (error) {
1077
+ if (executionBoundaryFailure(error)) throw error
1078
+ attempt = { ok: false, error: String(error?.message ?? error) }
1079
+ }
1080
+ await onSubscriptionResult(subscriptionReceipt(attempt, { ...sub.route, now }))
1024
1081
  const droppedEffort = originalEffort && reasoningRoute.reasoningEffort !== originalEffort ? originalEffort : null
1025
1082
  if (attempt?.ok === true && String(attempt.answer ?? '').trim()) {
1026
1083
  return {
@@ -1083,8 +1140,15 @@ async function routeWithBilling(plan, { route, billing = null, apiFallback, ...o
1083
1140
  // No subscription login: the existing CLI-with-key / catalog API behaviour.
1084
1141
  return executeAssignedTask({ ...options, route, apiFallback })
1085
1142
  }
1143
+ const confirmedSubscription = retrySubscription || login === 'subscription' || plan.mode === 'subscription-only'
1086
1144
  if (retrySubscription) options = { ...options, skipOfficial: null }
1087
1145
  const guarded = async args => {
1146
+ if (args?.attempted === true && !confirmedSubscription) {
1147
+ const pause = pauseFor('CLI 计费方式尚未核对,已暂停,未启动额外 API 调用。', '请先体检确认账号订阅或 API Key,再选择重试方式。')
1148
+ return { ok: false, paused: true, billing: 'unknown', pause, error: pause.reason }
1149
+ }
1150
+ if (args?.attempted === true && confirmedSubscription) await onSubscriptionResult(subscriptionReceipt(args.subscriptionResult,
1151
+ { provider, model, toolId: args.toolId ?? sub.toolId, channel: 'official-cli', now }))
1088
1152
  const vendor = vendorKey({ toolId: args?.toolId ?? sub.toolId })
1089
1153
  const info = classify(`${args?.detail ?? ''}
1090
1154
  ${args?.raw ?? ''}`, vendor)
@@ -1112,13 +1176,16 @@ ${args?.raw ?? ''}`, vendor)
1112
1176
  const pause = pauseFor(summary, args?.detail ?? '', { timedOut: args?.timedOut === true, exitCode: args?.exitCode ?? null })
1113
1177
  return { ok: false, paused: true, pause, error: pause.reason }
1114
1178
  }
1115
- return apiFallback(args)
1179
+ return { ...await apiFallback(args), billing: 'api' }
1116
1180
  }
1117
1181
  const result = await executeAssignedTask({ ...options, route, apiFallback: guarded, credentialMode: 'session-only' })
1182
+ if (confirmedSubscription && result.channel === 'official-cli' && (result.ok === true || result.cancelled && !result.notStarted)) {
1183
+ await onSubscriptionResult(subscriptionReceipt(result, { provider, model, toolId: sub.toolId, channel: 'official-cli', now }))
1184
+ }
1118
1185
  return {
1119
1186
  ...result,
1120
1187
  billingMode: plan.mode,
1121
- ...(result.ok && result.channel === 'official-cli' ? { billing: 'subscription' } : {}),
1188
+ ...(result.channel === 'official-cli' && !result.billing ? { billing: confirmedSubscription ? 'subscription' : 'unknown' } : {}),
1122
1189
  }
1123
1190
  }
1124
1191
 
@@ -1229,6 +1296,26 @@ export async function observeExecutionProgress(hook, value) {
1229
1296
  try { await hook(value) } catch { /* Progress reporting does not control execution. */ }
1230
1297
  }
1231
1298
 
1299
+ /** Required Host persistence is a dispatch boundary, unlike progress observers. */
1300
+ export async function awaitExecutionCheckpoint(hook, value, partial, stage) {
1301
+ if (typeof hook !== 'function') return
1302
+ try { await hook(value) } catch (cause) {
1303
+ if (executionBoundaryFailure(cause) && cause.execution) throw cause
1304
+ const budgetChanged = cause?.code === 'ROUTER_BUDGET_CHANGED'
1305
+ const error = new Error(budgetChanged ? String(cause.message) : `执行检查点保存失败:${String(cause?.message ?? cause)}`, { cause })
1306
+ error.code = budgetChanged ? 'ROUTER_BUDGET_CHANGED' : 'ROUTER_CHECKPOINT_FAILED'
1307
+ if (budgetChanged) {
1308
+ error.budget = cause.budget
1309
+ error.runId = cause.runId
1310
+ error.reason = cause.reason
1311
+ }
1312
+ error.checkpointStage = stage
1313
+ // The caller can retain paid receipts even when the persistence sink failed.
1314
+ error.execution = typeof partial === 'function' ? partial() : partial
1315
+ throw error
1316
+ }
1317
+ }
1318
+
1232
1319
  function checkedPackageSelections(packages, routes, overrides, runVerified, targets) {
1233
1320
  const preceding = new Set()
1234
1321
  const selected = {}
@@ -1305,11 +1392,12 @@ async function runManualTool(target, prompt, options) {
1305
1392
  */
1306
1393
  async function runPackages({ packages, task, routingBypassed, routes, previous = [], targets = null, overrides = {}, choices = {}, options }) {
1307
1394
  const routeList = Array.isArray(routes) ? routes : []
1308
- const { credentialsFor, onPackageStart, onPackage, onStage, billing, ...runOptions } = options
1395
+ const { credentialsFor, onPackageStart, onPackage, onStage, onCheckpointStart, onCheckpointResult, onSubscriptionResult, billing, ...runOptions } = options
1309
1396
  await observeExecutionProgress(onStage, 'preparing')
1310
1397
  const effectivePackages = checkedPackageSelections(packages, routeList, overrides, runOptions.runVerified, targets)
1311
1398
  const results = []
1312
1399
  const ranIds = []
1400
+ const partial = () => ({ ...aggregateOf([...results]), status: runOptions.signal?.aborted ? 'cancelled' : 'failed', ranIds: [...ranIds] })
1313
1401
  if (!runOptions.signal?.aborted) await observeExecutionProgress(onStage, 'executing')
1314
1402
  for (const item of effectivePackages) {
1315
1403
  const kept = previous.find(result => result.id === item.id)
@@ -1330,6 +1418,7 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1330
1418
  ...identity, answer: '', fallback: null, cancelled: true, notStarted: true, error: '执行已取消,此步骤未开始。' }
1331
1419
  results.push(cancelled)
1332
1420
  ranIds.push(item.id)
1421
+ await awaitExecutionCheckpoint(onCheckpointResult, cancelled, partial, 'result')
1333
1422
  await observeExecutionProgress(onPackage, cancelled)
1334
1423
  }
1335
1424
  if (runOptions.signal?.aborted) {
@@ -1349,6 +1438,7 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1349
1438
  }
1350
1439
  results.push(blocked)
1351
1440
  ranIds.push(item.id)
1441
+ await awaitExecutionCheckpoint(onCheckpointResult, blocked, partial, 'result')
1352
1442
  await observeExecutionProgress(onPackage, blocked)
1353
1443
  continue
1354
1444
  }
@@ -1364,9 +1454,15 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1364
1454
  continue
1365
1455
  }
1366
1456
  const startedAt = Date.now()
1367
- await observeExecutionProgress(onPackageStart, { ...item, ...identity, provider, model, startedAt,
1457
+ const starting = { ...item, ...identity, provider, model, startedAt,
1368
1458
  channel: target?.kind === 'tool' ? 'official-cli' : 'harness-llm',
1369
- ...(target?.kind === 'tool' ? { toolId: target.toolId } : {}) })
1459
+ ...(target?.kind === 'tool' ? { toolId: target.toolId } : {}) }
1460
+ await observeExecutionProgress(onPackageStart, starting)
1461
+ if (runOptions.signal?.aborted) {
1462
+ await cancelledBeforeStart()
1463
+ continue
1464
+ }
1465
+ await awaitExecutionCheckpoint(onCheckpointStart, starting, partial, 'start')
1370
1466
  if (runOptions.signal?.aborted) {
1371
1467
  await cancelledBeforeStart()
1372
1468
  continue
@@ -1378,6 +1474,11 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1378
1474
  else {
1379
1475
  result = await executeRouteWithBilling({
1380
1476
  ...runOptions,
1477
+ onSubscriptionResult: async event => {
1478
+ event.id = item.id
1479
+ await awaitExecutionCheckpoint(onSubscriptionResult, event,
1480
+ () => ({ ...partial(), packages: [...results, { ...event, ok: false, ran: !event.notStarted }] }), 'subscription-result')
1481
+ },
1381
1482
  billing: billing ? { ...billing, routes: billing.routes ?? routeList, ...(choices[item.id] ? { choice: choices[item.id] } : {}) } : null,
1382
1483
  credentials, route, task: promptText,
1383
1484
  })
@@ -1404,6 +1505,7 @@ async function runPackages({ packages, task, routingBypassed, routes, previous =
1404
1505
  }
1405
1506
  results.push(entry)
1406
1507
  ranIds.push(item.id)
1508
+ await awaitExecutionCheckpoint(onCheckpointResult, entry, partial, 'result')
1407
1509
  await observeExecutionProgress(onPackage, entry)
1408
1510
  }
1409
1511
  return { ...aggregateOf(results), ranIds }
@@ -99,7 +99,7 @@ function rootFromCommand(value) {
99
99
  const trimmed = value.trim()
100
100
  const quoted = /^"([^"]+)"/.exec(trimmed)
101
101
  const executable = quoted?.[1] ?? /^(.+?\.(?:exe|ico))(?=,|\s|$)/i.exec(trimmed)?.[1]
102
- return executable && win32.isAbsolute(executable) ? dirname(executable) : null
102
+ return executable && win32.isAbsolute(executable) ? win32.dirname(executable) : null
103
103
  }
104
104
 
105
105
  /** Explicit selection is authoritative, even when the selected bundle is missing. */
@@ -125,8 +125,8 @@ export function zcodeDiscoveryCandidates({ installDir = '', env = process.env, r
125
125
  }
126
126
  for (const parent of [env.LOCALAPPDATA, env.ProgramFiles, env['ProgramFiles(x86)'], env.ProgramW6432]) {
127
127
  if (typeof parent !== 'string' || !win32.isAbsolute(parent)) continue
128
- add(join(parent, 'Programs', 'ZCode'), 'standard-directory')
129
- add(join(parent, 'ZCode'), 'standard-directory')
128
+ add(win32.join(parent, 'Programs', 'ZCode'), 'standard-directory')
129
+ add(win32.join(parent, 'ZCode'), 'standard-directory')
130
130
  }
131
131
  return candidates
132
132
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0 — 2026-10-24
4
+
5
+ - Separate API financial accounting from subscription usage. API costs and API reservations govern monetary budgets; subscriptions track calls and reported input/output/cache tokens, with incomplete or missing usage retained as unknown.
6
+ - Keep confirmed subscription execution independent of exhausted API budgets or missing API prices. Before an actual API call or subscription-to-API fallback, estimate the actual API route and acquire the applicable confirmation and atomic reservation. A CLI using an API key remains API-billed.
7
+ - Preserve subscription-attempt receipts alongside API fallback results and retain both ledgers across retry replacement and history trimming. Old reference-dollar fields remain readable without converting them into current subscription money or fabricated token counts.
8
+ - Show API money and subscription quantities separately in the workbench, execution preview and history. Subscription tokens do not imply vendor allowance, remaining quota or percentage. Additional API quality/review calls still incur API charges.
9
+ - Upgrade persistent state to schema v3 while migrating readable v1/v2 histories and protected execution state. Fully stop all Hosts before updating and keep state backups before downgrading.
10
+
11
+ Final validation and release evidence are tracked in the [0.25.0 acceptance report](docs/releases/0.25.0-validation.md). No new real vendor inference or installed-DSH end-to-end claim is made by these changes.
12
+
13
+ ## 0.24.0 — 2026-10-24
14
+
15
+ - Reserve remaining execution estimates in shared persistent state before admission. Daily/monthly checks account for recorded API spending plus other active or interrupted reservations, preventing concurrent tasks from independently admitting the same estimated capacity. Unknown estimates or unresolved costs under a finite budget require explicit confirmation.
16
+ - Save mandatory package checkpoints before external calls and after receipts, with actual usage replacing the completed estimate before another paid step. Apply the lifecycle to routed runs, retries/reassignment, official teams, single-tool runs and consultations; optional review and quality-upgrade calls reserve extra capacity first. Checkpoint failures stop later calls and retain evidence.
17
+ - Recheck fresh actual spending and remaining reservations before each later paid call. Earlier costs above estimate or unresolved under a finite budget stop downstream calls; after worker cleanup, a budget stop retains completed receipts and releases unused holds. Subscription-to-API fallback re-estimates the actual route and rejects missing-price or over-budget calls. Save the first receipt and pending extra attempt before a quality upgrade starts.
18
+ - Preserve interrupted reservations and unknown usage when an execution owner is confirmed dead. Protect referenced run records from trimming, block rerun/reassignment while a hold exists, and distinguish actual spending from reserved estimates in the UI. No automatic replay, time-based hold expiry or manual reconciliation/release control is added.
19
+ - Upgrade state schema from v1 to v2 while preserving the ledger. Older plugin versions reject v2 state, so fully stop all Host processes before updating and back up state before a downgrade.
20
+ - Add Windows/Linux Node 24 CI for strict frozen dependency installation, the complete test suite, committed and rebuilt client consistency, offline preview compilation, and npm archive verification against raw Git blobs and the runtime import closure.
21
+
22
+ Validation evidence and pending release checks are tracked in the [0.24.0 acceptance report](docs/releases/0.24.0-validation.md). No new real provider or vendor CLI inference is claimed. Budget reservations are local estimates, not a provider billing cap; interrupted fees must be reconciled before their capacity can safely be reused.
23
+ - Preserve observed receipt timestamps across checkpoints; handle Windows lock contention and declared-platform PATH/ZCode paths, with canonical temporary-path and explicit adapter-capability CI fixtures.
24
+
25
+
3
26
  ## 0.23.1 — 2026-10-23
4
27
 
5
28
  - Check retry budgets against the target and every unfinished downstream package that will execute. Reassignment uses the selected route estimate; successful downstream packages are excluded and unknown prices remain unknown.
package/README.i18n.yaml CHANGED
@@ -1,4 +1,4 @@
1
1
  # README translation pairing for this repository.
2
2
  # Reviewed together; Git blob hashes identify the exact synchronized texts.
3
- README.md: 3bb1e3507fc7451861fb3463f52f68827fb71690
4
- README.zh.md: 1d15eb0bf9c5ebeac41b74d349b0a709f16288c8
3
+ README.md: 58ad39f472cdee7cbf9d4a2e07aa5b669e06683c
4
+ README.zh.md: 3737b74fdfb6f9ddf20e575217a5e07a5faa2a91