ccus-cli 0.1.26 → 0.2.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.
package/README.md CHANGED
@@ -162,7 +162,7 @@ ccus aggregate serve --input-dir ./team-exports
162
162
  - 默认输出一个 `json` 数据包,里面同时包含 `rawEvents`、`weeklySummary`、`dailySummaries`
163
163
  - 导出文件内容为**紧凑 JSON**(无缩进),并默认 **gzip 压缩**后写成 `.json.gz`;gzip 与紧凑化都只是存储/展示层变化,解压后的字段集合与 `schemaVersion` 不变
164
164
  - 默认写 `.json.gz`;若用 `--out` 指定一个非 `.gz` 结尾的路径,则按明文 JSON 写出(不压缩)
165
- - 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `6`,用于标识已使用 `fiveHourLatestUsagePct`、`fiveHourPeakUsagePct`、`sevenDayLatestUsagePct`、`sevenDayPeakUsagePct` 字段的新导出契约
165
+ - 当前导出 bundle / weeklySummary 的 `schemaVersion` 为 `8`:v7 起新增 `weeklySummary.codex` 与 `dailySummaries[].codex`(Codex CLI 的消息/请求/token 统计),v8 起该 codex 段再加 5h/7d 的 peak/latest 额度(从 `source="codex"` 事件重算、与 Claude 额度分开看);aggregate daily/weekly CSV 的主字段(消息/请求/token/额度)已把 Codex **叠加进 Claude 合计**(累加量相加、额度 peak 取两源 max、latest 两源相加、7d 累计含两源读数),不再单列 `codex*` 列,detail.csv 加 `source` 列区分来源
166
166
  - 默认文件名会带 git email 的帐号名前缀和起止日期,例如:`alice_export_2026-05-26_to_2026-06-01.json.gz`
167
167
  - `userMessageCount` 来自 `~/.claude/projects/**/*.jsonl` 的非 meta `type:user` 事件
168
168
  - `apiRequestCount` 与 token 指标来自 `~/.claude/projects/**/*.jsonl` 中带 `message.usage` 的 `type:assistant` 事件
@@ -172,15 +172,15 @@ ccus aggregate serve --input-dir ./team-exports
172
172
  ## 多人汇总
173
173
 
174
174
  - 输入目录放很多通过 `ccus export` 导出的 bundle 文件,`.json.gz`(gzip 压缩)与明文 `.json` 都能识别,gzip 文件读取时自动解压
175
- - `aggregate` 目前只接受 `schemaVersion: 6` 的 bundle;旧导出请先用当前版本重新 `ccus export`
176
- - 同一个人在多台电脑上各自导出 bundle 时会自动合并去重:去重以**天**为粒度,对每个「同人同天」取 `generatedAt` 最新的那份导出,避免同一台机器重复导出或周与周重叠造成翻倍;**周汇总不取整周单份,而是把按天去重后的各天数据上卷累加**,所以多台电脑在不同天产生的用量会正确合进同一周。usage(5h / 7d)从选中事件按真实时间戳重算(peak max、latest 取最新)
175
+ - `aggregate` 接受 `schemaVersion: 6/7/8` 的 bundle(v8 含 codex 额度,v7/v6 容错回退 null/零值);更旧的导出请先用当前版本重新 `ccus export`
176
+ - 同一个人在多台电脑上各自导出 bundle 时会自动合并去重:去重以**天**为粒度,对每个「同人同天」取 `generatedAt` 最新的那份导出,避免同一台机器重复导出或周与周重叠造成翻倍;**周汇总不取整周单份,而是把按天去重后的各天数据上卷累加**,所以多台电脑在不同天产生的用量会正确合进同一周。usage(5h / 7d)从选中事件按真实时间戳重算,Claude+Codex 合并:peak 取两源 max、latest 两源相加
177
177
  - `ccus aggregate --input-dir DIR --out-dir DIR`
178
178
  - 输出三个文件:
179
179
  - `detail.csv`:来自 winner bundle 的 `rawEvents`(同人同天只展开最新那份的事件),`contextUsedM` / `contextMaxM` 为单条事件的 context window token;另附带 `inputTokensM` / `outputTokensM` / `cacheReadInputTokensM`(按 `date` 取自当天 `dailySummaries` 的日总量,同一天多行会重复,不能按行求和)
180
180
  - `daily.csv`:同人同天取最新导出 bundle 的 `dailySummaries`,usage 从该 bundle 当天事件重算
181
181
  - `weekly.csv`:把同人同周的各天 winner(已按天去重)上卷累加得到,usage 从该周全部 winner 天的事件重算
182
182
  - `daily.csv` / `weekly.csv` 还各带一列 `sevenDayCumulativeUsagePct`:7 天额度的**累计真实使用量**,把锯齿波(涨到峰值→窗口重置归零→再涨)还原成实际消耗。计算分两层:先**去毛刺**(把持续短于 2 分钟的 stale 瞬时读数尖峰抹平,只保留真实持续的水平),再做**分段峰谷和**(按 reset 跌破段峰值一半切成上升段,每段累加「段内峰值−谷值」)。以百分比累加表达、可大于 100(用掉多于一个 7d 额度)。对 ±1 采样抖动和 stale 读数尖峰都鲁棒,不会被噪声虚增
183
- - 该列**绕开按天 winner**,对同一个人**所有机器、所有周**的 bundle `rawEvents` 先按时间合并去重成一条账号级曲线再算,绝不分机相加(同账号 7d 额度共享)
183
+ - 该列**绕开按天 winner**,对同一个人**所有机器、所有周**的 bundle `rawEvents`(含 Claude 与 Codex 两源 7d 读数,codex 事件不再过滤)先按时间合并去重成一条账号级曲线再算,绝不分机相加(同账号 7d 额度共享)
184
184
  - 区间内第一个有效样本无前值、不贡献增量,所以 **`daily.csv` 逐行相加只是对全局总量的近似**(跨天边界增量不计入单天);`weekly.csv` 在整周连续算、把跨天增量也计入,更连续,恒有 `weekly ≥ Σ 同周 daily`,想要更准的总量请看 weekly 这列
185
185
  - 无有效样本写空(`null`),有样本但无净增长写 `0`;`detail.csv` 不含此列(单事件行不承载区间累计语义)
186
186
  - CSV 里所有以 token 计的列(context 与 in/out/cache)都以百万(M)为单位(原始值除以 1,000,000),列名统一带 `M` 后缀;`contextWindowPct` 仍是百分比
@@ -255,6 +255,53 @@ ccus api config # 不带参数:打印当前配置
255
255
 
