ccus-cli 0.2.16 → 0.2.18

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/README.md CHANGED
@@ -167,10 +167,11 @@ ccus aggregate serve --input-dir ./team-exports
167
167
  - 默认输出一个 `json` 数据包,里面同时包含 `rawEvents`、`weeklySummary`、`dailySummaries`
168
168
  - 导出文件内容为**紧凑 JSON**(无缩进),并默认 **gzip 压缩**后写成 `.json.gz`;gzip 与紧凑化都只是存储/展示层变化,解压后的字段集合与 `schemaVersion` 不变
169
169
  - 默认写 `.json.gz`;若用 `--out` 指定一个非 `.gz` 结尾的路径,则按明文 JSON 写出(不压缩)
170
- - 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `10`。顶层 `pricing` 记录本地价格目录版本、USD 币种和 `event-time-standard-api` 基准;`weeklySummary.apiEquivalentCost` 与 `dailySummaries[].apiEquivalentCost` 分别包含 `claude`、`codex`、`total` 的金额及定价覆盖度。v10 延续 v9 的 Codex token 口径:`inputTokens = max(0, input_tokens - cached_input_tokens)`,缓存输入由 `cacheReadInputTokens` 单列
170
+ - 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `11`。顶层 `pricing` 记录本地价格目录版本、USD 币种和 `event-time-standard-api` 基准;`weeklySummary.apiEquivalentCost` 与 `dailySummaries[].apiEquivalentCost` 分别包含 `claude`、`codex`、`total` 的金额及定价覆盖度。v10 延续 v9 的 Codex token 口径:`inputTokens = max(0, input_tokens - cached_input_tokens)`,缓存输入由 `cacheReadInputTokens` 单列
171
+ - v11 在有未定价请求的成本结果中增加 `unpricedModels: [{ model, requestCount }]`,按原始模型名累计请求数,模型缺失记为 `null`,无未定价请求时省略。该明细仅写入 JSON,不增加页面或 CSV 字段;v10 导出仍可读取,但没有模型明细。
171
172
  - 等效 API 成本按每次请求的模型、发生时间和 token 分类套用内置标准同步 API 价格。Claude 区分输入、输出、缓存读取及 5 分钟/1 小时缓存写入;Codex 区分净输入、缓存输入和输出。它不是 Claude/Codex 订阅账单,也不含税费、折扣、批处理、工具附加费或区域溢价
172
173
  - 未知模型不会丢弃:有部分请求可定价时金额是已知小计,页面显示“至少为”;全部请求均无法定价时金额为不可用。个人 dashboard 在顶部第五张卡显示 Claude 与 Codex 的合计成本,不显示来源分项。全部模型价格集中维护在 `src/lib/api-pricing-catalog.json`,价格目录随 ccus 发布,统计过程不联网;个人与多人 dashboard 的合计成本卡下链接独立 `pricing.html` 并在新页面打开,价格页只保留一套标题,Codex 排在 Claude 前并按模型版本从新到旧排列。当前 Claude 目录包含 Opus 4.7、Opus 4.8、Opus 5、Sonnet 5 和 Fable 5;Sonnet 5 按事件时间区分活动价与标准价
