numable-codex-usage 0.1.0 → 0.1.2

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
@@ -1,6 +1,6 @@
1
1
  # Codex 用量采集器
2
2
 
3
- 在电脑上统计 Codex 用量,并按需同步到 Numable。首发版本为 `0.1.0`;各平台验证范围见文末兼容矩阵。
3
+ 在电脑上统计 Codex 用量,并按需同步到 Numable。当前版本为 `0.1.2`;各平台验证范围见文末兼容矩阵。
4
4
 
5
5
  需要 Node.js 18.17 或以上;建议使用当前长期支持版本。采集器不假设 Codex 自带可供外部使用的 Node.js。
6
6
 
@@ -28,7 +28,7 @@ node codex/cli.cjs status
28
28
 
29
29
  Codex 的设备删除同时清除历史统计和实时视图。删除不撤销该电脑已有的写入凭证,也不清理它的本机历史;仍在同步的电脑有新活动或下一次实时补偿上报时可以重新出现。想持续移除一台电脑,应先在那台电脑运行 `pause` 或 `uninstall`,再从另一台电脑执行 `forget`。服务在 24 小时内保留删除前的实时序号,拒绝旧包复活会话;更新的正常上报仍可恢复。
30
30
 
31
- 本机采集默认不联网。配置与历史保存在 `~/.numable/codex-usage/`,目录及文件分别以仅当前用户可访问的权限创建。
31
+ 本机统计默认不向 Numable 同步。已开启的额度检查会在采集时通过本机 Codex 协议请求官方额度,可用 `quota-auto-off` 关闭。配置与历史保存在 `~/.numable/codex-usage/`,目录及文件分别以仅当前用户可访问的权限创建。
32
32
 
33
33
  首次扫描需要读取现有会话;之后只处理追加的完整行。支持 `sessions/` 与 `archived_sessions/`,gzip 归档可直接读取;zstd 归档需要系统提供 `zstd`。缺少解码器时会标注未读取,不伪装成零用量。
34
34
 
@@ -54,6 +54,20 @@ node codex/cli.cjs quota-auto-on
54
54
 
55
55
  主动读取通过本机 Codex 协议完成,不创建会话,不要求复制 OpenAI 登录凭证。账号标识在本机匿名化;加入同一空间后使用空间范围的匿名标识,以便服务端合并同账号多台电脑的额度。不能识别账号的日志快照按设备分别展示。
56
56
 
57
+ 0.1.1 的 `init` 在首次配置或旧版未设置时开启每五分钟只读额度检查;明确执行过 `quota-auto-off` 的选择保持不变。初始化本身不启动协议读取或后台进程,也不自动上传历史。
58
+
59
+ 从0.1.0升级后,请运行以下命令;已运行的后台进程必须重启才能使用新版本:
60
+
61
+ ```sh
62
+ npm install -g numable-codex-usage@0.1.2
63
+ numable-codex stop
64
+ numable-codex init
65
+ numable-codex start
66
+ numable-codex status
67
+ ```
68
+
69
+ `status` 会显示 `quotaAuto`、`quotaStatus` 和开启指引;如需主动开启,可运行 `numable-codex quota-auto-on`。额度观测超过15分钟或窗口到期后,页面只保留带过期标识的上次值,不将其当作当前额度。电脑休眠、离线、采集停止或读取失败均可能导致观测过期。
70
+
57
71
  `quota-auto-on` 显式开启每五分钟读取,需后台采集运行;`quota-auto-off` 关闭。多个目录分别连接,单个目录失败会保留其旧观测并标注不完整。macOS 的命令行启动器不可用时,会尝试已安装桌面端的内置可执行文件;也可显式指定路径。
58
72
 
59
73
  ## 手机同步
@@ -160,3 +174,27 @@ Windows 上默认使用系统 Windows PowerShell,并额外经 cmd / PowerShell
160
174
  默认后台采集只在当前用户会话中运行;`start` 不注册开机启动服务。电脑重启后需再次运行。真实钩子和定时补偿互补,钩子尚未信任时仍可手动 `collect` 或运行 `start` 采集历史。