256
256
  > API 模式填充的 5h/7d 会和官方 `rate_limits` 走同一条聚合管线(含 7d 累计去毛刺算法,该算法针对 Claude 官方锯齿波设计);不同厂商的额度曲线语义可能不完全一致,混用聚合时注意。
257
257
 
258
+ ## Codex 额度采集(Codex CLI)
259
+
260
+ ccus 也能采集 Codex CLI 的 5 小时 / 周额度,复用同一条 statusline 落盘管线。Codex 没有 statusline hook,改用它的 **hooks.json `Stop` 事件**(每个 turn 结束触发)调起 ccus 的隐藏命令,再 spawn `codex app-server` 走 JSON-RPC `account/rateLimits/read` 拉额度。
261
+
262
+ **一键安装(推荐)**:
263
+
264
+ ```bash
265
+ ccus install --codex # 把 Stop hook 挂进 ~/.codex/hooks.json
266
+ ccus install --codex --data-dir <目录> # 让 Codex 事件落到指定数据目录
267
+ ccus install --codex --uninstall # 移除 ccus 的 Stop hook
268
+ ```
269
+
270
+ 装完**重启 Codex** 后会弹出 `hook need review` 审查提示,二选一通过即可:
271
+
272
+ - 选 `Trust all and continue`,直接信任全部 hook 并继续;
273
+ - 或选 `1 Review hooks`,在列表里找到 ccus 的 `Stop` hook,按 `t` 允许。
274
+
275
+ 也可启动 Codex 时加 `--dangerously-bypass-hook-trust` 跳过审查。
276
+
277
+ 或手动在 `~/.codex/hooks.json` 的 `Stop` 事件加一条 hook(与一键等价):
278
+
279
+ ```json
280
+ {
281
+ "hooks": {
282
+ "Stop": [
283
+ { "hooks": [ { "type": "command", "command": "ccus __codex-hook", "timeout": 60 } ] }
284
+ ]
285
+ }
286
+ }
287
+ ```
288
+
289
+ 之后每个 Codex turn 结束都会调起 `ccus __codex-hook`,把 hook payload(含 `cwd`、`session_id`,不含 usage)作为 stdin 的一个 JSON 对象传入。ccus 拉额度、落盘一条 `source="codex"` 的事件,**不写 stdout、不干预 Codex**(Stop 要求 stdout 空或 JSON,ccus 选空 + exit 0 = success;失败静默退出 0)。`ccus install --codex` 只往 `Stop` 追加一条 hook,保留其它事件 / 其它 hook / description / 格式;已是目标则提示已配置、不动文件。
290
+
291
+ > **为什么是 hooks.json 而不是 config.toml 的 notify?** Codex 原生也支持 `config.toml` 的 `notify`,但 orca 等会重写 `config.toml`(实测把 ccus 的 notify 顶成它自己的弹窗 notify,ccus 永远收不到回调)。hooks.json 不被频繁重写,是 hook-only 环境的持久触发入口;同一 `Stop` 事件下多条 hook 由 Codex 并发执行,ccus 与 orca 自带的 hook 互不干扰。
292
+
293
+ 要点:
294
+
295
+ - **只支持 Codex CLI**:hooks 与 `codex app-server` 都是 CLI 能力。Codex 桌面版 app(已并入 ChatGPT 桌面 app)不一定触发 CLI 的 hooks、也不一定随附可 spawn 的 `codex` 二进制,不在本期范围。
296
+ - **代理继承**:ccus 由 Codex 直接 spawn,已继承 Codex 的代理 / `CODEX_HOME`;它再 spawn `codex app-server` 时透传 `process.env`,整条链同环境。必须让 codex 子进程发请求(它读 `HTTP_PROXY`/`HTTPS_PROXY`),不能用 ccus 自己的 Node `fetch`(裸 fetch 不读代理、代理环境下直连失败)。
297
+ - **定时同步**:Stop hook 还兜底触发 `ccus sync`(与 Claude statusline 对称)——配过 `ccus sync config --target` 后,Codex 每 turn 结束都会检查 3h 周期,到期自动 export + 复制 bundle(含 Codex token/消息)到目标目录。只用 Codex、不开 Claude Code 时也能自动同步。
298
+ - **缓存节流**:Stop 每 turn 触发,额度按 5 分钟 TTL 缓存(`codex-quota-cache.json`),命中秒回不 spawn;过期才拉一次(带 ~10s 超时),失败回退旧缓存。
299
+ - **字段映射**:app-server 返回 `primary`(5h)/ `secondary`(weekly)两窗口,取各自的 `usedPercent`(驼峰,clamp 0–100);ccus 填进 `rate_limits.five_hour` / `seven_day` 的 `used_percentage`,`computeStatuslineEvent` 读时自动算出 usage。
300
+ - **token / 消息 / 额度都进 export / aggregate**:Codex 的 token、用户消息数、API 请求数从 `<CODEX_HOME>/sessions` 的 rollout 统计,进 `ccus export` 的 `weeklySummary.codex` / `dailySummaries[].codex` 段;Stop 落盘的额度快照(`source="codex"` 事件)也进 export/aggregate——export bundle 里 Codex 额度单列到 codex 段、与 Claude 分开看,aggregate daily/weekly CSV 则把 Codex **叠加进 Claude 主字段**合计(累加量相加、额度 peak 取两源 max、latest 两源相加、7d 累计含两源),不再单列 `codex*` 列;detail.csv 的 `source` 列区分来源。
301
+ - **Windows**:`install --codex` 在 Windows 写 `ccus.cmd __codex-hook`(npm 全局装会生成 `ccus.cmd`);Windows 上 Stop hook 偶发收到非法 JSON(已知 bug #23784),ccus 容错按无 payload 处理、仍照常拉额度 + 落盘。
302
+
303
+ > 该路径依赖 Codex 内部 app-server 协议(`account/rateLimits/read` 的返回结构)与 hooks payload schema,随版本变;解析层宽松,字段缺失返回 null、失败静默。
304
+
258
305
  ## 调试
259
306
 
260
307
  出问题时(比如 statusline 不出数据、导出/聚合结果不对),可以打开详细日志:
package/dist/cli.js CHANGED
@@ -38,12 +38,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.resolveExportOptions = resolveExportOptions;
41
+ exports.handleCodexHook = handleCodexHook;
42
+ exports.main = main;
41
43
  const node_http_1 = __importDefault(require("node:http"));
42
44
  const node_path_1 = __importDefault(require("node:path"));
43
45
  const dashboard_1 = require("./lib/dashboard");
44
46
  const dashboard_2 = require("./lib/dashboard");
45
47
  const aggregate_dashboard_1 = require("./lib/aggregate-dashboard");
46
48
  const claude_1 = require("./lib/claude");
49
+ const codex_sessions_1 = require("./lib/codex-sessions");
47
50
  const export_1 = require("./lib/export");
48
51
  const aggregate_1 = require("./lib/aggregate");
49
52
  const debug_1 = require("./lib/debug");
@@ -55,6 +58,8 @@ const payload_1 = require("./lib/payload");
55
58
  const paths_1 = require("./lib/paths");
56
59
  const scheduler_1 = require("./lib/scheduler");
57
60
  const api_mode_1 = require("./lib/api-mode");
61
+ const codex_fetcher_1 = require("./lib/codex-fetcher");
62
+ const codex_install_1 = require("./lib/codex-install");
58
63
  const sync_1 = require("./lib/sync");
59
64
  const storage_1 = require("./lib/storage");
60
65
  const time_1 = require("./lib/time");
@@ -62,7 +67,7 @@ const update_check_1 = require("./lib/update-check");
62
67
  const version_1 = require("./lib/version");
63
68
  /** CLI 帮助信息保持简洁,方便直接挂到 README 或终端里查看。 */
64
69
  function printHelp() {
65
- process.stdout.write(`ccus\n\nCommands:\n ccus install [--settings PATH] [--command CMD] [--data-dir PATH]\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/projects 本周活跃 session 打包成 zip,名如 projects_<dates>_<user>.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`);
70
+ 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/projects 本周活跃 session 打包成 zip,名如 projects_<dates>_<user>.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`);
66
71
  }
67
72
  /** 一个轻量的参数解析器,当前命令面不复杂,没必要引入额外依赖。 */
68
73
  function parseOptions(args) {
@@ -125,6 +130,9 @@ function buildDefaultStatuslineCommand(dataDir) {
125
130
  * 默认写 `~/.claude/settings.json`,只覆盖 statusLine 字段,其它设置原样保留。
126
131
  */
127
132
  async function handleInstall(options) {
133
+ if (getBooleanOption(options, "codex")) {
134
+ return handleCodexInstall(options);
135
+ }
128
136
  const settingsPath = node_path_1.default.resolve(getStringOption(options, "settings") ?? (0, paths_1.getClaudeSettingsPath)());
129
137
  const explicitCommand = getStringOption(options, "command");
130
138
  const dataDirOption = getStringOption(options, "data-dir");
@@ -140,6 +148,37 @@ async function handleInstall(options) {
140
148
  process.stdout.write(` replaced: ${result.previousCommand}\n`);
141
149
  }
142
150
  }
151
+ /**
152
+ * `ccus install --codex`:把 Stop hook 挂进 Codex 的 hooks.json,让 Codex 每 turn 结束调起 ccus 采集额度。
153
+ *
154
+ * 默认挂 `ccus __codex-hook`(Windows 用 `ccus.cmd`)到 `hooks.Stop`;带 `--data-dir` 时追加,让事件落到指定目录。
155
+ * `--codex --uninstall` 移除 ccus 的 Stop hook;`--config PATH` 覆盖 hooks.json 路径。
156
+ * hooks.json 是 orca 等 hook-only 环境的持久触发入口(config.toml 的 notify 会被 orca 覆盖,hooks.json 不会频繁重写)。
157
+ */
158
+ async function handleCodexInstall(options) {
159
+ const hooksPath = node_path_1.default.resolve(getStringOption(options, "config") ?? (0, paths_1.getCodexHooksPath)());
160
+ const dataDirOption = getStringOption(options, "data-dir");
161
+ const uninstall = getBooleanOption(options, "uninstall");
162
+ const baseArgs = dataDirOption
163
+ ? `__codex-hook --data-dir ${node_path_1.default.resolve(dataDirOption).replaceAll("\\", "/")}`
164
+ : `__codex-hook`;
165
+ const command = process.platform === "win32" ? `ccus.cmd ${baseArgs}` : `ccus ${baseArgs}`;
166
+ if (uninstall) {
167
+ const result = await (0, codex_install_1.uninstallCodexHook)(hooksPath);
168
+ process.stdout.write(result.removed
169
+ ? `已从 Codex hooks.json 移除 Stop hook:${result.hooksPath}\n`
170
+ : `Codex hooks.json 里未找到 ccus Stop hook:${result.hooksPath}\n`);
171
+ return;
172
+ }
173
+ const result = await (0, codex_install_1.installCodexHook)(hooksPath, command);
174
+ const header = result.created
175
+ ? `已创建 Codex hooks.json 并挂载 Stop hook:${result.hooksPath}`
176
+ : result.unchanged
177
+ ? `Codex Stop hook 已挂载:${result.hooksPath}`
178
+ : `已在 Codex hooks.json 挂载 Stop hook:${result.hooksPath}`;
179
+ process.stdout.write(`${header}\n Stop → ${JSON.stringify(command)}\n`);
180
+ process.stdout.write(` 首次需在 Codex 用 /hooks 信任该 hook;若 hooks.json 被 orca 等外部工具覆盖,重新执行本命令即可。\n`);
181
+ }
143
182
  /**
144
183
  * statusline 主路径:读 payload、归一化、落盘、输出单行状态文本。
145
184
  *
@@ -218,11 +257,16 @@ async function loadDashboardData(options, defaultRange) {
218
257
  // this-week 固定补齐到完整一周(周一到周日),即使本周还没过完,曲线 x 轴也按 7 天逐日展示;
219
258
  // 其它范围(today / last-week / 5h)不受影响。
220
259
  const window = (0, time_1.expandToFullWeekWindow)((0, time_1.resolveRange)(range, now));
221
- const events = (await (0, storage_1.readEventsForRange)(dataDir, range, now)).map((record) => (0, payload_1.computeStatuslineEvent)(record));
260
+ // events Claude Codex:个人看板额度叠加 Codex(peak max、latest 相加、7d 累计合并),与 aggregate 同口径。
261
+ // codex 消息柱图走 dailyUserMessages,不受影响。
262
+ const events = (await (0, storage_1.readEventsForRange)(dataDir, range, now))
263
+ .map((record) => (0, payload_1.computeStatuslineEvent)(record));
222
264
  const claudeDailyUsage = await (0, claude_1.summarizeClaudeProjectUsageByDay)(window.start, window.end);
265
+ const codexDailyUsage = await (0, codex_sessions_1.summarizeCodexSessionUsageByDay)(window.start, window.end);
223
266
  const dailyUserMessages = (0, time_1.enumerateDateKeys)(window.start, window.end).map((date) => ({
224
267
  date,
225
268
  userMessageCount: claudeDailyUsage.get(date)?.userMessageCount ?? 0,
269
+ codexUserMessageCount: codexDailyUsage.get(date)?.userMessageCount ?? 0,
226
270
  }));
227
271
  (0, debug_1.debugLog)("dashboard", "events loaded", { range, label: window.label, sampleCount: events.length, days: dailyUserMessages.length });
228
272
  const html = (0, dashboard_1.buildDashboardHtml)(events, window.label, window.start, window.end, dailyUserMessages);
@@ -323,10 +367,17 @@ async function runExport(options) {
323
367
  (0, debug_1.debugLog)("export", "range resolved", { range, label: window.label, start: window.start.toISOString(), end: window.end.toISOString() });
324
368
  const records = await (0, storage_1.readEventsForRange)(dataDir, range, now);
325
369
  const events = records.map((record) => (0, payload_1.computeStatuslineEvent)(record));
326
- const statuslineSummary = (0, dashboard_2.summarizeEvents)(events);
327
- const statuslineDailyRows = (0, export_1.buildSummaryRows)(events);
370
+ // source 分流:Claude usage 只算 claude 事件,Codex 额度单列(避免 codex 额度污染 claude usage)。
371
+ const claudeEvents = events.filter((event) => !(0, payload_1.isCodexSourceEvent)(event));
372
+ const codexEvents = events.filter(payload_1.isCodexSourceEvent);
373
+ const statuslineSummary = (0, dashboard_2.summarizeEvents)(claudeEvents);
374
+ const statuslineDailyRows = (0, export_1.buildSummaryRows)(claudeEvents);
375
+ const codexStatuslineSummary = (0, dashboard_2.summarizeEvents)(codexEvents);
376
+ const codexStatuslineDailyRows = (0, export_1.buildSummaryRows)(codexEvents);
328
377
  const claudeUsage = await (0, claude_1.summarizeClaudeProjectUsage)(window.start, window.end);
329
378
  const claudeDailyUsage = await (0, claude_1.summarizeClaudeProjectUsageByDay)(window.start, window.end);
379
+ const codexUsage = await (0, codex_sessions_1.summarizeCodexSessionUsage)(window.start, window.end);
380
+ const codexDailyUsage = await (0, codex_sessions_1.summarizeCodexSessionUsageByDay)(window.start, window.end);
330
381
  (0, debug_1.debugLog)("export", "data collected", {
331
382
  statuslineSamples: records.length,
332
383
  claudeProjectFiles: claudeUsage.matchedFileCount,
@@ -344,7 +395,7 @@ async function runExport(options) {
344
395
  exportUserName = gitIdentity.userName;
345
396
  }
346
397
  const weeklySummary = {
347
- schemaVersion: 6,
398
+ schemaVersion: 8,
348
399
  generatedAt: new Date().toISOString(),
349
400
  range: {
350
401
  label: window.label,
@@ -364,6 +415,17 @@ async function runExport(options) {
364
415
  outputTokens: claudeUsage.outputTokens,
365
416
  cacheReadInputTokens: claudeUsage.cacheReadInputTokens,
366
417
  },
418
+ codex: {
419
+ userMessageCount: codexUsage.userMessageCount,
420
+ apiRequestCount: codexUsage.apiRequestCount,
421
+ inputTokens: codexUsage.inputTokens,
422
+ outputTokens: codexUsage.outputTokens,
423
+ cacheReadInputTokens: codexUsage.cacheReadInputTokens,
424
+ fiveHourPeakUsagePct: codexStatuslineSummary.fiveHourPeakUsagePct,
425
+ fiveHourLatestUsagePct: codexStatuslineSummary.fiveHourLatestUsagePct,
426
+ sevenDayPeakUsagePct: codexStatuslineSummary.sevenDayPeakUsagePct,
427
+ sevenDayLatestUsagePct: codexStatuslineSummary.sevenDayLatestUsagePct,
428
+ },
367
429
  statusline: {
368
430
  sampleCount: statuslineSummary.sampleCount,
369
431
  uniqueSessions: statuslineSummary.uniqueSessions,
@@ -383,9 +445,12 @@ async function runExport(options) {
383
445
  },
384
446
  };
385
447
  const statuslineDailyMap = new Map(statuslineDailyRows.map((row) => [row.date, row]));
448
+ const codexStatuslineDailyMap = new Map(codexStatuslineDailyRows.map((row) => [row.date, row]));
386
449
  const dailySummaries = (0, time_1.enumerateDateKeys)(window.start, window.end).map((date) => {
387
450
  const row = statuslineDailyMap.get(date);
388
451
  const claudeDay = claudeDailyUsage.get(date);
452
+ const codexDay = codexDailyUsage.get(date);
453
+ const codexRow = codexStatuslineDailyMap.get(date);
389
454
  return {
390
455
  date,
391
456
  userMessageCount: claudeDay?.userMessageCount ?? 0,
@@ -393,6 +458,17 @@ async function runExport(options) {
393
458
  inputTokens: claudeDay?.inputTokens ?? 0,
394
459
  outputTokens: claudeDay?.outputTokens ?? 0,
395
460
  cacheReadInputTokens: claudeDay?.cacheReadInputTokens ?? 0,
461
+ codex: {
462
+ userMessageCount: codexDay?.userMessageCount ?? 0,
463
+ apiRequestCount: codexDay?.apiRequestCount ?? 0,
464
+ inputTokens: codexDay?.inputTokens ?? 0,
465
+ outputTokens: codexDay?.outputTokens ?? 0,
466
+ cacheReadInputTokens: codexDay?.cacheReadInputTokens ?? 0,
467
+ fiveHourPeakUsagePct: codexRow?.fiveHourPeakUsagePct ?? null,
468
+ fiveHourLatestUsagePct: codexRow?.fiveHourLatestUsagePct ?? null,
469
+ sevenDayPeakUsagePct: codexRow?.sevenDayPeakUsagePct ?? null,
470
+ sevenDayLatestUsagePct: codexRow?.sevenDayLatestUsagePct ?? null,
471
+ },
396
472
  sampleCount: row?.sampleCount ?? 0,
397
473
  fiveHourLatestUsagePct: row?.fiveHourLatestUsagePct ?? null,
398
474
  fiveHourPeakUsagePct: row?.fiveHourPeakUsagePct ?? null,
@@ -403,7 +479,7 @@ async function runExport(options) {
403
479
  };
404
480
  });
405
481
  const bundle = {
406
- schemaVersion: 6,
482
+ schemaVersion: 8,
407
483
  generatedAt: new Date().toISOString(),
408
484
  range: {
409
485
  label: window.label,
@@ -836,6 +912,127 @@ async function handleBackgroundSync(options) {
836
912
  (0, debug_1.debugLog)("sync", "background sync failed", error instanceof Error ? (error.stack ?? error.message) : String(error));
837
913
  }
838
914
  }
915
+ /**
916
+ * Codex 回调(notify / hook)共享的采集入口:拉额度(走缓存)→ 构造 source=codex 事件落盘 → 心跳 → 兜底 sync。
917
+ *
918
+ * 全程不写 stdout(Codex 继承的 stdio 写了会污染终端);失败 debugLog 到 stderr 后静默。
919
+ * 拉不到额度(未安装 / 未登录 / 无缓存)时不落空事件,静默返回。
920
+ */
921
+ async function recordCodexEvent(dataDir, identity) {
922
+ (0, debug_1.debugLog)("codex-event", "start", { dataDir, cwd: identity.cwd, sessionId: identity.sessionId });
923
+ try {
924
+ const quota = await (0, codex_fetcher_1.resolveCodexQuota)(dataDir);
925
+ if (quota === null) {
926
+ (0, debug_1.debugLog)("codex-event", "no quota available, skip persist");
927
+ return;
928
+ }
929
+ const payload = { source: "codex" };
930
+ if (identity.sessionId) {
931
+ payload.session_id = identity.sessionId;
932
+ }
933
+ if (identity.cwd) {
934
+ payload.workspace = { current_dir: identity.cwd };
935
+ }
936
+ const record = (0, payload_1.createPersistedStatuslineEvent)(payload);
937
+ const gitIdentity = await (0, git_1.readGitIdentity)();
938
+ record.gitUserName = gitIdentity.userName;
939
+ record.gitUserEmail = gitIdentity.userEmail;
940
+ record.gitUserAccount = (0, time_1.extractGitEmailAccount)(gitIdentity.userEmail);
941
+ (0, api_mode_1.applyQuotaToPayload)(record.rawPayload, { fiveHour: quota.fiveHour, sevenDay: quota.sevenDay, level: null });
942
+ await (0, storage_1.appendEvent)(dataDir, record);
943
+ (0, debug_1.debugLog)("codex-event", "persisted", { fiveHour: quota.fiveHour, sevenDay: quota.sevenDay });
944
+ }
945
+ catch (error) {
946
+ (0, debug_1.debugLog)("codex-event", "failed", error instanceof Error ? (error.stack ?? error.message) : String(error));
947
+ }
948
+ finally {
949
+ // Codex 继承的 stdio 写 stdout/stderr 会污染终端;改写心跳文件留痕,证明回调被触发(notify 与 hook 共用)。
950
+ try {
951
+ const fsp = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
952
+ await fsp.writeFile(node_path_1.default.join(dataDir, "codex-notify.heartbeat"), `${new Date().toISOString()}\n`, "utf8");
953
+ }
954
+ catch {
955
+ // 心跳写失败静默,不影响主流程。
956
+ }
957
+ // Codex 没有 statusline,回调兜底触发定时同步(与 Claude statusline 的 maybeSpawnBackgroundSync 对称):
958
+ // 每 turn 检查一次 sync 周期,到期才 spawn detached __sync,不阻塞、不写 stdout。
959
+ (0, sync_1.maybeSpawnBackgroundSync)(dataDir);
960
+ }
961
+ }
962
+ /**
963
+ * 隐藏命令 `__codex-notify`:Codex `notify`(config.toml,agent-turn-complete)回调入口。
964
+ *
965
+ * Codex 每 turn 结束 spawn 本命令,把 notify JSON 作为最后一个 argv 追加(含 cwd / thread-id)。
966
+ * 从 argv 解析身份 → recordCodexEvent。orca 等会覆盖 config.toml 的环境改用 `__codex-hook`(hooks.json)。
967
+ */
968
+ async function handleCodexNotify(args) {
969
+ const options = parseOptions(args);
970
+ const dataDir = getDataDir(options);
971
+ const notify = parseCodexNotify(extractCodexNotifyJson(args));
972
+ await recordCodexEvent(dataDir, { cwd: notify.cwd, sessionId: notify.threadId });
973
+ }
974
+ /**
975
+ * 隐藏命令 `__codex-hook`:Codex hooks.json 的 Stop 事件回调入口(orca 等 hook-only 环境用)。
976
+ *
977
+ * Codex 把 hook payload 作为 stdin 的一个 JSON 对象传入(含 cwd / session_id / hook_event_name)。
978
+ * 解析身份 → recordCodexEvent。不写 stdout(Stop 要求 stdout 空或 JSON;ccus 选空 + exit 0 = success,不干预 Codex)。
979
+ * stdin 非法 JSON(Windows Stop 偶发 #23784)容错:当无 payload 处理,仍照常拉额度 + 落盘。
980
+ */
981
+ async function handleCodexHook(args, deps = {}) {
982
+ const options = parseOptions(args);
983
+ const dataDir = getDataDir(options);
984
+ const read = deps.readStdin ?? io_1.readStdin;
985
+ let cwd = null;
986
+ let sessionId = null;
987
+ try {
988
+ const raw = await read();
989
+ if (raw.trim()) {
990
+ const parsed = JSON.parse(raw);
991
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
992
+ const record = parsed;
993
+ if (typeof record["cwd"] === "string") {
994
+ cwd = record["cwd"];
995
+ }
996
+ if (typeof record["session_id"] === "string") {
997
+ sessionId = record["session_id"];
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+ catch (error) {
1003
+ (0, debug_1.debugLog)("codex-hook", "stdin parse failed", error instanceof Error ? error.message : String(error));
1004
+ }
1005
+ await recordCodexEvent(dataDir, { cwd, sessionId });
1006
+ }
1007
+ /** Codex 把 notify JSON 作为最后一个 argv 追加;取末尾首个以 `{` 开头的参数。 */
1008
+ function extractCodexNotifyJson(args) {
1009
+ for (let index = args.length - 1; index >= 0; index -= 1) {
1010
+ if (args[index].trim().startsWith("{")) {
1011
+ return args[index];
1012
+ }
1013
+ }
1014
+ return null;
1015
+ }
1016
+ /** 宽松解析 notify JSON:cwd + thread-id(兼容 threadId 驼峰);非法返回空。 */
1017
+ function parseCodexNotify(json) {
1018
+ if (!json) {
1019
+ return { cwd: null, threadId: null };
1020
+ }
1021
+ try {
1022
+ const parsed = JSON.parse(json);
1023
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1024
+ return { cwd: null, threadId: null };
1025
+ }
1026
+ const record = parsed;
1027
+ const cwd = typeof record["cwd"] === "string" ? record["cwd"] : null;
1028
+ const rawThreadId = record["thread-id"] ?? record["threadId"];
1029
+ const threadId = typeof rawThreadId === "string" ? rawThreadId : null;
1030
+ return { cwd, threadId };
1031
+ }
1032
+ catch {
1033
+ return { cwd: null, threadId: null };
1034
+ }
1035
+ }
839
1036
  /** 解析时长字面量为毫秒:纯数字按 ms,支持 ms/s/m/h 后缀。非法返回 null。 */
840
1037
  function parseDurationMs(raw) {
841
1038
  if (raw === undefined) {
@@ -1136,6 +1333,14 @@ async function main(args = process.argv.slice(2)) {
1136
1333
  await handleBackgroundSync(parseOptions(args.slice(1)));
1137
1334
  return;
1138
1335
  }
1336
+ if (group === "__codex-notify") {
1337
+ await handleCodexNotify(args.slice(1));
1338
+ return;
1339
+ }
1340
+ if (group === "__codex-hook") {
1341
+ await handleCodexHook(args.slice(1));
1342
+ return;
1343
+ }
1139
1344
  if (group === "install") {
1140
1345
  await handleInstall(parseOptions(args.slice(1)));
1141
1346
  return;
@@ -297,7 +297,7 @@ function renderFiveHourUsageChart(people, detailRows) {
297
297
  const built = people
298
298
  .map((person, index) => {
299
299
  const personRows = detailRows
300
- .filter((row) => row.personKey === person.personKey)
300
+ .filter((row) => row.personKey === person.personKey && row.source !== "codex")
301
301
  .map((row) => ({ t: new Date(row.timestamp).getTime(), five: row.usagePct, seven: row.sevenDayUsagePct }))
302
302
  .filter((point) => Number.isFinite(point.t))
303
303
  .sort((left, right) => left.t - right.t);
@@ -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.addNullable = addNullable;
6
7
  exports.loadWeeklyExportBundles = loadWeeklyExportBundles;
7
8
  exports.computeCumulativeSevenDayCurve = computeCumulativeSevenDayCurve;
8
9
  exports.computeCumulativeSevenDay = computeCumulativeSevenDay;
@@ -23,6 +24,12 @@ function maxOrNull(values) {
23
24
  const numbers = values.filter((v) => v !== null);
24
25
  return numbers.length > 0 ? Math.max(...numbers) : null;
25
26
  }
27
+ /** 两个可空数值相加:null 视为 0;两者皆 null 返回 null(表示两源都无该指标)。用于 codex+claude latest 叠加。 */
28
+ function addNullable(a, b) {
29
+ if (a === null && b === null)
30
+ return null;
31
+ return (a ?? 0) + (b ?? 0);
32
+ }
26
33
  function isRecord(value) {
27
34
  return typeof value === "object" && value !== null && !Array.isArray(value);
28
35
  }
@@ -43,7 +50,7 @@ function isWeeklyExportBundle(value) {
43
50
  if (!isRecord(value)) {
44
51
  return false;
45
52
  }
46
- if (value.schemaVersion !== 6) {
53
+ if (typeof value.schemaVersion !== "number" || ![6, 7, 8].includes(value.schemaVersion)) {
47
54
  return false;
48
55
  }
49
56
  if (!Array.isArray(value.rawEvents) || !Array.isArray(value.dailySummaries) || !isRecord(value.weeklySummary) || !isRecord(value.identity) || !isRecord(value.range)) {
@@ -124,7 +131,7 @@ async function loadWeeklyExportBundles(inputDir) {
124
131
  }
125
132
  }
126
133
  if (invalidFiles.length > 0) {
127
- throw new Error(`Unsupported export bundle schema in files: ${invalidFiles.join(", ")}. Re-export with current ccus so aggregate receives schemaVersion 6 bundles.`);
134
+ throw new Error(`Unsupported export bundle schema in files: ${invalidFiles.join(", ")}. Re-export with current ccus so aggregate receives schemaVersion 6/7/8 bundles.`);
128
135
  }
129
136
  return bundles;
130
137
  }
@@ -419,7 +426,8 @@ function buildPersonSevenDayCurve(bundles) {
419
426
  const collected = new Map();
420
427
  for (const { bundle } of bundles) {
421
428
  const personKey = bundlePersonKey(bundle);
422
- for (const record of bundle.rawEvents.filter(isPersistedStatuslineEvent)) {
429
+ // Claude + Codex 两源读数:codex 7d 读数不再过滤,并入同一条曲线算累计
430
+ for (const record of bundle.rawEvents.filter((r) => isPersistedStatuslineEvent(r))) {
423
431
  const event = (0, payload_1.computeStatuslineEvent)(record);
424
432
  if (event.sevenDayUsagePct === null) {
425
433
  continue;
@@ -468,20 +476,28 @@ function buildAggregatedDetailRows(bundles) {
468
476
  for (const rep of reps) {
469
477
  const events = bundleEventsByDate(rep.bundle).get(rep.date) ?? [];
470
478
  for (const event of events) {
479
+ const isCodex = (0, payload_1.isCodexSourceEvent)(event);
471
480
  rows.push({
472
481
  ...event,
473
482
  personKey: rep.personKey,
474
483
  weekKey: weekKey(new Date(event.timestamp)),
475
484
  dateKey: rep.date,
476
- inputTokens: rep.day.inputTokens,
477
- outputTokens: rep.day.outputTokens,
478
- cacheReadInputTokens: rep.day.cacheReadInputTokens,
485
+ source: isCodex ? "codex" : "claude",
486
+ // codex 事件无单事件 token 语义(token 在 daySummary.codex 按天聚合),不附 claude 的日总量。
487
+ inputTokens: isCodex ? 0 : rep.day.inputTokens,
488
+ outputTokens: isCodex ? 0 : rep.day.outputTokens,
489
+ cacheReadInputTokens: isCodex ? 0 : rep.day.cacheReadInputTokens,
479
490
  });
480
491
  }
481
492
  }
482
493
  }
483
494
  return rows.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
484
495
  }
496
+ const ZERO_CODEX = { userMessageCount: 0, apiRequestCount: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, fiveHourPeakUsagePct: null, fiveHourLatestUsagePct: null, sevenDayPeakUsagePct: null, sevenDayLatestUsagePct: null };
497
+ /** 取 daySummary 的 codex 快照;缺字段(手编 bundle)回退零值,避免 reduce 抛错或 NaN。 */
498
+ function codexOf(day) {
499
+ return day.codex ?? ZERO_CODEX;
500
+ }
485
501
  /**
486
502
  * 展开 daily.csv:同人同天的不同机器数据直接叠加(计数字段相加),usage 从所有机器该天事件合并后重算。
487
503
  * 同机器重复导出由 selectDailyRepresentatives 在分组阶段去重,不会翻倍。
@@ -493,21 +509,23 @@ function buildAggregatedDailyRows(bundles) {
493
509
  for (const reps of repsMap.values()) {
494
510
  const { personKey, date } = reps[0];
495
511
  // 不同机器的独立数据直接叠加
496
- const userMessageCount = reps.reduce((sum, r) => sum + r.day.userMessageCount, 0);
497
- const apiRequestCount = reps.reduce((sum, r) => sum + r.day.apiRequestCount, 0);
498
- const inputTokens = reps.reduce((sum, r) => sum + r.day.inputTokens, 0);
499
- const outputTokens = reps.reduce((sum, r) => sum + r.day.outputTokens, 0);
500
- const cacheReadInputTokens = reps.reduce((sum, r) => sum + r.day.cacheReadInputTokens, 0);
512
+ // 累加量含 Codex:Claude + Codex 同字段相加
513
+ const userMessageCount = reps.reduce((sum, r) => sum + r.day.userMessageCount + codexOf(r.day).userMessageCount, 0);
514
+ const apiRequestCount = reps.reduce((sum, r) => sum + r.day.apiRequestCount + codexOf(r.day).apiRequestCount, 0);
515
+ const inputTokens = reps.reduce((sum, r) => sum + r.day.inputTokens + codexOf(r.day).inputTokens, 0);
516
+ const outputTokens = reps.reduce((sum, r) => sum + r.day.outputTokens + codexOf(r.day).outputTokens, 0);
517
+ const cacheReadInputTokens = reps.reduce((sum, r) => sum + r.day.cacheReadInputTokens + codexOf(r.day).cacheReadInputTokens, 0);
501
518
  const sampleCount = reps.reduce((sum, r) => sum + r.day.sampleCount, 0);
502
519
  const uniqueSessions = reps.reduce((sum, r) => sum + r.day.uniqueSessions, 0);
503
520
  const uniqueWorkspaces = reps.reduce((sum, r) => sum + r.day.uniqueWorkspaces, 0);
504
- // usage 从所有机器该天事件合并后重算;rawEvents 缺失时用各代表 daySummary 回退
521
+ // 按 source 分流:Claude usage 只算 claude 事件,Codex usage 单列重算(避免 codex 额度污染 claude usage)。
505
522
  const allEvents = reps.flatMap((r) => bundleEventsByDate(r.bundle).get(date) ?? []);
506
- const usage = recomputeUsage(allEvents);
507
- const fiveHourPeakFallback = maxOrNull(reps.map((r) => r.day.fiveHourPeakUsagePct));
508
- const sevenDayPeakFallback = maxOrNull(reps.map((r) => r.day.sevenDayPeakUsagePct));
509
- const fiveHourLatestFallback = reps.find((r) => r.day.fiveHourLatestUsagePct !== null)?.day.fiveHourLatestUsagePct ?? null;
510
- const sevenDayLatestFallback = reps.find((r) => r.day.sevenDayLatestUsagePct !== null)?.day.sevenDayLatestUsagePct ?? null;
523
+ const claudeEvents = allEvents.filter((event) => !(0, payload_1.isCodexSourceEvent)(event));
524
+ const codexEvents = allEvents.filter(payload_1.isCodexSourceEvent);
525
+ const usage = recomputeUsage(claudeEvents);
526
+ const codexUsage = recomputeUsage(codexEvents);
527
+ // 额度回退:rawEvents 缺时从 daySummary 取(Claude+Codex 合并,见 fallbackWeeklyUsage)
528
+ const fallback = fallbackWeeklyUsage(reps.map((r) => r.day));
511
529
  // 累计指标走全样本合并曲线,不走单机的 recomputeUsage,避免漏掉另一台机器的样本。
512
530
  const sevenDayCumulativeUsagePct = computeCumulativeSevenDay(sliceCurveByDate(curves.get(personKey) ?? [], date));
513
531
  rows.push({
@@ -519,10 +537,10 @@ function buildAggregatedDailyRows(bundles) {
519
537
  outputTokens,
520
538
  cacheReadInputTokens,
521
539
  sampleCount,
522
- fiveHourPeakUsagePct: usage.fiveHourPeakUsagePct ?? fiveHourPeakFallback,
523
- fiveHourLatestUsagePct: usage.fiveHourLatestUsagePct ?? fiveHourLatestFallback,
524
- sevenDayPeakUsagePct: usage.sevenDayPeakUsagePct ?? sevenDayPeakFallback,
525
- sevenDayLatestUsagePct: usage.sevenDayLatestUsagePct ?? sevenDayLatestFallback,
540
+ fiveHourPeakUsagePct: maxOrNull([usage.fiveHourPeakUsagePct, codexUsage.fiveHourPeakUsagePct]) ?? fallback.fiveHourPeakUsagePct,
541
+ fiveHourLatestUsagePct: addNullable(usage.fiveHourLatestUsagePct, codexUsage.fiveHourLatestUsagePct) ?? fallback.fiveHourLatestUsagePct,
542
+ sevenDayPeakUsagePct: maxOrNull([usage.sevenDayPeakUsagePct, codexUsage.sevenDayPeakUsagePct]) ?? fallback.sevenDayPeakUsagePct,
543
+ sevenDayLatestUsagePct: addNullable(usage.sevenDayLatestUsagePct, codexUsage.sevenDayLatestUsagePct) ?? fallback.sevenDayLatestUsagePct,
526
544
  sevenDayCumulativeUsagePct,
527
545
  uniqueSessions,
528
546
  uniqueWorkspaces,
@@ -531,15 +549,23 @@ function buildAggregatedDailyRows(bundles) {
531
549
  return rows.sort((left, right) => `${left.personKey}|${left.date}`.localeCompare(`${right.personKey}|${right.date}`));
532
550
  }
533
551
  /** 周级 usage 回退:rawEvents 缺失时从各天 daySummary 自带值取 peak(max)/ latest(date 最新非空)。 */
552
+ /**
553
+ * rawEvents 缺失时的额度回退:从各天 daySummary 取。
554
+ * Claude 与 Codex 合并:peak 取两源 max、latest 两源相加(与主字段叠加口径一致)。daily/weekly 共用。
555
+ */
534
556
  function fallbackWeeklyUsage(days) {
535
557
  const byDateDesc = [...days].sort((left, right) => right.date.localeCompare(left.date));
536
- const fivePeaks = days.map((day) => day.fiveHourPeakUsagePct).filter((value) => value !== null);
537
- const sevenPeaks = days.map((day) => day.sevenDayPeakUsagePct).filter((value) => value !== null);
558
+ const fivePeaks = days.flatMap((day) => [day.fiveHourPeakUsagePct, codexOf(day).fiveHourPeakUsagePct]);
559
+ const sevenPeaks = days.flatMap((day) => [day.sevenDayPeakUsagePct, codexOf(day).sevenDayPeakUsagePct]);
560
+ const claudeFiveLatest = byDateDesc.find((day) => day.fiveHourLatestUsagePct !== null)?.fiveHourLatestUsagePct ?? null;
561
+ const codexFiveDay = byDateDesc.find((day) => codexOf(day).fiveHourLatestUsagePct !== null);
562
+ const claudeSevenLatest = byDateDesc.find((day) => day.sevenDayLatestUsagePct !== null)?.sevenDayLatestUsagePct ?? null;
563
+ const codexSevenDay = byDateDesc.find((day) => codexOf(day).sevenDayLatestUsagePct !== null);
538
564
  return {
539
- fiveHourPeakUsagePct: fivePeaks.length > 0 ? (0, time_1.roundNumber)(Math.max(...fivePeaks), 1) : null,
540
- fiveHourLatestUsagePct: byDateDesc.find((day) => day.fiveHourLatestUsagePct !== null)?.fiveHourLatestUsagePct ?? null,
541
- sevenDayPeakUsagePct: sevenPeaks.length > 0 ? (0, time_1.roundNumber)(Math.max(...sevenPeaks), 1) : null,
542
- sevenDayLatestUsagePct: byDateDesc.find((day) => day.sevenDayLatestUsagePct !== null)?.sevenDayLatestUsagePct ?? null,
565
+ fiveHourPeakUsagePct: (0, time_1.roundNumber)(maxOrNull(fivePeaks), 1),
566
+ fiveHourLatestUsagePct: addNullable(claudeFiveLatest, codexFiveDay ? codexOf(codexFiveDay).fiveHourLatestUsagePct : null),
567
+ sevenDayPeakUsagePct: (0, time_1.roundNumber)(maxOrNull(sevenPeaks), 1),
568
+ sevenDayLatestUsagePct: addNullable(claudeSevenLatest, codexSevenDay ? codexOf(codexSevenDay).sevenDayLatestUsagePct : null),
543
569
  };
544
570
  }
545
571
  /**
@@ -573,11 +599,13 @@ function buildAggregatedWeeklyRows(bundles) {
573
599
  groups.set(key, acc);
574
600
  }
575
601
  const day = rep.day;
576
- acc.userMessageCount += day.userMessageCount;
577
- acc.apiRequestCount += day.apiRequestCount;
578
- acc.inputTokens += day.inputTokens;
579
- acc.outputTokens += day.outputTokens;
580
- acc.cacheReadInputTokens += day.cacheReadInputTokens;
602
+ const codexDay = codexOf(day);
603
+ // 累加量含 Codex:Claude + Codex 同字段相加
604
+ acc.userMessageCount += day.userMessageCount + codexDay.userMessageCount;
605
+ acc.apiRequestCount += day.apiRequestCount + codexDay.apiRequestCount;
606
+ acc.inputTokens += day.inputTokens + codexDay.inputTokens;
607
+ acc.outputTokens += day.outputTokens + codexDay.outputTokens;
608
+ acc.cacheReadInputTokens += day.cacheReadInputTokens + codexDay.cacheReadInputTokens;
581
609
  acc.sampleCount += day.sampleCount;
582
610
  acc.uniqueSessions += day.uniqueSessions;
583
611
  acc.uniqueWorkspaces += day.uniqueWorkspaces;
@@ -587,7 +615,11 @@ function buildAggregatedWeeklyRows(bundles) {
587
615
  }
588
616
  const rows = [];
589
617
  for (const acc of groups.values()) {
590
- const usage = recomputeUsage(acc.events);
618
+ // 按 source 分流:Claude usage 只算 claude 事件,Codex usage 单列重算。
619
+ const claudeEvents = acc.events.filter((event) => !(0, payload_1.isCodexSourceEvent)(event));
620
+ const codexEvents = acc.events.filter(payload_1.isCodexSourceEvent);
621
+ const usage = recomputeUsage(claudeEvents);
622
+ const codexUsage = recomputeUsage(codexEvents);
591
623
  const fallback = fallbackWeeklyUsage(acc.days);
592
624
  // 整周累计:在整周合并曲线上一次性求正增量,跨天边界增量被计入,故 weekly ≥ Σ daily。
593
625
  const sevenDayCumulativeUsagePct = computeCumulativeSevenDay(sliceCurveByWeek(curves.get(acc.personKey) ?? [], acc.week));
@@ -600,10 +632,10 @@ function buildAggregatedWeeklyRows(bundles) {
600
632
  outputTokens: acc.outputTokens,
601
633
  cacheReadInputTokens: acc.cacheReadInputTokens,
602
634
  sampleCount: acc.sampleCount,
603
- fiveHourPeakUsagePct: usage.fiveHourPeakUsagePct ?? fallback.fiveHourPeakUsagePct,
604
- fiveHourLatestUsagePct: usage.fiveHourLatestUsagePct ?? fallback.fiveHourLatestUsagePct,
605
- sevenDayPeakUsagePct: usage.sevenDayPeakUsagePct ?? fallback.sevenDayPeakUsagePct,
606
- sevenDayLatestUsagePct: usage.sevenDayLatestUsagePct ?? fallback.sevenDayLatestUsagePct,
635
+ fiveHourPeakUsagePct: maxOrNull([usage.fiveHourPeakUsagePct, codexUsage.fiveHourPeakUsagePct]) ?? fallback.fiveHourPeakUsagePct,
636
+ fiveHourLatestUsagePct: addNullable(usage.fiveHourLatestUsagePct, codexUsage.fiveHourLatestUsagePct) ?? fallback.fiveHourLatestUsagePct,
637
+ sevenDayPeakUsagePct: maxOrNull([usage.sevenDayPeakUsagePct, codexUsage.sevenDayPeakUsagePct]) ?? fallback.sevenDayPeakUsagePct,
638
+ sevenDayLatestUsagePct: addNullable(usage.sevenDayLatestUsagePct, codexUsage.sevenDayLatestUsagePct) ?? fallback.sevenDayLatestUsagePct,
607
639
  sevenDayCumulativeUsagePct,
608
640
  uniqueSessions: acc.uniqueSessions,
609
641
  uniqueWorkspaces: acc.uniqueWorkspaces,