ccus-cli 0.2.12 → 0.2.14

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
@@ -310,9 +310,9 @@ ccus install --codex --uninstall # 移除 ccus 的 Stop hook
310
310
 
311
311
  - **只支持 Codex CLI**:hooks 与 `codex app-server` 都是 CLI 能力。Codex 桌面版 app(已并入 ChatGPT 桌面 app)不一定触发 CLI 的 hooks、也不一定随附可 spawn 的 `codex` 二进制,不在本期范围。
312
312
  - **代理继承**:ccus 由 Codex 直接 spawn,已继承 Codex 的代理 / `CODEX_HOME`;它再 spawn `codex app-server` 时透传 `process.env`,整条链同环境。主路径必须让 codex 子进程发请求(它读 `HTTP_PROXY` / `HTTPS_PROXY`);wham 回退走 ccus 自己的 Node HTTP,但经统一 env 代理通道(读 `CCUS_PROXY` / `https_proxy` / `http_proxy` / `all_proxy` / `NO_PROXY`,`CCUS_PROXY` 优先、小写优先、对齐 curl),代理环境同样可达。
313
- - **无 codex 命令时回退 wham**:本机没装 `codex` CLI(spawn 返回 `unavailable`)且无新鲜缓存时,ccus 改走 ChatGPT 后端 `wham/usage` HTTP 直连——读 `~/.codex/auth.json` 的 OAuth token(仅 chatgpt 登录模式)、按各窗 `limit_window_seconds` 认 5h / 7d 桶,照常落盘 `source="codex"` 事件。让「装过 codex 留下 hook + auth.json、但 codex 未入 PATH / 已卸载」的用户也能采到额度。仅 `unavailable` 触发,超时 / RPC 错(`error`)不回退。
313
+ - **app-server 失败时回退 wham**:无新鲜缓存时,app-server 返回 `unavailable` / `error` / 空额度或直接抛错,ccus 都会继续走 ChatGPT 后端 `wham/usage` HTTP 直连——读 `~/.codex/auth.json` 的 OAuth token(仅 chatgpt 登录模式)、按各窗 `limit_window_seconds` 认 5h / 7d 桶。HTTP 成功则照常落盘 `source="codex"` 事件;两路都失败则跳过本次事件,不使用过期缓存额度。
314
314
  - **定时同步**:Stop hook 还兜底触发 `ccus sync`(与 Claude statusline 对称)——配过 `ccus sync config --target` 后,Codex 每 turn 结束都会检查 3h 周期,到期自动 export + 复制 bundle(含 Codex token/消息)到目标目录。只用 Codex、不开 Claude Code 时也能自动同步。
315
- - **缓存节流**:Stop 每 turn 触发,额度按 5 分钟 TTL 缓存(`codex-quota-cache.json`),命中秒回不 spawn;过期才拉一次(带 ~10s 超时),失败回退旧缓存。
315
+ - **缓存节流**:Stop 每 turn 触发,额度按 5 分钟 TTL 缓存(`codex-quota-cache.json`),新鲜缓存命中秒回不 spawn;过期后依次尝试 app-server 与 wham,两路失败返回空,不回退过期缓存。
316
316
  - **字段映射**:app-server 返回 `primary`(5h)/ `secondary`(weekly)两窗口,取各自的 `usedPercent`(驼峰,clamp 0–100);ccus 填进 `rate_limits.five_hour` / `seven_day` 的 `used_percentage`,`computeStatuslineEvent` 读时自动算出 usage。
317
317
  - **token / 消息 / 额度都进 export / aggregate**:Codex 的 token、用户消息数、API 请求数合并读取 `<CODEX_HOME>/sessions` 与 `%APPDATA%/orca/codex-runtime-home/home/sessions` 的 rollout;同一相对路径的副本按 JSONL 事件去重,并排除 `source.subagent.other="guardian"` 的 Codex Desktop 内部安全审查会话。统计进入 `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` 列区分来源。
