ccus-cli 0.2.28 → 0.2.30

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
@@ -206,7 +206,7 @@ ccus aggregate serve --input-dir ./team-exports
206
206
 
207
207
  - `ccus sessions` 默认打包本周;支持 `tw`、`lw`、`today`、`5h` 等与 `export` 相同的范围写法。
208
208
  - 只选择文件中至少一条记录落入时间窗的 session,但 zip 内保存完整原始文件,不裁剪内容。
209
- - Claude 文件保持 `<projectDir>/<sessionId>.jsonl` 路径;Codex rollout 合并 `<CODEX_HOME>/sessions` 与 `%APPDATA%/orca/codex-runtime-home/home/sessions`,按相对路径和 JSONL 事件去重后保持 `codex/<sessions 相对路径>`。
209
+ - Claude 文件保持 `<projectDir>/<sessionId>.jsonl` 路径;Codex rollout 合并 `<CODEX_HOME>/sessions`、`%APPDATA%/orca/codex-runtime-home/home/sessions` 与 `%APPDATA%/orca/codex-accounts/<账号 ID>/home/sessions`,自动发现所有账号,按相对路径和 JSONL 事件去重后保持 `codex/<sessions 相对路径>`。
210
210
  - 默认写入 `<data-dir>/sessions/projects_<start>_<end>_<gitUserName>.zip`;`--out` 可指定路径。
211
211
 
212
212
  ## 多人汇总
@@ -361,7 +361,7 @@ ccus install --codex --uninstall # 移除 ccus 的 Stop hook
361
361
  - **定时同步**:Stop hook 还兜底触发 `ccus sync`(与 Claude statusline 对称)——配过 `ccus sync config --target` 后,Codex 每 turn 结束都会检查 3h 周期,到期自动 export + 复制 bundle(含 Codex token/消息)到目标目录。只用 Codex、不开 Claude Code 时也能自动同步。
362
362
  - **缓存节流**:Stop 每 turn 触发,额度按 5 分钟 TTL 缓存(`codex-quota-cache.json`),新鲜缓存命中秒回不 spawn;过期后依次尝试 app-server 与 wham,两路失败返回空,不回退过期缓存。
363
363
  - **字段映射**:app-server 返回 `primary`(5h)/ `secondary`(weekly)两窗口,取各自的 `usedPercent`(驼峰,clamp 0–100);ccus 填进 `rate_limits.five_hour` / `seven_day` 的 `used_percentage`,`computeStatuslineEvent` 读时自动算出 usage。
364
- - **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` 列区分来源。
364
+ - **token / 消息 / 额度都进 export / aggregate**:Codex 的 token、用户消息数、API 请求数合并读取 `<CODEX_HOME>/sessions`、`%APPDATA%/orca/codex-runtime-home/home/sessions` 与 `%APPDATA%/orca/codex-accounts/<账号 ID>/home/sessions` 的 rollout;`CODEX_HOME` 指向 Orca runtime 或托管账号时仍扫描标准 `~/.codex/sessions`,其它自定义目录沿用原有覆盖语义。同一相对路径的副本按 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` 列区分来源。
365
365
  - **Windows**:`install --codex` 在 Windows 写 `ccus.cmd __codex-hook`(npm 全局装会生成 `ccus.cmd`);Windows 上 Stop hook 偶发收到非法 JSON(已知 bug #23784),ccus 容错按无 payload 处理、仍照常拉额度 + 落盘。
366
366
 
367
367
  > 该路径依赖 Codex 内部 app-server 协议(`account/rateLimits/read` 的返回结构)与 hooks payload schema,随版本变;解析层宽松,字段缺失返回 null、失败静默。
@@ -20,6 +20,7 @@ const paths_1 = require("./paths");
20
20
  // app-server 子进程参数:read-only + never,避免新版 Codex 拒绝已移除的 untrusted 审批策略。
21
21
  const CODEX_ARGS = ["-s", "read-only", "-a", "never", "app-server"];
22
22
  const DEFAULT_TIMEOUT_MS = 10_000;
23
+ const CLEANUP_TIMEOUT_MS = 2_000;
23
24
  // notify 每 turn 触发,额度缓存 5 分钟,命中秒回避免阻塞 Codex 主流程。
24
25
  const DEFAULT_TTL_MS = 5 * 60 * 1000;
25
26
  // ChatGPT 后端 wham/usage 回退:app-server 未返回可用额度时走,直连读 auth.json 的 OAuth token。
@@ -187,6 +188,38 @@ const defaultSpawn = (command, args, options) => {
187
188
  const merged = process.platform === "win32" ? { ...options, shell: true } : options;
188
189
  return (0, node_child_process_1.spawn)(command, args, merged);
189
190
  };
191
+ /** Windows 必须先清理整棵树,再关闭管道;先杀 shell 会失去后代进程的关联。 */
192
+ async function cleanupCodexChild(child, kill) {
193
+ try {
194
+ if (kill) {
195
+ if (process.platform === "win32" && child.pid !== undefined) {
196
+ await new Promise((resolve, reject) => {
197
+ (0, node_child_process_1.execFile)("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
198
+ windowsHide: true,
199
+ timeout: CLEANUP_TIMEOUT_MS,
200
+ }, (error) => error ? reject(error) : resolve());
201
+ });
202
+ }
203
+ else {
204
+ child.kill();
205
+ }
206
+ }
207
+ }
208
+ catch (error) {
209
+ (0, debug_1.debugLog)("codex", "process cleanup failed", error instanceof Error ? error.message : String(error));
210
+ try {
211
+ child.kill();
212
+ }
213
+ catch { /* 清理失败不覆盖查询结果。 */ }
214
+ }
215
+ finally {
216
+ // 即使 taskkill 失败,也不让后代继承的管道或进程句柄阻塞 Hook 退出。
217
+ child.stdin.destroy();
218
+ child.stdout.destroy();
219
+ child.stderr.destroy();
220
+ child.unref();
221
+ }
222
+ }
190
223
  /**
191
224
  * spawn `codex app-server` 走 JSON-RPC 握手 + `account/rateLimits/read` 拉额度。
192
225
  *
@@ -220,16 +253,12 @@ async function fetchCodexQuota(options = {}) {
220
253
  clearTimeout(timer);
221
254
  timer = null;
222
255
  }
223
- // settled 标志已保证 resolve 幂等,无需再手动摘 listener;kill 终止子进程后其 stdio 自然关闭。
224
- if (kill && child) {
225
- try {
226
- child.kill();
227
- }
228
- catch {
229
- // kill 失败不影响结果。
230
- }
256
+ if (child) {
257
+ void cleanupCodexChild(child, kill).then(() => resolve(outcome));
258
+ }
259
+ else {
260
+ resolve(outcome);
231
261
  }
232
- resolve(outcome);
233
262
  };
234
263
  try {
235
264
  child = spawnFn("codex", CODEX_ARGS, {
@@ -247,17 +276,30 @@ async function fetchCodexQuota(options = {}) {
247
276
  (0, debug_1.debugLog)("codex", "rpc timeout", { timeoutMs });
248
277
  finish(empty("error"), true);
249
278
  }, timeoutMs);
279
+ // 管道错误(例如清理期间的 EPIPE)是异步事件,不能仅靠 write 的 try/catch。
280
+ for (const stream of [child.stdin, child.stdout, child.stderr]) {
281
+ stream.on("error", (error) => {
282
+ (0, debug_1.debugLog)("codex", "stdio error", error.message);
283
+ finish(empty("error"), true);
284
+ });
285
+ }
286
+ child.stderr.resume();
250
287
  const send = (message) => {
288
+ if (settled)
289
+ return;
251
290
  try {
252
291
  child?.stdin.write(message);
253
292
  }
254
293
  catch (error) {
255
294
  (0, debug_1.debugLog)("codex", "stdin write failed", error instanceof Error ? error.message : String(error));
295
+ finish(empty("error"), true);
256
296
  }
257
297
  };
258
298
  const initId = (rpcId += 1);
259
299
  send(buildRpcMessage(initId, "initialize", { clientInfo: { name: "ccus", version: "1.0.0" } }));
260
300
  child.stdout.on("data", (chunk) => {
301
+ if (settled)
302
+ return;
261
303
  buffer += chunk.toString();
262
304
  let newline;
263
305
  while ((newline = buffer.indexOf("\n")) !== -1) {
@@ -292,13 +334,14 @@ async function fetchCodexQuota(options = {}) {
292
334
  const quota = parseRateLimitsResult(message.result);
293
335
  (0, debug_1.debugLog)("codex", "quota parsed", quota);
294
336
  finish({ ...quota, status: "ok" }, true);
337
+ return;
295
338
  }
296
339
  }
297
340
  });
298
341
  child.on("error", (err) => {
299
342
  const enoent = err.code === "ENOENT";
300
343
  (0, debug_1.debugLog)("codex", "spawn error", { code: err.code, message: err.message });
301
- finish(empty(enoent ? "unavailable" : "error"));
344
+ finish(empty(enoent ? "unavailable" : "error"), true);
302
345
  });
303
346
  child.on("close", (code) => {
304
347
  (0, debug_1.debugLog)("codex", "app-server exited before quota", { code });
package/dist/lib/paths.js CHANGED
@@ -19,6 +19,7 @@ exports.getCodexConfigPath = getCodexConfigPath;
19
19
  exports.getCodexHooksPath = getCodexHooksPath;
20
20
  exports.getClaudeDataDir = getClaudeDataDir;
21
21
  exports.getClaudeSettingsPath = getClaudeSettingsPath;
22
+ const node_fs_1 = __importDefault(require("node:fs"));
22
23
  const node_os_1 = __importDefault(require("node:os"));
23
24
  const node_path_1 = __importDefault(require("node:path"));
24
25
  /**
@@ -85,19 +86,46 @@ function getOrcaCodexHome() {
85
86
  const appData = process.env.APPDATA;
86
87
  return appData ? node_path_1.default.join(appData, "orca", "codex-runtime-home", "home") : null;
87
88
  }
89
+ /** 动态发现 Orca 托管账号,添加/移除账号后下一次扫描即可生效。 */
90
+ function getOrcaCodexAccountHomes() {
91
+ const appData = process.env.APPDATA;
92
+ if (!appData) {
93
+ return [];
94
+ }
95
+ const accountsRoot = node_path_1.default.join(appData, "orca", "codex-accounts");
96
+ try {
97
+ return node_fs_1.default.readdirSync(accountsRoot, { withFileTypes: true })
98
+ .filter((entry) => entry.isDirectory())
99
+ .map((entry) => node_path_1.default.resolve(accountsRoot, entry.name, "home"))
100
+ .sort();
101
+ }
102
+ catch (error) {
103
+ const code = error.code;
104
+ if (code === "ENOENT" || code === "ENOTDIR") {
105
+ return [];
106
+ }
107
+ throw error;
108
+ }
109
+ }
88
110
  /**
89
111
  * Codex session 的全部数据源。
90
112
  *
91
- * 默认合并 `~/.codex` 与 Orca runtime home;`CODEX_HOME` 指向 Orca 时仍保留
113
+ * 默认合并 `~/.codex`、Orca runtime home 与各托管账号 home;`CODEX_HOME` 指向 Orca 时仍保留
92
114
  * `~/.codex`,其它显式覆盖则沿用原有覆盖语义。路径按平台规则去重。
93
115
  */
94
116
  function getCodexSessionHomes() {
95
117
  const configuredHome = node_path_1.default.resolve(getCodexHome());
96
118
  const defaultHome = node_path_1.default.resolve(node_path_1.default.join(node_os_1.default.homedir(), ".codex"));
97
119
  const orcaHome = getOrcaCodexHome();
98
- const candidates = orcaHome && samePath(configuredHome, orcaHome)
99
- ? [defaultHome, configuredHome]
100
- : [configuredHome, ...(orcaHome ? [node_path_1.default.resolve(orcaHome)] : [])];
120
+ const orcaHomes = [
121
+ ...(orcaHome ? [node_path_1.default.resolve(orcaHome)] : []),
122
+ ...getOrcaCodexAccountHomes(),
123
+ ];
124
+ const candidates = [
125
+ ...(orcaHomes.some((home) => samePath(configuredHome, home)) ? [defaultHome] : []),
126
+ configuredHome,
127
+ ...orcaHomes,
128
+ ];
101
129
  const seen = new Set();
102
130
  return candidates.filter((candidate) => {
103
131
  const key = pathKey(candidate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccus-cli",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Claude Code statusline usage logger and dashboard CLI",
5
5
  "type": "commonjs",
6
6
  "bin": {