161
175
 
162
176
  Windows、不同 Codex 入口、四端真机与线上发布进度见 meta 仓 `doc/codex-usage-impl-plan.md`,不能用本机测试通过代替。
177
+
178
+
179
+ ## macOS 系统托管与退出诊断(0.1.2)
180
+
181
+ ```sh
182
+ npm install -g numable-codex-usage@0.1.2
183
+ numable-codex autostart-on
184
+ numable-codex status
185
+ ```
186
+
187
+ `autostart-on` 为当前用户安装 LaunchAgent,并将已有手动采集进程切换到系统托管:登录后自动启动,进程退出后由系统恢复(节流约15秒)。无需管理员权限;不会创建同步空间、修改凭证或开启已暂停的同步。首次使用仍需先 `init`,是否同步由 `connect/pause/resume` 控制。开机后需要登录该用户,退出登录或电脑休眠期间不保证上传。
188
+
189
+ 托管使用明确的命令参数,不经过 Shell,登录项不含同步令牌。启用时将当前已验证的 Node.js 可执行文件固定到状态目录的 `runtime/node`,先验证能够独立运行,避免原桌面应用升级或启动器路径失效影响后台。需要 Node.js 18.17 或以上;全局 npm 程序被移除或目录改名后,需重新安装并运行 `autostart-on`。Node.js 安全更新后应再次运行该命令刷新固定运行时。
190
+
191
+ - `stop` 会先卸载本次已加载的系统任务,再确认采集进程停止,避免刚停就被拉起;登录项文件保留,下次登录恢复。
192
+ - `start` 在有登录项时重新加载系统任务;未配置托管时沿用手动后台方式。
193
+ - `autostart-off` 移除登录项并停止后台,保留配置、历史与同步开关。
194
+ - `uninstall` 同时移除本工具系统托管和事件钩子、暂停同步,保留历史。直接 `npm uninstall` 前请先执行 `numable-codex uninstall`。
195
+
196
+ 本版系统托管支持 **macOS**。Windows / Linux 仍用原有 `start/watch/stop`,新增诊断日志同样可用;不宣称这些平台已有系统自启,Windows 原生仍待验。
197
+
198
+ `status` 新增 `background.running`、`heartbeatAt`、系统托管 `enabled/loaded`、系统记录的运行次数/最近退出码或信号、最后采集/统计上传时间与日志路径。开关开启、进程存活、心跳更新和上传成功是四个不同状态,不能只凭同步开关判断在线。
199
+
200
+ 诊断日志位于状态目录 `logs/diagnostic.jsonl`,仅当前用户可读;单文件约1 MiB,最多保留三个轮换备份。记录启动、正常停止原因、采集开始/成功/失败、耗时、连续失败次数、HTTP状态码和固定错误分类;不写错误原文、堆栈、令牌、项目/文件路径、命令参数、账号标识或会话内容。安静模式致命失败也会记录并返回非零,便于系统恢复。日志写入失败不阻止采集;SIGKILL、系统关机等可能没有退出事件,不能据此编造退出原因。
package/cli.cjs CHANGED
@@ -5,6 +5,8 @@ const path = require('node:path');
5
5
  const os = require('node:os');
6
6
  const crypto = require('node:crypto');
7
7
  const { spawn } = require('node:child_process');
8
+ const background = require('./lib/background.cjs');
9
+ const { diagnostic, errorKind, watcher } = require('./lib/diagnostics.cjs');
8
10
  const { collect, initialState, atomicWrite, hash } = require('./lib/collect.cjs');
9
11
  const { readQuotaHomes } = require('./lib/quota.cjs');
10
12
  const { sanitizeHook, absorbHook, buildLive } = require('./lib/live.cjs');
@@ -101,7 +103,7 @@ async function request(config, route, body, token) {
101
103
  const r = await fetch(config.endpoint + route, { method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: controller.signal,
102
104
  headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
103
105
  ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
104
- if (!r.ok) throw new Error(`服务请求失败(${r.status}),请检查连接后重试`);
106
+ if (!r.ok) throw Object.assign(new Error(`服务请求失败(${r.status}),请检查连接后重试`), { httpStatus: r.status });
105
107
  return await r.json();
106
108
  } finally { clearTimeout(timer); }
107
109
  }
@@ -252,7 +254,7 @@ async function scan(args, p, config) {
252
254
  async function main(argv = process.argv.slice(2)) {
253
255
  const args = parse(argv), p = paths(args);
254
256
  if (args.command === 'help' || args.command === '--help') {
255
- console.log('Codex 用量\ninit:初始化本机采集(默认不同步)\ncollect:增量采集;--rebuild:重算\nsummary:查看统计;--json:导出纯数字快照\nstatus:查看采集与同步状态\nquota:主动读取额度;--codex-binary 指定 Codex\nquota-auto-on/off:开启或关闭每五分钟读取额度\nlive-on/off:开启或关闭匿名实时状态;live:查看实时投影\ndevices:列出已接入电脑\nforget:删除 --device 指定的其他电脑数据\nconnect:创建读取凭证并开启同步\ncredentials:显示手机使用的读取凭证\npause:暂停同步\nresume:恢复已绑定的同步\njoin:从标准输入读取另一台电脑生成的加入串\nlink:生成供自己另一台电脑使用的加入串\ninstall:安装事件钩子(之后需在 Codex 中信任)\nuninstall:仅移除本工具安装的钩子,保留本机记录\nstart:后台启动并确认运行;watch:在前台每 60 秒补充采集;stop:请求停止后台采集\n通用选项:--home 目录(可重复)、--state-dir 目录、--timezone 时区、--endpoint 服务地址、--projects on|off');
257
+ console.log('Codex 用量\ninit:初始化本机采集(默认不同步)\ncollect:增量采集;--rebuild:重算\nsummary:查看统计;--json:导出纯数字快照\nstatus:查看采集与同步状态\nquota:主动读取额度;--codex-binary 指定 Codex\nquota-auto-on/off:开启或关闭每五分钟读取额度\nlive-on/off:开启或关闭匿名实时状态;live:查看实时投影\ndevices:列出已接入电脑\nforget:删除 --device 指定的其他电脑数据\nconnect:创建读取凭证并开启同步\ncredentials:显示手机使用的读取凭证\npause:暂停同步\nresume:恢复已绑定的同步\njoin:从标准输入读取另一台电脑生成的加入串\nlink:生成供自己另一台电脑使用的加入串\ninstall:安装事件钩子(之后需在 Codex 中信任)\nuninstall:仅移除本工具安装的钩子,保留本机记录\nautostart-on/off:开启/移除 macOS 登录自启与退出自动恢复\nstart:后台启动并确认运行;watch:在前台每 60 秒补充采集;stop:请求停止后台采集\n通用选项:--home 目录(可重复)、--state-dir 目录、--timezone 时区、--endpoint 服务地址、--projects on|off');
256
258
  return;
257
259
  }
258
260
  const config = configFor(args, p);
@@ -270,7 +272,12 @@ async function main(argv = process.argv.slice(2)) {
270
272
  process.stdout.write('{}\n');
271
273
  return;
272
274
  }
273
- if (args.command === 'init') { await saveConfig(p, config); output('本机采集已初始化,手机同步尚未开启'); return; }
275
+ if (args.command === 'init') {
276
+ // 首次配置或旧版未设置时启用只读额度检查,保留用户明确关闭的选择。
277
+ if (typeof read(p.config, {}).quotaAuto !== 'boolean') config.quotaAuto = true;
278
+ await saveConfig(p, config);
279
+ output(`本机采集已初始化;手机同步${config.sync ? '已开启' : '尚未开启'}。${config.quotaAuto ? '已启用每五分钟只读额度检查,运行 start 保持后台采集;可用 quota-auto-off 关闭' : '额度自动读取已关闭;可用 quota-auto-on 开启'}`); return;
280
+ }
274
281
  if (args.command === 'collect') {
275
282
  if (args.hookWorker) {
276
283
  let release;
@@ -318,10 +325,15 @@ async function main(argv = process.argv.slice(2)) {
318
325
  if (!snapshot) throw new Error('还没有用量记录,请先运行 collect');
319
326
  output(snapshot); return;
320
327
  }
328
+ if (args.command === 'autostart-on' || args.command === 'autostart-off') {
329
+ if (!fs.existsSync(p.config)) throw new Error('请先运行 init 初始化本机采集');
330
+ if (args.command === 'autostart-on') await background.enable(p.dir); else await background.disable(p.dir);
331
+ output(args.command === 'autostart-on' ? '系统托管已开启:登录后自动启动,异常退出后自动恢复' : '系统托管已移除,后台采集已停止'); return;
332
+ }
321
333
  if (args.command === 'status') {
322
334
  const snapshot = read(p.snapshot, null);
323
- output({ initialized: fs.existsSync(p.config), sync: config.sync, paired: !!config.writeToken, live: config.live,
324
- quotaAuto: config.quotaAuto === true, quotaStatus: config.quotaStatus || null, syncError: config.syncError || null,
335
+ output({ background: { ...watcher(p.dir), ...background.serviceStatus(p.dir) }, diagnosticLog: path.join(p.dir, 'logs/diagnostic.jsonl'), lastCollect: config.lastCollect || null, lastStatsPush: config.lastStatsPush || null, initialized: fs.existsSync(p.config), sync: config.sync, paired: !!config.writeToken, live: config.live,
336
+ quotaAuto: config.quotaAuto === true, quotaHint: config.quotaAuto ? '请保持后台采集运行;读取失败时检查 Codex 登录状态' : '运行 numable-codex quota-auto-on,再运行 numable-codex start', quotaStatus: config.quotaStatus || null, syncError: config.syncError ?? null,
325
337
  collectedAt: snapshot?.collectedAt || null, timezone: config.timezone, projects: config.projects,
326
338
  coverage: snapshot?.coverage || [], diagnostics: snapshot?.diagnostics || null, collection: snapshot?.collection || null,
327
339
  hooksInstalled: !!config.installed?.length, hooksTrust: config.installed?.length ? 'check-in-codex' : null });
@@ -376,13 +388,15 @@ async function main(argv = process.argv.slice(2)) {
376
388
  output('事件钩子已安装。请在 Codex 中检查并信任新增钩子,信任后才会自动采集'); return;
377
389
  }
378
390
  if (args.command === 'uninstall') {
391
+ if (background.installed(p.dir)) await background.disable(p.dir); else await background.stop(p.dir);
379
392
  uninstallHooks(config); config.installed = []; config.sync = false; await saveConfig(p, config);
380
393
  atomicWrite(path.join(p.dir, 'stop.json'), { at: Date.now() }); output('已移除本工具的事件钩子并暂停同步,本机记录仍保留'); return;
381
394
  }
382
- if (args.command === 'stop') { atomicWrite(path.join(p.dir, 'stop.json'), { at: Date.now() }); output('已请求停止后台采集'); return; }
395
+ if (args.command === 'stop') { await background.stop(p.dir); output('后台采集已停止;已配置的登录自启会在下次登录后恢复'); return; }
383
396
  if (args.command === 'start') {
384
397
  if (!fs.existsSync(p.config)) throw new Error('请先运行 init 初始化本机采集');
385
- const watchLock = path.join(p.dir, 'watch.lock'), previous = read(watchLock, null);
398
+ if (background.installed(p.dir)) { await background.managedStart(p.dir); output('系统托管的后台采集已启动'); return; }
399
+ const watchLock = path.join(p.dir, 'watch.lock'), previous = watcher(p.dir);
386
400
  if (previous?.pid) {
387
401
  try { process.kill(previous.pid, 0); output('后台采集已在运行'); return; }
388
402
  catch (e) { if (e.code !== 'ESRCH') throw e; }
@@ -390,33 +404,51 @@ async function main(argv = process.argv.slice(2)) {
390
404
  const child = spawn(process.execPath, [__filename, 'watch', '--state-dir', p.dir, '--quiet'], { detached: true, stdio: 'ignore', windowsHide: true });
391
405
  let failed = false; child.once('error', () => { failed = true; }); child.unref();
392
406
  for (let i = 0; i < 40 && !failed; i++) {
393
- if (read(watchLock, null)?.pid === child.pid) { output('后台采集已启动'); return; }
407
+ if (watcher(p.dir).pid === child.pid) { output('后台采集已启动'); return; }
394
408
  await new Promise((resolve) => setTimeout(resolve, 50));
395
409
  }
396
410
  throw new Error('未能确认后台采集启动,请运行 watch 查看状态');
397
411
  }
398
412
  if (args.command === 'watch') {
399
413
  const release = lock(path.join(p.dir, 'watch.lock')), startedAt = Date.now();
400
- let stopped = false;
401
- process.once('SIGINT', () => { stopped = true; }); process.once('SIGTERM', () => { stopped = true; });
414
+ let stopped = false, reason = 'normal', failures = 0;
415
+ diagnostic(p.dir, 'watch_start', { sync: config.sync, quotaAuto: config.quotaAuto });
416
+ atomicWrite(path.join(p.dir, 'heartbeat.json'), { pid: process.pid, at: Date.now() });
417
+ process.once('SIGINT', () => { stopped = true; reason = 'signal'; }); process.once('SIGTERM', () => { stopped = true; reason = 'signal'; });
402
418
  try {
403
419
  while (!stopped) {
404
- if ((read(path.join(p.dir, 'stop.json'), {})?.at || 0) >= startedAt) break;
405
- try { await scan(args, p, configFor(args, p)); } catch { if (!args.quiet) console.error('本轮采集未完成,将在下一轮重试'); }
420
+ if ((read(path.join(p.dir, 'stop.json'), {})?.at || 0) >= startedAt) { reason = 'stop_request'; break; }
421
+ const scanStart = Date.now(); diagnostic(p.dir, 'scan_start');
422
+ try {
423
+ await scan(args, p, configFor(args, p)); failures = 0;
424
+ const latest = read(p.config, {}); diagnostic(p.dir, 'scan_ok', { elapsedMs: Date.now() - scanStart, lastCollect: latest.lastCollect, lastStatsPush: latest.lastStatsPush, sync: latest.sync, quotaStatus: latest.quotaStatus });
425
+ } catch (error) {
426
+ failures++; diagnostic(p.dir, 'scan_failed', { elapsedMs: Date.now() - scanStart, failures, kind: errorKind(error), httpStatus: error.httpStatus });
427
+ if (!args.quiet) console.error('本轮采集未完成,将在下一轮重试');
428
+ }
406
429
  for (let i = 0; i < 60 && !stopped; i++) {
407
- if ((read(path.join(p.dir, 'stop.json'), {})?.at || 0) >= startedAt) { stopped = true; break; }
430
+ if (i % 10 === 0) atomicWrite(path.join(p.dir, 'heartbeat.json'), { pid: process.pid, at: Date.now(), failures });
431
+ if ((read(path.join(p.dir, 'stop.json'), {})?.at || 0) >= startedAt) { stopped = true; reason = 'stop_request'; break; }
408
432
  await new Promise((resolve) => setTimeout(resolve, 1000));
409
433
  }
410
434
  }
411
- } finally { release(); }
435
+ } finally { diagnostic(p.dir, 'watch_stop', { reason }); release(); }
412
436
  return;
413
437
  }
414
438
  throw new Error('无法识别命令,请运行 help 查看用法');
415
439
  }
416
- if (require.main === module) main().catch((e) => {
440
+ if (require.main === module) {
441
+ const failure = error => {
442
+ try { diagnostic(paths(parse(process.argv.slice(2))).dir, 'fatal', { kind: errorKind(error) }); } catch {}
443
+ process.exit(1);
444
+ };
445
+ process.once('uncaughtException', failure); process.once('unhandledRejection', failure);
446
+ main().catch((e) => {
447
+ try { diagnostic(paths(parse(process.argv.slice(2))).dir, 'fatal', { kind: errorKind(e), httpStatus: e.httpStatus }); } catch {}
417
448
  if (process.argv[2] === 'hook') { process.stdout.write('{}\n'); return; }
418
- if (process.argv.includes('--quiet')) return;
449
+ if (process.argv.includes('--quiet')) { process.exitCode = 1; return; }
419
450
  console.error(e.message && !/ENOENT|EACCES|ENOSPC|fetch failed/.test(e.message) ? e.message : '操作未完成,请检查本机目录、网络与可用空间后重试');
420
451
  process.exitCode = 1;
421
- });
452
+ });
453
+ }
422
454
  module.exports = { parse, paths, endpoint, configFor, lock, installHooks, uninstallHooks, scan, request, main, hookCommand };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+ const crypto = require('node:crypto');
6
+ const { spawnSync } = require('node:child_process');
7
+ const { watcher, diagnostic } = require('./diagnostics.cjs');
8
+ const { atomicWrite } = require('./collect.cjs');
9
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
10
+ function serviceSpec(dir, { home = os.homedir(), uid = process.getuid?.(), node = process.execPath, cli = path.resolve(__dirname, '../cli.cjs'), envPath = process.env.PATH || '' } = {}) {
11
+ const label = 'app.numable.codex-usage.' + crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, 12);
12
+ const file = path.join(home, 'Library/LaunchAgents', label + '.plist');
13
+ const escape = value => {
14
+ if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(value)) throw new Error('启动目录包含不支持的字符');
15
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
16
+ };
17
+ const args = [node, cli, 'watch', '--state-dir', path.resolve(dir), '--quiet'];
18
+ const searchPath = [path.dirname(node), envPath, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(':');
19
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array>${args.map(a => '<string>' + escape(a) + '</string>').join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>15</integer><key>ExitTimeOut</key><integer>20</integer><key>ProcessType</key><string>Background</string><key>EnvironmentVariables</key><dict><key>PATH</key><string>${escape(searchPath)}</string></dict><key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string></dict></plist>\n`;
20
+ return { label, file, xml, domain: 'gui/' + uid, target: 'gui/' + uid + '/' + label };
21
+ }
22
+ function control(args, allowFailure = false) {
23
+ const r = spawnSync('/bin/launchctl', args, { encoding: 'utf8', timeout: 25000 });
24
+ if (r.status !== 0 && !allowFailure) throw new Error('系统托管操作未完成,请运行 status 检查后台状态');
25
+ return r.status === 0;
26
+ }
27
+ function installed(dir) { return process.platform === 'darwin' && fs.existsSync(serviceSpec(dir).file); }
28
+ function serviceStatus(dir) {
29
+ const enabled = installed(dir);
30
+ if (!enabled) return { supported: process.platform === 'darwin', enabled: false, loaded: false };
31
+ const r = spawnSync('/bin/launchctl', ['print', serviceSpec(dir).target], { encoding: 'utf8', timeout: 10000 });
32
+ const text = r.stdout || '', number = pattern => { const m = pattern.exec(text); return m ? Number(m[1]) : null; };
33
+ return { supported: true, enabled, loaded: r.status === 0, runs: number(/\bruns = (\d+)/), lastExitCode: number(/last exit code = (\d+)/), lastSignal: number(/last terminating signal = [^\n]*?:?\s(\d+)/) };
34
+ }
35
+ async function waitRunning(dir) {
36
+ for (let i = 0; i < 100; i++) { const state = watcher(dir); if (state.running && state.heartbeatAt) return state; await delay(100); }
37
+ throw new Error('未能确认后台采集启动,请检查 status 与诊断日志');
38
+ }
39
+ async function stop(dir) {
40
+ if (installed(dir)) { const spec = serviceSpec(dir); if (control(['print', spec.target], true)) control(['bootout', spec.target]); }
41
+ atomicWrite(path.join(dir, 'stop.json'), { at: Date.now() });
42
+ for (let i = 0; i < 200; i++) { if (!watcher(dir).running) return; await delay(100); }
43
+ throw new Error('后台仍在完成本轮采集,请稍后再次运行 stop');
44
+ }
45
+ async function managedStart(dir) {
46
+ const spec = serviceSpec(dir);
47
+ if (!control(['print', spec.target], true)) control(['bootstrap', spec.domain, spec.file]);
48
+ control(['kickstart', spec.target]); return waitRunning(dir);
49
+ }
50
+ async function enable(dir) {
51
+ if (process.platform !== 'darwin') throw new Error('系统托管目前支持 macOS;当前平台请使用 start 或 watch');
52
+ const [major, minor] = process.versions.node.split('.').map(Number);
53
+ if (major < 18 || major === 18 && minor < 17) throw new Error('系统托管需要 Node.js 18.17 或以上');
54
+ await stop(dir);
55
+ // 固定本次已验证的运行时,避免启动器或桌面应用更新后原路径消失。
56
+ const runtime = path.join(dir, 'runtime'); fs.mkdirSync(runtime, { recursive: true, mode: 0o700 });
57
+ const node = path.join(runtime, 'node'), staged = node + '.tmp-' + process.pid;
58
+ fs.copyFileSync(process.execPath, staged); fs.chmodSync(staged, 0o700);
59
+ const probe = spawnSync(staged, ['--version'], { encoding: 'utf8', timeout: 10000 });
60
+ if (probe.status !== 0 || probe.stdout.trim() !== process.version) { fs.unlinkSync(staged); throw new Error('固定运行时校验失败,系统托管未启用'); }
61
+ fs.renameSync(staged, node);
62
+ const spec = serviceSpec(dir, { node }); fs.mkdirSync(path.dirname(spec.file), { recursive: true });
63
+ const temporary = spec.file + '.tmp-' + process.pid;
64
+ fs.writeFileSync(temporary, spec.xml, { mode: 0o600 }); fs.chmodSync(temporary, 0o600); fs.renameSync(temporary, spec.file);
65
+ await managedStart(dir); diagnostic(dir, 'service_on');
66
+ }
67
+ async function disable(dir) {
68
+ if (process.platform !== 'darwin') throw new Error('当前平台没有本工具的系统托管配置');
69
+ await stop(dir); const spec = serviceSpec(dir); if (fs.existsSync(spec.file)) fs.unlinkSync(spec.file); diagnostic(dir, 'service_off');
70
+ }
71
+ module.exports = { serviceSpec, serviceStatus, installed, managedStart, enable, disable, stop, waitRunning };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const VERSION = require('../package.json').version;
5
+ const EVENTS = new Set(['watch_start', 'watch_stop', 'scan_start', 'scan_ok', 'scan_failed', 'fatal', 'service_on', 'service_off']);
6
+ // 诊断仅接收固定事件与数字/布尔字段,不接受原始错误、路径、凭证或会话内容。
7
+ function diagnostic(dir, event, fields = {}) {
8
+ if (!EVENTS.has(event)) return;
9
+ try {
10
+ const logs = path.join(dir, 'logs'); fs.mkdirSync(logs, { recursive: true, mode: 0o700 });
11
+ const file = path.join(logs, 'diagnostic.jsonl');
12
+ if (fs.existsSync(file) && fs.statSync(file).size >= 1024 * 1024) {
13
+ for (let i = 2; i >= 0; i--) {
14
+ const from = i ? file + '.' + i : file, to = file + '.' + (i + 1);
15
+ if (i === 2) { try { fs.unlinkSync(to); } catch {} }
16
+ if (fs.existsSync(from)) fs.renameSync(from, to);
17
+ }
18
+ }
19
+ const safe = {};
20
+ for (const key of ['elapsedMs', 'failures', 'quotaAuto', 'sync', 'lastCollect', 'lastStatsPush']) {
21
+ const value = fields[key]; if (typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) safe[key] = value;
22
+ }
23
+ if (Number.isInteger(fields.httpStatus) && fields.httpStatus >= 100 && fields.httpStatus <= 599) safe.httpStatus = fields.httpStatus;
24
+ if (['ready', 'partial', 'unavailable'].includes(fields.quotaStatus)) safe.quotaStatus = fields.quotaStatus;
25
+ if (['signal', 'stop_request', 'normal'].includes(fields.reason)) safe.reason = fields.reason;
26
+ if (['busy', 'network', 'storage', 'operation'].includes(fields.kind)) safe.kind = fields.kind;
27
+ fs.appendFileSync(file, JSON.stringify({ at: Date.now(), pid: process.pid, version: VERSION, event, ...safe }) + '\n', { mode: 0o600 });
28
+ fs.chmodSync(file, 0o600);
29
+ } catch { /* 日志故障不能阻止采集或暴露原始异常。 */ }
30
+ }
31
+ function errorKind(error) {
32
+ if (error?.code === 'LOCK_BUSY') return 'busy';
33
+ if (['ENOENT', 'EACCES', 'ENOSPC'].includes(error?.code)) return 'storage';
34
+ if (error?.name === 'AbortError' || error?.name === 'TypeError' || /^服务请求失败/.test(error?.message || '')) return 'network';
35
+ return 'operation';
36
+ }
37
+ function watcher(dir) {
38
+ try {
39
+ const lock = JSON.parse(fs.readFileSync(path.join(dir, 'watch.lock'), 'utf8'));
40
+ if (!Number.isSafeInteger(lock.pid) || lock.pid <= 0) return { running: false };
41
+ process.kill(lock.pid, 0);
42
+ let heartbeat = null;
43
+ try { heartbeat = JSON.parse(fs.readFileSync(path.join(dir, 'heartbeat.json'), 'utf8')); } catch {}
44
+ return { running: true, pid: lock.pid, heartbeatAt: heartbeat?.pid === lock.pid ? heartbeat.at : null };
45
+ } catch { return { running: false }; }
46
+ }
47
+ module.exports = { diagnostic, errorKind, watcher };
package/lib/quota.cjs CHANGED
@@ -46,7 +46,7 @@ function readQuota({ binary = process.env.NUMABLE_CODEX_BINARY || 'codex', home,
46
46
  finish(null, { observedAt, scope: hash(salt, 'quota-scope'), supported: quotas.length > 0, quotas });
47
47
  }
48
48
  });
49
- send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'numable_codex_usage', version: '0.1.0' }, capabilities: { experimentalApi: true } } });
49
+ send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'numable_codex_usage', version: '0.1.2' }, capabilities: { experimentalApi: true } } });
50
50
  });
51
51
  }
52
52
  async function readQuotaHomes({ homes, salt, profileSalt = salt, previous, ...options }) {
package/package.json CHANGED
@@ -1,10 +1,20 @@
1
1
  {
2
2
  "name": "numable-codex-usage",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "在本机统计 Codex 用量,并按需同步到 Numable",
5
5
  "license": "MIT",
6
- "engines": { "node": ">=18.17" },
7
- "bin": { "numable-codex": "cli.cjs" },
8
- "files": ["cli.cjs", "lib/", "README.md"],
9
- "scripts": { "test": "node --test ../test/codex-*.test.cjs" }
6
+ "engines": {
7
+ "node": ">=18.17"
8
+ },
9
+ "bin": {
10
+ "numable-codex": "cli.cjs"
11
+ },
12
+ "files": [
13
+ "cli.cjs",
14
+ "lib/",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test ../test/codex-*.test.cjs"
19
+ }
10
20
  }