318
318
  - **Windows**:`install --codex` 在 Windows 写 `ccus.cmd __codex-hook`(npm 全局装会生成 `ccus.cmd`);Windows 上 Stop hook 偶发收到非法 JSON(已知 bug #23784),ccus 容错按无 payload 处理、仍照常拉额度 + 落盘。
package/dist/cli.js CHANGED
@@ -264,8 +264,12 @@ async function loadDashboardData(options, defaultRange) {
264
264
  // codex 消息柱图走 dailyUserMessages,不受影响。
265
265
  const events = (await (0, storage_1.readEventsForRange)(dataDir, range, now))
266
266
  .map((record) => (0, payload_1.computeStatuslineEvent)(record));
267
- const claudeDailyUsage = await (0, claude_1.summarizeClaudeProjectUsageByDay)(window.start, window.end);
268
- const codexDailyUsage = await (0, codex_sessions_1.summarizeCodexSessionUsageByDay)(window.start, window.end);
267
+ const [claudeUsage, codexUsage] = await Promise.all([
268
+ (0, claude_1.summarizeClaudeProjectUsageCombined)(window.start, window.end),
269
+ (0, codex_sessions_1.summarizeCodexSessionUsageCombined)(window.start, window.end),
270
+ ]);
271
+ const claudeDailyUsage = claudeUsage.daily;
272
+ const codexDailyUsage = codexUsage.daily;
269
273
  const dailyUserMessages = (0, time_1.enumerateDateKeys)(window.start, window.end).map((date) => ({
270
274
  date,
271
275
  userMessageCount: claudeDailyUsage.get(date)?.userMessageCount ?? 0,
@@ -397,10 +401,14 @@ async function runExport(options) {
397
401
  const statuslineDailyRows = (0, export_1.buildSummaryRows)(claudeEvents);
398
402
  const codexStatuslineSummary = (0, dashboard_2.summarizeEvents)(codexEvents);
399
403
  const codexStatuslineDailyRows = (0, export_1.buildSummaryRows)(codexEvents);
400
- const claudeUsage = await (0, claude_1.summarizeClaudeProjectUsage)(window.start, window.end);
401
- const claudeDailyUsage = await (0, claude_1.summarizeClaudeProjectUsageByDay)(window.start, window.end);
402
- const codexUsage = await (0, codex_sessions_1.summarizeCodexSessionUsage)(window.start, window.end);
403
- const codexDailyUsage = await (0, codex_sessions_1.summarizeCodexSessionUsageByDay)(window.start, window.end);
404
+ const [claudeScan, codexScan] = await Promise.all([
405
+ (0, claude_1.summarizeClaudeProjectUsageCombined)(window.start, window.end),
406
+ (0, codex_sessions_1.summarizeCodexSessionUsageCombined)(window.start, window.end),
407
+ ]);
408
+ const claudeUsage = claudeScan.weekly;
409
+ const claudeDailyUsage = claudeScan.daily;
410
+ const codexUsage = codexScan.weekly;
411
+ const codexDailyUsage = codexScan.daily;
404
412
  const claudeWeeklyCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([...claudeDailyUsage.values()].map((day) => day.apiEquivalentCost));
405
413
  const codexWeeklyCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([...codexDailyUsage.values()].map((day) => day.apiEquivalentCost));
406
414
  (0, debug_1.debugLog)("export", "data collected", {
@@ -823,6 +831,7 @@ async function handleBackgroundCheckUpdate(options) {
823
831
  * 不带参数则用已存配置同步。最终仍无目标目录则报错引导用户先配置。
824
832
  */
825
833
  async function handleSync(options) {
834
+ const startedAt = process.hrtime.bigint();
826
835
  const dataDir = getDataDir(options);
827
836
  const target = getStringOption(options, "target");
828
837
  const interval = getStringOption(options, "interval");
@@ -848,6 +857,8 @@ async function handleSync(options) {
848
857
  if (result.archivedLastWeekDest) {
849
858
  process.stdout.write(`已归档上一周到 ${result.archivedLastWeekDest}\n`);
850
859
  }
860
+ const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
861
+ process.stdout.write(`耗时:${(0, sync_1.formatSyncElapsed)(elapsedMs)}\n`);
851
862
  }
852
863
  /**
853
864
  * `ccus sync status`:打印当前同步配置、上次同步时间与是否到期。
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.summarizeClaudeProjectUsage = summarizeClaudeProjectUsage;
7
+ exports.summarizeClaudeProjectUsageCombined = summarizeClaudeProjectUsageCombined;
7
8
  exports.findActiveSessionFiles = findActiveSessionFiles;
8
9
  exports.summarizeClaudeProjectUsageByDay = summarizeClaudeProjectUsageByDay;
9
10
  const promises_1 = __importDefault(require("node:fs/promises"));
@@ -84,6 +85,14 @@ function localDateKey(date) {
84
85
  const day = String(date.getDate()).padStart(2, "0");
85
86
  return `${year}-${month}-${day}`;
86
87
  }
88
+ async function fileMayContainRange(filePath, start) {
89
+ try {
90
+ return (await promises_1.default.stat(filePath)).mtimeMs >= start.getTime();
91
+ }
92
+ catch {
93
+ return true;
94
+ }
95
+ }
87
96
  /**
88
97
  * 判断一条 `type:"user"` 事件是否算作一次用户请求。
89
98
  *
@@ -113,50 +122,18 @@ function isHumanUserMessage(record) {
113
122
  }
114
123
  return true;
115
124
  }
116
- function summarizeProjectTranscript(content, start, end) {
117
- const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
118
- const summary = {
119
- userMessageCount: 0,
120
- apiRequestCount: 0,
121
- inputTokens: 0,
122
- outputTokens: 0,
123
- cacheReadInputTokens: 0,
124
- apiEquivalentCost: (0, api_equivalent_cost_1.emptyApiEquivalentCost)(),
125
- };
126
- for (const line of lines) {
127
- try {
128
- const record = JSON.parse(line);
129
- const timestamp = isRecord(record) ? getString(record.timestamp) : null;
130
- if (!isRecord(record) || !timestampInRange(timestamp, start, end)) {
131
- continue;
132
- }
133
- if (isHumanUserMessage(record)) {
134
- summary.userMessageCount += 1;
135
- }
136
- if (record.type !== "assistant" || !isRecord(record.message) || !isRecord(record.message.usage)) {
137
- continue;
138
- }
139
- const assistantUsage = summarizeAssistantUsage(record.message, timestamp);
140
- summary.apiRequestCount += 1;
141
- summary.inputTokens += assistantUsage.inputTokens;
142
- summary.outputTokens += assistantUsage.outputTokens;
143
- summary.cacheReadInputTokens += assistantUsage.cacheReadInputTokens;
144
- summary.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([summary.apiEquivalentCost, assistantUsage.apiEquivalentCost]);
145
- }
146
- catch {
147
- continue;
148
- }
149
- }
150
- return summary;
151
- }
152
125
  /**
153
126
  * 从 Claude 本地 project transcript 中统计本周消息数、请求数、token 用量和标准 API 等效成本。
154
127
  */
155
128
  async function summarizeClaudeProjectUsage(start, end) {
129
+ return (await summarizeClaudeProjectUsageCombined(start, end)).weekly;
130
+ }
131
+ /** 单次扫描同时生成周汇总与按天汇总。 */
132
+ async function summarizeClaudeProjectUsageCombined(start, end) {
156
133
  const claudeDataDir = (0, paths_1.getClaudeDataDir)();
157
134
  const projectDir = node_path_1.default.join(claudeDataDir, "projects");
158
135
  const files = await collectProjectJsonlFiles(projectDir);
159
- const totals = {
136
+ const weekly = {
160
137
  userMessageCount: 0,
161
138
  apiRequestCount: 0,
162
139
  inputTokens: 0,
@@ -166,22 +143,75 @@ async function summarizeClaudeProjectUsage(start, end) {
166
143
  matchedFileCount: files.length,
167
144
  claudeDataDir,
168
145
  };
146
+ const daily = new Map();
169
147
  for (const filePath of files) {
148
+ if (!(await fileMayContainRange(filePath, start))) {
149
+ continue;
150
+ }
151
+ let content = "";
170
152
  try {
171
- const content = await promises_1.default.readFile(filePath, "utf8");
172
- const summary = summarizeProjectTranscript(content, start, end);
173
- totals.userMessageCount += summary.userMessageCount;
174
- totals.apiRequestCount += summary.apiRequestCount;
175
- totals.inputTokens += summary.inputTokens;
176
- totals.outputTokens += summary.outputTokens;
177
- totals.cacheReadInputTokens += summary.cacheReadInputTokens;
178
- totals.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([totals.apiEquivalentCost, summary.apiEquivalentCost]);
153
+ content = await promises_1.default.readFile(filePath, "utf8");
179
154
  }
180
155
  catch {
181
156
  continue;
182
157
  }
158
+ const fileSummary = {
159
+ userMessageCount: 0,
160
+ apiRequestCount: 0,
161
+ inputTokens: 0,
162
+ outputTokens: 0,
163
+ cacheReadInputTokens: 0,
164
+ apiEquivalentCost: (0, api_equivalent_cost_1.emptyApiEquivalentCost)(),
165
+ };
166
+ const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
167
+ for (const line of lines) {
168
+ try {
169
+ const record = JSON.parse(line);
170
+ const timestamp = isRecord(record) ? getString(record.timestamp) : null;
171
+ if (!isRecord(record) || !timestampInRange(timestamp, start, end)) {
172
+ continue;
173
+ }
174
+ const date = localDateKey(new Date(timestamp));
175
+ const day = daily.get(date) ?? {
176
+ date,
177
+ userMessageCount: 0,
178
+ apiRequestCount: 0,
179
+ inputTokens: 0,
180
+ outputTokens: 0,
181
+ cacheReadInputTokens: 0,
182
+ apiEquivalentCost: (0, api_equivalent_cost_1.emptyApiEquivalentCost)(),
183
+ };
184
+ if (isHumanUserMessage(record)) {
185
+ fileSummary.userMessageCount += 1;
186
+ day.userMessageCount += 1;
187
+ }
188
+ if (record.type === "assistant" && isRecord(record.message) && isRecord(record.message.usage)) {
189
+ const usage = summarizeAssistantUsage(record.message, timestamp);
190
+ fileSummary.apiRequestCount += 1;
191
+ fileSummary.inputTokens += usage.inputTokens;
192
+ fileSummary.outputTokens += usage.outputTokens;
193
+ fileSummary.cacheReadInputTokens += usage.cacheReadInputTokens;
194
+ fileSummary.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([fileSummary.apiEquivalentCost, usage.apiEquivalentCost]);
195
+ day.apiRequestCount += 1;
196
+ day.inputTokens += usage.inputTokens;
197
+ day.outputTokens += usage.outputTokens;
198
+ day.cacheReadInputTokens += usage.cacheReadInputTokens;
199
+ day.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([day.apiEquivalentCost, usage.apiEquivalentCost]);
200
+ }
201
+ daily.set(date, day);
202
+ }
203
+ catch {
204
+ continue;
205
+ }
206
+ }
207
+ weekly.userMessageCount += fileSummary.userMessageCount;
208
+ weekly.apiRequestCount += fileSummary.apiRequestCount;
209
+ weekly.inputTokens += fileSummary.inputTokens;
210
+ weekly.outputTokens += fileSummary.outputTokens;
211
+ weekly.cacheReadInputTokens += fileSummary.cacheReadInputTokens;
212
+ weekly.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([weekly.apiEquivalentCost, fileSummary.apiEquivalentCost]);
183
213
  }
184
- return totals;
214
+ return { weekly, daily };
185
215
  }
186
216
  /**
187
217
  * 找出 ~/.claude/projects 中在指定时间范围内有活动的 session 文件,返回文件路径及结构信息。
@@ -230,57 +260,6 @@ async function findActiveSessionFiles(start, end) {
230
260
  * 按天汇总 Claude project transcript 中的消息数、请求数、token 用量和标准 API 等效成本。
231
261
  */
232
262
  async function summarizeClaudeProjectUsageByDay(start, end) {
233
- const claudeDataDir = (0, paths_1.getClaudeDataDir)();
234
- const projectDir = node_path_1.default.join(claudeDataDir, "projects");
235
- const files = await collectProjectJsonlFiles(projectDir);
236
- const daily = new Map();
237
- for (const filePath of files) {
238
- let content = "";
239
- try {
240
- content = await promises_1.default.readFile(filePath, "utf8");
241
- }
242
- catch {
243
- continue;
244
- }
245
- const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
246
- for (const line of lines) {
247
- try {
248
- const record = JSON.parse(line);
249
- if (!isRecord(record)) {
250
- continue;
251
- }
252
- const timestamp = getString(record.timestamp);
253
- if (!timestampInRange(timestamp, start, end)) {
254
- continue;
255
- }
256
- const date = localDateKey(new Date(timestamp));
257
- const current = daily.get(date) ?? {
258
- date,
259
- userMessageCount: 0,
260
- apiRequestCount: 0,
261
- inputTokens: 0,
262
- outputTokens: 0,
263
- cacheReadInputTokens: 0,
264
- apiEquivalentCost: (0, api_equivalent_cost_1.emptyApiEquivalentCost)(),
265
- };
266
- if (isHumanUserMessage(record)) {
267
- current.userMessageCount += 1;
268
- }
269
- if (record.type === "assistant" && isRecord(record.message) && isRecord(record.message.usage)) {
270
- const assistantUsage = summarizeAssistantUsage(record.message, timestamp);
271
- current.apiRequestCount += 1;
272
- current.inputTokens += assistantUsage.inputTokens;
273
- current.outputTokens += assistantUsage.outputTokens;
274
- current.cacheReadInputTokens += assistantUsage.cacheReadInputTokens;
275
- current.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([current.apiEquivalentCost, assistantUsage.apiEquivalentCost]);
276
- }
277
- daily.set(date, current);
278
- }
279
- catch {
280
- continue;
281
- }
282
- }
283
- }
284
- return daily;
263
+ return (await summarizeClaudeProjectUsageCombined(start, end)).daily;
285
264
  }
286
265
  //# sourceMappingURL=claude.js.map
@@ -22,7 +22,7 @@ const CODEX_ARGS = ["-s", "read-only", "-a", "never", "app-server"];
22
22
  const DEFAULT_TIMEOUT_MS = 10_000;
23
23
  // notify 每 turn 触发,额度缓存 5 分钟,命中秒回避免阻塞 Codex 主流程。
24
24
  const DEFAULT_TTL_MS = 5 * 60 * 1000;
25
- // ChatGPT 后端 wham/usage 回退:仅本机无 codex CLI(spawn unavailable)时走,直连读 auth.json 的 OAuth token。
25
+ // ChatGPT 后端 wham/usage 回退:app-server 未返回可用额度时走,直连读 auth.json 的 OAuth token。
26
26
  const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
27
27
  const WHAM_TIMEOUT_MS = 15_000;
28
28
  // wham 各窗口自带的时长(秒):5h 窗 18000、周窗 604800,按它认桶、不假设 primary/secondary 顺序。
@@ -306,7 +306,7 @@ async function fetchCodexQuota(options = {}) {
306
306
  });
307
307
  });
308
308
  }
309
- // --- wham/usage HTTP 直连回退(仅主路径 unavailable 时由 resolveCodexQuota 编排) ---
309
+ // --- wham/usage HTTP 直连回退(主路径未返回可用额度时由 resolveCodexQuota 编排) ---
310
310
  /** 读 wham 窗口使用率:主字段 `used_percent`(下划线,wham 实际返回),驼峰 / `used_percentage` 防御性 fallback;窗口为 null 返回 null。 */
311
311
  function readWhamUsedPercent(window) {
312
312
  if (!window) {
@@ -380,7 +380,7 @@ function emptyWhamOutcome(status) {
380
380
  /**
381
381
  * wham/usage HTTP 直连回退:读 auth.json 的 chatgpt OAuth token → `GET wham/usage` → parseWhamUsage 认桶。
382
382
  *
383
- * 仅在主路径 `unavailable`(本机无 codex CLI)时由 resolveCodexQuota 编排调用。全程不抛错:
383
+ * 主路径未返回可用额度时由 resolveCodexQuota 编排调用。全程不抛错:
384
384
  * 无 token / HTTP 失败 / 非 2xx / JSON 异常 / 解析空 → `{ status: "error", ... }`,成功 → `{ status: "ok", ... }`。
385
385
  * 请求经 api-mode 的 httpRequest(自带统一 env 代理通道,代理环境也能回退采到额度)。
386
386
  */
@@ -465,7 +465,7 @@ function hasAnyQuota(quota) {
465
465
  return quota.fiveHour !== null || quota.sevenDay !== null;
466
466
  }
467
467
  /**
468
- * 缓存优先解析额度:新鲜直接返回;过期拉一次(带超时);拉取失败 / 无有效数据回退旧缓存,全失败返回 null。
468
+ * 缓存优先解析额度:新鲜直接返回;过期先拉 app-server,未得到可用额度再拉 wham;两路均失败或全空返回 null。
469
469
  *
470
470
  * notify 路径用,全程不抛错、不写 stdout。`options.fetcher` 供测试注入。
471
471
  */
@@ -487,30 +487,24 @@ async function resolveCodexQuota(dataDir, options = {}) {
487
487
  if (outcome.status === "ok" && hasAnyQuota(outcome)) {
488
488
  return await persist({ fiveHour: outcome.fiveHour, sevenDay: outcome.sevenDay, resetsAt: outcome.resetsAt });
489
489
  }
490
- // 主路径 unavailable(本机无 codex CLI)→ wham/usage HTTP 直连回退;
491
- // error(超时 / RPC 错 / 进程崩)不触发,避免瞬时故障多扛一次 15s HTTP。
492
- if (outcome.status === "unavailable") {
493
- try {
494
- const whamOutcome = await whamFetcher({
495
- codexHomePath: options.fetchOptions?.codexHomePath,
496
- env: options.fetchOptions?.env,
497
- });
498
- if (whamOutcome.status === "ok" && hasAnyQuota(whamOutcome)) {
499
- return await persist({ fiveHour: whamOutcome.fiveHour, sevenDay: whamOutcome.sevenDay, resetsAt: whamOutcome.resetsAt });
500
- }
501
- (0, debug_1.debugLog)("codex", "wham fallback did not yield usable quota", { status: whamOutcome.status });
502
- }
503
- catch (error) {
504
- (0, debug_1.debugLog)("codex", "wham fallback threw", error instanceof Error ? error.message : String(error));
505
- }
506
- }
507
- else {
508
- (0, debug_1.debugLog)("codex", "fetch did not yield usable quota", { status: outcome.status });
490
+ (0, debug_1.debugLog)("codex", "app-server did not yield usable quota", { status: outcome.status });
491
+ }
492
+ catch (error) {
493
+ (0, debug_1.debugLog)("codex", "app-server fetch threw", error instanceof Error ? error.message : String(error));
494
+ }
495
+ try {
496
+ const whamOutcome = await whamFetcher({
497
+ codexHomePath: options.fetchOptions?.codexHomePath,
498
+ env: options.fetchOptions?.env,
499
+ });
500
+ if (whamOutcome.status === "ok" && hasAnyQuota(whamOutcome)) {
501
+ return await persist({ fiveHour: whamOutcome.fiveHour, sevenDay: whamOutcome.sevenDay, resetsAt: whamOutcome.resetsAt });
509
502
  }
503
+ (0, debug_1.debugLog)("codex", "wham fallback did not yield usable quota", { status: whamOutcome.status });
510
504
  }
511
505
  catch (error) {
512
- (0, debug_1.debugLog)("codex", "fetch threw, fallback to cache", error instanceof Error ? error.message : String(error));
506
+ (0, debug_1.debugLog)("codex", "wham fallback threw", error instanceof Error ? error.message : String(error));
513
507
  }
514
- return cached ? quotaFromCache(cached) : null;
508
+ return null;
515
509
  }
516
510
  //# sourceMappingURL=codex-fetcher.js.map
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.findActiveCodexSessionFiles = findActiveCodexSessionFiles;
7
+ exports.summarizeCodexSessionUsageCombined = summarizeCodexSessionUsageCombined;
7
8
  exports.summarizeCodexSessionUsage = summarizeCodexSessionUsage;
8
9
  exports.summarizeCodexSessionUsageByDay = summarizeCodexSessionUsageByDay;
9
10
  const promises_1 = __importDefault(require("node:fs/promises"));
@@ -126,8 +127,36 @@ function mergeRolloutContents(contents) {
126
127
  }
127
128
  return mergedLines.length > 0 ? `${mergedLines.join("\n")}\n` : "";
128
129
  }
129
- async function readMergedRollout(group) {
130
- const reads = await Promise.all(group.filePaths.map(async (filePath) => {
130
+ async function readRolloutFileRef(filePath) {
131
+ try {
132
+ const stat = await promises_1.default.stat(filePath, { bigint: true });
133
+ return {
134
+ filePath,
135
+ identityKey: stat.ino === 0n ? null : `${stat.dev}:${stat.ino}`,
136
+ mtimeMs: Number(stat.mtimeMs),
137
+ };
138
+ }
139
+ catch {
140
+ return { filePath, identityKey: null, mtimeMs: null };
141
+ }
142
+ }
143
+ function isCompletePrefix(shorter, longer) {
144
+ return shorter.length === 0
145
+ || ((shorter.endsWith("\n") || shorter.endsWith("\r")) && longer.startsWith(shorter));
146
+ }
147
+ async function readMergedRollout(group, start) {
148
+ const refs = await Promise.all(group.filePaths.map((filePath) => readRolloutFileRef(filePath)));
149
+ if (start && refs.length > 0 && refs.every((ref) => ref.mtimeMs !== null && ref.mtimeMs < start.getTime())) {
150
+ return null;
151
+ }
152
+ const uniqueRefs = new Map();
153
+ for (const ref of refs) {
154
+ const key = ref.identityKey ?? `path:${rolloutPathKey(ref.filePath)}`;
155
+ if (!uniqueRefs.has(key)) {
156
+ uniqueRefs.set(key, ref);
157
+ }
158
+ }
159
+ const reads = await Promise.all([...uniqueRefs.values()].map(async ({ filePath }) => {
131
160
  try {
132
161
  return await promises_1.default.readFile(filePath, "utf8");
133
162
  }
@@ -135,7 +164,18 @@ async function readMergedRollout(group) {
135
164
  return null;
136
165
  }
137
166
  }));
138
- return mergeRolloutContents(reads.filter((content) => content !== null));
167
+ const contents = reads.filter((content) => content !== null);
168
+ if (contents.length === 0) {
169
+ return "";
170
+ }
171
+ if (contents.length === 1 || contents.every((content) => content === contents[0])) {
172
+ return contents[0];
173
+ }
174
+ const longest = contents.reduce((current, content) => content.length > current.length ? content : current);
175
+ if (contents.every((content) => content === longest || isCompletePrefix(content, longest))) {
176
+ return longest;
177
+ }
178
+ return mergeRolloutContents(contents);
139
179
  }
140
180
  function timestampInRange(timestamp, start, end) {
141
181
  if (!timestamp) {
@@ -155,6 +195,9 @@ async function findActiveCodexSessionFiles(start, end) {
155
195
  for (const group of groups) {
156
196
  try {
157
197
  const content = await readMergedRollout(group);
198
+ if (content === null) {
199
+ continue;
200
+ }
158
201
  const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
159
202
  let hasInRange = false;
160
203
  for (const line of lines) {
@@ -203,7 +246,23 @@ function extractLastTokenUsage(payload) {
203
246
  * turn_id 全局去重(取最早 timestamp),不能在单文件层计数。
204
247
  * - `event_msg` + `payload.type=="token_count"`:一次模型请求,token 取 `info.last_token_usage` 增量。
205
248
  */
206
- function summarizeRollout(content, start, end) {
249
+ function ensureCodexDay(daily, date) {
250
+ let current = daily.get(date);
251
+ if (!current) {
252
+ current = {
253
+ date,
254
+ userMessageCount: 0,
255
+ apiRequestCount: 0,
256
+ inputTokens: 0,
257
+ outputTokens: 0,
258
+ cacheReadInputTokens: 0,
259
+ apiEquivalentCost: (0, api_equivalent_cost_1.emptyApiEquivalentCost)(),
260
+ };
261
+ daily.set(date, current);
262
+ }
263
+ return current;
264
+ }
265
+ function summarizeRollout(content, start, end, daily) {
207
266
  const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
208
267
  const result = {
209
268
  turns: [],
@@ -242,21 +301,27 @@ function summarizeRollout(content, start, end) {
242
301
  if (usage) {
243
302
  const { inputTokens, cacheReadInputTokens } = splitInputTokens(usage);
244
303
  const outputTokens = getNumber(usage.output_tokens);
304
+ const cost = (0, api_equivalent_cost_1.priceApiRequest)({
305
+ provider: "codex",
306
+ timestamp: timestamp,
307
+ model: currentModel,
308
+ inputTokens,
309
+ outputTokens,
310
+ cacheReadInputTokens,
311
+ });
245
312
  result.apiRequestCount += 1;
246
313
  result.inputTokens += inputTokens;
247
314
  result.outputTokens += outputTokens;
248
315
  result.cacheReadInputTokens += cacheReadInputTokens;
249
- result.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([
250
- result.apiEquivalentCost,
251
- (0, api_equivalent_cost_1.priceApiRequest)({
252
- provider: "codex",
253
- timestamp: timestamp,
254
- model: currentModel,
255
- inputTokens,
256
- outputTokens,
257
- cacheReadInputTokens,
258
- }),
259
- ]);
316
+ result.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([result.apiEquivalentCost, cost]);
317
+ if (daily) {
318
+ const day = ensureCodexDay(daily, (0, time_1.localDateKey)(new Date(timestamp)));
319
+ day.apiRequestCount += 1;
320
+ day.inputTokens += inputTokens;
321
+ day.outputTokens += outputTokens;
322
+ day.cacheReadInputTokens += cacheReadInputTokens;
323
+ day.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([day.apiEquivalentCost, cost]);
324
+ }
260
325
  }
261
326
  }
262
327
  }
@@ -273,10 +338,11 @@ function summarizeRollout(content, start, end) {
273
338
  * 消息数 = task_started 的 distinct turn_id(跨文件去重)。重放副本会让同一 turn_id 出现在多个文件,
274
339
  * 故用全局 Map<turn_id, minMs> 收集(取最早 timestamp = 真实发生时刻,早于任何重放副本),最后取 size。
275
340
  */
276
- async function summarizeCodexSessionUsage(start, end) {
341
+ async function summarizeCodexSessionUsageCombined(start, end) {
277
342
  const codexDataDir = (0, paths_1.getCodexHome)();
278
343
  const groups = await collectRolloutFileGroups();
279
344
  const turnMinMs = new Map();
345
+ const daily = new Map();
280
346
  let apiRequestCount = 0;
281
347
  let inputTokens = 0;
282
348
  let outputTokens = 0;
@@ -284,14 +350,14 @@ async function summarizeCodexSessionUsage(start, end) {
284
350
  let apiEquivalentCost = (0, api_equivalent_cost_1.emptyApiEquivalentCost)();
285
351
  for (const group of groups) {
286
352
  try {
287
- const content = await readMergedRollout(group);
288
- if (isGuardianRollout(content)) {
353
+ const content = await readMergedRollout(group, start);
354
+ if (content === null || isGuardianRollout(content)) {
289
355
  continue;
290
356
  }
291
- const parsed = summarizeRollout(content, start, end);
357
+ const parsed = summarizeRollout(content, start, end, daily);
292
358
  for (const turn of parsed.turns) {
293
- const prev = turnMinMs.get(turn.turnId);
294
- if (prev === undefined || turn.ms < prev) {
359
+ const previous = turnMinMs.get(turn.turnId);
360
+ if (previous === undefined || turn.ms < previous) {
295
361
  turnMinMs.set(turn.turnId, turn.ms);
296
362
  }
297
363
  }
@@ -305,17 +371,26 @@ async function summarizeCodexSessionUsage(start, end) {
305
371
  continue;
306
372
  }
307
373
  }
374
+ for (const ms of turnMinMs.values()) {
375
+ ensureCodexDay(daily, (0, time_1.localDateKey)(new Date(ms))).userMessageCount += 1;
376
+ }
308
377
  return {
309
- userMessageCount: turnMinMs.size,
310
- apiRequestCount,
311
- inputTokens,
312
- outputTokens,
313
- cacheReadInputTokens,
314
- apiEquivalentCost,
315
- matchedFileCount: groups.length,
316
- codexDataDir,
378
+ weekly: {
379
+ userMessageCount: turnMinMs.size,
380
+ apiRequestCount,
381
+ inputTokens,
382
+ outputTokens,
383
+ cacheReadInputTokens,
384
+ apiEquivalentCost,
385
+ matchedFileCount: groups.length,
386
+ codexDataDir,
387
+ },
388
+ daily,
317
389
  };
318
390
  }
391
+ async function summarizeCodexSessionUsage(start, end) {
392
+ return (await summarizeCodexSessionUsageCombined(start, end)).weekly;
393
+ }
319
394
  /**
320
395
  * 按天汇总 Codex session rollout 中的消息数、请求数、token 用量和标准 API 等效成本。
321
396
  * 与周汇总一致,排除 Codex Desktop 的 guardian 安全审查 rollout。
@@ -324,93 +399,6 @@ async function summarizeCodexSessionUsage(start, end) {
324
399
  * 且重放副本跨天不重复)。token 维度按 token_count 事件 timestamp 的本地日累加。
325
400
  */
326
401
  async function summarizeCodexSessionUsageByDay(start, end) {
327
- const groups = await collectRolloutFileGroups();
328
- const turnMinMs = new Map();
329
- const daily = new Map();
330
- const ensureDay = (date) => {
331
- let current = daily.get(date);
332
- if (!current) {
333
- current = {
334
- date,
335
- userMessageCount: 0,
336
- apiRequestCount: 0,
337
- inputTokens: 0,
338
- outputTokens: 0,
339
- cacheReadInputTokens: 0,
340
- apiEquivalentCost: (0, api_equivalent_cost_1.emptyApiEquivalentCost)(),
341
- };
342
- daily.set(date, current);
343
- }
344
- return current;
345
- };
346
- for (const group of groups) {
347
- const content = await readMergedRollout(group);
348
- if (isGuardianRollout(content)) {
349
- continue;
350
- }
351
- const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
352
- let currentModel = null;
353
- for (const line of lines) {
354
- try {
355
- const record = JSON.parse(line);
356
- if (!isRecord(record) || !isRecord(record.payload)) {
357
- continue;
358
- }
359
- const payload = record.payload;
360
- if (record.type === "turn_context") {
361
- if (typeof payload.model === "string") {
362
- currentModel = payload.model;
363
- }
364
- continue;
365
- }
366
- const timestamp = getString(record.timestamp);
367
- if (!timestampInRange(timestamp, start, end)) {
368
- continue;
369
- }
370
- if (record.type !== "event_msg") {
371
- continue;
372
- }
373
- const ms = new Date(timestamp).getTime();
374
- if (payload.type === "task_started" && typeof payload.turn_id === "string") {
375
- if (Number.isFinite(ms)) {
376
- const prev = turnMinMs.get(payload.turn_id);
377
- if (prev === undefined || ms < prev) {
378
- turnMinMs.set(payload.turn_id, ms);
379
- }
380
- }
381
- }
382
- if (payload.type === "token_count") {
383
- const usage = extractLastTokenUsage(payload);
384
- if (usage) {
385
- const current = ensureDay((0, time_1.localDateKey)(new Date(ms)));
386
- const { inputTokens, cacheReadInputTokens } = splitInputTokens(usage);
387
- const outputTokens = getNumber(usage.output_tokens);
388
- current.apiRequestCount += 1;
389
- current.inputTokens += inputTokens;
390
- current.outputTokens += outputTokens;
391
- current.cacheReadInputTokens += cacheReadInputTokens;
392
- current.apiEquivalentCost = (0, api_equivalent_cost_1.mergeApiEquivalentCosts)([
393
- current.apiEquivalentCost,
394
- (0, api_equivalent_cost_1.priceApiRequest)({
395
- provider: "codex",
396
- timestamp: timestamp,
397
- model: currentModel,
398
- inputTokens,
399
- outputTokens,
400
- cacheReadInputTokens,
401
- }),
402
- ]);
403
- }
404
- }
405
- }
406
- catch {
407
- continue;
408
- }
409
- }
410
- }
411
- for (const ms of turnMinMs.values()) {
412
- ensureDay((0, time_1.localDateKey)(new Date(ms))).userMessageCount += 1;
413
- }
414
- return daily;
402
+ return (await summarizeCodexSessionUsageCombined(start, end)).daily;
415
403
  }
416
404
  //# sourceMappingURL=codex-sessions.js.map
package/dist/lib/sync.js CHANGED
@@ -10,6 +10,7 @@ exports.readSyncConfig = readSyncConfig;
10
10
  exports.writeSyncConfig = writeSyncConfig;
11
11
  exports.readSyncStateSync = readSyncStateSync;
12
12
  exports.isSyncDue = isSyncDue;
13
+ exports.formatSyncElapsed = formatSyncElapsed;
13
14
  exports.performSync = performSync;
14
15
  exports.maybeSpawnBackgroundSync = maybeSpawnBackgroundSync;
15
16
  const node_child_process_1 = require("node:child_process");
@@ -159,6 +160,10 @@ function isSyncDue(config, state, now = new Date()) {
159
160
  }
160
161
  return now.getTime() - last >= interval.ms;
161
162
  }
163
+ /** 把同步耗时统一格式化为秒,保留三位小数。 */
164
+ function formatSyncElapsed(elapsedMs) {
165
+ return `${(Math.max(0, elapsedMs) / 1000).toFixed(3)}s`;
166
+ }
162
167
  /**
163
168
  * 导出某个 range 的 bundle,并复制到目标目录下对应的「按周」子目录。
164
169
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {