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 +2 -2
- package/dist/lib/codex-fetcher.js +53 -10
- package/dist/lib/paths.js +32 -4
- package/package.json +1 -1
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-
|
|
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-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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
|
|
99
|
-
? [
|
|
100
|
-
|
|
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);
|