ccus-cli 0.2.9 → 0.2.10

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
@@ -178,7 +178,7 @@ ccus aggregate serve --input-dir ./team-exports
178
178
 
179
179
  - `ccus sessions` 默认打包本周;支持 `tw`、`lw`、`today`、`5h` 等与 `export` 相同的范围写法。
180
180
  - 只选择文件中至少一条记录落入时间窗的 session,但 zip 内保存完整原始文件,不裁剪内容。
181
- - Claude 文件保持 `<projectDir>/<sessionId>.jsonl` 路径;Codex rollout 保持 `codex/<CODEX_HOME sessions 的相对路径>`。
181
+ - Claude 文件保持 `<projectDir>/<sessionId>.jsonl` 路径;Codex rollout 合并 `<CODEX_HOME>/sessions` 与 `%APPDATA%/orca/codex-runtime-home/home/sessions`,按相对路径和 JSONL 事件去重后保持 `codex/<sessions 相对路径>`。
182
182
  - 默认写入 `<data-dir>/sessions/projects_<start>_<end>_<gitUserName>.zip`;`--out` 可指定路径。
183
183
 
184
184
  ## 多人汇总
@@ -311,7 +311,7 @@ ccus install --codex --uninstall # 移除 ccus 的 Stop hook
311
311
  - **定时同步**:Stop hook 还兜底触发 `ccus sync`(与 Claude statusline 对称)——配过 `ccus sync config --target` 后,Codex 每 turn 结束都会检查 3h 周期,到期自动 export + 复制 bundle(含 Codex token/消息)到目标目录。只用 Codex、不开 Claude Code 时也能自动同步。
312
312
  - **缓存节流**:Stop 每 turn 触发,额度按 5 分钟 TTL 缓存(`codex-quota-cache.json`),命中秒回不 spawn;过期才拉一次(带 ~10s 超时),失败回退旧缓存。
313
313
  - **字段映射**:app-server 返回 `primary`(5h)/ `secondary`(weekly)两窗口,取各自的 `usedPercent`(驼峰,clamp 0–100);ccus 填进 `rate_limits.five_hour` / `seven_day` 的 `used_percentage`,`computeStatuslineEvent` 读时自动算出 usage。