173
- - GPT-6 使用 `gpt-6-astra` 定价,兼容 `gpt-6` 别名及推理强度后缀。自 2026-09-03 起,每百万 token 输入 / 缓存读取 / 输出为 $10 / $1 / $50;输入与缓存读取合计超过 272K 时为 $20 / $2 / $75。来源:[OpenAI 模型定价](https://developers.openai.com/api/docs/models/gpt-6-astra)。价格目录版本为 `2026-09-14`,导出 schemaVersion 仍为 10
174
+ - GPT-6 使用 `gpt-6-astra` 定价,兼容 `gpt-6` 别名及推理强度后缀。自 2026-09-03 起,每百万 token 输入 / 缓存读取 / 输出为 $10 / $1 / $50;输入与缓存读取合计超过 272K 时为 $20 / $2 / $75。来源:[OpenAI 模型定价](https://developers.openai.com/api/docs/models/gpt-6-astra)。价格目录版本为 `2026-09-14`
174
175
  - `gpt-5.3-codex-spark` 自 2026-02-12 起按 `gpt-5.3-codex` 价格估算:每百万 token 输入 $1.75、输出 $14、缓存读取 $0.175。这是本项目采用的估算单价,并非 Spark 官方公开 API 定价。
175
176
  - 默认文件名会带 git email 的帐号名前缀和起止日期,例如:`alice_export_2026-05-26_to_2026-06-01.json.gz`
176
177
  - `userMessageCount` 来自 `~/.claude/projects/**/*.jsonl` 的非 meta `type:user` 事件
@@ -188,7 +189,7 @@ ccus aggregate serve --input-dir ./team-exports
188
189
  ## 多人汇总
189
190
 
190
191
  - 输入目录放很多通过 `ccus export` 导出的 bundle 文件,`.json.gz`(gzip 压缩)与明文 `.json` 都能识别,gzip 文件读取时自动解压
191
- - `aggregate` 接受 `schemaVersion: 6/7/8/9/10`。v6–v9 继续聚合原统计;存在 API 请求时成本为空并全部计入未定价,没有请求时成本为 0。旧版与 v10 或不同价格目录混用时,`pricingCatalogVersion` 按规则标记为 `mixed`
192
+ - `aggregate` 接受 `schemaVersion: 6/7/8/9/10/11`。v6–v9 继续聚合原统计;存在 API 请求时成本为空并全部计入未定价,没有请求时成本为 0。旧版与 v10 或不同价格目录混用时,`pricingCatalogVersion` 按规则标记为 `mixed`
192
193
  - 同一个人在多台电脑上各自导出 bundle 时会自动合并去重:去重以**天**为粒度,对每个「同人同天」取 `generatedAt` 最新的那份导出,避免同一台机器重复导出或周与周重叠造成翻倍;**周汇总不取整周单份,而是把按天去重后的各天数据上卷累加**,所以多台电脑在不同天产生的用量会正确合进同一周。usage(5h / 7d)从选中事件按真实时间戳重算,Claude+Codex 合并:peak 取两源 max、latest 两源相加
193
194
  - `ccus aggregate --input-dir DIR --out-dir DIR`
194
195
  - 输出三个文件:
@@ -208,6 +209,22 @@ ccus aggregate serve --input-dir ./team-exports
208
209
 
209
210
  ## 定时同步
210
211
 
212
+ session 内容已更新但文件修改时间仍旧,导致统计遗漏时,可手动修复:
213
+
214
+ ```bash
215
+ ccus sessions repair --dry-run # 预览全部待修复文件
216
+ ccus sessions repair # 修复全部类型
217
+ ccus sessions repair codex # 仅修复 Codex(含 Orca)
218
+ ccus sessions repair claude # 仅修复 Claude
219
+ ccus sessions repair codex --dry-run # 仅预览 Codex
220
+ ccus sessions repair codex --min-gap 3h # 至少落后 3 小时才修复
221
+ ccus sync # 重新统计并同步
222
+ ```
223
+
224
+ 预览和执行均按实际文件标识去重:Codex 与 Orca 的硬链接只显示、计数一次,修复一个路径会同时更新所有硬链接路径。普通独立副本仍分别处理。
225
+
226
+ 默认扫描 Claude projects、Codex 与 Orca sessions 的 JSONL;指定类型时只扫描对应目录。从尾部分块读取最后一条非空记录。默认仅当文件修改时间落后于记录的 `timestamp`,且两者按本机时区跨自然日时修复(不是必须相差 24 小时)。`--min-gap` 改为按实际时差判断,支持非负整数加 `m`(分钟)、`h`(小时)、`d`(24 小时);达到阈值即修复,`0m` 表示任何正差值。保留更晚的修改时间,不改写会话内容。末条 JSON 不完整、时间戳缺失或无效时跳过并列出原因。此命令不限制本周,处理所选类型的所有历史 session;日常统计的修改时间预筛保持不变。
227
+
211
228
  把每个人的 `ccus export` 攒到一个共享目录(团队网盘 / 共享盘)通常是手动的,容易漏。`ccus sync` 用来自动化这一步:周期到了就 `export` 一次,并把导出文件**复制**到目标目录下**按周命名的子目录**里。
212
229
 
213
230
  ```bash
package/dist/cli.js CHANGED
@@ -70,7 +70,7 @@ const api_equivalent_cost_1 = require("./lib/api-equivalent-cost");
70
70
  const version_1 = require("./lib/version");
71
71
  /** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
72
72
  function printHelp() {
73
- process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH] (默认装 Claude statusLine;--codex 改写 Codex 的 ~/.codex/config.toml notify,配 --uninstall 移除)\n ccus statusline emit [--data-dir PATH] [--input FILE] [--no-store]\n ccus dashboard build [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard open [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard serve [--range today|this-week|last-week|5h] [--port 0] [--host 127.0.0.1] [--open] [--data-dir PATH]\n ccus export [RANGE] [--out FILE] [--data-dir PATH] (RANGE: this-week|tw, last-week|lw, today, 5h; e.g. ccus export lw)\n ccus sessions [RANGE] [--out FILE] [--data-dir PATH] (把 Claude 与 Codex 的活跃 session 打包成 zip,默认本周)\n ccus aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n ccus sync [--data-dir PATH]\n ccus sync config [--target DIR] [--interval 3h|daily|<N>h|<N>m] [--range this-week] [--suffix NAME | --no-suffix] [--data-dir PATH]\n ccus sync install [--print] [--data-dir PATH] (注册每周五 18:00 的系统调度器)\n ccus sync uninstall [--print] (卸载系统调度器)\n ccus sync status [--data-dir PATH]\n ccus api config [--enable|--disable] [--provider zhipu|custom] [--token-env NAME] [--token VAL] [--url URL] [--project P] [--organization O] [--ttl 5m] [--extractor-file FILE] [--data-dir PATH]\n ccus api test [--data-dir PATH] (立即拉取第三方额度并打印,验证配置是否生效)\n ccus api status [--data-dir PATH]\n ccus open [--data-dir PATH] [--print]\n ccus update [--data-dir PATH]\n ccus --version\n\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
73
+ process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH] (默认装 Claude statusLine;--codex 改写 Codex 的 ~/.codex/config.toml notify,配 --uninstall 移除)\n ccus statusline emit [--data-dir PATH] [--input FILE] [--no-store]\n ccus dashboard build [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard open [--range today|this-week|last-week|5h] [--out FILE] [--data-dir PATH]\n ccus dashboard serve [--range today|this-week|last-week|5h] [--port 0] [--host 127.0.0.1] [--open] [--data-dir PATH]\n ccus export [RANGE] [--out FILE] [--data-dir PATH] (RANGE: this-week|tw, last-week|lw, today, 5h; e.g. ccus export lw)\n ccus sessions [RANGE] [--out FILE] [--data-dir PATH] (把 Claude 与 Codex 的活跃 session 打包成 zip,默认本周)\n ccus sessions repair [codex|claude] [--min-gap 3h] [--dry-run] (按末条记录时间修复 session 修改时间,默认仅跨天)\n ccus aggregate --input-dir DIR [--out-dir DIR]\n ccus aggregate serve --input-dir DIR [--port 0] [--host 127.0.0.1]\n ccus sync [--data-dir PATH]\n ccus sync config [--target DIR] [--interval 3h|daily|<N>h|<N>m] [--range this-week] [--suffix NAME | --no-suffix] [--data-dir PATH]\n ccus sync install [--print] [--data-dir PATH] (注册每周五 18:00 的系统调度器)\n ccus sync uninstall [--print] (卸载系统调度器)\n ccus sync status [--data-dir PATH]\n ccus api config [--enable|--disable] [--provider zhipu|custom] [--token-env NAME] [--token VAL] [--url URL] [--project P] [--organization O] [--ttl 5m] [--extractor-file FILE] [--data-dir PATH]\n ccus api test [--data-dir PATH] (立即拉取第三方额度并打印,验证配置是否生效)\n ccus api status [--data-dir PATH]\n ccus open [--data-dir PATH] [--print]\n ccus update [--data-dir PATH]\n ccus --version\n\nGlobal flags:\n --verbose | --debug | -v 输出详细调试日志到 stderr(等价于设置 CCUS_DEBUG=1),方便排查问题\n`);
74
74
  }
75
75
  /** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
76
76
  function parseOptions(args) {
@@ -428,7 +428,7 @@ async function runExport(options) {
428
428
  exportUserName = gitIdentity.userName;
429
429
  }
430
430
  const weeklySummary = {
431
- schemaVersion: 10,
431
+ schemaVersion: 11,
432
432
  generatedAt: new Date().toISOString(),
433
433
  range: {
434
434
  label: window.label,
@@ -524,7 +524,7 @@ async function runExport(options) {
524
524
  };
525
525
  });
526
526
  const bundle = {
527
- schemaVersion: 10,
527
+ schemaVersion: 11,
528
528
  generatedAt: new Date().toISOString(),
529
529
  range: {
530
530
  label: window.label,
@@ -743,9 +743,48 @@ async function handleSessions(options) {
743
743
  await fsNode.writeFile(outputPath, zipBuffer);
744
744
  process.stdout.write(`${outputPath}\n`);
745
745
  }
746
- /**
747
- * 解析 sessions 命令的参数,支持 RANGE 作为位置参数简写。
748
- */
746
+ /** 手动修复 session 修改时间,并报告逐文件结果。 */
747
+ async function handleRepairSessionMtimes(args) {
748
+ const startedAt = performance.now();
749
+ let source;
750
+ let dryRun = false;
751
+ let minGapMs;
752
+ for (let index = 0; index < args.length; index += 1) {
753
+ const arg = args[index];
754
+ if (arg === "--dry-run")
755
+ dryRun = true;
756
+ else if (arg === "--min-gap" && minGapMs === undefined) {
757
+ const match = /^(\d+)(m|h|d)$/.exec(args[++index] ?? "");
758
+ if (!match)
759
+ throw new Error("--min-gap 需要非负整数和单位 m/h/d,例如 30m、3h、1d;0m 表示任何正差值。");
760
+ minGapMs = Number(match[1]) * ({ m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2]]);
761
+ if (!Number.isSafeInteger(minGapMs))
762
+ throw new Error("--min-gap 数值过大。");
763
+ }
764
+ else if (["--verbose", "--debug", "-v"].includes(arg))
765
+ continue;
766
+ else if ((arg === "codex" || arg === "claude") && source === undefined)
767
+ source = arg;
768
+ else
769
+ throw new Error(`不支持的修复参数:${arg}。用法:ccus sessions repair [codex|claude] [--min-gap 3h] [--dry-run]`);
770
+ }
771
+ const { repairSessionMtimes } = await Promise.resolve().then(() => __importStar(require("./lib/session-mtime")));
772
+ const results = await repairSessionMtimes(dryRun, source, minGapMs);
773
+ for (const result of results) {
774
+ if (result.status === "repaired") {
775
+ process.stdout.write(`${dryRun ? "待修复" : "已修复"} ${result.filePath}: ${result.before.toISOString()} → ${result.after.toISOString()}\n`);
776
+ }
777
+ else if (result.status === "skipped" || result.status === "failed") {
778
+ process.stderr.write(`${result.status === "failed" ? "失败" : "跳过"} ${result.filePath}: ${result.reason}\n`);
779
+ }
780
+ }
781
+ const count = (status) => results.filter((result) => result.status === status).length;
782
+ process.stdout.write(`扫描 ${results.length},${dryRun ? "待修复" : "已修复"} ${count("repaired")},无需修复 ${count("unchanged")},跳过 ${count("skipped")},失败 ${count("failed")}\n`);
783
+ process.stdout.write(`耗时:${(0, sync_1.formatSyncElapsed)(performance.now() - startedAt)}\n`);
784
+ if (count("failed") > 0)
785
+ process.exitCode = 1;
786
+ }
787
+ /** 解析 sessions 命令的参数,支持 RANGE 作为位置参数简写。 */
749
788
  function resolveSessionsOptions(action, args, rest) {
750
789
  if (!action || action.startsWith("--")) {
751
790
  return parseOptions(args.slice(1));
@@ -1423,6 +1462,10 @@ async function main(args = process.argv.slice(2)) {
1423
1462
  await handleExport(exportOptions);
1424
1463
  return;
1425
1464
  }
1465
+ if (group === "sessions" && action === "repair") {
1466
+ await handleRepairSessionMtimes(rest);
1467
+ return;
1468
+ }
1426
1469
  if (group === "sessions") {
1427
1470
  const sessionsOptions = resolveSessionsOptions(action, args, rest);
1428
1471
  await handleSessions(sessionsOptions);
@@ -55,7 +55,12 @@ function hasApiEquivalentCostResultShape(value) {
55
55
  value.pricedApiRequestCount >= 0 &&
56
56
  typeof value.unpricedApiRequestCount === "number" &&
57
57
  Number.isInteger(value.unpricedApiRequestCount) &&
58
- value.unpricedApiRequestCount >= 0);
58
+ value.unpricedApiRequestCount >= 0 &&
59
+ (value.unpricedModels === undefined || (Array.isArray(value.unpricedModels) &&
60
+ value.unpricedModels.every((item) => isRecord(item) &&
61
+ (typeof item.model === "string" || item.model === null) &&
62
+ typeof item.requestCount === "number" && Number.isSafeInteger(item.requestCount) && item.requestCount > 0) &&
63
+ value.unpricedModels.reduce((sum, item) => sum + item.requestCount, 0) === value.unpricedApiRequestCount)));
59
64
  }
60
65
  function hasApiEquivalentCostBreakdownShape(value) {
61
66
  return (isRecord(value) &&
@@ -74,7 +79,7 @@ function isWeeklyExportBundle(value) {
74
79
  if (!isRecord(value)) {
75
80
  return false;
76
81
  }
77
- if (typeof value.schemaVersion !== "number" || ![6, 7, 8, 9, 10].includes(value.schemaVersion)) {
82
+ if (typeof value.schemaVersion !== "number" || ![6, 7, 8, 9, 10, 11].includes(value.schemaVersion)) {
78
83
  return false;
79
84
  }
80
85
  if (!Array.isArray(value.rawEvents) || !Array.isArray(value.dailySummaries) || !isRecord(value.weeklySummary) || !isRecord(value.identity) || !isRecord(value.range)) {
@@ -83,7 +88,7 @@ function isWeeklyExportBundle(value) {
83
88
  if (!hasWeeklyStatuslineShape(value.weeklySummary.statusline)) {
84
89
  return false;
85
90
  }
86
- if (value.schemaVersion !== 10) {
91
+ if (value.schemaVersion < 10) {
87
92
  return true;
88
93
  }
89
94
  return (hasPricingMetadataShape(value.pricing) &&
@@ -163,7 +168,7 @@ async function loadWeeklyExportBundles(inputDir) {
163
168
  }
164
169
  }
165
170
  if (invalidFiles.length > 0) {
166
- throw new Error(`Unsupported export bundle schema in files: ${invalidFiles.join(", ")}. Re-export with current ccus so aggregate receives schemaVersion 6/7/8/9/10 bundles.`);
171
+ throw new Error(`Unsupported export bundle schema in files: ${invalidFiles.join(", ")}. Re-export with current ccus so aggregate receives schemaVersion 6/7/8/9/10/11 bundles.`);
167
172
  }
168
173
  return bundles;
169
174
  }
@@ -563,7 +568,7 @@ function codexOf(day) {
563
568
  * 把代表日转换为成本贡献。v10 使用按请求计价后导出的日汇总成本;旧版无法补算,全部请求视为未定价。
564
569
  */
565
570
  function apiCostContribution(rep) {
566
- if (rep.bundle.schemaVersion === 10) {
571
+ if (rep.bundle.schemaVersion >= 10) {
567
572
  return {
568
573
  result: rep.day.apiEquivalentCost.total,
569
574
  catalogVersion: rep.bundle.pricing.catalogVersion,
@@ -162,7 +162,10 @@ function emptyApiEquivalentCost() {
162
162
  function priceApiRequest(request, catalog = exports.API_PRICING_CATALOG) {
163
163
  const price = findApiModelPrice(request, catalog);
164
164
  if (!price) {
165
- return { estimatedUsd: null, pricedApiRequestCount: 0, unpricedApiRequestCount: 1 };
165
+ return {
166
+ estimatedUsd: null, pricedApiRequestCount: 0, unpricedApiRequestCount: 1,
167
+ unpricedModels: [{ model: request.model, requestCount: 1 }],
168
+ };
166
169
  }
167
170
  return {
168
171
  estimatedUsd: computeRequestUsd(request, price),
@@ -175,7 +178,11 @@ function mergeApiEquivalentCosts(results) {
175
178
  let estimatedUsd = 0;
176
179
  let pricedApiRequestCount = 0;
177
180
  let unpricedApiRequestCount = 0;
181
+ const models = new Map();
178
182
  for (const result of results) {
183
+ for (const { model, requestCount } of result.unpricedModels ?? []) {
184
+ models.set(model, (models.get(model) ?? 0) + requestCount);
185
+ }
179
186
  pricedApiRequestCount += result.pricedApiRequestCount;
180
187
  unpricedApiRequestCount += result.unpricedApiRequestCount;
181
188
  if (result.estimatedUsd !== null) {
@@ -186,6 +193,7 @@ function mergeApiEquivalentCosts(results) {
186
193
  estimatedUsd: pricedApiRequestCount > 0 || unpricedApiRequestCount === 0 ? estimatedUsd : null,
187
194
  pricedApiRequestCount,
188
195
  unpricedApiRequestCount,
196
+ ...(models.size > 0 ? { unpricedModels: [...models].sort(([a], [b]) => a === b ? 0 : a === null ? -1 : b === null ? 1 : a < b ? -1 : 1).map(([model, requestCount]) => ({ model, requestCount })) } : {}),
189
197
  };
190
198
  }
191
199
  validatePricingCatalog(exports.API_PRICING_CATALOG);
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.collectProjectJsonlFiles = collectProjectJsonlFiles;
6
7
  exports.summarizeClaudeProjectUsage = summarizeClaudeProjectUsage;
7
8
  exports.summarizeClaudeProjectUsageCombined = summarizeClaudeProjectUsageCombined;
8
9
  exports.findActiveSessionFiles = findActiveSessionFiles;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.repairSessionMtimes = repairSessionMtimes;
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const claude_1 = require("./claude");
10
+ const paths_1 = require("./paths");
11
+ const time_1 = require("./time");
12
+ /** 从尾部按块取最后一条非空 JSON;异常末行不猜测时间。 */
13
+ async function readLastTimestamp(file, size) {
14
+ let position = size;
15
+ const chunks = [];
16
+ while (position > 0) {
17
+ const length = Math.min(position, 16 * 1024);
18
+ position -= length;
19
+ const buffer = Buffer.allocUnsafe(length);
20
+ const { bytesRead } = await file.read(buffer, 0, length, position);
21
+ if (bytesRead !== length)
22
+ return null;
23
+ let end = length;
24
+ if (chunks.length === 0) {
25
+ while (end > 0 && [9, 10, 13, 32].includes(buffer[end - 1]))
26
+ end -= 1;
27
+ if (end === 0)
28
+ continue;
29
+ }
30
+ const newline = buffer.lastIndexOf(10, end - 1);
31
+ chunks.push(buffer.subarray(newline + 1, end));
32
+ if (newline >= 0 || position === 0) {
33
+ try {
34
+ const record = JSON.parse(Buffer.concat(chunks.reverse()).toString("utf8"));
35
+ if (typeof record !== "object" || record === null || !("timestamp" in record)
36
+ || typeof record.timestamp !== "string")
37
+ return null;
38
+ const timestamp = new Date(record.timestamp).getTime();
39
+ return Number.isFinite(timestamp) ? timestamp : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ async function repairSessionMtimes(dryRun, source, minGapMs) {
49
+ const directories = [
50
+ ...(source !== "codex" ? [node_path_1.default.join((0, paths_1.getClaudeDataDir)(), "projects")] : []),
51
+ ...(source !== "claude" ? (0, paths_1.getCodexSessionHomes)().map((home) => node_path_1.default.join(home, "sessions")) : []),
52
+ ];
53
+ const files = [...new Set((await Promise.all(directories.map(claude_1.collectProjectJsonlFiles))).flat())];
54
+ const results = [];
55
+ const seenFiles = new Set();
56
+ for (const filePath of files) {
57
+ try {
58
+ const file = await promises_1.default.open(filePath, dryRun ? "r" : "r+");
59
+ try {
60
+ const before = await file.stat({ bigint: true });
61
+ const identity = before.ino === 0n ? `path:${filePath}` : `${before.dev}:${before.ino}`;
62
+ if (seenFiles.has(identity))
63
+ continue;
64
+ seenFiles.add(identity);
65
+ const timestamp = await readLastTimestamp(file, Number(before.size));
66
+ if (timestamp === null) {
67
+ results.push({ filePath, status: "skipped", reason: "末条记录无有效时间戳" });
68
+ continue;
69
+ }
70
+ const current = await file.stat({ bigint: true });
71
+ if (current.size !== before.size || current.mtimeMs !== before.mtimeMs) {
72
+ results.push({ filePath, status: "skipped", reason: "读取期间文件发生变化,请稍后重试" });
73
+ continue;
74
+ }
75
+ const after = new Date(timestamp);
76
+ const gap = timestamp - Number(current.mtimeMs);
77
+ const meetsThreshold = minGapMs === undefined
78
+ ? (0, time_1.localDateKey)(current.mtime) !== (0, time_1.localDateKey)(after)
79
+ : gap >= minGapMs;
80
+ if (gap <= 0 || !meetsThreshold) {
81
+ results.push({ filePath, status: "unchanged" });
82
+ continue;
83
+ }
84
+ if (!dryRun)
85
+ await file.utimes(before.atime, after);
86
+ results.push({ filePath, status: "repaired", before: current.mtime, after });
87
+ }
88
+ finally {
89
+ await file.close();
90
+ }
91
+ }
92
+ catch (error) {
93
+ results.push({ filePath, status: "failed", reason: error.message });
94
+ }
95
+ }
96
+ return results;
97
+ }
98
+ //# sourceMappingURL=session-mtime.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {