ccus-cli 0.2.8 → 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 统计,进 `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);
@@ -19,6 +19,29 @@ function getNumber(value) {
19
19
  function getString(value) {
20
20
  return typeof value === "string" && value.length > 0 ? value : null;
21
21
  }
22
+ /** Guardian 是 Codex Desktop 的动作安全审查子代理,不代表用户请求或业务模型调用。 */
23
+ function isGuardianRollout(content) {
24
+ let offset = 0;
25
+ while (offset < content.length) {
26
+ const newline = content.indexOf("\n", offset);
27
+ const line = content.slice(offset, newline === -1 ? content.length : newline).trim();
28
+ offset = newline === -1 ? content.length : newline + 1;
29
+ try {
30
+ const record = JSON.parse(line);
31
+ if (!isRecord(record) || record.type !== "session_meta" || !isRecord(record.payload)) {
32
+ continue;
33
+ }
34
+ const source = record.payload.source;
35
+ return isRecord(source)
36
+ && isRecord(source.subagent)
37
+ && source.subagent.other === "guardian";
38
+ }
39
+ catch {
40
+ continue;
41
+ }
42
+ }
43
+ return false;
44
+ }
22
45
  async function collectRolloutFiles(directoryPath) {
23
46
  try {
24
47
  const entries = await promises_1.default.readdir(directoryPath, { withFileTypes: true });
@@ -38,6 +61,74 @@ async function collectRolloutFiles(directoryPath) {
38
61
  throw error;
39
62
  }
40
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
+ }
41
132
  function timestampInRange(timestamp, start, end) {
42
133
  if (!timestamp) {
43
134
  return false;
@@ -46,17 +137,16 @@ function timestampInRange(timestamp, start, end) {
46
137
  return Number.isFinite(value) && value >= start.getTime() && value <= end.getTime();
47
138
  }
48
139
  /**
49
- * 找出 `<CODEX_HOME>/sessions` 中在指定时间范围内有活动的 rollout 文件。
140
+ * 找出标准 Codex 与 Orca sessions 中在指定时间范围内有活动的 rollout 文件。
50
141
  *
51
- * 只判断文件里是否存在范围内的记录,不过滤内容,导出时完整复制原始文件。
142
+ * 只判断合并后的文件里是否存在范围内的记录,不过滤内容,导出时写入完整合并结果。
52
143
  */
53
144
  async function findActiveCodexSessionFiles(start, end) {
54
- const sessionsDir = node_path_1.default.join((0, paths_1.getCodexHome)(), "sessions");
55
- const files = await collectRolloutFiles(sessionsDir);
145
+ const groups = await collectRolloutFileGroups();
56
146
  const result = [];
57
- for (const filePath of files) {
147
+ for (const group of groups) {
58
148
  try {
59
- const content = await promises_1.default.readFile(filePath, "utf8");
149
+ const content = await readMergedRollout(group);
60
150
  const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
61
151
  let hasInRange = false;
62
152
  for (const line of lines) {
@@ -73,8 +163,9 @@ async function findActiveCodexSessionFiles(start, end) {
73
163
  }
74
164
  if (hasInRange) {
75
165
  result.push({
76
- filePath,
77
- relativePath: node_path_1.default.relative(sessionsDir, filePath),
166
+ filePath: group.filePaths[0],
167
+ relativePath: group.relativePath,
168
+ content,
78
169
  });
79
170
  }
80
171
  }
@@ -149,23 +240,26 @@ function summarizeRollout(content, start, end) {
149
240
  return result;
150
241
  }
151
242
  /**
152
- * Codex 本地 session rollout(<CODEX_HOME>/sessions 下递归的 .jsonl)统计消息数、请求数和 token 用量。
243
+ * 从标准 Codex 与 Orca 本地 session rollout 统计消息数、请求数和 token 用量。
244
+ * Codex Desktop 的 guardian 安全审查 rollout 整体排除,避免把内部审批轮次计为用户使用量。
153
245
  *
154
246
  * 消息数 = task_started 的 distinct turn_id(跨文件去重)。重放副本会让同一 turn_id 出现在多个文件,
155
247
  * 故用全局 Map<turn_id, minMs> 收集(取最早 timestamp = 真实发生时刻,早于任何重放副本),最后取 size。
156
248
  */
157
249
  async function summarizeCodexSessionUsage(start, end) {
158
250
  const codexDataDir = (0, paths_1.getCodexHome)();
159
- const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
160
- const files = await collectRolloutFiles(sessionsDir);
251
+ const groups = await collectRolloutFileGroups();
161
252
  const turnMinMs = new Map();
162
253
  let apiRequestCount = 0;
163
254
  let inputTokens = 0;
164
255
  let outputTokens = 0;
165
256
  let cacheReadInputTokens = 0;
166
- for (const filePath of files) {
257
+ for (const group of groups) {
167
258
  try {
168
- const content = await promises_1.default.readFile(filePath, "utf8");
259
+ const content = await readMergedRollout(group);
260
+ if (isGuardianRollout(content)) {
261
+ continue;
262
+ }
169
263
  const parsed = summarizeRollout(content, start, end);
170
264
  for (const turn of parsed.turns) {
171
265
  const prev = turnMinMs.get(turn.turnId);
@@ -188,20 +282,19 @@ async function summarizeCodexSessionUsage(start, end) {
188
282
  inputTokens,
189
283
  outputTokens,
190
284
  cacheReadInputTokens,
191
- matchedFileCount: files.length,
285
+ matchedFileCount: groups.length,
192
286
  codexDataDir,
193
287
  };
194
288
  }
195
289
  /**
196
290
  * 按天汇总 Codex session rollout 中的消息数、请求数和 token 用量。
291
+ * 与周汇总一致,排除 Codex Desktop 的 guardian 安全审查 rollout。
197
292
  *
198
293
  * 消息数同 weekly:先全局 Map<turn_id, minMs> 去重,再按 minMs 的本地日归桶(保证 weekly = Σ daily、
199
294
  * 且重放副本跨天不重复)。token 维度按 token_count 事件 timestamp 的本地日累加。
200
295
  */
201
296
  async function summarizeCodexSessionUsageByDay(start, end) {
202
- const codexDataDir = (0, paths_1.getCodexHome)();
203
- const sessionsDir = node_path_1.default.join(codexDataDir, "sessions");
204
- const files = await collectRolloutFiles(sessionsDir);
297
+ const groups = await collectRolloutFileGroups();
205
298
  const turnMinMs = new Map();
206
299
  const daily = new Map();
207
300
  const ensureDay = (date) => {
@@ -219,12 +312,9 @@ async function summarizeCodexSessionUsageByDay(start, end) {
219
312
  }
220
313
  return current;
221
314
  };
222
- for (const filePath of files) {
223
- let content = "";
224
- try {
225
- content = await promises_1.default.readFile(filePath, "utf8");
226
- }
227
- catch {
315
+ for (const group of groups) {
316
+ const content = await readMergedRollout(group);
317
+ if (isGuardianRollout(content)) {
228
318
  continue;
229
319
  }
230
320
  const lines = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
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.8",
3
+ "version": "0.2.10",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {