balance-dsh 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +559 -175
  2. package/lib/index.js +306 -20
  3. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -124,13 +124,24 @@ async function sessionStampMap(ctx) {
124
124
  * 拿到最新数字,而余额/今日本来就是几十秒一轮的节奏,感知不到。
125
125
  */
126
126
  const persistReadAt = new Map(); // sessionId -> 上次真正解压的时刻
127
+ // 按会话增长的表一律夹上限(2026-10-31 第二十二轮审计):Map 保持插入序,淘汰最旧的键。
128
+ const PERSIST_READ_MAX = 240;
129
+ function capSessionMap(map, max = PERSIST_READ_MAX) {
130
+ while (map.size > max) {
131
+ const oldest = map.keys().next();
132
+ if (oldest.done) return;
133
+ map.delete(oldest.value);
134
+ }
135
+ }
127
136
  async function inspectPersistedCached(ctx, sessionId, minIntervalMs = 0) {
128
137
  if (typeof sessionId !== "string" || sessionId === "") return null;
129
- // 每帧按 4 KB 估算占用:实测 35.8 MB 的 zstd 会话 = 19331 帧 / 解压后 134 MB
130
- // (约 6.9 KB/帧)。这个数只用来做淘汰决策,不参与任何计费。
138
+ // 每帧按 7 KB 估算占用:实测 35.8 MB 的 zstd 会话 = 19331 帧 / 解压后 134 MB
139
+ // (约 6.9 KB/帧)。⚠️ 2026-10-31 第二十二轮审计:原来写 4096,低估约 40% ——
140
+ // 512 MB 的预算实际能吃到 ~800 MB 常驻,和 DSH 同进程时会 OOM。
141
+ // 这个数只用来做淘汰决策,不参与任何计费。
131
142
  const estimateBytes = (value) => {
132
143
  const events = value?.events;
133
- return Array.isArray(events) ? events.length * 4096 : 0;
144
+ return Array.isArray(events) ? events.length * 7000 : 0;
134
145
  };
135
146
  const dropEntry = (key) => {
136
147
  const rec = persistCache.get(key);
@@ -143,7 +154,10 @@ async function inspectPersistedCached(ctx, sessionId, minIntervalMs = 0) {
143
154
  };
144
155
  const now = Date.now();
145
156
  const stamps = await sessionStampMap(ctx);
146
- const hasStamp = stamps.has(sessionId);
157
+ // ⚠️ 列表里有这条 id,但 updatedAt/createdAt 都是空(新版本 DSH 的某些条目如此)时,
158
+ // 指纹恒为 "",`hit.stamp === stamp` 永远成立 → 走 30 分钟长效 TTL,内容更新了也读到
159
+ // 冻结的旧事件(2026-10-31 第二十二轮审计)。所以「空指纹」一律按拿不到指纹处理。
160
+ const hasStamp = stamps.has(sessionId) && stamps.get(sessionId) !== "";
147
161
  const stamp = hasStamp ? stamps.get(sessionId) : "";
148
162
 
149
163
  const hit = persistCache.get(sessionId);
@@ -198,6 +212,11 @@ async function inspectPersistedCached(ctx, sessionId, minIntervalMs = 0) {
198
212
  return value;
199
213
  } finally {
200
214
  persistInFlight.delete(sessionId);
215
+ // ⚠️ 失败也要记重读时刻(2026-10-31 第二十二轮审计):这条只写在成功路径时,
216
+ // 大会话文件损坏/被占的情况下 minIntervalMs 的节流永远不生效 ——
217
+ // `?session=` 每次请求都完整解压一遍(约 800 ms 同步 CPU),切会话还会再试第二个 id。
218
+ if (minIntervalMs > 0) persistReadAt.set(sessionId, Date.now());
219
+ capSessionMap(persistReadAt);
201
220
  }
202
221
  })();
203
222
  persistInFlight.set(sessionId, task);
@@ -739,7 +758,7 @@ function formatBalance(balance) {
739
758
  // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
740
759
  // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
741
760
  // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
742
- const PLUGIN_VERSION = "2.1.0";
761
+ const PLUGIN_VERSION = "2.1.2";
743
762
 
744
763
  /** 磁盘上**实际安装**的版本号。
745
764
  *
@@ -843,6 +862,188 @@ function updateTarballUrl(version) {
843
862
  return "https://registry.npmjs.org/balance-dsh/-/balance-dsh-" + version + ".tgz";
844
863
  }
845
864
 
865
+ /** 插件目录所在的 profile 根(.../profiles/<名字>/node_modules/balance-dsh → .../profiles/<名字>)。
866
+ *
867
+ * 用户反馈(2026-10-31):「在插件设置里更新之后重启会导致版本回归,更新完是 2.1.0,
868
+ * 重启了之后又变成 2.0.8」。根因就是这里 —— 就地覆盖文件只改了 node_modules 里的内容,
869
+ * profile 的 package.json / pnpm-lock.yaml 里锁的还是旧版本;DSH 重启时按声明版本重新
870
+ * 落地那一份,于是又退回 2.0.8。所以必须同时把「声明的版本」也更新掉,见下面
871
+ * installViaPackageManager。 */
872
+ async function profileDirOf(pluginDir) {
873
+ if (typeof pluginDir !== "string" || pluginDir === "") return "";
874
+ const { dirname, basename, join } = await import("node:path");
875
+ const fs = await import("node:fs/promises");
876
+ /** 这一层是不是「声明了 balance-dsh 的 profile 根」。 */
877
+ const declares = async (dir) => {
878
+ try {
879
+ const raw = JSON.parse(await fs.readFile(join(dir, "package.json"), "utf8"));
880
+ return raw != null && typeof raw === "object" && raw.dependencies != null && Object.hasOwn(raw.dependencies, "balance-dsh");
881
+ } catch {
882
+ return false;
883
+ }
884
+ };
885
+ // 常规布局:<profile>/node_modules/<包>
886
+ const nodeModules = dirname(pluginDir);
887
+ if (basename(nodeModules) === "node_modules") {
888
+ const profile = dirname(nodeModules);
889
+ if (await declares(profile)) return profile;
890
+ }
891
+ // 兜底:pnpm 的虚拟仓是 <profile>/node_modules/.pnpm/<包>@<版本>/node_modules/<包>,
892
+ // 上一级不是 profile —— 往上找「package.json 里声明了 balance-dsh」的那一层,最多 6 级。
893
+ // 认不出来返回 ""(开发目录),调用方按「本地装法」处理,就地覆盖就是最终状态。
894
+ let dir = dirname(pluginDir);
895
+ for (let i = 0; i < 6 && dir !== ""; i += 1) {
896
+ if (await declares(dir)) return dir;
897
+ const parent = dirname(dir);
898
+ if (parent === dir) break;
899
+ dir = parent;
900
+ }
901
+ return "";
902
+ }
903
+
904
+ /** profile 里 balance-dsh 的依赖声明。只有**registry 版本号**才需要走包管理器:
905
+ * `file:` / `link:` / `git` 这类指向本地目录的装法,就地覆盖文件就是最终状态,不会被回退。 */
906
+ async function profileSpecOf(profileDir) {
907
+ if (profileDir === "") return "";
908
+ try {
909
+ const { readFile } = await import("node:fs/promises");
910
+ const { join } = await import("node:path");
911
+ const raw = JSON.parse(await readFile(join(profileDir, "package.json"), "utf8"));
912
+ const spec = raw?.dependencies?.["balance-dsh"];
913
+ if (typeof spec !== "string") return "";
914
+ const trimmed = spec.trim();
915
+ if (trimmed === "") return "";
916
+ // file:/link:/portal:/workspace:/git+https: 这些都不指向 registry 版本号。
917
+ // ⚠️ git 要写成 `git(?:\+[a-z]+)?`:实参是 `git+https://…`,光写 `git\+` 后面
918
+ // 跟的是 `https:` 而不是冒号,会漏过去被当成 registry 号(探针 2026-10-31 抓到)。
919
+ if (/^(file|link|portal|workspace|git(?:\+[a-z]+)?|https?):/i.test(trimmed)) return "";
920
+ if (trimmed.startsWith(".") || trimmed.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(trimmed)) return "";
921
+ return trimmed;
922
+ } catch {
923
+ return "";
924
+ }
925
+ }
926
+
927
+ /** 用 DSH 自带的 pnpm 真正装一遍:它会把 profile 的 package.json + pnpm-lock.yaml + node_modules
928
+ * 一起改成新版本,重启不会再退回旧号。失败时把两个文件按原样恢复(对齐 DSH 插件管理器自己的做法)。 */
929
+ async function installViaPackageManager(profileDir, version) {
930
+ const result = { ok: false, error: "", output: "" };
931
+ if (profileDir === "" || version === "") {
932
+ result.error = "没有 profile 目录";
933
+ return result;
934
+ }
935
+ const path = await import("node:path");
936
+ const fs = await import("node:fs/promises");
937
+ const roots = [];
938
+ const res = typeof process !== "undefined" && typeof process.resourcesPath === "string" ? process.resourcesPath : "";
939
+ if (res !== "") {
940
+ roots.push(path.join(res, "runtime", "primary-runtime", "dependencies"));
941
+ roots.push(path.join(res, "app", "runtime", "primary-runtime", "dependencies"));
942
+ }
943
+ const exe = typeof process !== "undefined" && typeof process.execPath === "string" ? process.execPath : "";
944
+ if (exe !== "") {
945
+ const base = path.dirname(exe);
946
+ roots.push(path.join(base, "runtime", "primary-runtime", "dependencies"));
947
+ roots.push(path.join(base, "resources", "runtime", "primary-runtime", "dependencies"));
948
+ roots.push(path.join(base, "..", "resources", "runtime", "primary-runtime", "dependencies"));
949
+ }
950
+ let nodeExe = "";
951
+ let pnpmMjs = "";
952
+ for (const root of roots) {
953
+ const nodeTry = process.platform === "win32"
954
+ ? path.join(root, "node", "bin", "node.exe")
955
+ : path.join(root, "node", "bin", "node");
956
+ const pnpmTry = path.join(root, "pnpm", "bin", "pnpm.mjs");
957
+ try {
958
+ await fs.access(nodeTry);
959
+ await fs.access(pnpmTry);
960
+ nodeExe = nodeTry;
961
+ pnpmMjs = pnpmTry;
962
+ break;
963
+ } catch {
964
+ // 继续试下一个候选根
965
+ }
966
+ }
967
+ if (nodeExe === "" || pnpmMjs === "") {
968
+ result.error = "找不到 DSH 自带的 pnpm";
969
+ return result;
970
+ }
971
+
972
+ // 备份两个会被 pnpm 改写的文件:失败要原样恢复,绝不能让 profile 停在半改状态
973
+ const saved = [];
974
+ for (const name of ["package.json", "pnpm-lock.yaml"]) {
975
+ const file = path.join(profileDir, name);
976
+ try {
977
+ saved.push({ file, text: await fs.readFile(file, "utf8") });
978
+ } catch {
979
+ saved.push({ file, text: null });
980
+ }
981
+ }
982
+ const restore = async () => {
983
+ for (const item of saved) {
984
+ try {
985
+ if (item.text === null) await fs.rm(item.file, { force: true });
986
+ else await fs.writeFile(item.file, item.text, "utf8");
987
+ } catch {
988
+ // 恢复失败也只能作罢,至少已经报错给用户
989
+ }
990
+ }
991
+ };
992
+
993
+ const { spawn } = await import("node:child_process");
994
+ const args = [
995
+ pnpmMjs,
996
+ "add",
997
+ "balance-dsh@" + version,
998
+ "--config.minimum-release-age=0",
999
+ "--config.confirmModulesPurge=false",
1000
+ "--reporter=append-only",
1001
+ "--registry",
1002
+ "https://registry.npmjs.org/",
1003
+ ];
1004
+ const env = { ...process.env };
1005
+ delete env.ELECTRON_RUN_AS_NODE;
1006
+ delete env.ELECTRON_NO_ATTACH_CONSOLE;
1007
+ const child = spawn(nodeExe, args, { cwd: profileDir, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
1008
+ let out = "";
1009
+ child.stdout?.on("data", (chunk) => {
1010
+ out += String(chunk);
1011
+ if (out.length > 20000) out = out.slice(-20000);
1012
+ });
1013
+ child.stderr?.on("data", (chunk) => {
1014
+ out += String(chunk);
1015
+ if (out.length > 20000) out = out.slice(-20000);
1016
+ });
1017
+ const done = await new Promise((resolve) => {
1018
+ const timer = setTimeout(() => {
1019
+ try {
1020
+ child.kill();
1021
+ } catch {
1022
+ // 进程可能已经退出
1023
+ }
1024
+ resolve({ code: -1, timedOut: true });
1025
+ }, 240000);
1026
+ child.on("error", (error) => {
1027
+ clearTimeout(timer);
1028
+ resolve({ code: -1, error: error instanceof Error ? error.message : String(error) });
1029
+ });
1030
+ child.on("close", (code) => {
1031
+ clearTimeout(timer);
1032
+ resolve({ code: typeof code === "number" ? code : -1, timedOut: false });
1033
+ });
1034
+ });
1035
+ result.output = out.trim().split(/\r?\n/).slice(-6).join("\n");
1036
+ if (done.code === 0) {
1037
+ result.ok = true;
1038
+ return result;
1039
+ }
1040
+ await restore();
1041
+ result.error = done.timedOut
1042
+ ? "pnpm 超过 4 分钟没有结束(已中止,profile 已恢复原状)"
1043
+ : (done.error || ("pnpm 退出码 " + String(done.code)));
1044
+ return result;
1045
+ }
1046
+
846
1047
  /** 就地更新本插件:下载 npm 上的最新 tarball → 解包 → 逐文件覆盖自己的安装目录。
847
1048
  *
848
1049
  * 几个刻意的取舍:
@@ -868,6 +1069,37 @@ async function installLatestVersion(ctx) {
868
1069
  const root = await installedPluginDir();
869
1070
  if (root === "") return { ok: false, error: "定位不到插件安装目录" };
870
1071
 
1072
+ // ★ 先看这一份装法是不是「按 registry 版本装进 profile 的」。
1073
+ // 是的话,光覆盖 node_modules 里的文件没用 —— profile 的 package.json / pnpm-lock.yaml
1074
+ // 还锁着旧版本,DSH 重启时按声明版本重新落地,版本就退回旧号(用户 2026-10-31 报的现象)。
1075
+ // 所以这种情况先让 DSH 自带的 pnpm 真正装一遍,声明与文件一起变,重启才不丢。
1076
+ const profileDir = await profileDirOf(root);
1077
+ const registrySpec = await profileSpecOf(profileDir);
1078
+ const manualCommand = "dsh plugin install balance-dsh@" + latest;
1079
+ let pnpmFailure = "";
1080
+ let pnpmOutput = "";
1081
+ if (registrySpec !== "") {
1082
+ const viaPnpm = await installViaPackageManager(profileDir, latest);
1083
+ if (viaPnpm.ok) {
1084
+ updateCache = { at: 0, value: null };
1085
+ return {
1086
+ ok: true,
1087
+ installed: true,
1088
+ from: currentVer,
1089
+ to: latest,
1090
+ via: "pnpm",
1091
+ persisted: true,
1092
+ files: 0,
1093
+ failed: [],
1094
+ restart: true,
1095
+ };
1096
+ }
1097
+ // pnpm 跑不起来(没有自带 runtime / 断网 / 被策略拦):继续走就地覆盖,
1098
+ // 但必须如实告诉用户「这次不持久」,并给出能真正生效的命令。
1099
+ pnpmFailure = viaPnpm.error;
1100
+ pnpmOutput = viaPnpm.output;
1101
+ }
1102
+
871
1103
  let gz;
872
1104
  try {
873
1105
  const signal =
@@ -953,6 +1185,17 @@ async function installLatestVersion(ctx) {
953
1185
  installed: true,
954
1186
  from: currentVer,
955
1187
  to: latest,
1188
+ via: "files",
1189
+ // registry 装法只覆盖文件 = 重启会被 profile 里锁的旧版本覆盖回去,必须如实标出来
1190
+ persisted: registrySpec === "",
1191
+ warning: registrySpec === ""
1192
+ ? ""
1193
+ : "文件已覆盖到 " + latest + ",但本机是按 npm 版本装进 profile 的,profile 里锁定的还是旧版本;"
1194
+ + "重启后可能退回旧号(" + (pnpmFailure || "自带 pnpm 不可用") + ")。"
1195
+ + "请执行 " + manualCommand + " 后再重启,或用 设置 → 插件 里的更新。",
1196
+ command: manualCommand,
1197
+ pnpmError: pnpmFailure,
1198
+ pnpmOutput,
956
1199
  files: written.length,
957
1200
  failed,
958
1201
  backup: backupRoot,
@@ -1774,7 +2017,9 @@ async function writeJsonAtomic(file, value) {
1774
2017
  const { mkdir, writeFile, rename } = await import("node:fs/promises");
1775
2018
  const { dirname } = await import("node:path");
1776
2019
  await mkdir(dirname(file), { recursive: true });
1777
- const tmp = `${file}.tmp`;
2020
+ // ⚠️ 临时名必须唯一(2026-10-31 第二十二轮审计):固定 `.tmp` 时两次并发写会互相截断,
2021
+ // rename 也可能报 ENOENT。saveCalendarCache 早就改成唯一名了,这里跟上。
2022
+ const tmp = file + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
1778
2023
  await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1779
2024
  await rename(tmp, file);
1780
2025
  }
@@ -1918,7 +2163,8 @@ async function writeSettingsFile(file, settings) {
1918
2163
  } catch {
1919
2164
  // 首次写盘(还没有原文件):没有可备份的东西,继续
1920
2165
  }
1921
- const tmp = `${file}.tmp`;
2166
+ // 唯一临时名:并发写(设置卡片连点保存)时固定 `.tmp` 会互相截断。
2167
+ const tmp = file + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
1922
2168
  await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
1923
2169
  await rename(tmp, file);
1924
2170
  }
@@ -2082,9 +2328,16 @@ async function updateTodayCostWithSession(ctx, config, state, sessionId, events)
2082
2328
  );
2083
2329
  const localRec = local.get(todayKey)?.bySession?.get(sessionId);
2084
2330
  const bySession = { ...(base.bySession ?? {}) };
2331
+ // ⚠️ 桶只能往大走(2026-10-31 第二十二轮审计):`events` 可能是被
2332
+ // SESSION_REREAD_MIN_MS 节流后的**旧数组**(`?session=` 4 秒内复用上一份),
2333
+ // 直接覆盖会让今日金额往回跳(实测 0.168 → 0.112),而且这个较小的 provisional
2334
+ // 值还会写进 state.cache.value。当天消耗只增不减,取较大者即可。
2335
+ const prevRec = bySession[sessionKey];
2336
+ const nextCost = Number(localRec?.cost ?? 0);
2337
+ const nextTokens = Number(localRec?.totalTokens ?? 0);
2085
2338
  bySession[sessionKey] = {
2086
- cost: Number(localRec?.cost ?? 0),
2087
- totalTokens: Number(localRec?.totalTokens ?? 0),
2339
+ cost: Math.max(Number(prevRec?.cost ?? 0), nextCost),
2340
+ totalTokens: Math.max(Number(prevRec?.totalTokens ?? 0), nextTokens),
2088
2341
  };
2089
2342
  let cost = 0;
2090
2343
  let totalTokens = 0;
@@ -2276,8 +2529,7 @@ async function buildTodayCost(ctx, config, reuse = null) {
2276
2529
  // 假的 0 一旦落盘会被 readTodaySnapshot 认成"今天的值",界面就一直显示 ¥0.0000,
2277
2530
  // 而且后台重算只要继续踩同一个坑就永远修不回来。所以这种情况宁可不落盘,
2278
2531
  // 让下次冷启动重新现算。
2279
- const suspiciousEmpty = listedCount > 0 && candidates.length === 0;
2280
- const selected = candidates
2532
+ const suspiciousEmpty = listedCount > 0 && candidates.length === 0; const selected = candidates
2281
2533
  // 今日用自己的上限(默认 200),不再跟统计面板的 80 绑在一起 —— 见 TODAY_MAX_SESSIONS。
2282
2534
  .slice(0, clampNumber(config?.todaySessionLimit, 10, 2000, TODAY_MAX_SESSIONS))
2283
2535
  .map((x) => x.id);
@@ -2298,6 +2550,10 @@ async function buildTodayCost(ctx, config, reuse = null) {
2298
2550
  }
2299
2551
  const daily = new Map();
2300
2552
  let cursor = 0;
2553
+ // 成功读到事件的会话数(2026-10-31 第二十二轮审计)。⚠️ 原来只要「候选非空」就当正常,
2554
+ // 而候选是按 list() 的 updatedAt 筛出来的、跟"读不读得出来"无关 —— 所有会话的 inspect
2555
+ // 都抛错时 cost 会是 0,却带着新鲜 at 落盘,界面长期 ¥0.0000(被当成今天的定型值)。
2556
+ let okCount = 0;
2301
2557
  const worker = async () => {
2302
2558
  while (cursor < selected.length) {
2303
2559
  const sessionId = selected[cursor++];
@@ -2314,6 +2570,7 @@ async function buildTodayCost(ctx, config, reuse = null) {
2314
2570
  daily,
2315
2571
  sessionId,
2316
2572
  );
2573
+ okCount += 1;
2317
2574
  } catch {
2318
2575
  // 单个会话读不出来不影响总额
2319
2576
  }
@@ -2328,8 +2585,7 @@ async function buildTodayCost(ctx, config, reuse = null) {
2328
2585
  // 单线程下并发不会更快,但能让不同会话的解压机会**交错**,切会话时不必等前一个走完。
2329
2586
  // 真正的阻塞控制靠下面每会话解压之后的 setImmediate 让出。
2330
2587
  await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
2331
- const day = daily.get(todayKey);
2332
- // bySession 摊平成普通对象:快照要能 JSON 序列化,Map 存不了。
2588
+ const day = daily.get(todayKey); // bySession 摊平成普通对象:快照要能 JSON 序列化,Map 存不了。
2333
2589
  const bySession = {};
2334
2590
  if (day?.bySession != null) {
2335
2591
  for (const [id, rec] of day.bySession) bySession[id] = { cost: rec.cost, totalTokens: rec.totalTokens };
@@ -2341,7 +2597,10 @@ async function buildTodayCost(ctx, config, reuse = null) {
2341
2597
  sessions: day?.sessionIds?.size ?? 0,
2342
2598
  bySession,
2343
2599
  // 只给 computeAndStoreToday 用(决定要不要落盘),客户端不读这个字段。
2344
- suspiciousEmpty,
2600
+ // ⚠️ 除了「候选被筛空」,还要判「一个会话都没读成功」(2026-10-31 第二十二轮审计):
2601
+ // list() 正常、每个 inspect 都抛错时 candidates 非空、cost 却是 0 —— 那种 0 是假的,
2602
+ // 带着新鲜 at 落盘就会长期显示 ¥0.0000。
2603
+ suspiciousEmpty: suspiciousEmpty || (listedCount > 0 && okCount === 0),
2345
2604
  };
2346
2605
  }
2347
2606
 
@@ -2356,7 +2615,18 @@ async function computeAndStoreToday(ctx, config, state, reuse = null) {
2356
2615
  // 就永远修不回来 —— 两害相权,宁可不写。
2357
2616
  const snapshot = value.suspiciousEmpty ? { ...value, at: 0 } : { ...value, at: Date.now() };
2358
2617
  try {
2359
- if (!value.suspiciousEmpty) await writeTodaySnapshot(state.snapshotFile, snapshot);
2618
+ if (!value.suspiciousEmpty) {
2619
+ // ⚠️ 写今天这份之前,先把昨天那份结算进 balance-daily-settled.json
2620
+ // (2026-10-31 第二十二轮审计):结算原来只在 apply 的启动块里跑一次,
2621
+ // 桌面端常驻不重启、跨过北京时间 0 点后,昨天快照的收尾部分会被今天这份直接覆盖,
2622
+ // 统计里昨天就少一截。这里是"即将覆盖"的唯一位置,放这里天然只多一次小 JSON 读。
2623
+ try {
2624
+ await settleStaleTodaySnapshot(ctx);
2625
+ } catch {
2626
+ // 结算失败不影响今天的数字
2627
+ }
2628
+ await writeTodaySnapshot(state.snapshotFile, snapshot);
2629
+ }
2360
2630
  } catch {
2361
2631
  // 落盘失败:当作只有内存缓存,下次启动再算一遍
2362
2632
  }
@@ -2411,12 +2681,21 @@ async function resolveTodayCost(ctx, config, state, reuse = null) {
2411
2681
  }
2412
2682
  const snapshot = await readTodaySnapshot(state.snapshotFile);
2413
2683
  if (snapshot != null) {
2414
- state.cache.value = snapshot;
2415
- const age = snapshot.at > 0 ? now - snapshot.at : Number.POSITIVE_INFINITY;
2684
+ // ⚠️ 增量值(provisional)可能**比快照大**(快照是几十秒前算的),直接拿快照覆盖
2685
+ // 会让今日金额往回跳、而且带着 partial=false 让客户端不再补拉,锁死在旧值上
2686
+ // (2026-10-31 第二十二轮审计:实测 17.3 → 16.0)。取较大者,并强制 partial=true
2687
+ // 让客户端继续补拉、后台重算把真值补上。
2688
+ const prev = state.cache.value;
2689
+ const keepPrev = prev != null && prev.provisional === true
2690
+ && Number(prev.cost ?? 0) > Number(snapshot.cost ?? 0);
2691
+ const chosen = keepPrev ? prev : snapshot;
2692
+ const forcedPartial = keepPrev;
2693
+ state.cache.value = chosen;
2694
+ const age = chosen.at > 0 ? now - chosen.at : Number.POSITIVE_INFINITY;
2416
2695
  const fresh = age < state.staleMs;
2417
- state.cache.expiresAt = now + (fresh ? state.staleMs - age : 3000);
2418
- if (!fresh) scheduleTodayRecompute(ctx, config, state);
2419
- return { value: snapshot, partial: !fresh };
2696
+ state.cache.expiresAt = now + (fresh && !forcedPartial ? state.staleMs - age : 3000);
2697
+ if (!fresh || forcedPartial) scheduleTodayRecompute(ctx, config, state);
2698
+ return { value: chosen, partial: !fresh || forcedPartial };
2420
2699
  }
2421
2700
  }
2422
2701
  // 快照不可用 / 带了 reuse:现算。reuse 路径算完同样落盘,下一次冷启动照样秒开。
@@ -3145,6 +3424,13 @@ let statsInFlight = null;
3145
3424
  }
3146
3425
  if (wantUpdate) {
3147
3426
  if (req.method === "POST") {
3427
+ // ⚠️ 必须和设置写入同一道同源校验(2026-10-31 第二十二轮审计):
3428
+ // 这条分支会真的下载 npm 包并覆盖插件安装目录。少了校验,本机任意网页
3429
+ // 用一个 text/plain 的简单请求就能触发写盘(CSRF)。
3430
+ if (!isSameOriginJsonRequest(req)) {
3431
+ writeJson(res, 403, { ok: false, error: "forbidden" });
3432
+ return;
3433
+ }
3148
3434
  // POST → 下载 npm 上最新 tarball 覆盖本机安装(见 installLatestVersion)。
3149
3435
  // 下载 + 逐文件写盘期间 await 会让出事件循环,不会卡住余额请求。
3150
3436
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",