314
- - **token / 消息 / 额度都进 export / aggregate**:Codex 的 token、用户消息数、API 请求数从 `<CODEX_HOME>/sessions` 的 rollout 统计,并排除 `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` 列区分来源。
314
+ - **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` 列区分来源。
315
315
  - **Windows**:`install --codex` 在 Windows 写 `ccus.cmd __codex-hook`(npm 全局装会生成 `ccus.cmd`);Windows 上 Stop hook 偶发收到非法 JSON(已知 bug #23784),ccus 容错按无 payload 处理、仍照常拉额度 + 落盘。
316
316
 
317
317
  > 该路径依赖 Codex 内部 app-server 协议(`account/rateLimits/read` 的返回结构)与 hooks payload schema,随版本变;解析层宽松,字段缺失返回 null、失败静默。
package/dist/cli.js CHANGED
@@ -683,7 +683,7 @@ async function handleSessions(options) {
683
683
  }));
684
684
  const codexEntries = codexSessions.map(async (session) => ({
685
685
  name: `codex/${session.relativePath.replaceAll("\\", "/")}`,
686
- data: await fsRead(session.filePath),
686
+ data: Buffer.from(session.content, "utf8"),
687
687
  }));
688
688
  const entries = await Promise.all([...claudeEntries, ...codexEntries]);
689
689
  const zipBuffer = await buildZipBuffer(entries);
@@ -61,6 +61,74 @@ async function collectRolloutFiles(directoryPath) {
61
61
  throw error;
62
62
  }
63
63
  }
64
+ function rolloutPathKey(relativePath) {
65
+ const normalized = relativePath.replaceAll("\\", "/");
66
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
67
+ }
68
+ /** 收集标准 Codex 与 Orca session,并按 sessions 下的相对路径归并同一 rollout。 */
69
+ async function collectRolloutFileGroups() {
70
+ const sessionDirs = (0, paths_1.getCodexSessionHomes)().map((home) => node_path_1.default.join(home, "sessions"));
71
+ const filesByDir = await Promise.all(sessionDirs.map((directory) => collectRolloutFiles(directory)));
72
+ const groups = new Map();
73
+ for (let index = 0; index < sessionDirs.length; index += 1) {
74
+ const sessionsDir = sessionDirs[index];
75
+ for (const filePath of filesByDir[index]) {
76
+ const relativePath = node_path_1.default.relative(sessionsDir, filePath);
77
+ const key = rolloutPathKey(relativePath);
78
+ const group = groups.get(key);
79
+ if (group) {
80
+ group.filePaths.push(filePath);
81
+ }
82
+ else {
83
+ groups.set(key, { relativePath, filePaths: [filePath] });
84
+ }
85
+ }
86
+ }
87
+ return [...groups.values()];
88
+ }
89
+ function rolloutLineKey(line) {
90
+ try {
91
+ return JSON.stringify(JSON.parse(line));
92
+ }
93
+ catch {
94
+ return line;
95
+ }
96
+ }
97
+ /**
98
+ * 合并同一 rollout 在不同根目录下的副本。
99
+ *
100
+ * 对每种 JSONL 行保留各副本中的最大出现次数,既消除完整/部分镜像造成的重复,
101
+ * 又保留任一副本独有的新增事件以及单个源内真实存在的重复行。
102
+ */
103
+ function mergeRolloutContents(contents) {
104
+ const mergedLines = [];
105
+ const mergedCounts = new Map();
106
+ for (const content of contents) {
107
+ const sourceCounts = new Map();
108
+ const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
109
+ for (const line of lines) {
110
+ const key = rolloutLineKey(line);
111
+ const sourceCount = (sourceCounts.get(key) ?? 0) + 1;
112
+ sourceCounts.set(key, sourceCount);
113
+ if (sourceCount > (mergedCounts.get(key) ?? 0)) {
114
+ mergedCounts.set(key, sourceCount);
115
+ mergedLines.push(line);
116
+ }
117
+ }
118
+ }
119
+ return mergedLines.length > 0 ? `${mergedLines.join("\n")}\n` : "";
120
+ }
121
+ async function readMergedRollout(group) {
122
+ const reads = await Promise.all(group.filePaths.map(async (filePath) => {
123
+ try {
124
+ return await promises_1.default.readFile(filePath, "utf8");
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }));
130
+ return mergeRolloutContents(reads.filter((content) => content !== null));
131
+ }
64
132
  function timestampInRange(timestamp, start, end) {
65
133
  if (!timestamp) {
66
134
  return false;
@@ -69,17 +137,16 @@ function timestampInRange(timestamp, start, end) {
69
137
  return Number.isFinite(value) && value >= start.getTime() && value <= end.getTime();
70
138
  }
71
139
  /**
72
- * 找出 `<CODEX_HOME>/sessions` 中在指定时间范围内有活动的 rollout 文件。
140
+ * 找出标准 Codex 与 Orca sessions 中在指定时间范围内有活动的 rollout 文件。
73
141
  *
74
- * 只判断文件里是否存在范围内的记录,不过滤内容,导出时完整复制原始文件。
142
+ * 只判断合并后的文件里是否存在范围内的记录,不过滤内容,导出时写入完整合并结果。
75
143
  */
76
144
  async function findActiveCodexSessionFiles(start, end) {
77
- const sessionsDir = node_path_1.default.join((0, paths_1.getCodexHome)(), "sessions");
78
- const files = await collectRolloutFiles(sessionsDir);
145
+ const groups = await collectRolloutFileGroups();
79
146
  const result = [];
80
- for (const filePath of files) {
147
+ for (const group of groups) {
81
148
  try {
82
- const content = await promises_1.default.readFile(filePath, "utf8");
149
+ const content = await readMergedRollout(group);
83
150
  const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
84
151
  let hasInRange = false;
85
152
  for (const line of lines) {
@@ -96,8 +163,9 @@ async function findActiveCodexSessionFiles(start, end) {
96
163
  }
97
164
  if (hasInRange) {
98
165
  result.push({
99
- filePath,
100
- relativePath: node_path_1.default.relative(sessionsDir, filePath),
166
+ filePath: group.filePaths[0],
167
+ relativePath: group.relativePath,
168
+ content,
101
169
  });
102
170
  }
103
171
  }
@@ -172,7 +240,7 @@ function summarizeRollout(content, start, end) {
172
240
  return result;
173
241
  }
174
242
  /**
175
- * Codex 本地 session rollout(<CODEX_HOME>/sessions 下递归的 .jsonl)统计消息数、请求数和 token 用量。
243
+ * 从标准 Codex 与 Orca 本地 session rollout 统计消息数、请求数和 token 用量。
176
244
  * Codex Desktop 的 guardian 安全审查 rollout 整体排除,避免把内部审批轮次计为用户使用量。
177
245
  *
178
246
  * 消息数 = task_started 的 distinct turn_id(跨文件去重)。重放副本会让同一 turn_id 出现在多个文件,
@@ -180,16 +248,15 @@ function summarizeRollout(content, start, end) {
180
248
  */
181
249
  async function summarizeCodexSessionUsage(start, end) {
182
250
  const codexDataDir = (0, paths_1.getCodexHome)();
183
- const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
184
- const files = await collectRolloutFiles(sessionsDir);
251
+ const groups = await collectRolloutFileGroups();
185
252
  const turnMinMs = new Map();
186
253
  let apiRequestCount = 0;
187
254
  let inputTokens = 0;
188
255
  let outputTokens = 0;
189
256
  let cacheReadInputTokens = 0;
190
- for (const filePath of files) {
257
+ for (const group of groups) {
191
258
  try {
192
- const content = await promises_1.default.readFile(filePath, "utf8");
259
+ const content = await readMergedRollout(group);
193
260
  if (isGuardianRollout(content)) {
194
261
  continue;
195
262
  }
@@ -215,7 +282,7 @@ async function summarizeCodexSessionUsage(start, end) {
215
282
  inputTokens,
216
283
  outputTokens,
217
284
  cacheReadInputTokens,
218
- matchedFileCount: files.length,
285
+ matchedFileCount: groups.length,
219
286
  codexDataDir,
220
287
  };
221
288
  }
@@ -227,9 +294,7 @@ async function summarizeCodexSessionUsage(start, end) {
227
294
  * 且重放副本跨天不重复)。token 维度按 token_count 事件 timestamp 的本地日累加。
228
295
  */
229
296
  async function summarizeCodexSessionUsageByDay(start, end) {
230
- const codexDataDir = (0, paths_1.getCodexHome)();
231
- const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
232
- const files = await collectRolloutFiles(sessionsDir);
297
+ const groups = await collectRolloutFileGroups();
233
298
  const turnMinMs = new Map();
234
299
  const daily = new Map();
235
300
  const ensureDay = (date) => {
@@ -247,14 +312,8 @@ async function summarizeCodexSessionUsageByDay(start, end) {
247
312
  }
248
313
  return current;
249
314
  };
250
- for (const filePath of files) {
251
- let content = "";
252
- try {
253
- content = await promises_1.default.readFile(filePath, "utf8");
254
- }
255
- catch {
256
- continue;
257
- }
315
+ for (const group of groups) {
316
+ const content = await readMergedRollout(group);
258
317
  if (isGuardianRollout(content)) {
259
318
  continue;
260
319
  }
package/dist/lib/paths.js CHANGED
@@ -13,6 +13,8 @@ exports.getApiConfigPath = getApiConfigPath;
13
13
  exports.getApiQuotaCachePath = getApiQuotaCachePath;
14
14
  exports.getCodexQuotaCachePath = getCodexQuotaCachePath;
15
15
  exports.getCodexHome = getCodexHome;
16
+ exports.getOrcaCodexHome = getOrcaCodexHome;
17
+ exports.getCodexSessionHomes = getCodexSessionHomes;
16
18
  exports.getCodexConfigPath = getCodexConfigPath;
17
19
  exports.getCodexHooksPath = getCodexHooksPath;
18
20
  exports.getClaudeDataDir = getClaudeDataDir;
@@ -78,6 +80,41 @@ function getCodexQuotaCachePath(dataDir) {
78
80
  function getCodexHome() {
79
81
  return process.env.CODEX_HOME ?? node_path_1.default.join(node_os_1.default.homedir(), ".codex");
80
82
  }
83
+ /** Orca 内置 Codex 的运行时数据目录;非 Orca 环境下返回 null。 */
84
+ function getOrcaCodexHome() {
85
+ const appData = process.env.APPDATA;
86
+ return appData ? node_path_1.default.join(appData, "orca", "codex-runtime-home", "home") : null;
87
+ }
88
+ /**
89
+ * Codex session 的全部数据源。
90
+ *
91
+ * 默认合并 `~/.codex` 与 Orca runtime home;`CODEX_HOME` 指向 Orca 时仍保留
92
+ * `~/.codex`,其它显式覆盖则沿用原有覆盖语义。路径按平台规则去重。
93
+ */
94
+ function getCodexSessionHomes() {
95
+ const configuredHome = node_path_1.default.resolve(getCodexHome());
96
+ const defaultHome = node_path_1.default.resolve(node_path_1.default.join(node_os_1.default.homedir(), ".codex"));
97
+ const orcaHome = getOrcaCodexHome();
98
+ const candidates = orcaHome && samePath(configuredHome, orcaHome)
99
+ ? [defaultHome, configuredHome]
100
+ : [configuredHome, ...(orcaHome ? [node_path_1.default.resolve(orcaHome)] : [])];
101
+ const seen = new Set();
102
+ return candidates.filter((candidate) => {
103
+ const key = pathKey(candidate);
104
+ if (seen.has(key)) {
105
+ return false;
106
+ }
107
+ seen.add(key);
108
+ return true;
109
+ });
110
+ }
111
+ function pathKey(value) {
112
+ const resolved = node_path_1.default.resolve(value);
113
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
114
+ }
115
+ function samePath(left, right) {
116
+ return pathKey(left) === pathKey(right);
117
+ }
81
118
  /** Codex CLI 用户级 config.toml 路径,`ccus install --codex` 往里写 notify。 */
82
119
  function getCodexConfigPath() {
83
120
  return node_path_1.default.join(getCodexHome(), "config.toml");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {