dsh-desktop-statusbar 2.1.1 → 2.2.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.
package/lib/index.js CHANGED
@@ -40,6 +40,7 @@
40
40
  import { z } from 'zod'
41
41
  import { adapterFor, adaptersFor, pluginRoutesIn } from './balance-api.js'
42
42
  import { holidayHtmlToText, parseHolidayNotice } from './holiday.js'
43
+ import { isUpdating, lastUpdateResult, resolveSelfDir, selfUpdateOnce } from './self-update.js'
43
44
  import { appendFileSync, closeSync, copyFileSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
44
45
  import { basename, dirname, join } from 'node:path'
45
46
  import { homedir } from 'node:os'
@@ -142,10 +143,29 @@ const sessionCapacityProjection = {
142
143
 
143
144
  /* -------------------------------------------------------- sessionProgress */
144
145
 
145
- /** 一次压缩落在哪一轮哪一步(压缩事件只带轮号,步号取压缩前最后一步;取不到给 null)。 */
146
+ /**
147
+ * 一次**成功**压缩落在哪一轮哪一步。
148
+ * turn 允许 null:用户手动 /compact 是「轮间压缩」(standalone),真实日志里 turn 就是 null
149
+ *(见 ~/.dsh/sessions 的 cmd-* 事务);自动压缩才带当前轮号。
150
+ * step 取压缩前最后一步;取不到(轮间压缩 / 轮首)给 null。
151
+ * afterTurn:只有轮间压缩才有值 —— 压缩发生时最后已知的轮号,客户端据此显示「第 N 轮后」,
152
+ * 不用再把 turn=null 显示成孤零零的横杠;自动压缩有自己的轮号,afterTurn 恒为 null。
153
+ * manual:用户手动(斜杠命令)触发;自动压缩没有 sourceCommandId。
154
+ * 成功/失败**只**看 compaction/end 的 data.error:带 error 是失败,不带就是成功。
155
+ * 不要去数 compaction/summary —— summary 缺失不能反过来否决一次没有 error 的 end
156
+ *(宿主 invariant 会把「成功 end 没有 summary」当成日志损坏拒绝,那是宿主的事,投影不承担)。
157
+ */
146
158
  const compactMark = z.object({
147
- turn: z.number(),
159
+ turn: z.number().nullable(),
148
160
  step: z.number().nullable(),
161
+ afterTurn: z.number().nullable(),
162
+ manual: z.boolean(),
163
+ }).strict()
164
+
165
+ /** 一次技能注入:名字 + 是不是用户手动(斜杠命令)触发。重复注入也各记一条,客户端自己聚合。 */
166
+ const skillMark = z.object({
167
+ name: z.string(),
168
+ manual: z.boolean(),
149
169
  }).strict()
150
170
 
151
171
  const sessionProgressSchema = z.object({
@@ -154,13 +174,14 @@ const sessionProgressSchema = z.object({
154
174
  toolCalls: z.number(),
155
175
  /* 工具名 → 调用次数:气泡里「工具调用」那一行点开要逐个列出来 */
156
176
  toolCounts: z.record(z.string(), z.number()),
157
- skills: z.array(z.string()),
177
+ /* 每次注入一条,按发生顺序;manual 区分斜杠命令注入与 skill 工具调用 */
178
+ skills: z.array(skillMark),
158
179
  /* 注入进会话的全局提示词文件名(~/.dsh/AGENTS.md → 'AGENTS.md');没注入给 null */
159
180
  globalInstruction: z.string().nullable(),
160
181
  compactCount: z.number(),
161
182
  compactTurn: z.number().nullable(),
162
183
  compactStep: z.number().nullable(),
163
- /* 每一次压缩的落点,按发生顺序排:气泡里「上下文压缩」那一行点开逐条列 */
184
+ /* 只列成功压缩,按发生顺序:气泡里「上下文压缩」那一行点开逐条列;失败的不进来 */
164
185
  compacts: z.array(compactMark),
165
186
  }).strict()
166
187
 
@@ -218,12 +239,18 @@ function globalInstructionOf(data) {
218
239
  * 所以在 host 侧按事件折一份:
219
240
  * step/start → 本轮走到第几步(轮号一变就归零,step 是轮内序号不是会话累计)
220
241
  * tool/call → 整个会话的工具调用次数 + 每个工具名各自的次数;
221
- * 工具名是 skill 的那种再把技能名按注入顺序记进 skills
242
+ * 工具名是 skill 的那种再把技能名(manual:false)按注入顺序记进 skills
222
243
  * user/message → 全局提示词(agent-instructions 注入的那种)的文件名;
223
- * 斜杠命令注入的技能(skill-invocation)也补进 skills
224
- * compaction/start → 压缩次数 + 这一次的落点;压缩事件只带轮号,
225
- * 压缩发生在哪一步取压缩前最后一条同轮 step/start
226
- * 这几种事件在真实日志里都齐(75 个会话:11208 次工具调用、22 个压缩事件)。
244
+ * 斜杠命令注入的技能(skill-invocation)以 manual:true 补进 skills
245
+ * compaction/end → 只认**成功**的压缩:判定只看 end 的 data.error(有 error=失败,无 error=成功)。
246
+ * 宿主语义(app.asar 里 dsh-compaction 的 invariant):成功时 end 不带 error,
247
+ * 且日志里会有一条 compaction/summary;失败(模型 400/429 等)只发 end + error、
248
+ * 没有 summary。**但不数 summary**:没 summary 的 end 也不能反过来判失败,
249
+ * 否则就成了「压缩后没有 summary 就算失败」这种没验证过的启发式。
250
+ * 轮号取 end.data.turn(用户手动 /compact 是轮间压缩,turn=null),
251
+ * 步号取压缩前最后一条同轮 step/start;手动触发看 end.data.sourceCommandId
252
+ * (start/summary/end 三个事件都带同一个命令 id,自动压缩没有)。
253
+ * 这几种事件在真实日志里都齐(62 个会话:17 次压缩事务,其中成功 4、失败 13,手动 11)。
227
254
  */
228
255
  const sessionProgressProjection = {
229
256
  key: 'desktopStatusbarProgress',
@@ -251,7 +278,8 @@ const sessionProgressProjection = {
251
278
  }
252
279
  if (event.type === 'user/message') {
253
280
  const skill = skillInvocationOf(event.data)
254
- if (skill !== null) return { ...state, skills: state.skills.concat([skill]) }
281
+ /* 用户输入 /技能名 走的就是这条:算手动触发 */
282
+ if (skill !== null) return { ...state, skills: state.skills.concat([{ name: skill, manual: true }]) }
255
283
  const name = globalInstructionOf(event.data)
256
284
  if (name === null || state.globalInstruction === name) return state
257
285
  return { ...state, globalInstruction: name }
@@ -268,29 +296,41 @@ const sessionProgressProjection = {
268
296
  ...state,
269
297
  toolCalls: state.toolCalls + 1,
270
298
  toolCounts: counts,
271
- /* 技能有两种注入来源:调 skill 工具(这里)与斜杠命令(user/message 分支);
272
- 名字按注入顺序排,重复注入也照记(数量 = 数组长度) */
273
- skills: skill === null ? state.skills : state.skills.concat([skill]),
299
+ /* 技能有两种注入来源:调 skill 工具(这里,自动/模型触发)与斜杠命令(user/message 分支,
300
+ 用户手动触发);名字按注入顺序排,重复注入也照记(数量 = 数组长度) */
301
+ skills: skill === null ? state.skills : state.skills.concat([{ name: skill, manual: false }]),
274
302
  }
275
303
  }
276
- if (event.type === 'compaction/start') {
304
+ if (event.type === 'compaction/end') {
277
305
  const data = event.data === undefined || event.data === null ? {} : event.data
278
- const turn = typeof data.turn === 'number' ? data.turn : state.turn
279
- /* 压缩前没有同轮的步(压缩落在轮首)时给 null,客户端那一格显示横杠 */
280
- const step = turn === state.turn && state.turnSteps > 0 ? state.turnSteps : null
306
+ /* 失败压缩不发 summary、end 带 error:不计次数、不落档,客户端只显示成功的那些 */
307
+ if (data.error !== undefined && data.error !== null) return state
308
+ /* 轮间压缩(用户手动 /compact)turn=null;自动压缩用事件自己的轮号 */
309
+ const hasTurn = typeof data.turn === 'number'
310
+ const turn = hasTurn ? data.turn : null
311
+ /* 轮间压缩没有轮号,但客户端要显示「第 N 轮后」:N 取压缩发生时最后已知的轮号。
312
+ state.turn 初值是 0(还没跑过任何轮),这时没有「第 N 轮后」可讲,给 null。 */
313
+ const afterTurn = hasTurn ? null : (typeof state.turn === 'number' && state.turn > 0 ? state.turn : null)
314
+ /* 步号取压缩前最后一条同轮 step/start:压缩事务中间不会插 step/start(真实日志里
315
+ 成功事务中间只有一条 compact-checkpoint 的 user/message),end 到达时 turnSteps 仍是压缩前步数 */
316
+ const step = turn !== null && turn === state.turn && state.turnSteps > 0 ? state.turnSteps : null
317
+ /* 手动 /compact 的三个压缩事件都带 sourceCommandId(命令 id),自动压缩没有 */
318
+ const manual = data.sourceCommandId !== undefined && data.sourceCommandId !== null
281
319
  return {
282
320
  ...state,
283
321
  compactCount: state.compactCount + 1,
284
322
  compactTurn: turn,
285
323
  compactStep: step,
286
- compacts: state.compacts.concat([{ turn: turn, step: step }]),
324
+ compacts: state.compacts.concat([{ turn: turn, step: step, afterTurn: afterTurn, manual: manual }]),
287
325
  }
288
326
  }
289
327
  return state
290
328
  },
291
329
  wire: { viewSchema: sessionProgressSchema, view: (state) => state },
292
- // 字段变了(加 globalInstruction、技能多一条斜杠命令来源),提升版本重建旧状态
293
- stateVersion: 6,
330
+ // 字段变了(skills 变成 {name, manual}、compacts 加 manual 且只记成功、压缩 turn 可为 null),提升版本重建旧状态
331
+ // 7 → 8:compacts 新增 afterTurn(轮间/手动压缩显示「第 N 轮后」,替代原来的横杠)。
332
+ // 旧缓存里每条压缩记录没有这个字段,必须重放才能补出来。
333
+ stateVersion: 8,
294
334
  }
295
335
 
296
336
  /* ------------------------------------------------------------ sessionUsage */
@@ -327,8 +367,11 @@ function findUsageInStream(stream) {
327
367
  }
328
368
 
329
369
  function usageOf(event) {
330
- if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
370
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt' && event.type !== 'compaction/summary') return undefined
331
371
  const data = event.data === null || event.data === undefined ? {} : event.data
372
+ /* compaction/summary 是压缩引擎自己发起的一次摘要调用:data.usage 就是它的用量(没有 stream)。
373
+ 真实会话里这 4 笔摘要都是带用量的,漏掉会让「本轮/总计费用」都比实际少。 */
374
+ if (event.type === 'compaction/summary') return data.usage
332
375
  let usage = event.type === 'assistant/message' ? data.usage : undefined
333
376
  const fromStream = findUsageInStream(data.stream)
334
377
  if (fromStream !== undefined) usage = fromStream
@@ -404,9 +447,79 @@ const MAX_CALLS = 4000
404
447
  const appendCalls = (calls, more) =>
405
448
  calls.length + more.length <= MAX_CALLS ? calls.concat(more) : calls.slice(calls.length + more.length - MAX_CALLS).concat(more)
406
449
 
450
+ /**
451
+ * 被 MAX_CALLS 截掉的调用,按「模型 + 峰谷档」累计的 token。
452
+ *
453
+ * 为什么需要它:客户端算「总计费用」是**逐条调用**计价(每条按自己发生的时刻定峰谷),
454
+ * 而 calls 只保留最近 MAX_CALLS 条;超出部分原先直接丢弃,费用就少算了 ——
455
+ * 实测 5000 次调用少 20%、2 万次少 80%,而「总计用量」用的是官方全量数据,
456
+ * 于是同一个界面上用量是全量、费用是残缺的,且没有任何提示。
457
+ *
458
+ * 这里把丢掉的那些按档累计,客户端用档位价补算即可 —— 档位在 host 侧按北京时间判定,
459
+ * 与逐条计价同一套规则,所以两段相加与「一条不丢地算」结果一致。
460
+ */
461
+ const overflowTier = z.object({
462
+ peak: usageBucket,
463
+ offPeak: usageBucket,
464
+ }).strict()
465
+
466
+ /** 往 overflow 里加一笔;返回新的 overflow(不改原对象)。 */
467
+ function addOverflow(overflow, model, parts, peak) {
468
+ const zero = () => ({ input: 0, cacheRead: 0, cacheWrite: 0, output: 0 })
469
+ const prev = overflow[model] ?? { peak: zero(), offPeak: zero() }
470
+ const tierName = peak === true ? 'peak' : 'offPeak'
471
+ const tier = prev[tierName]
472
+ return {
473
+ ...overflow,
474
+ [model]: {
475
+ ...prev,
476
+ [tierName]: {
477
+ input: tier.input + parts.input,
478
+ cacheRead: tier.cacheRead + parts.cacheRead,
479
+ cacheWrite: tier.cacheWrite + parts.cacheWrite,
480
+ output: tier.output + parts.output,
481
+ },
482
+ },
483
+ }
484
+ }
485
+
486
+ /**
487
+ * 把一笔调用并进总计:models 按模型累加、calls 追加,
488
+ * 超过 MAX_CALLS 被挤掉的最老几条按「模型 + 峰谷档」补进 overflow。
489
+ * 正常 assistant 调用与 compaction/summary 两处共用一份实现 —— 两条路各写一遍迟早对不齐口径。
490
+ */
491
+ function addCallTotals(base, one) {
492
+ const calls = Array.isArray(base.calls) ? base.calls : []
493
+ const prevModel = base.models[one.model]
494
+ const bucket = {
495
+ input: (prevModel === undefined ? 0 : prevModel.input) + one.input,
496
+ cacheRead: (prevModel === undefined ? 0 : prevModel.cacheRead) + one.cacheRead,
497
+ cacheWrite: (prevModel === undefined ? 0 : prevModel.cacheWrite) + one.cacheWrite,
498
+ output: (prevModel === undefined ? 0 : prevModel.output) + one.output,
499
+ }
500
+ /* 追加后若超过 MAX_CALLS,最前面那几条会被丢掉。丢掉的是**真实用过的 token**,
501
+ 必须把它们按档累计进 overflow,否则客户端逐条计价时会少算(见 addOverflow 说明)。
502
+ 用合并前后的长度差算出丢了几条 —— 它们一定是最老的,也就是 calls 队首那几条。 */
503
+ const merged = appendCalls(calls, [one])
504
+ const droppedCount = calls.length + 1 - merged.length
505
+ let overflow = typeof base.overflow === 'object' && base.overflow !== null ? base.overflow : {}
506
+ for (let i = 0; i < droppedCount; i += 1) {
507
+ const gone = calls[i]
508
+ if (gone === undefined) break
509
+ overflow = addOverflow(overflow, gone.model, gone, isPeakTime(gone.at))
510
+ }
511
+ return {
512
+ calls: merged,
513
+ overflow,
514
+ models: { ...base.models, [one.model]: bucket },
515
+ }
516
+ }
517
+
407
518
  const sessionUsageSchema = z.object({
408
519
  models: z.record(z.string(), usageBucket),
409
520
  calls: z.array(usageCall),
521
+ /* 被 MAX_CALLS 截掉的那部分(见 addOverflow 的说明)。老缓存没有这个键,所以可选。 */
522
+ overflow: z.record(z.string(), overflowTier).optional(),
410
523
  turn: z.number().nullable(),
411
524
  current: lastUsage.nullable(),
412
525
  last: lastUsage.nullable(),
@@ -414,12 +527,27 @@ const sessionUsageSchema = z.object({
414
527
  可空且可选 —— 万一某条路径没带上这两个键,也不至于让整个 state 过不了校验。 */
415
528
  routeModel: z.string().nullable().optional(),
416
529
  routeProvider: z.string().nullable().optional(),
530
+ /* compactionId → 该次压缩的轮号与开始时刻。compaction/summary 没有 turn,只能靠 start 记下的映射
531
+ 才能判断这笔摘要属于哪一轮;at 用来圈定「防双计」的时间窗。老缓存没有这个键,所以可选。 */
532
+ compactions: z.record(z.string(), z.object({
533
+ turn: z.number().nullable(),
534
+ at: z.number(),
535
+ }).strict()).optional(),
536
+ /* 已经计过费的 compactionId:同一笔摘要只计一次。老缓存没有这个键,所以可选。 */
537
+ countedCompactions: z.array(z.string()).optional(),
538
+ /* 压缩摘要**累计**的 token 四段(只含摘要、不含普通调用)。
539
+ 为什么需要它:「总计用量」那一栏的数据来自官方 tokenUsage 投影,而官方只折
540
+ assistant 事件、**不含压缩摘要**;「总计费用」是我们自己逐条算的、**含摘要**。
541
+ 同一面板里两个数字口径不一致(费用算进去了、用量没算)。用户拍板「官方投影继续用,
542
+ 只把摘要这部分额外加上」—— 这份累计就是那个「额外加上」的部分。
543
+ 老缓存没有这个键,所以可选。 */
544
+ summaryParts: usageBucket.optional(),
417
545
  }).strict()
418
546
 
419
547
  const sessionUsageProjection = {
420
548
  key: 'desktopStatusbarUsage',
421
549
  stateSchema: sessionUsageSchema,
422
- init: () => ({ models: {}, calls: [], current: null, last: null, turn: null, routeModel: null, routeProvider: null }),
550
+ init: () => ({ models: {}, calls: [], overflow: {}, current: null, last: null, turn: null, routeModel: null, routeProvider: null, compactions: {}, countedCompactions: [], summaryParts: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }),
423
551
  apply: (state, event) => {
424
552
  const calls = Array.isArray(state.calls) ? state.calls : []
425
553
  const turnOf = (event) => {
@@ -433,6 +561,20 @@ const sessionUsageProjection = {
433
561
  if (state.current === null) return { ...state, turn: started }
434
562
  return { ...state, calls, current: null, last: state.current, turn: started }
435
563
  }
564
+ /* 压缩事务开始时先记下 compactionId → 轮号:compaction/summary 事件本身没有 turn,
565
+ 只能靠这条映射判断这笔摘要费用算不算「本轮」。turn 允许 null(轮间/手动压缩)。 */
566
+ if (event.type === 'compaction/start') {
567
+ const data = event.data === null || event.data === undefined ? {} : event.data
568
+ if (typeof data.compactionId !== 'string' || data.compactionId.length === 0) return state
569
+ const compactions = typeof state.compactions === 'object' && state.compactions !== null ? state.compactions : {}
570
+ return {
571
+ ...state,
572
+ compactions: {
573
+ ...compactions,
574
+ [data.compactionId]: { turn: typeof data.turn === 'number' ? data.turn : null, at: event.time },
575
+ },
576
+ }
577
+ }
436
578
  // 先记下"这次请求用的是哪个模型":没有 message 的事件(assistant/attempt)靠它补型号
437
579
  const routed = routedModelOf(event)
438
580
  const base = routed === null ? state : {
@@ -443,23 +585,24 @@ const sessionUsageProjection = {
443
585
  const usage = usageOf(event)
444
586
  if (usage === undefined || usage === null) return base
445
587
  // 型号两条路:常规事件从 message.source 取;没有 message 的用上面记下的最近一次请求
446
- const message = event.data === null || event.data === undefined ? null : event.data.message
588
+ const data = event.data === null || event.data === undefined ? {} : event.data
589
+ const message = data.message
447
590
  const source = message === null || message === undefined ? null : message.source
448
591
  const fromMessage = source !== null && source.kind === 'model' && typeof source.model === 'string'
449
- const model = fromMessage ? source.model : (typeof base.routeModel === 'string' ? base.routeModel : '')
450
- // 两条路都拿不到型号:放弃这一笔,好过抛错把整条投影(以及同一次驱动的其它投影)带停
592
+ /* 摘要调用是压缩引擎自己发起的(没有 message),型号只认 summary 自己的 data.model;
593
+ 不能退回 routeModel —— 那是用户上一轮对话的模型,摘要往往跑在另一个模型上。 */
594
+ const isCompactionSummary = event.type === 'compaction/summary'
595
+ const model = isCompactionSummary
596
+ ? (typeof data.model === 'string' && data.model.length > 0 ? data.model : '')
597
+ : (fromMessage ? source.model : (typeof base.routeModel === 'string' ? base.routeModel : ''))
598
+ // 拿不到型号:放弃这一笔,好过抛错把整条投影(以及同一次驱动的其它投影)带停
451
599
  if (model.length === 0) return base
452
- const provider = fromMessage && typeof source.provider === 'string'
453
- ? source.provider
454
- : (typeof base.routeProvider === 'string' ? base.routeProvider : '')
455
- const currentModel = base.models[model]
600
+ const provider = isCompactionSummary
601
+ ? (typeof data.provider === 'string' ? data.provider : '')
602
+ : (fromMessage && typeof source.provider === 'string'
603
+ ? source.provider
604
+ : (typeof base.routeProvider === 'string' ? base.routeProvider : ''))
456
605
  const parts = usageParts(usage)
457
- const bucket = {
458
- input: (currentModel === undefined ? 0 : currentModel.input) + parts.input,
459
- cacheRead: (currentModel === undefined ? 0 : currentModel.cacheRead) + parts.cacheRead,
460
- cacheWrite: (currentModel === undefined ? 0 : currentModel.cacheWrite) + parts.cacheWrite,
461
- output: (currentModel === undefined ? 0 : currentModel.output) + parts.output,
462
- }
463
606
  const one = {
464
607
  at: event.time,
465
608
  model,
@@ -468,13 +611,102 @@ const sessionUsageProjection = {
468
611
  cacheWrite: parts.cacheWrite,
469
612
  output: parts.output,
470
613
  }
614
+ /* 摘要事件走单独的归轮路径:它没有 turn,绝不能套用下面"轮次变化就重开 current"的逻辑,
615
+ 否则上一轮/轮间压缩的摘要会并进当前轮,或者把 current/last 打乱。 */
616
+ if (isCompactionSummary) {
617
+ /* 摘要的去重身份:正常情况下用 upstream 给的 compactionId;
618
+ 拿不到时退回 `event.seq`(**与本文件 usageEventKey 的既有做法一致**,见
619
+ `'compact:' + (compactionId ?? event.seq)`)。为什么不直接跳过空 id:
620
+ 那会把一笔真实用量彻底丢掉(费用和用量都少算),而重复计费比漏算更糟但
621
+ 两者都有代价 —— 用 seq 兜底能做到"同一事件重复到达只计一次",
622
+ 因为同一事件的 seq 相同、不同事件必然不同。 */
623
+ const rawId = typeof data.compactionId === 'string' ? data.compactionId : ''
624
+ const compactionId = rawId.length > 0
625
+ ? rawId
626
+ : (typeof event.seq === 'number' ? 'seq:' + event.seq : '')
627
+ const counted = Array.isArray(base.countedCompactions) ? base.countedCompactions : []
628
+ // 同一笔压缩只计一次:重放或重复事件不能让费用翻倍
629
+ if (compactionId.length > 0 && counted.includes(compactionId)) return base
630
+ const compactions = typeof base.compactions === 'object' && base.compactions !== null ? base.compactions : {}
631
+ /* 只有 upstream 真的给了 id 时才有 start 映射可查(空 id 的 start 在更早的分支就被丢弃了) */
632
+ const opened = rawId.length > 0 ? compactions[rawId] : undefined
633
+ /* 防旧宿主双计:旧版本可能把同一笔用量既落成 assistant/message 又落成 summary。
634
+ 窗口 = compaction/start 到本 summary 之间;四条用量分量完全相同才算同一笔
635
+ (token 数精确到个位,不同请求撞上的概率可以忽略)。 */
636
+ const duplicate = opened !== undefined && calls.some((call) =>
637
+ call.at >= opened.at && call.at <= event.time
638
+ && call.input === one.input && call.cacheRead === one.cacheRead
639
+ && call.cacheWrite === one.cacheWrite && call.output === one.output)
640
+ if (duplicate) return base
641
+ const totals = addCallTotals(base, one)
642
+ /* 摘要用量单独累计一份(与按模型的 models 那份分开):
643
+ 客户端「总计用量」用官方 tokenUsage 投影(不含摘要),需要这一份把摘要补回去,
644
+ 才能让「总计用量」与「总计费用」口径一致。老 state 没有这个键,按 0 起算。 */
645
+ const prevSummary = base.summaryParts !== undefined && base.summaryParts !== null
646
+ && typeof base.summaryParts === 'object' ? base.summaryParts : null
647
+ const summaryParts = {
648
+ input: (prevSummary === null ? 0 : Number(prevSummary.input) || 0) + one.input,
649
+ cacheRead: (prevSummary === null ? 0 : Number(prevSummary.cacheRead) || 0) + one.cacheRead,
650
+ cacheWrite: (prevSummary === null ? 0 : Number(prevSummary.cacheWrite) || 0) + one.cacheWrite,
651
+ output: (prevSummary === null ? 0 : Number(prevSummary.output) || 0) + one.output,
652
+ }
653
+ /* 轮归属三条规则:
654
+ ① 映射到数字 turn 且等于当前 turn → 同时进总计与本轮;
655
+ ② 映射到数字 turn 但不等当前轮 → 只进总计;
656
+ ③ 映射为 null(**轮间压缩**,手动 / 自动都是 null)→ 只进总计,
657
+ **并把「本轮」刷新成这一笔**;
658
+ 没有映射(日志截断/旧版本)→ 兜底归当前轮,不抛错。
659
+
660
+ 为什么叫「轮间压缩」而不是「手动压缩」:宿主语义上两者是同一件事 ——
661
+ 手动 `/compact` 必须等这一轮回复结束、用户才能输入,所以它**必然**是轮间压缩,
662
+ `compaction/start` 的 turn 就是 null(见 tests/verify-host.js 里「用户手动 /compact:
663
+ 轮间压缩(start/summary/end 都带 sourceCommandId,turn=null)」那条夹具)。
664
+
665
+ 反过来说:**自动压缩**(对话进行中达到阈值时触发)的 turn 是当前轮号,
666
+ 所以它根本不会走到这个分支 —— 它由上面的 `belongsToCurrent` 命中、
667
+ **累加进当前轮**(那笔费用属于本轮,用户明确要求过)。
668
+
669
+ 用户对这一条的原话:「手动压缩的时候,上一轮对话明显已经结束了,此时本轮显示的
670
+ 数字依旧是上一轮对话产生的费用,而不是本次手动压缩产生的费用。」 */
671
+ const mappedTurn = opened === undefined ? undefined : opened.turn
672
+ const isInterTurnCompaction = mappedTurn === null
673
+ const belongsToCurrent = mappedTurn === undefined
674
+ ? base.current !== null
675
+ : (mappedTurn !== null && stateTurn !== null && mappedTurn === stateTurn)
676
+ let current = base.current
677
+ if (belongsToCurrent || isInterTurnCompaction) {
678
+ /* belongsToCurrent:并进当前轮(累加)—— 自动压缩走这条;
679
+ isInterTurnCompaction:**替换**(不累加)—— 手动 /compact 走这条,
680
+ 它代表"上一轮已结束,现在这笔是本轮"。 */
681
+ const prev = belongsToCurrent && base.current !== null && Array.isArray(base.current.calls)
682
+ ? base.current
683
+ : null
684
+ current = {
685
+ provider,
686
+ model,
687
+ time: event.time,
688
+ input: (prev === null ? 0 : prev.input) + one.input,
689
+ cacheRead: (prev === null ? 0 : prev.cacheRead) + one.cacheRead,
690
+ cacheWrite: (prev === null ? 0 : prev.cacheWrite) + one.cacheWrite,
691
+ output: (prev === null ? 0 : prev.output) + one.output,
692
+ calls: (prev === null ? [] : prev.calls).concat([one]),
693
+ }
694
+ }
695
+ return {
696
+ ...base,
697
+ ...totals,
698
+ current,
699
+ summaryParts,
700
+ countedCompactions: compactionId.length > 0 ? counted.concat([compactionId]) : counted,
701
+ }
702
+ }
471
703
  const turn = turnOf(event)
472
704
  // 轮次变化就重开本轮汇总:不能假设一定有 turn/start
473
705
  const sameTurn = base.current !== null && Array.isArray(base.current.calls)
474
706
  && (turn === null || stateTurn === null || turn === stateTurn)
475
707
  const prev = sameTurn ? base.current : null
476
708
  const callsForTurn = prev === null ? [] : prev.calls
477
- const summary = {
709
+ const turnSummary = {
478
710
  provider,
479
711
  model,
480
712
  time: event.time,
@@ -483,15 +715,14 @@ const sessionUsageProjection = {
483
715
  cacheWrite: (prev === null ? 0 : prev.cacheWrite) + one.cacheWrite,
484
716
  output: (prev === null ? 0 : prev.output) + one.output,
485
717
  }
486
- const current = { ...summary, calls: callsForTurn.concat([one]) }
718
+ const current = { ...turnSummary, calls: callsForTurn.concat([one]) }
487
719
  return {
488
720
  ...base,
721
+ ...addCallTotals(base, one),
489
722
  turn: turn === null ? stateTurn : turn,
490
- calls: appendCalls(calls, [one]),
491
723
  current,
492
724
  // 轮次变化时把上一轮结算进 last
493
725
  last: prev === null && base.current !== null ? base.current : base.last,
494
- models: { ...base.models, [model]: bucket },
495
726
  }
496
727
  },
497
728
  wire: {
@@ -503,7 +734,16 @@ const sessionUsageProjection = {
503
734
  // 界面上的「本轮」因此与「总计」对不上,提升版本强制从事件日志重放。
504
735
  // 4 → 5:旧缓存里缺「会话起标题」那几笔 —— attempt 事件没有 message,取不到型号时
505
736
  // 整条投影抛错、该会话的账从此停摆。提升版本强制重放,把这些笔补回账本。
506
- stateVersion: 5,
737
+ // 5 → 6:新增 overflow(被 MAX_CALLS 截掉那段的按档累计)。旧缓存里这个字段不存在,
738
+ // 长会话的费用会一直少算截掉的部分,必须重放才能补出来。
739
+ // 6 → 7:compaction/summary 的摘要调用(真实会话里有 4 笔带用量)以前整笔漏掉,
740
+ // 「本轮/总计费用」都比实际少。新增 compactions/countedCompactions 两个字段做归轮与防双计,
741
+ // 旧缓存里没有它们,必须重放才会把这 4 笔补进账本。
742
+ // 7 → 8:新增 summaryParts(压缩摘要的 token 累计),供客户端把它加到「总计用量」上 ——
743
+ // 官方 tokenUsage 投影不含摘要,导致「总计费用」含摘要而「总计用量」不含,同一面板两个口径。
744
+ // 旧缓存没有这个字段,不重放的话历史会话的「总计用量」会永远少那几笔摘要。
745
+ // 同一次 bump 也覆盖「轮间/手动压缩刷新本轮」的新语义(current 的重建规则变了)。
746
+ stateVersion: 8,
507
747
  }
508
748
 
509
749
  /* -------------------------------------------------------- sessionTimeRange */
@@ -806,26 +1046,82 @@ function sendJson(res, status, body) {
806
1046
  res.end(text)
807
1047
  }
808
1048
 
809
- /** 读一小段 JSON 请求体(只用于本插件自己的上报接口,超过 4KB 直接断开)。 */
1049
+ /* 写请求的来源校验:只拦「带跨站 Origin 的 POST」。
1050
+ 插件把 API 挂在 127.0.0.1 的 webServer 上,浏览器里任意网页都能对本机发简单请求
1051
+ (`Content-Type: text/plain` 的 POST 不触发 CORS 预检,页面读不到响应也照样**把请求送达**)。
1052
+ 实测:伪造的价格库被完整接受,settleUnpriced 随后按攻击者单价结算进 `days[].cost`,
1053
+ 而 cost 一旦记下就不再改动 —— 污染不可逆;同一面还能触发 /api/self-update 真跑 pnpm 安装。
1054
+ 按用户确认的最小方案只做这一条判定,**不**引入 CORS 预检、**不**影响 GET:
1055
+ · 没有 Origin(命令行 / curl / 宿主内部)→ 放行;
1056
+ · Origin 的 host 与请求的 host 相同 → 同源,放行;
1057
+ · 带跨站 Origin(含 opaque 的 `null`)的 POST → 由调用方回 403。
1058
+ 取不到 Host 头时判不了,保守放行(本机 webServer 一定带 Host,这一支只防意外形状)。 */
1059
+ function isCrossSiteWrite(req) {
1060
+ if (req === null || req === undefined || req.method !== 'POST') return false
1061
+ const headers = req.headers
1062
+ if (headers === null || headers === undefined || typeof headers !== 'object') return false
1063
+ const raw = typeof headers.origin === 'string' ? headers.origin.trim() : ''
1064
+ if (raw.length === 0) return false
1065
+ const target = typeof headers.host === 'string' ? headers.host.trim().toLowerCase() : ''
1066
+ if (target.length === 0) return false
1067
+ /* opaque 来源(沙箱 iframe、file:// 页面)与谁都不是同源 */
1068
+ if (raw.toLowerCase() === 'null') return true
1069
+ const schemeAt = raw.indexOf('://')
1070
+ const rest = schemeAt === -1 ? raw : raw.slice(schemeAt + 3)
1071
+ const authority = rest.split('/')[0].split('?')[0].toLowerCase()
1072
+ const at = authority.indexOf('@')
1073
+ const hostOnly = at === -1 ? authority : authority.slice(at + 1)
1074
+ if (hostOnly.length === 0) return true
1075
+ return hostOnly !== target
1076
+ }
1077
+
1078
+ /* 请求体上限。原先统一 4KB,但**价格库会随模型数增长**:实测一条模型约 186 字节,
1079
+ 24 个模型就超过 4096 → 请求被直接断开,客户端 fetch 抛错又被 `.catch(() => {})` 吞掉,
1080
+ 于是「设置页改的单价只对底栏生效、活跃总览恒用旧价」而且毫无提示(实测缺陷)。
1081
+ 这里的接口只服务本机、只收本插件自己的小 JSON,放宽到 256KB 足够且没有实际风险。 */
1082
+ const JSON_BODY_LIMIT = 256 * 1024
1083
+
1084
+ /**
1085
+ * 读一小段 JSON 请求体。
1086
+ *
1087
+ * 收的是**原始 Buffer**,最后一次性解码:以前写 `raw += chunk`,每个 chunk 各自跑一次
1088
+ * `toString('utf8')`,一个汉字的 3 个字节被切在两个 chunk 里时两半都变成 U+FFFD ——
1089
+ * 实测中文模型名被污染成 `���定义模型`,接口照样回 `ok:true`,之后查价永远匹配不上。
1090
+ * 上限也按**字节数**判(`raw.length` 是字符数,一个汉字只算 1,拿它当字节数会让
1091
+ * 256KB 的上限实际放到 768KB)。
1092
+ *
1093
+ * @returns {Promise<object|null>} 解析失败或超限返回 null
1094
+ */
810
1095
  function readJsonBody(req) {
811
1096
  return new Promise((resolve) => {
812
- let raw = ''
1097
+ const chunks = []
1098
+ let size = 0
813
1099
  let settled = false
1100
+ let overLimit = false
814
1101
  const finish = (value) => {
815
1102
  if (settled) return
816
1103
  settled = true
817
1104
  resolve(value)
818
1105
  }
819
1106
  req.on('data', (chunk) => {
820
- raw += chunk
821
- if (raw.length > 4096) {
1107
+ if (overLimit === true) return
1108
+ /* 非 Buffer 的 chunk(测试里的字符串)也按同一语义折成字节数 */
1109
+ const buf = Buffer.isBuffer(chunk) === true ? chunk : Buffer.from(String(chunk), 'utf8')
1110
+ size += buf.length
1111
+ if (size > JSON_BODY_LIMIT) {
1112
+ overLimit = true
1113
+ /* 先记下超限,再断开。**不要**在 destroy 之后写响应(socket 已销毁,写不进去)。 */
1114
+ chunks.length = 0
822
1115
  req.destroy()
823
1116
  finish(null)
1117
+ return
824
1118
  }
1119
+ chunks.push(buf)
825
1120
  })
826
1121
  req.on('end', () => {
1122
+ if (overLimit === true) return
827
1123
  try {
828
- const parsed = JSON.parse(raw)
1124
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
829
1125
  finish(parsed !== null && typeof parsed === 'object' ? parsed : null)
830
1126
  } catch (error) {
831
1127
  finish(null)
@@ -1218,6 +1514,10 @@ async function queryLocalAdapter(ctx, adapter, now, provider) {
1218
1514
  isCredits: parsed.isCredits === true,
1219
1515
  isPercent: parsed.isPercent === true,
1220
1516
  parts: parsed.parts,
1517
+ /* 积分套餐明细(workbuddy 新插件的 credits.packages 投影,见 balance-api.js 的 parse)。
1518
+ 字段名固定 rewards;**只有适配器真的给了才带上** —— 老插件/非积分平台没有这个键,
1519
+ 客户端据此走原来的纯总数显示。失败态(上面几个分支)一律不带。 */
1520
+ ...(parsed.rewards === undefined ? {} : { rewards: parsed.rewards }),
1221
1521
  toppedUp: partAmount(parsed.parts, 'toppedUp'),
1222
1522
  granted: partAmount(parsed.parts, 'granted'),
1223
1523
  spent: partAmount(parsed.parts, 'spent'),
@@ -1363,41 +1663,55 @@ async function queryBalance(ctx, force, provider, model) {
1363
1663
  *
1364
1664
  * 为什么必须落盘:会话一旦被删除,日志就没了,而周总计必须把已经发生的消耗留住。
1365
1665
  * 台账按「事件」去重(同一 turn/step 只算一次、assistant/message 优先),于是重复扫描不会
1366
- * 重复计数,删掉会话也不会让已记下的数字缩水;窗口是自然周(周一 00:00 起)。
1666
+ * 重复计数,删掉会话也不会让已记下的数字缩水;窗口是**滚动的「近 7 天」**
1667
+ * (见 recentStartMs:今天往前推 6 天,不再对齐自然周)。
1367
1668
  * 全程只读 token 数字,不读正文,台账里也只有数字、没有对话内容。
1368
1669
  */
1369
1670
 
1370
1671
  /**
1371
- * 柱状窗口的起点:今天 00:00(北京时间)往前推 6 天,也就是「近 7 天」。
1672
+ * 柱状窗口的起点:**本机时区**今天 00:00 往前推 6 天,也就是「近 7 天」。
1372
1673
  * 这里用的是滚动窗口,不再对齐自然周 —— 用户看到的是最近七天,而不是本周一到现在。
1674
+ *
1675
+ * 日期一律按**装机者所在时区**算(见 localDateKey 的说明),不再固定 UTC+8。
1373
1676
  */
1374
1677
  function recentStartMs(now) {
1375
- const BEIJING = 8 * 3600 * 1000
1376
- const shifted = new Date(now + BEIJING)
1377
- const todayStart = Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) - BEIJING
1378
- return todayStart - 6 * 24 * 3600 * 1000
1678
+ const d = new Date(now)
1679
+ /* 按**日历字段**回退 6 天,不要写 `本地今天 00:00 - 6*24h`:
1680
+ 有夏令时的时区里,这 6 天中有一天只有 23 小时(春季拨快),
1681
+ 减固定毫秒会落到 7 天前的 23:00,被 localDateKey 记成前一天 ——
1682
+ 于是窗口变成「7 天前…昨天」,「今天」那根柱子消失、当天用量落在窗口外。
1683
+ 实测纽约/伦敦/悉尼/圣地亚哥各错 18 天/3 年(UTC+8 的机器上永远看不到)。 */
1684
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - 6).getTime()
1379
1685
  }
1380
1686
 
1381
- /** 本月 1 号 00:00(北京时间)的毫秒时间戳。 */
1687
+ /** 本月 1 号 00:00(本机时区)的毫秒时间戳。 */
1382
1688
  function monthStartMs(now) {
1383
- const BEIJING = 8 * 3600 * 1000
1384
- const shifted = new Date(now + BEIJING)
1385
- return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), 1) - BEIJING
1689
+ const d = new Date(now)
1690
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
1386
1691
  }
1387
1692
 
1388
1693
  const padTwo = (value) => (value < 10 ? '0' + String(value) : String(value))
1389
1694
 
1390
- /** 当年 1 月 1 日 00:00(北京时间)的毫秒时间戳 —— 12 个月的统计要从这里扫起。 */
1695
+ /** 当年 1 月 1 日 00:00(本机时区)的毫秒时间戳 —— 12 个月的统计要从这里扫起。 */
1391
1696
  function yearStartMs(now) {
1392
- const BEIJING = 8 * 3600 * 1000
1393
- const shifted = new Date(now + BEIJING)
1394
- return Date.UTC(shifted.getUTCFullYear(), 0, 1) - BEIJING
1697
+ const d = new Date(now)
1698
+ return new Date(d.getFullYear(), 0, 1).getTime()
1395
1699
  }
1396
1700
 
1397
- /** 北京时间(UTC+8)的日期键 'YYYY-MM-DD' —— DeepSeek 的峰谷与账单都按北京时间。 */
1701
+ /**
1702
+ * 日期键 'YYYY-MM-DD' —— 按**装机者所在时区**取,与客户端同一口径。
1703
+ *
1704
+ * 曾经固定用北京时间(UTC+8):那是 DeepSeek 峰谷计价与账单的口径,但**日期归属**
1705
+ * 不该跟着它走 —— 使用者看的是自己日历上的「今天/昨天」,UTC+8 装机时两地一致,
1706
+ * 非 UTC+8 的机器上活跃总览会整体错一天(今天那格高亮成昨天、未来/过去的灰阶也偏)。
1707
+ *
1708
+ * 注意区分两件事,别把这里改回去:
1709
+ * · **日期归属**(这条函数)= 装机者时区,纯粹是给人看的日历。
1710
+ * · **峰谷计价**(isPeakTime)= 北京时间,那是 DeepSeek 的官方计费规则,与装机位置无关。
1711
+ */
1398
1712
  function localDateKey(ms) {
1399
- const date = new Date(ms + 8 * 3600 * 1000)
1400
- return date.getUTCFullYear() + '-' + padTwo(date.getUTCMonth() + 1) + '-' + padTwo(date.getUTCDate())
1713
+ const date = new Date(ms)
1714
+ return date.getFullYear() + '-' + padTwo(date.getMonth() + 1) + '-' + padTwo(date.getDate())
1401
1715
  }
1402
1716
 
1403
1717
  /* 官方参考价(CNY / 每百万 tokens),与客户端价格表同源、与官方定价页一致。
@@ -1607,7 +1921,7 @@ let extraHolidays = null
1607
1921
  /** 落盘过的法定节假日(带缓存);文件不存在或读不动就当没有这回事。 */
1608
1922
  function extraHolidayDays() {
1609
1923
  if (extraHolidays !== null) return extraHolidays
1610
- const parsed = readLedgerFile(holidaysPath())
1924
+ const parsed = readJsonObjectFile(holidaysPath())
1611
1925
  extraHolidays = parsed !== null && Array.isArray(parsed.days)
1612
1926
  ? parsed.days.filter((one) => typeof one === 'string')
1613
1927
  : []
@@ -1711,6 +2025,10 @@ function sanitizePriceBook(raw) {
1711
2025
  output: keep(value.output),
1712
2026
  }
1713
2027
  : null)
2028
+ /* 普通对象字面量,但写入一律走 defineBookEntry:键名是 `__proto__` 时
2029
+ `out[name] = {...}` 是**设置原型**而不是建自有属性 —— 那条目会从 Object.keys
2030
+ 里消失(静默丢条目)、返回对象的原型还被换掉(把不可信输入挂成原型,是原型污染)。
2031
+ 用 defineProperty 建可枚举的自有属性,行为与其它键名一致。 */
1714
2032
  const out = {}
1715
2033
  const names = Object.keys(raw)
1716
2034
  for (let i = 0; i < names.length; i += 1) {
@@ -1724,7 +2042,7 @@ function sanitizePriceBook(raw) {
1724
2042
  /* 保留「是否峰谷计价」:没勾的条目是**全天一个价**,计价时两档都要取 peak,
1725
2043
  否则空闲时段会挑到那个没填的档、把费用算成 0(实测缺陷)。缺省按两档。 */
1726
2044
  const code = entry.currency === 'USD' ? 'USD' : (entry.currency === 'CNY' ? 'CNY' : null)
1727
- out[names[i]] = {
2045
+ defineBookEntry(out, names[i], {
1728
2046
  peak: peak !== null ? peak : offPeak,
1729
2047
  offPeak: offPeak !== null ? offPeak : peak,
1730
2048
  tiered: entry.tiered !== false,
@@ -1734,7 +2052,7 @@ function sanitizePriceBook(raw) {
1734
2052
  host 不替它决定(否则往返一次多出一个字段,深比较基准会被悄悄改掉)。
1735
2053
  注意:必须保留,不保留就等于清洗时把币种丢了。 */
1736
2054
  ...(code === null ? {} : { currency: code }),
1737
- }
2055
+ })
1738
2056
  }
1739
2057
  return Object.keys(out).length === 0 ? null : out
1740
2058
  }
@@ -1746,16 +2064,46 @@ function sanitizePriceBook(raw) {
1746
2064
  *
1747
2065
  * 它以前的作用是"不一致就把台账整本丢丢掉重扫",那会连已经算好的费用一起改掉,
1748
2066
  * 会话日志一旦被删,历史数字就再也算不回来。现在它只读不写、只用来触发补算。
2067
+ *
2068
+ * 格式(v4 起):`fp4:` + JSON 数组。以前是 `名字:四项:四项|名字:…` 的拼接,两个坑:
2069
+ * · 键名里带 `:`(Ollama 的 `qwen2.5:14b`)或 `|`(`a|b`)会把分隔符吃掉,
2070
+ * `parseFingerprint` 要么整条丢、要么还原出**错的键名** → 回扫把"当年已按自建价
2071
+ * 算过钱"的事件误判成"当时缺价",再补一遍 → 同一笔用量计两次、当天 cost 翻倍;
2072
+ * · 只序列化四项数字,`tiered`(峰谷计价开关)与 `currency` 改了却算不出变化 →
2073
+ * 用户把「峰谷计价」改成「全天同价」或换币种时 `changed=false`,欠账一直挂着不结算。
2074
+ * JSON 天然带转义,键名不再可能撞上分隔符;`tiered` / `currency` 一起进指纹。
2075
+ *
2076
+ * 币种按**缺省即 CNY** 归一:host 的 `sanitizePriceBook` 不补默认币种、客户端会落成显式
2077
+ * `CNY`,两侧必须算出同一串(见 tests/verify-price-parity.js 的差分断言)。
2078
+ * 两档都空的条目(没定价)不进指纹 —— 它们不参与计价,也不参与"当时有没有价"的判定。
1749
2079
  */
2080
+ const FINGERPRINT_MARK = 'fp4:'
2081
+
1750
2082
  function fingerprintOf(book) {
1751
- const names = Object.keys(book === null || book === undefined ? {} : book).sort()
1752
- const parts = []
2083
+ const source = book === null || book === undefined ? {} : book
2084
+ const names = Object.keys(source).sort()
2085
+ const rows = []
2086
+ const tierOf = (t) => (t === null || t === undefined
2087
+ ? null
2088
+ : [numOrZero(t.input), numOrZero(t.cacheRead), numOrZero(t.cacheWrite), numOrZero(t.output)])
1753
2089
  for (let i = 0; i < names.length; i += 1) {
1754
- const one = book[names[i]]
1755
- const pick = (t) => (t === null || t === undefined ? 'x' : [numOrZero(t.input), numOrZero(t.cacheRead), numOrZero(t.cacheWrite), numOrZero(t.output)].join(','))
1756
- parts.push(names[i] + ':' + pick(one.peak) + ':' + pick(one.offPeak))
2090
+ const one = source[names[i]]
2091
+ const rawPeak = tierOf(one === null || one === undefined ? null : one.peak)
2092
+ const rawOffPeak = tierOf(one === null || one === undefined ? null : one.offPeak)
2093
+ if (rawPeak === null && rawOffPeak === null) continue
2094
+ /* 两档互相补全,与 `sanitizePriceBook` 同一语义:只给一档的条目(缺省那档为 undefined)
2095
+ 在库里、在 parse 回来的库里都表现为"两档同价"。不补全的话 `parseFingerprint`
2096
+ 会把缺档还原成有值的那档,`fingerprintOf(parse(fp)) !== fp` —— 往返不成立,
2097
+ 而 verify-price-parity 正是靠"指纹往返回到原串"守住格式的。 */
2098
+ rows.push([
2099
+ names[i],
2100
+ rawPeak !== null ? rawPeak : rawOffPeak,
2101
+ rawOffPeak !== null ? rawOffPeak : rawPeak,
2102
+ one !== null && one !== undefined && one.tiered === false ? 0 : 1,
2103
+ one !== null && one !== undefined && one.currency === 'USD' ? 'USD' : 'CNY',
2104
+ ])
1757
2105
  }
1758
- return parts.join('|')
2106
+ return FINGERPRINT_MARK + JSON.stringify(rows)
1759
2107
  }
1760
2108
 
1761
2109
  function priceFingerprint() {
@@ -1764,10 +2112,16 @@ function priceFingerprint() {
1764
2112
 
1765
2113
  /**
1766
2114
  * 指纹 → 价格库(fingerprintOf 的逆运算),迁移回扫用它还原"当时的价格库"。
1767
- * 单条形状对不上(段数不是 3、数字不合法)就跳过那一条;整体为空的返回 null。
2115
+ * 单条形状对不上(不是数组、数字不合法)就跳过那一条;整体为空的返回 null。
2116
+ *
2117
+ * 两种格式都认:
2118
+ * · `fp4:` 前缀 → 新格式(JSON,可往返);
2119
+ * · 别的(老台账里落盘的旧格式)→ 按老的 `:` / `|` 拼接解析,只为让升级前的
2120
+ * 台账还能还原出当年的价目表(解析不出来就返回 null,调用方回退内置表,与旧行为一致)。
1768
2121
  */
1769
2122
  function parseFingerprint(text) {
1770
2123
  if (typeof text !== 'string' || text.length === 0) return null
2124
+ if (text.startsWith(FINGERPRINT_MARK) === true) return parseFingerprintRows(text.slice(FINGERPRINT_MARK.length))
1771
2125
  const out = {}
1772
2126
  const segments = text.split('|')
1773
2127
  const tier = (raw) => {
@@ -1790,11 +2144,56 @@ function parseFingerprint(text) {
1790
2144
  const peak = tier(fields[1])
1791
2145
  const offPeak = tier(fields[2])
1792
2146
  if (peak === null && offPeak === null) continue
1793
- out[fields[0]] = { peak: peak !== null ? peak : offPeak, offPeak: offPeak !== null ? offPeak : peak }
2147
+ defineBookEntry(out, fields[0], { peak: peak !== null ? peak : offPeak, offPeak: offPeak !== null ? offPeak : peak })
2148
+ }
2149
+ return Object.keys(out).length === 0 ? null : out
2150
+ }
2151
+
2152
+ /** 把 `fp4:` 后面的 JSON 数组还原成价格库。 */
2153
+ function parseFingerprintRows(text) {
2154
+ let rows = null
2155
+ try {
2156
+ rows = JSON.parse(text)
2157
+ } catch (error) {
2158
+ return null
2159
+ }
2160
+ if (Array.isArray(rows) !== true) return null
2161
+ const out = {}
2162
+ for (let i = 0; i < rows.length; i += 1) {
2163
+ const row = rows[i]
2164
+ if (Array.isArray(row) !== true || row.length < 5) continue
2165
+ const name = row[0]
2166
+ if (typeof name !== 'string' || name.length === 0) continue
2167
+ const tier = (raw) => {
2168
+ if (Array.isArray(raw) !== true || raw.length !== 4) return null
2169
+ const one = {
2170
+ input: Number(raw[0]),
2171
+ cacheRead: Number(raw[1]),
2172
+ cacheWrite: Number(raw[2]),
2173
+ output: Number(raw[3]),
2174
+ }
2175
+ if (Number.isFinite(one.input) !== true || Number.isFinite(one.cacheRead) !== true
2176
+ || Number.isFinite(one.cacheWrite) !== true || Number.isFinite(one.output) !== true) return null
2177
+ return one
2178
+ }
2179
+ const peak = tier(row[1])
2180
+ const offPeak = tier(row[2])
2181
+ if (peak === null && offPeak === null) continue
2182
+ defineBookEntry(out, name, {
2183
+ peak: peak !== null ? peak : offPeak,
2184
+ offPeak: offPeak !== null ? offPeak : peak,
2185
+ tiered: row[3] !== 0,
2186
+ currency: row[4] === 'USD' ? 'USD' : 'CNY',
2187
+ })
1794
2188
  }
1795
2189
  return Object.keys(out).length === 0 ? null : out
1796
2190
  }
1797
2191
 
2192
+ /** 往价格库里写一条:`__proto__` 这类键名必须建成自有属性(见 sanitizePriceBook 处的说明)。 */
2193
+ function defineBookEntry(target, name, value) {
2194
+ Object.defineProperty(target, name, { value: value, enumerable: true, writable: true, configurable: true })
2195
+ }
2196
+
1798
2197
  /** 一笔用量按单价折成人民币;模型没有价格表就返回 0(不计入花费)。 */
1799
2198
  function tokenCost(model, at, parts) {
1800
2199
  const entry = priceEntryFor(model)
@@ -2131,23 +2530,48 @@ async function subagentIndexSnapshot(files) {
2131
2530
  }
2132
2531
 
2133
2532
  /**
2134
- * 一段父会话日志里的"本轮起点":最后一条 `turn/start` 的时间 —— 但**和最后一条
2135
- * `user/message` 取较大的那个**。都找不到就返回 null(调用方据此判 sinceSource='none')。
2533
+ * 一段父会话日志里的"本轮起点"。三条时间取 max,规则见下:
2534
+ * ① 最后一条 `turn/start`;
2535
+ * ② 最后一条**真实用户输入**的 `user/message` —— 但**只有**它落在
2536
+ * 「最后一条 `turn/end` 之后、且最后一条 `turn/end` 晚于最后一条 `turn/start`」
2537
+ * 这一段里,才算"新一轮已经开始、而它的 `turn/start` 还没落盘"的候选;
2538
+ * ③ 日志里完全没有 `turn/start` 时,退回用最后一条真实用户消息(老兜底)。
2539
+ * 都找不到就返回 null(调用方据此判 sinceSource='none')。
2540
+ *
2541
+ * **为什么只认 `source.kind === 'user'` 的 `user/message`**(2026-10-17 修复):
2542
+ * 同一轮里 DSH 会往父会话日志注入很多**不是用户输入**的 `user/message` ——
2543
+ * 子代理结算(`subagent-settled`)、队友消息转发(`team-message` / `agent-message`)、
2544
+ * 后台任务完成(`tool-jobs`)、技能目录刷新(`skill-catalog`)、审批回答
2545
+ * (`user-approval`)、运行时上下文(`runtime-context`)……它们**都不改轮次**。
2546
+ * 旧实现把所有 `user/message` 都算进来,于是"最后一条"常常是这些通知:本轮窗口
2547
+ * 起点被推到**子代理结算那一刻**,队友在本轮已经花掉的钱整段被排除(用户实测
2548
+ * 「所有子代理运行完之后『本轮』卡住不涨」;本机真实日志复现:本轮应 ¥34.62 →
2549
+ * 实际 ¥10.58)。只认真实用户输入才能既保住"新一轮已开始"的兜底,又不被注入
2550
+ * 通知推进起点。
2551
+ *
2552
+ * **为什么真人中途插话(steering)也不能算新轮起点**(2026-10-17 补边界):
2553
+ * DSH 支持同一轮里用户再发消息(本轮日志里就有:唯一一条 `turn/start` 之后 7 分钟
2554
+ * 又来了一条 `source.kind='user'` 的消息,`turn/end` 还没出现)。这种消息只改变
2555
+ * 这一轮的上下文、**不开新轮**;若把它当起点,「本轮」会从插话那一刻重新算,把插话
2556
+ * 之前子代理在本轮花掉的钱再次抹掉(实测:本轮 ¥4.06,而总计 ¥5.03,残差 ¥0.97)。
2557
+ * 所以判据是"**轮已经关了**"才认用户消息:看 `turn/end` 是否晚于最后一条
2558
+ * `turn/start`(关轮 = 上一轮已结束,此时新 USER 消息代表新轮已起、`turn/start`
2559
+ * 还没落盘);turn 还开着就只认 `turn/start`。
2136
2560
  *
2137
- * 为什么不是"有 turn/start 就优先用它":用户消息比最后一条 turn/start 还新,说明
2138
- * **新一轮已经开始、而它的 `turn/start` 还没落盘**;此时沿用旧的 turn/start 会把
2139
- * **上一轮**的队友用量算进"本轮",方向正是用户不允许的"偏大"。取较新的那个时间
2140
- * 只会把窗口往回收(宁少不多),符合"本轮只能小不能大"。
2141
2561
  * 取**最大值**而不是"文件里最后一行":正常日志是追加写的,两者一致;万一有乱序行,
2142
2562
  * 取最大值更稳。
2143
2563
  */
2144
2564
  function lastTurnStartOfText(text) {
2145
2565
  if (typeof text !== 'string' || text.length === 0) return null
2146
- let at = null
2566
+ let lastTurnStart = null
2567
+ let lastTurnEnd = null
2568
+ let lastUser = null
2147
2569
  const lines = text.split('\n')
2148
2570
  for (let i = 0; i < lines.length; i += 1) {
2149
2571
  const line = lines[i]
2150
- if (line.indexOf('"turn/start"') < 0 && line.indexOf('"user/message"') < 0) continue
2572
+ /* 便宜的预筛:只解析可能相关的三种事件 */
2573
+ if (line.indexOf('"turn/start"') < 0 && line.indexOf('"turn/end"') < 0
2574
+ && line.indexOf('"user/message"') < 0) continue
2151
2575
  let event
2152
2576
  try {
2153
2577
  event = JSON.parse(line)
@@ -2155,10 +2579,29 @@ function lastTurnStartOfText(text) {
2155
2579
  continue
2156
2580
  }
2157
2581
  if (event === null || typeof event !== 'object' || typeof event.time !== 'number') continue
2158
- if (event.type !== 'turn/start' && event.type !== 'user/message') continue
2159
- if (at === null || event.time > at) at = event.time
2582
+ if (event.type === 'turn/start') {
2583
+ if (lastTurnStart === null || event.time > lastTurnStart) lastTurnStart = event.time
2584
+ continue
2585
+ }
2586
+ if (event.type === 'turn/end') {
2587
+ if (lastTurnEnd === null || event.time > lastTurnEnd) lastTurnEnd = event.time
2588
+ continue
2589
+ }
2590
+ if (event.type !== 'user/message') continue
2591
+ /* 只认真实用户输入:source 缺失(老日志/坏行)也宁可不当成新轮 —— 少算一侧
2592
+ 的代价是多算上一轮末的队友用量(偏大),比"整轮花费被抹掉"可接受。 */
2593
+ const data = event.data !== null && event.data !== undefined && typeof event.data === 'object' ? event.data : {}
2594
+ const source = data.source !== null && data.source !== undefined && typeof data.source === 'object' ? data.source : null
2595
+ if (source === null || source.kind !== 'user') continue
2596
+ if (lastUser === null || event.time > lastUser) lastUser = event.time
2160
2597
  }
2161
- return at
2598
+ /* 完全没有 turn/start:老日志/异常日志的兜底 —— 用最后一条真实用户消息(原来就是这样) */
2599
+ if (lastTurnStart === null) return lastUser
2600
+ /* 用户消息只有在"上一轮已结束(turn/end 晚于 turn/start)、且消息晚于那个 turn/end"
2601
+ 时才是"新轮已开始、turn/start 还没落盘"的证据;turn 还开着就只是本轮插话,不算 */
2602
+ const turnClosed = lastTurnEnd !== null && lastTurnEnd > lastTurnStart
2603
+ const userStartedNewTurn = turnClosed === true && lastUser !== null && lastUser > lastTurnEnd
2604
+ return userStartedNewTurn === true && lastUser > lastTurnStart ? lastUser : lastTurnStart
2162
2605
  }
2163
2606
 
2164
2607
  /**
@@ -2399,9 +2842,11 @@ function subagentFileUsage(one, fromMs, fingerprint) {
2399
2842
  * (只收紧、不放大;`since=0` 是显式整会话,不收敛);真收敛了会带
2400
2843
  * `sinceClamped: true`(`sinceSource` 仍是 `client`);
2401
2844
  * · `host`:没传 → host 读**父会话自己**的日志推一个起点(最后一条 `turn/start`,
2402
- * 但和最后一条 `user/message` 取较新的那个 —— 后者更新说明新一轮已开始、其
2403
- * `turn/start` 还没落盘,沿用旧起点会把上一轮算进来);而且**推出来的起点之后
2404
- * 必须真的有用量事件**,否则说明起点无从验证,同样当 `none`(宁少不多);
2845
+ * 但和最后一条**真实用户输入**的 `user/message` 取较新的那个 —— 后者更新说明新一轮
2846
+ * 已开始、其 `turn/start` 还没落盘,沿用旧起点会把上一轮算进来;同轮注入的
2847
+ * subagent-settled / team-message / tool-jobs 等通知不算,见 lastTurnStartOfText);
2848
+ * 而且**推出来的起点之后必须真的有用量事件**,否则说明起点无从验证,同样当
2849
+ * `none`(宁少不多);
2405
2850
  * · `none`:父会话日志缺失/读不出,或推出来的起点事后没有事件 → 本轮**什么都不并**
2406
2851
  * (整条返回零值),绝不退回整会话 —— 用户规则:本轮只能小不能大;
2407
2852
  * 要整会话(「总计」那一项)就显式传 `since=0`。
@@ -2448,8 +2893,9 @@ async function collectSubagentUsageNow(sessionId, sinceMs) {
2448
2893
 
2449
2894
  /* 窗口起点从哪来:① 客户端给了(它精确知道自己那一轮)→ `client`;
2450
2895
  ② 没给就问 host 自己 —— 读**父会话自己**的日志取最后一条 turn/start(或更新的
2451
- user/message,见 lastTurnStartOfText)→ `host`;③ 两者都没有(父会话文件缺失/读不出)
2452
- → `none`:**本轮什么都不并**(用户规则:本轮只能小不能大,绝不能拿整会话的量顶上)。
2896
+ 真实用户 user/message,见 lastTurnStartOfText;同轮注入的通知不算)→ `host`;
2897
+ ③ 两者都没有(父会话文件缺失/读不出)→ `none`:**本轮什么都不并**
2898
+ (用户规则:本轮只能小不能大,绝不能拿整会话的量顶上)。
2453
2899
  另外 host 推的起点还要过一道"事后有事件"的验证(见下面聚合之后的那段)。 */
2454
2900
  const parentEntry = index.byId.get(sessionId)
2455
2901
  let sinceSource = 'none'
@@ -2531,7 +2977,9 @@ async function collectSubagentUsageNow(sessionId, sinceMs) {
2531
2977
  total.stats.ttftSteps += agg.stats.ttftSteps
2532
2978
  total.stats.decodeMs += agg.stats.decodeMs
2533
2979
  total.stats.decodeTokens += agg.stats.decodeTokens
2534
- if (i % 4 === 3) await new Promise((resolve) => setImmediate(resolve))
2980
+ /* 每 16 个让出一次事件循环:146+ 个队友时 4 个一让太碎,宿主每秒都在被问,
2981
+ 让出次数少一点能明显压低单次聚合的调度开销(文件用量已按文件缓存,重算只是少数)。 */
2982
+ if (i % 16 === 15) await new Promise((resolve) => setImmediate(resolve))
2535
2983
  }
2536
2984
  /* host 推出来的起点必须有"事后证据"才算有效。**证据有两条,任一条成立即可**:
2537
2985
  ① 父会话**自己**在窗口内有用量事件(fromMs 之后有 assistant/message 或
@@ -2650,9 +3098,13 @@ function emptyLedger() {
2650
3098
  }
2651
3099
 
2652
3100
  /**
2653
- * 读一个台账文件;文件不存在、读不出或不是合法 JSON 对象时返回 null。
3101
+ * 读一个 JSON 对象文件;不存在、读不出或不是合法 JSON 对象时返回 null。
3102
+ *
3103
+ * 名字**刻意不叫** readLedgerFile:它不只读台账,`holidays.json` 也走这里
3104
+ * (两者都是「一个 JSON 对象,坏了就当没有」的语义)。用台账专属的名字会让人
3105
+ * 以为改动只该影响台账。
2654
3106
  */
2655
- function readLedgerFile(file) {
3107
+ function readJsonObjectFile(file) {
2656
3108
  try {
2657
3109
  const parsed = JSON.parse(readFileSync(file, 'utf8'))
2658
3110
  return parsed !== null && typeof parsed === 'object' ? parsed : null
@@ -2704,9 +3156,9 @@ function logLedgerNote(message) {
2704
3156
  }
2705
3157
 
2706
3158
  function readLedger() {
2707
- const parsed = readLedgerFile(ledgerPath())
3159
+ const parsed = readJsonObjectFile(ledgerPath())
2708
3160
  if (parsed !== null) return migrateLedger(parsed)
2709
- const backup = readLedgerFile(ledgerPath() + '.bak')
3161
+ const backup = readJsonObjectFile(ledgerPath() + '.bak')
2710
3162
  if (backup !== null) {
2711
3163
  logLedgerNote('usage-ledger: 主文件读不出来,已回退到自动备份')
2712
3164
  const recovered = migrateLedger(backup)
@@ -2730,7 +3182,7 @@ function writeLedger(ledger) {
2730
3182
  let kept = false
2731
3183
  /* 只有"上一份是能解析的好数据"才留成备份:主文件损坏时我们刚回退过 .bak,
2732
3184
  若这时把损坏内容复制成新 .bak,备份就只能救命一次(实测缺陷)。 */
2733
- if (readLedgerFile(target) !== null) {
3185
+ if (readJsonObjectFile(target) !== null) {
2734
3186
  try {
2735
3187
  copyFileSync(target, target + '.bak')
2736
3188
  kept = true
@@ -2759,7 +3211,14 @@ function writeLedger(ledger) {
2759
3211
  function addUnpriced(day, model, parts, peakTime) {
2760
3212
  const name = typeof model === 'string' && model.length > 0 ? model : '(未知模型)'
2761
3213
  const bucket = day.unpriced ?? {}
2762
- const one = bucket[name] ?? {
3214
+ /* 必须按**自有属性**判断,不能用 `bucket[name] ?? 默认值`:模型名是 JS 原型属性名
3215
+ (`constructor` / `toString` / `valueOf` / `__proto__` …)时,`bucket[name]` 会取到
3216
+ 原型上的函数或原型对象,`??` 不触发 —— 于是 `one.peak` 是 undefined,下面
3217
+ `tier.input += …` 抛 TypeError。抛错发生在 collectIntoLedger / backfillUnpriced 里,
3218
+ queueLedgerJob 随之 reject,`/api/daily-usage` 恒返回 ledger-failed,活跃总览整块打不开。
3219
+ 凡是"用户/上游给的字符串当对象键"的地方都要走这条判据。 */
3220
+ const hasOwn = Object.prototype.hasOwnProperty.call(bucket, name)
3221
+ const one = hasOwn === true ? bucket[name] : {
2763
3222
  peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
2764
3223
  offPeak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 },
2765
3224
  }
@@ -2768,7 +3227,8 @@ function addUnpriced(day, model, parts, peakTime) {
2768
3227
  tier.cacheRead += parts.cacheRead
2769
3228
  tier.cacheWrite += parts.cacheWrite
2770
3229
  tier.output += parts.output
2771
- bucket[name] = one
3230
+ /* 同理:`bucket[name] = one` 在 name 为 `__proto__` 时是设置原型而不是建条目 */
3231
+ defineBookEntry(bucket, name, one)
2772
3232
  day.unpriced = bucket
2773
3233
  }
2774
3234
 
@@ -2989,13 +3449,29 @@ function pruneLedger(ledger) {
2989
3449
 
2990
3450
  /** 把台账里的日桶铺成「近 7 天」固定 7 格(最早一天在最左、今天是最后一格),并算合计;另附当月与全年。 */
2991
3451
  function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
2992
- const DAY = 24 * 3600 * 1000
2993
3452
  const days = []
2994
3453
  const totals = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0, calls: 0, cost: 0 }
3454
+ /* 逐日按**日历**推进,不用 rangeStart + i*24h:后者在夏令时切换日会重复/跳天
3455
+ (同 monthUsagePayload 的说明)。rangeStart 本身已是本地 00:00。 */
3456
+ const startDate = new Date(rangeStart)
2995
3457
  for (let i = 0; i < 7; i += 1) {
2996
- /* 按北京时间逐日推进,不受机器时区影响 */
2997
- const key = localDateKey(rangeStart + i * DAY)
2998
- const bucket = ledger.days[key] ?? { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 } }
3458
+ const at = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate() + i).getTime()
3459
+ const key = localDateKey(at)
3460
+ const raw = ledger.days[key]
3461
+ /* 日桶逐个字段兜底:台账被外部改坏(不是对象、或字段缺失/非数字)时,
3462
+ 直接相加会得到 NaN,整个 totals 都变成 NaN、JSON 里还丢键。
3463
+ monthUsagePayload / yearUsagePayload 一直有兜底,只有这里没有。 */
3464
+ const bucket = raw !== null && raw !== undefined && typeof raw === 'object'
3465
+ ? {
3466
+ input: Number(raw.input) || 0,
3467
+ cacheRead: Number(raw.cacheRead) || 0,
3468
+ cacheWrite: Number(raw.cacheWrite) || 0,
3469
+ output: Number(raw.output) || 0,
3470
+ calls: Number(raw.calls) || 0,
3471
+ cost: Number(raw.cost) || 0,
3472
+ peak: raw.peak,
3473
+ }
3474
+ : { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, calls: 0, cost: 0, peak: undefined }
2999
3475
  const total = bucket.input + bucket.cacheRead + bucket.cacheWrite + bucket.output
3000
3476
  const cost = typeof bucket.cost === 'number' && Number.isFinite(bucket.cost) ? bucket.cost : 0
3001
3477
  /* 高峰那一份:老台账没有 peak 字段时按 0 处理 */
@@ -3009,8 +3485,6 @@ function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
3009
3485
  peak.total = peak.input + peak.cacheRead + peak.cacheWrite + peak.output
3010
3486
  days.push({
3011
3487
  date: key,
3012
- /* 窗口内第几天(0 = 最早那天,6 = 今天);不再是星期几 */
3013
- offset: i,
3014
3488
  input: bucket.input,
3015
3489
  cacheRead: bucket.cacheRead,
3016
3490
  cacheWrite: bucket.cacheWrite,
@@ -3048,22 +3522,31 @@ function weekUsagePayload(ledger, rangeStart, monthStart, meta) {
3048
3522
  * 「月内最高的一天」(要标紫的那天)只在当月天里评,避免被上月的量抢走。
3049
3523
  */
3050
3524
  function monthUsagePayload(ledger, monthStart) {
3051
- const BEIJING = 8 * 3600 * 1000
3052
- const DAY = 24 * 3600 * 1000
3053
- const first = new Date(monthStart + BEIJING)
3054
- const year = first.getUTCFullYear()
3055
- const month = first.getUTCMonth() + 1
3056
- const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
3057
- const firstWeekday = (first.getUTCDay() + 6) % 7 /* 周一=0 */
3058
- const gridStart = monthStart - firstWeekday * DAY
3059
- const monthEnd = monthStart + (daysInMonth - 1) * DAY
3060
- const lastWeekday = (new Date(monthEnd + BEIJING).getUTCDay() + 6) % 7
3525
+ /* 一律按本机时区读年月日,与 localDateKey 同口径(别在这里加 8 小时偏移) */
3526
+ const first = new Date(monthStart)
3527
+ const year = first.getFullYear()
3528
+ const month = first.getMonth() + 1
3529
+ const daysInMonth = new Date(year, month, 0).getDate()
3530
+ const firstWeekday = (first.getDay() + 6) % 7 /* 周一=0 */
3531
+ const monthEnd = new Date(year, month - 1, daysInMonth).getTime()
3532
+ const lastWeekday = (new Date(year, month - 1, daysInMonth).getDay() + 6) % 7
3061
3533
  const gridDays = daysInMonth + firstWeekday + (6 - lastWeekday)
3534
+ /* 网格起点:当月 1 号往前退 firstWeekday 天。
3535
+ 用**日历逐日构造**而不是 `起点 + i * 24h` —— 后者在有夏令时的时区会错:
3536
+ 切换日本地时间整体挪 1 小时,+24h 可能落回同一天(出现两格同一天、另一天缺失),
3537
+ 实测纽约 / 伦敦 / 悉尼 / 圣地亚哥都会中(UTC+8 的机器上永远看不到)。
3538
+ 逐日构造让每格都稳稳落在本地 00:00。 */
3539
+ const gridStartDate = new Date(year, month - 1, 1 - firstWeekday)
3540
+ const dayAt = (i) => new Date(
3541
+ gridStartDate.getFullYear(),
3542
+ gridStartDate.getMonth(),
3543
+ gridStartDate.getDate() + i
3544
+ ).getTime()
3062
3545
  const days = []
3063
3546
  let max = 0
3064
3547
  let maxDate = null
3065
3548
  for (let i = 0; i < gridDays; i += 1) {
3066
- const at = gridStart + i * DAY
3549
+ const at = dayAt(i)
3067
3550
  const key = localDateKey(at)
3068
3551
  const inMonth = at >= monthStart && at <= monthEnd
3069
3552
  const bucket = ledger.days[key]
@@ -3100,13 +3583,13 @@ function monthUsagePayload(ledger, monthStart) {
3100
3583
  * 客户端拿它画「活跃总览」标题下面那 12 个方块(配色规则与右侧月历一致)。
3101
3584
  */
3102
3585
  function yearUsagePayload(ledger, now) {
3103
- const BEIJING = 8 * 3600 * 1000
3104
- const year = new Date(now + BEIJING).getUTCFullYear()
3586
+ /* 一律按本机时区读年月日,与 localDateKey 同口径 */
3587
+ const year = new Date(now).getFullYear()
3105
3588
  const months = []
3106
3589
  let max = 0
3107
3590
  let maxMonth = null
3108
3591
  for (let m = 1; m <= 12; m += 1) {
3109
- const daysInMonth = new Date(Date.UTC(year, m, 0)).getUTCDate()
3592
+ const daysInMonth = new Date(year, m, 0).getDate()
3110
3593
  let input = 0
3111
3594
  let cacheRead = 0
3112
3595
  let cacheWrite = 0
@@ -3114,7 +3597,7 @@ function yearUsagePayload(ledger, now) {
3114
3597
  let calls = 0
3115
3598
  let cost = 0
3116
3599
  for (let d = 1; d <= daysInMonth; d += 1) {
3117
- const bucket = ledger.days[localDateKey(Date.UTC(year, m - 1, d) - BEIJING)]
3600
+ const bucket = ledger.days[localDateKey(new Date(year, m - 1, d).getTime())]
3118
3601
  if (bucket === undefined) continue
3119
3602
  input += bucket.input || 0
3120
3603
  cacheRead += bucket.cacheRead || 0
@@ -3181,6 +3664,44 @@ function appendTipDiag(body) {
3181
3664
  }
3182
3665
  }
3183
3666
 
3667
+ /**
3668
+ * 把客户端上报的「更新失败」描述成一条能直接拿去问 AI 的日志文本。
3669
+ *
3670
+ * 字段都是**客户端自己知道、宿主不知道**的:它在哪个环节失败、目标版本是谁、
3671
+ * 浏览器抛了什么异常。刻意不在这里做任何判断或修复 —— 只把事实摊平成一行,
3672
+ * 让用户把日志贴给 AI 时信息是完整的(这是用户提「补日志」的原始诉求)。
3673
+ *
3674
+ * 只取有限长度的字符串,避免恶意/异常的超长 body 把日志撑爆。
3675
+ *
3676
+ * @param {object|null} body 客户端 POST 上来的 JSON
3677
+ * @returns {string}
3678
+ */
3679
+ function describeClientUpdateFailure(body) {
3680
+ const src = body !== null && body !== undefined && typeof body === 'object' ? body : {}
3681
+ const text = (value, max) => {
3682
+ if (value === null || value === undefined) return null
3683
+ const raw = typeof value === 'string' ? value : String(value)
3684
+ const limit = max === undefined ? 200 : max
3685
+ return raw.length > limit ? raw.slice(0, limit) + '…' : raw
3686
+ }
3687
+ const parts = []
3688
+ const reason = text(src.reason, 60)
3689
+ parts.push('reason=' + (reason === null ? 'unknown' : reason))
3690
+ parts.push('target=' + (text(src.target, 40) ?? 'null'))
3691
+ parts.push('current=' + (text(src.current, 40) ?? 'null'))
3692
+ /* 时间戳取客户端那一刻:宿主收到的时间可能晚很多(比如用户在断网时才点) */
3693
+ parts.push('at=' + (typeof src.at === 'number' && Number.isFinite(src.at) ? new Date(src.at).toISOString() : 'null'))
3694
+ const stage = text(src.stage, 60)
3695
+ if (stage !== null) parts.push('stage=' + stage)
3696
+ /* HTTP 状态码:fetch 对 4xx/5xx 是 resolve 不是 reject,必须单独记 */
3697
+ if (typeof src.httpStatus === 'number' && Number.isFinite(src.httpStatus)) parts.push('http=' + String(src.httpStatus))
3698
+ const detail = text(src.detail, 300)
3699
+ if (detail !== null && detail.length > 0) parts.push('detail=' + detail)
3700
+ /* 失败那一次尝试了多久:能区分"秒失败(被拒/被拦)"与"等很久才超时" */
3701
+ if (typeof src.elapsedMs === 'number' && Number.isFinite(src.elapsedMs)) parts.push('elapsed=' + String(Math.round(src.elapsedMs)) + 'ms')
3702
+ return parts.join(' ')
3703
+ }
3704
+
3184
3705
  /* 台账只在「有新数据」时才重扫(文件 mtime+size 没变就跳过),因此这层只是省掉重复的 stat。
3185
3706
  不挂定时器:打开活跃总览面板时会带 force=1 现扫一次,数据只在被看的时候才需要最新。 */
3186
3707
  const WEEK_USAGE_CACHE_MS = 30000
@@ -3397,203 +3918,296 @@ export function apply(ctx) {
3397
3918
  ctx.effect(() => ctx.webServer.register({
3398
3919
  kind: 'prefix',
3399
3920
  path: '/dsh-desktop-statusbar/api',
3921
+ /* 整体兜底:handler 里任何一处抛错(如 ctx.get('credentials') 在 try 之外被
3922
+ queryBalance 调到、读盘/解压意外抛出)原本会让这个 Promise reject ——
3923
+ webServer 不替我们收尾,于是既不写响应也不关连接,客户端 fetch **永久 pending**
3924
+ (底栏余额永远停在「查询中」)。这里统一写 500 并结束响应;已经写过响应头
3925
+ (headersSent)时不再二次写,只结束连接,避免 ERR_HTTP_HEADERS_SENT。 */
3400
3926
  handler: async (req, res) => {
3401
- const url = new URL(req.url ?? '/', 'http://127.0.0.1')
3402
- if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
3403
- /* POST = 客户端把它的价格库(localStorage 那份,用户可增删改)报上来。
3404
- 活跃总览的 cost 由 host 记账时算出,必须用同一份价格,否则用户在设置页
3405
- 加/改的单价只对底栏生效、活跃总览恒为旧值(实测 2026-10-07)。 */
3406
- if (req.method === 'POST') {
3407
- const body = await readJsonBody(req)
3408
- const book = body === null ? null : sanitizePriceBook(body.models)
3409
- if (book === null) {
3410
- sendJson(res, 400, { ok: false, reason: 'bad-models' })
3411
- return
3412
- }
3413
- /* 判断"价格是否变了"用的快照在队列里取(见下):并排上报不会双双报"已变更" */
3414
- /* **在队列里换价格库**:换库必须与"读台账→结算→写盘"是同一段原子操作,
3415
- 否则在途扫描会一半用旧价、一半用新价,同一天的数字混两套价(实测缺陷)。 */
3416
- let settled = { entries: 0, amount: 0 }
3417
- let changed = false
3418
- const swapped = await queueLedgerJob(async () => {
3419
- const before = activePriceFingerprint
3420
- clientPrices = book
3421
- const nowFingerprint = priceFingerprint()
3422
- activePriceFingerprint = nowFingerprint
3423
- const nowChanged = before !== nowFingerprint
3424
- /* 价格变了只做一件事:把"当时没有单价、还没算钱"的欠账按新价结算掉。
3425
- 已经算好的费用一律不动,台账也不重建 —— 所以会话日志被删也不影响已有数字。 */
3426
- if (nowChanged !== true) return { changed: false, settled: { entries: 0, amount: 0 } }
3427
- const ledger = readLedger()
3428
- const done = settleUnpriced(ledger)
3429
- /* 两种情况才落盘:真结算了欠账;或者盘上还是老版本,顺手把迁移结果固化一次
3430
- (免得每次都重迁一遍)。没有台账时不在这里创建。
3431
- **绝不改写 priceFingerprint** —— 那个字段记的是"上次记账用的那份价目表",
3432
- 是回扫判断"当时有没有价"的唯一依据;价格上报抢先改写它,历史缺价明细就永远补不出来。 */
3433
- const onDisk = readLedgerFile(ledgerPath())
3434
- const stale = onDisk !== null && onDisk.version !== LEDGER_VERSION
3435
- if (done.entries > 0 || stale) {
3436
- ledger.updatedAt = Date.now()
3437
- writeLedger(ledger)
3438
- }
3439
- return { changed: true, settled: done }
3440
- })
3441
- changed = swapped.changed
3442
- settled = swapped.settled
3443
- if (changed) {
3444
- /* 面板的数字要重新取一次(payload 缓存在上面) */
3445
- weekUsageCache = { at: 0, value: null }
3446
- weekUsageScan = null
3447
- }
3448
- logHost(ctx, 'prices: 收到客户端价格库 ' + String(Object.keys(book).length) + ' 条'
3449
- + (changed
3450
- ? '(已变更,结算欠账 ' + String(settled.entries) + ' 项 ¥' + String(settled.amount) + ')'
3451
- : '(与当前一致)'))
3452
- sendJson(res, 200, { ok: true, changed: changed, count: Object.keys(book).length, settled: settled })
3453
- return
3454
- }
3455
- /* GET = 官方参考价(价格库为空时客户端拿它自愈),与用户价格库无关 */
3456
- sendJson(res, 200, {
3457
- models: {
3458
- 'deepseek-flash': TOKEN_PRICES.peak['deepseek-flash'] === undefined ? null : {
3459
- peak: TOKEN_PRICES.peak['deepseek-flash'],
3460
- offPeak: TOKEN_PRICES.offPeak['deepseek-flash'],
3461
- },
3462
- 'deepseek-v4-pro': {
3463
- peak: TOKEN_PRICES.peak['deepseek-v4-pro'],
3464
- offPeak: TOKEN_PRICES.offPeak['deepseek-v4-pro'],
3465
- },
3466
- },
3467
- note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
3468
- })
3469
- return
3470
- }
3471
- if (url.pathname === '/dsh-desktop-statusbar/api/holidays') {
3472
- const raw = url.searchParams.get('year')
3473
- const year = raw === null ? NaN : Number(raw)
3474
- if (Number.isInteger(year) !== true || year < 2020 || year > 2100) {
3475
- sendJson(res, 400, { ok: false, reason: 'bad-year' })
3476
- return
3477
- }
3927
+ try {
3928
+ await handleApi(req, res, ctx)
3929
+ } catch (error) {
3930
+ logHost(ctx, 'api: 未捕获异常 ' + probeDetail(error))
3478
3931
  try {
3479
- const result = await fetchHolidayDays(year)
3480
- if (result.ok === true) {
3481
- /* 落盘:host 自己也要用这份假期表判峰谷,否则 2027 年起底栏与活跃总览口径不一致 */
3482
- const merged = saveHolidayDays(result.days)
3483
- logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length)
3484
- + ' 天(累计 ' + String(merged.length) + ' 天,已落盘)')
3485
- }
3486
- sendJson(res, 200, result)
3487
- } catch (error) {
3488
- const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'fetch-failed'
3489
- logHost(ctxRef, 'holidays: 抓取失败 ' + detail)
3490
- sendJson(res, 200, { ok: false, reason: 'fetch-failed' })
3932
+ if (res.headersSent === true) res.end()
3933
+ else sendJson(res, 500, { ok: false, reason: 'internal-error' })
3934
+ } catch (nested) {
3935
+ /* 连接已经断了,没什么可做的 */
3491
3936
  }
3492
- return
3493
3937
  }
3494
- if (url.pathname === '/dsh-desktop-statusbar/api/version') {
3495
- /* 启动自检可能还没回来:等这一次在途的查询,别让客户端误判成「没有新版」 */
3496
- if (latestVersion.checkedAt === 0 && versionCheck !== null) {
3497
- try {
3498
- latestVersion = await versionCheck
3499
- } catch (error) {
3500
- /* 保持 latest 为 null,下面按「查不到」处理 */
3501
- }
3502
- }
3503
- const raw = url.searchParams.get('current')
3504
- const current = raw === null ? '' : raw.trim()
3505
- sendJson(res, 200, {
3506
- ok: latestVersion.latest !== null,
3507
- current,
3508
- latest: latestVersion.latest,
3509
- /* 只有确实拿到版本号、且它比本地这个新,才算「有更新」 */
3510
- hasUpdate: latestVersion.latest !== null && current.length > 0
3511
- && compareVersions(latestVersion.latest, current) > 0,
3512
- checkedAt: latestVersion.checkedAt,
3513
- error: latestVersion.error,
3514
- })
3938
+ },
3939
+ }), 'dsh-desktop-statusbar: balance api')
3940
+ }
3941
+
3942
+ async function handleApi(req, res, ctx) {
3943
+ /* 带跨站 Origin 的写请求一律拒绝:浏览器里任意网页都能对本机发简单请求,
3944
+ 必须在**改任何状态之前**挡住(判定见 isCrossSiteWrite 的说明)。 */
3945
+ if (isCrossSiteWrite(req) === true) {
3946
+ logHost(ctx, 'api: 拒绝跨站写请求 origin=' + String(req.headers.origin))
3947
+ sendJson(res, 403, { ok: false, reason: 'cross-origin' })
3948
+ return
3949
+ }
3950
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
3951
+ if (url.pathname === '/dsh-desktop-statusbar/api/prices') {
3952
+ /* POST = 客户端把它的价格库(localStorage 那份,用户可增删改)报上来。
3953
+ 活跃总览的 cost 由 host 记账时算出,必须用同一份价格,否则用户在设置页
3954
+ 加/改的单价只对底栏生效、活跃总览恒为旧值(实测 2026-10-07)。 */
3955
+ if (req.method === 'POST') {
3956
+ const body = await readJsonBody(req)
3957
+ const book = body === null ? null : sanitizePriceBook(body.models)
3958
+ if (book === null) {
3959
+ sendJson(res, 400, { ok: false, reason: 'bad-models' })
3515
3960
  return
3516
3961
  }
3517
- if (url.pathname === '/dsh-desktop-statusbar/api/active-model') {
3518
- if (req.method === 'POST') {
3519
- const body = await readJsonBody(req)
3520
- const model = body !== null && typeof body.model === 'string' ? body.model.trim() : ''
3521
- if (model.length === 0) {
3522
- sendJson(res, 400, { ok: false, reason: 'model-required' })
3523
- return
3524
- }
3525
- activeModel = {
3526
- provider: typeof body.provider === 'string' && body.provider.length > 0 ? body.provider : null,
3527
- model: model,
3528
- at: Date.now(),
3529
- }
3530
- sendJson(res, 200, { ok: true })
3531
- return
3962
+ /* 判断"价格是否变了"用的快照在队列里取(见下):并排上报不会双双报"已变更" */
3963
+ /* **在队列里换价格库**:换库必须与"读台账→结算→写盘"是同一段原子操作,
3964
+ 否则在途扫描会一半用旧价、一半用新价,同一天的数字混两套价(实测缺陷)。 */
3965
+ let settled = { entries: 0, amount: 0 }
3966
+ let changed = false
3967
+ const swapped = await queueLedgerJob(async () => {
3968
+ const before = activePriceFingerprint
3969
+ clientPrices = book
3970
+ const nowFingerprint = priceFingerprint()
3971
+ activePriceFingerprint = nowFingerprint
3972
+ const nowChanged = before !== nowFingerprint
3973
+ /* 价格变了只做一件事:把"当时没有单价、还没算钱"的欠账按新价结算掉。
3974
+ 已经算好的费用一律不动,台账也不重建 —— 所以会话日志被删也不影响已有数字。 */
3975
+ if (nowChanged !== true) return { changed: false, settled: { entries: 0, amount: 0 } }
3976
+ const ledger = readLedger()
3977
+ const done = settleUnpriced(ledger)
3978
+ /* 两种情况才落盘:真结算了欠账;或者盘上还是老版本,顺手把迁移结果固化一次
3979
+ (免得每次都重迁一遍)。没有台账时不在这里创建。
3980
+ **绝不改写 priceFingerprint** —— 那个字段记的是"上次记账用的那份价目表",
3981
+ 是回扫判断"当时有没有价"的唯一依据;价格上报抢先改写它,历史缺价明细就永远补不出来。 */
3982
+ const onDisk = readJsonObjectFile(ledgerPath())
3983
+ const stale = onDisk !== null && onDisk.version !== LEDGER_VERSION
3984
+ if (done.entries > 0 || stale) {
3985
+ ledger.updatedAt = Date.now()
3986
+ writeLedger(ledger)
3532
3987
  }
3533
- sendJson(res, 200, {
3534
- ok: true,
3535
- provider: activeModel.provider,
3536
- model: activeModel.model,
3537
- at: activeModel.at,
3538
- })
3539
- return
3988
+ return { changed: true, settled: done }
3989
+ })
3990
+ changed = swapped.changed
3991
+ settled = swapped.settled
3992
+ if (changed) {
3993
+ /* 面板的数字要重新取一次(payload 缓存在上面) */
3994
+ weekUsageCache = { at: 0, value: null }
3995
+ weekUsageScan = null
3540
3996
  }
3541
- if (url.pathname === '/dsh-desktop-statusbar/api/subagent-usage') {
3542
- /* 底栏「总计」和「本轮」的队友部分:子智能体是独立会话,它的用量只能这样按 parentSession
3543
- 找回来。`since` 是可选的窗口起点(毫秒):客户端拿"本轮起点"传进来,「本轮」就只算
3544
- 本轮里队友花了多少;不传就是整会话(「总计」用)。
3545
- 参数不对(客户端拿不到会话 id / 传了别的东西)给 400;会话没有队友、或队友日志
3546
- 全都解不出来时给零值而不是 404 —— 客户端按"加上 0"处理,界面不该因此报错。 */
3547
- const raw = url.searchParams.get('sessionId')
3548
- const sessionId = raw === null ? '' : raw.trim()
3549
- if (sessionId.length === 0 || sessionId.length > 200) {
3550
- sendJson(res, 400, { ok: false, reason: 'bad-session' })
3551
- return
3552
- }
3553
- /* 非法/缺失的 since 一律当"没传":空串、非有限数、负数、以及**未来时刻**。
3554
- 本轮起点必然在过去,未来时刻只可能是客户端传了坏值;真按它过滤会把队友的
3555
- 数字静默清零(少算钱),所以宁可忽略。留 5 分钟余量容忍时钟抖动。
3556
- "当没传"的新语义(task-18 起):由 host 读父会话日志代推起点;连父会话都没有
3557
- 就本轮为空(`sinceSource='none'`),**不**退回整会话 —— 要整会话就显式传 `since=0`。 */
3558
- const rawSince = url.searchParams.get('since')
3559
- const sinceText = rawSince === null ? '' : rawSince.trim()
3560
- const parsedSince = sinceText.length === 0 ? NaN : Number(sinceText)
3561
- const usableSince = Number.isFinite(parsedSince) === true && parsedSince >= 0
3562
- && parsedSince <= Date.now() + 5 * 60 * 1000
3563
- /* 这个接口只读日志、不排队:它不碰台账,没有与扫描抢写盘的问题 */
3564
- let value
3565
- try {
3566
- value = await collectSubagentUsage(sessionId, usableSince === true ? parsedSince : undefined)
3567
- } catch (error) {
3568
- logHost(ctx, 'subagent-usage: 汇总失败 ' + probeDetail(error))
3569
- value = { ok: false, reason: 'scan-failed' }
3570
- }
3571
- sendJson(res, 200, value)
3572
- return
3997
+ logHost(ctx, 'prices: 收到客户端价格库 ' + String(Object.keys(book).length) + ' 条'
3998
+ + (changed
3999
+ ? '(已变更,结算欠账 ' + String(settled.entries) + ' 项 ¥' + String(settled.amount) + ')'
4000
+ : '(与当前一致)'))
4001
+ sendJson(res, 200, { ok: true, changed: changed, count: Object.keys(book).length, settled: settled })
4002
+ return
4003
+ }
4004
+ /* GET = 官方参考价(价格库为空时客户端拿它自愈),与用户价格库无关 */
4005
+ sendJson(res, 200, {
4006
+ models: {
4007
+ 'deepseek-flash': TOKEN_PRICES.peak['deepseek-flash'] === undefined ? null : {
4008
+ peak: TOKEN_PRICES.peak['deepseek-flash'],
4009
+ offPeak: TOKEN_PRICES.offPeak['deepseek-flash'],
4010
+ },
4011
+ 'deepseek-v4-pro': {
4012
+ peak: TOKEN_PRICES.peak['deepseek-v4-pro'],
4013
+ offPeak: TOKEN_PRICES.offPeak['deepseek-v4-pro'],
4014
+ },
4015
+ },
4016
+ note: 'CNY per 1M tokens, 高峰=北京时间周一至周五 9-12、14-18(法定节假日与周末全天按空闲价)',
4017
+ })
4018
+ return
4019
+ }
4020
+ if (url.pathname === '/dsh-desktop-statusbar/api/holidays') {
4021
+ const raw = url.searchParams.get('year')
4022
+ const year = raw === null ? NaN : Number(raw)
4023
+ if (Number.isInteger(year) !== true || year < 2020 || year > 2100) {
4024
+ sendJson(res, 400, { ok: false, reason: 'bad-year' })
4025
+ return
4026
+ }
4027
+ try {
4028
+ const result = await fetchHolidayDays(year)
4029
+ if (result.ok === true) {
4030
+ /* 落盘:host 自己也要用这份假期表判峰谷,否则 2027 年起底栏与活跃总览口径不一致 */
4031
+ const merged = saveHolidayDays(result.days)
4032
+ logHost(ctxRef, 'holidays: ' + String(year) + ' 年 ' + String(result.days.length)
4033
+ + ' 天(累计 ' + String(merged.length) + ' 天,已落盘)')
3573
4034
  }
3574
- if (url.pathname === '/dsh-desktop-statusbar/api/daily-usage') {
3575
- let value
3576
- try {
3577
- value = await refreshWeekUsage(url.searchParams.get('force') === '1', ctx)
3578
- } catch (error) {
3579
- logHost(ctx, 'usage: 台账刷新失败 ' + probeDetail(error))
3580
- value = { ok: false, reason: 'ledger-failed' }
3581
- }
3582
- sendJson(res, 200, value)
3583
- return
4035
+ sendJson(res, 200, result)
4036
+ } catch (error) {
4037
+ const detail = error !== null && error !== undefined && typeof error.message === 'string' ? error.message : 'fetch-failed'
4038
+ logHost(ctxRef, 'holidays: 抓取失败 ' + detail)
4039
+ sendJson(res, 200, { ok: false, reason: 'fetch-failed' })
4040
+ }
4041
+ return
4042
+ }
4043
+ if (url.pathname === '/dsh-desktop-statusbar/api/version') {
4044
+ /* 启动自检可能还没回来:等这一次在途的查询,别让客户端误判成「没有新版」 */
4045
+ if (latestVersion.checkedAt === 0 && versionCheck !== null) {
4046
+ try {
4047
+ latestVersion = await versionCheck
4048
+ } catch (error) {
4049
+ /* 保持 latest 为 null,下面按「查不到」处理 */
3584
4050
  }
3585
- if (url.pathname === '/dsh-desktop-statusbar/api/tip-diag') {
3586
- if (req.method === 'POST') appendTipDiag(await readJsonBody(req))
3587
- sendJson(res, 200, { ok: true })
4051
+ }
4052
+ const raw = url.searchParams.get('current')
4053
+ const current = raw === null ? '' : raw.trim()
4054
+ sendJson(res, 200, {
4055
+ ok: latestVersion.latest !== null,
4056
+ current,
4057
+ latest: latestVersion.latest,
4058
+ /* 只有确实拿到版本号、且它比本地这个新,才算「有更新」 */
4059
+ hasUpdate: latestVersion.latest !== null && current.length > 0
4060
+ && compareVersions(latestVersion.latest, current) > 0,
4061
+ checkedAt: latestVersion.checkedAt,
4062
+ error: latestVersion.error,
4063
+ })
4064
+ return
4065
+ }
4066
+ if (url.pathname === '/dsh-desktop-statusbar/api/self-update') {
4067
+ /* 一键更新:由本插件自己完成,不依赖「插件管理器」这个别的插件,也不走
4068
+ pnpm 的 24 小时发布年龄策略(做法见 self-update.js 顶部说明)。
4069
+ GET = 问当前状态(界面挂在更新中途刷新时能接上);POST = 发起一次更新。 */
4070
+ if (req.method !== 'POST') {
4071
+ const last = lastUpdateResult()
4072
+ sendJson(res, 200, {
4073
+ ok: true,
4074
+ busy: isUpdating(),
4075
+ last: last === null ? null : {
4076
+ ok: last.ok === true,
4077
+ reason: last.reason === undefined ? null : last.reason,
4078
+ from: last.from === undefined ? null : last.from,
4079
+ to: last.to === undefined ? null : last.to,
4080
+ },
4081
+ })
4082
+ return
4083
+ }
4084
+ const body = await readJsonBody(req)
4085
+ const raw = body === null || typeof body.version !== 'string' ? '' : body.version.trim()
4086
+ /* 目标版本必须是明确的三段式版本号:不接受 latest/tag/范围,避免装到别的东西。
4087
+ 空则不传 —— host 会自己问 registry 的最新版。 */
4088
+ if (raw.length > 0 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(raw) !== true) {
4089
+ sendJson(res, 400, { ok: false, reason: 'bad-version' })
4090
+ return
4091
+ }
4092
+ if (isUpdating() === true) {
4093
+ sendJson(res, 409, { ok: false, reason: 'busy' })
4094
+ return
4095
+ }
4096
+ let target = raw
4097
+ if (target.length === 0) {
4098
+ /* 客户端没带版本就自己查一次最新的 */
4099
+ const checked = await checkLatestVersion()
4100
+ latestVersion = checked
4101
+ if (checked.latest === null) {
4102
+ sendJson(res, 200, { ok: false, reason: checked.error === null ? 'no-version' : checked.error })
3588
4103
  return
3589
4104
  }
3590
- if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
3591
- sendJson(res, 404, { ok: false, reason: 'not-found' })
4105
+ target = checked.latest
4106
+ }
4107
+ const selfDir = resolveSelfDir()
4108
+ logHost(ctxRef, 'update: 收到更新请求 → ' + target + '(当前目录 ' + selfDir + ')')
4109
+ const value = await selfUpdateOnce({
4110
+ selfDir,
4111
+ version: target,
4112
+ logger: (message) => logHost(ctxRef, message),
4113
+ })
4114
+ logHost(ctxRef, 'update: 结果 ' + JSON.stringify(value))
4115
+ sendJson(res, 200, value)
4116
+ return
4117
+ }
4118
+ if (url.pathname === '/dsh-desktop-statusbar/api/active-model') {
4119
+ if (req.method === 'POST') {
4120
+ const body = await readJsonBody(req)
4121
+ const model = body !== null && typeof body.model === 'string' ? body.model.trim() : ''
4122
+ if (model.length === 0) {
4123
+ sendJson(res, 400, { ok: false, reason: 'model-required' })
3592
4124
  return
3593
4125
  }
3594
- const force = url.searchParams.get('force') === '1'
3595
- const value = await queryBalance(ctx, force, url.searchParams.get('provider'), url.searchParams.get('model'))
3596
- sendJson(res, 200, Object.assign({}, value, { probe: accountProbeNote }))
3597
- },
3598
- }), 'dsh-desktop-statusbar: balance api')
4126
+ activeModel = {
4127
+ provider: typeof body.provider === 'string' && body.provider.length > 0 ? body.provider : null,
4128
+ model: model,
4129
+ at: Date.now(),
4130
+ }
4131
+ sendJson(res, 200, { ok: true })
4132
+ return
4133
+ }
4134
+ sendJson(res, 200, {
4135
+ ok: true,
4136
+ provider: activeModel.provider,
4137
+ model: activeModel.model,
4138
+ at: activeModel.at,
4139
+ })
4140
+ return
4141
+ }
4142
+ if (url.pathname === '/dsh-desktop-statusbar/api/subagent-usage') {
4143
+ /* 底栏「总计」和「本轮」的队友部分:子智能体是独立会话,它的用量只能这样按 parentSession
4144
+ 找回来。`since` 是可选的窗口起点(毫秒):客户端拿"本轮起点"传进来,「本轮」就只算
4145
+ 本轮里队友花了多少;不传就是整会话(「总计」用)。
4146
+ 参数不对(客户端拿不到会话 id / 传了别的东西)给 400;会话没有队友、或队友日志
4147
+ 全都解不出来时给零值而不是 404 —— 客户端按"加上 0"处理,界面不该因此报错。 */
4148
+ const raw = url.searchParams.get('sessionId')
4149
+ const sessionId = raw === null ? '' : raw.trim()
4150
+ if (sessionId.length === 0 || sessionId.length > 200) {
4151
+ sendJson(res, 400, { ok: false, reason: 'bad-session' })
4152
+ return
4153
+ }
4154
+ /* 非法/缺失的 since 一律当"没传":空串、非有限数、负数、以及**未来时刻**。
4155
+ 本轮起点必然在过去,未来时刻只可能是客户端传了坏值;真按它过滤会把队友的
4156
+ 数字静默清零(少算钱),所以宁可忽略。留 5 分钟余量容忍时钟抖动。
4157
+ "当没传"的新语义(task-18 起):由 host 读父会话日志代推起点;连父会话都没有
4158
+ 就本轮为空(`sinceSource='none'`),**不**退回整会话 —— 要整会话就显式传 `since=0`。 */
4159
+ const rawSince = url.searchParams.get('since')
4160
+ const sinceText = rawSince === null ? '' : rawSince.trim()
4161
+ const parsedSince = sinceText.length === 0 ? NaN : Number(sinceText)
4162
+ const usableSince = Number.isFinite(parsedSince) === true && parsedSince >= 0
4163
+ && parsedSince <= Date.now() + 5 * 60 * 1000
4164
+ /* 这个接口会排队(collectSubagentUsage 内部走 queueLedgerJob)——
4165
+ 别按「只读、不排队」去理解它的时序。此处原先的注释与实现矛盾,已改正。 */
4166
+ let value
4167
+ try {
4168
+ value = await collectSubagentUsage(sessionId, usableSince === true ? parsedSince : undefined)
4169
+ } catch (error) {
4170
+ logHost(ctx, 'subagent-usage: 汇总失败 ' + probeDetail(error))
4171
+ value = { ok: false, reason: 'scan-failed' }
4172
+ }
4173
+ sendJson(res, 200, value)
4174
+ return
4175
+ }
4176
+ if (url.pathname === '/dsh-desktop-statusbar/api/daily-usage') {
4177
+ let value
4178
+ try {
4179
+ value = await refreshWeekUsage(url.searchParams.get('force') === '1', ctx)
4180
+ } catch (error) {
4181
+ logHost(ctx, 'usage: 台账刷新失败 ' + probeDetail(error))
4182
+ value = { ok: false, reason: 'ledger-failed' }
4183
+ }
4184
+ sendJson(res, 200, value)
4185
+ return
4186
+ }
4187
+ if (url.pathname === '/dsh-desktop-statusbar/api/tip-diag') {
4188
+ if (req.method === 'POST') appendTipDiag(await readJsonBody(req))
4189
+ sendJson(res, 200, { ok: true })
4190
+ return
4191
+ }
4192
+ if (url.pathname === '/dsh-desktop-statusbar/api/update-failure') {
4193
+ /* 客户端侧更新失败的上报口。为什么需要它:宿主侧失败已经在
4194
+ `update: 结果 {...}` 那条日志里了,但**客户端自己**的失败(fetch 被代理拦、
4195
+ 响应解不出来、网络断)发生在浏览器里,宿主完全不知道 —— 用户点完只看到
4196
+ 「更新失败」,日志里查不到任何痕迹,想用 AI 排查也无从下手。
4197
+ 这里把它补成一条**结构化日志行**:既有给人看的中文摘要,也有完整 JSON。
4198
+ 不做任何状态变更、不重试安装,只落日志,所以是纯诊断面。 */
4199
+ if (req.method === 'POST') {
4200
+ const body = await readJsonBody(req)
4201
+ logHost(ctx, 'update: 客户端失败 ' + describeClientUpdateFailure(body))
4202
+ }
4203
+ sendJson(res, 200, { ok: true })
4204
+ return
4205
+ }
4206
+ if (url.pathname !== '/dsh-desktop-statusbar/api/balance') {
4207
+ sendJson(res, 404, { ok: false, reason: 'not-found' })
4208
+ return
4209
+ }
4210
+ const force = url.searchParams.get('force') === '1'
4211
+ const value = await queryBalance(ctx, force, url.searchParams.get('provider'), url.searchParams.get('model'))
4212
+ sendJson(res, 200, Object.assign({}, value, { probe: accountProbeNote }))
3599
4213
  }