@token-dashboard/codex-usage-uploader 0.1.1 → 0.1.3

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  - 后台常驻运行,增量扫描本地 Codex session 数据
6
6
  - 首次安装自动补采全量历史
7
- - macOS launchd 托管,开机自启
7
+ - macOS launchd 托管,登录自启动可选
8
8
 
9
9
  > 仅支持 macOS,需要 Node.js >= 22.13.0。
10
10
 
@@ -16,7 +16,7 @@
16
16
  npx @token-dashboard/codex-usage-uploader init
17
17
  ```
18
18
 
19
- 安装过程中会自动读取 Codex 登录态(`~/.codex/auth.json`)获取身份信息。如果读取不到邮箱,会在终端交互式提示你手动填写。
19
+ 安装过程中会自动读取 Codex 登录态(`~/.codex/auth.json`)获取身份信息。如果读取不到邮箱,会在终端交互式提示你手动填写;同时会询问是否在这台 Mac 上启用登录自启动。
20
20
 
21
21
  安装完成后,`codex-usage-uploader` 命令会被写入 `~/bin/`,后续可直接使用。
22
22
 
@@ -28,7 +28,7 @@ npx @token-dashboard/codex-usage-uploader init
28
28
  codex-usage-uploader init
29
29
  ```
30
30
 
31
- 首次运行时完成安装与配置;重复运行会升级本地版本并重启后台服务。后端地址默认为 `http://101.126.66.51:8086`,如需自定义可通过 `--backend-url` 指定。
31
+ 首次运行时完成安装与配置;重复运行会升级本地版本并重启后台服务,同时可重新选择是否启用登录自启动。后端地址默认为 `http://101.126.66.51:8086`,如需自定义可通过 `--backend-url` 指定。
32
32
 
33
33
  ### 绑定身份
34
34
 
@@ -50,6 +50,24 @@ codex-usage-uploader bind --email you@company.com --yes
50
50
  | `codex-usage-uploader logs` | 查看服务日志(默认最近 100 行) |
51
51
  | `codex-usage-uploader logs --lines 500` | 查看更多日志 |
52
52
 
53
+ ### 查看本机 token 用量
54
+
55
+ ```bash
56
+ codex-usage-uploader usage
57
+ codex-usage-uploader usage --period today
58
+ codex-usage-uploader usage --period 7d
59
+ codex-usage-uploader usage --from 2026-04-01 --to 2026-04-17
60
+ ```
61
+
62
+ `usage` 是一个纯本地只读命令,不依赖后端查询,也不要求先执行 `init`。它会重新扫描当前机器上的 Codex rollout 文件,默认同时包含:
63
+
64
+ - `~/.codex/sessions`
65
+ - 同级的 `~/.codex/archived_sessions`
66
+
67
+ 默认输出是一张纯文本表格,列为 `Date`、`Models`、`Input`、`Output`、`Reasoning`、`Cache Read`、`Total Tokens`,最后一行固定为 `Total`。默认 `--period=all` 时,表格覆盖全历史所有有 token 使用的日期。
68
+
69
+ 统计口径与现有 uploader/后端保持一致,使用 `token_count` 事件中的 `last_*` token 字段,而不是累计 `total_*` 字段。切天时区使用当前机器时区。
70
+
53
71
  ### 清除本地状态
54
72
 
55
73
  ```bash
@@ -81,16 +99,20 @@ codex-usage-uploader uninstall
81
99
  | `--employee-name <name>` | 绑定姓名 |
82
100
  | `--employee-id <id>` | 绑定工号 |
83
101
  | `--interval <seconds>` | 扫描间隔秒数(默认 30) |
102
+ | `--period <value>` | `usage` 时间范围:`today`、`7d`、`30d`、`all` |
103
+ | `--from <date>` | `usage` 开始日期(`YYYY-MM-DD`) |
104
+ | `--to <date>` | `usage` 结束日期(`YYYY-MM-DD`) |
84
105
  | `--yes` | 跳过所有交互确认 |
85
106
  | `--lines <n>` | `logs` 命令输出行数(默认 100) |
86
107
  | `-h, --help` | 显示帮助 |
87
108
 
88
109
  ## 工作原理
89
110
 
90
- 1. **init** 将包安装到 `~/.codex-usage-uploader/`,绑定身份后前台执行一次全量历史补采,完成后启动 macOS launchd 后台服务。
111
+ 1. **init** 将包安装到 `~/.codex-usage-uploader/`,绑定身份后前台执行一次全量历史补采,完成后启动 macOS launchd 后台服务,并按交互选择决定是否启用登录自启动。
91
112
  2. 后台服务每 30 秒(可通过 `--interval` 调整)增量扫描 `~/.codex/sessions/` 下的 rollout 文件。
92
113
  3. 扫描到的 token 用量数据暂存在本地 SQLite 队列中,满足条件后自动封批上传至后端。
93
- 4. 所有数据在上报前已做去敏处理,仅包含 session 元信息、turn 上下文摘要和 token 计数。
114
+ 4. `usage` 命令会按需重扫本地 `sessions + archived_sessions`,直接在终端计算并展示本机 token 用量,不写入本地状态库,也不访问后端。
115
+ 5. 所有数据在上报前已做去敏处理,仅包含 session 元信息、turn 上下文摘要和 token 计数。
94
116
 
95
117
  ## 本地文件
96
118
 
@@ -99,6 +121,7 @@ codex-usage-uploader uninstall
99
121
  | `~/.codex-usage-uploader/config.json` | 运行配置 |
100
122
  | `~/.codex-usage-uploader/state.sqlite` | 本地状态数据库 |
101
123
  | `~/.codex-usage-uploader/logs/` | 服务日志 |
124
+ | `~/.codex-usage-uploader/launchd/` | launchd service plist |
102
125
 
103
126
  ## License
104
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@token-dashboard/codex-usage-uploader",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Codex 用量上报 CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,15 @@
12
12
  "README.md"
13
13
  ],
14
14
  "scripts": {
15
- "test": "node --no-warnings --test tests/*.test.mjs"
15
+ "init": "node bin/codex-usage-uploader.js init --backend-url http://localhost:8086",
16
+ "start": "node bin/codex-usage-uploader.js start",
17
+ "stop": "node bin/codex-usage-uploader.js stop",
18
+ "restart": "node bin/codex-usage-uploader.js restart",
19
+ "status": "node bin/codex-usage-uploader.js status",
20
+ "usage": "node bin/codex-usage-uploader.js usage",
21
+ "logs": "node bin/codex-usage-uploader.js logs",
22
+ "clear": "node bin/codex-usage-uploader.js clear",
23
+ "uninstall": "node bin/codex-usage-uploader.js uninstall"
16
24
  },
17
25
  "engines": {
18
26
  "node": ">=22.13.0"
package/src/cli.js CHANGED
@@ -3,9 +3,10 @@ import path from 'node:path';
3
3
  import { CodexUsageUploader } from './collector.js';
4
4
  import { identityIsBound, promptConfirm } from './auth.js';
5
5
  import { CLI_NAME, DEFAULT_BACKEND_URL, DEFAULT_CONFIG_FILE, PRODUCT_NAME } from './constants.js';
6
- import { findPackageRoot, installCurrentPackage } from './install.js';
6
+ import { ensurePathInShellConfigs, findPackageRoot, installCurrentPackage, removePathFromShellConfigs } from './install.js';
7
7
  import { LaunchdServiceManager } from './launchd.js';
8
- import { formatStatusOutput, mergeRuntimeConfig } from './runtime-config.js';
8
+ import { collectLocalUsage, getLocalTimeZone } from './local-usage.js';
9
+ import { formatStatusOutput, mergeRuntimeConfig, saveRuntimeConfig } from './runtime-config.js';
9
10
  import { StateDb } from './state-db.js';
10
11
 
11
12
  const HELP_TEXT = `${PRODUCT_NAME}
@@ -18,6 +19,7 @@ Usage:
18
19
  ${CLI_NAME} stop
19
20
  ${CLI_NAME} restart
20
21
  ${CLI_NAME} status
22
+ ${CLI_NAME} usage [--period <today|7d|30d|all>] [--from <date>] [--to <date>]
21
23
  ${CLI_NAME} logs [--lines 100]
22
24
  ${CLI_NAME} uninstall
23
25
 
@@ -33,6 +35,9 @@ Common options:
33
35
  --employee-name <name> Bind employee name
34
36
  --device-id <id> Override device ID
35
37
  --hostname <name> Override hostname
38
+ --period <value> Usage range: today, 7d, 30d, or all
39
+ --from <date> Usage range start date (YYYY-MM-DD)
40
+ --to <date> Usage range end date (YYYY-MM-DD)
36
41
  --yes Skip interactive prompts
37
42
  --package-spec <spec> npm install spec used by init
38
43
  --lines <n> Number of log lines for service logs
@@ -50,6 +55,9 @@ class CliOperationalError extends Error {
50
55
  export const cliDeps = {
51
56
  findPackageRoot,
52
57
  installCurrentPackage,
58
+ ensurePathInShellConfigs,
59
+ removePathFromShellConfigs,
60
+ promptConfirm,
53
61
  createUploader(runtime) {
54
62
  return new CodexUsageUploader({
55
63
  sessionsDir: runtime.sessionsDir,
@@ -57,6 +65,7 @@ export const cliDeps = {
57
65
  backendUrl: runtime.backendUrl,
58
66
  intervalSeconds: runtime.intervalSeconds,
59
67
  codexAuthPath: runtime.codexAuthPath,
68
+ persistentCollectorIdPath: runtime.persistentCollectorIdPath,
60
69
  });
61
70
  },
62
71
  createServiceManager(runtime) {
@@ -102,6 +111,9 @@ export function parseCliArgs(argv) {
102
111
  '--employee-name',
103
112
  '--device-id',
104
113
  '--hostname',
114
+ '--period',
115
+ '--from',
116
+ '--to',
105
117
  '--package-spec',
106
118
  '--lines',
107
119
  ]);
@@ -158,6 +170,15 @@ function assignOption(options, key, value) {
158
170
  case '--hostname':
159
171
  options.hostname = value;
160
172
  break;
173
+ case '--period':
174
+ options.period = value;
175
+ break;
176
+ case '--from':
177
+ options.from = value;
178
+ break;
179
+ case '--to':
180
+ options.to = value;
181
+ break;
161
182
  case '--package-spec':
162
183
  options.packageSpec = value;
163
184
  break;
@@ -257,44 +278,21 @@ function formatDuration(durationMs) {
257
278
  function printCatchUpProgress(event) {
258
279
  switch (event.phase) {
259
280
  case 'start':
260
- console.log(
261
- `[catch-up] start files=${event.totalFiles} queued_batches=${
262
- event.bufferingBatchCount +
263
- event.pendingBatchCount +
264
- event.retryingBatchCount
265
- } queued_events=${event.queuedEvents}`,
266
- );
281
+ console.log(`[scan] start files=${event.totalFiles}`);
267
282
  return;
268
283
  case 'file':
269
284
  console.log(
270
- `[catch-up] file ${event.filesProcessed}/${event.totalFiles} current=${event.file} events=${event.eventsParsed} queued_batches=${
271
- event.bufferingBatchCount +
272
- event.pendingBatchCount +
273
- event.retryingBatchCount
274
- } queued_events=${event.queuedEvents}`,
275
- );
276
- return;
277
- case 'upload':
278
- console.log(
279
- `[catch-up] uploaded_batches=${event.batchesUploaded} last_batch=${event.batchKey} remaining_batches=${
280
- event.bufferingBatchCount +
281
- event.pendingBatchCount +
282
- event.retryingBatchCount
283
- } remaining_events=${event.queuedEvents}`,
285
+ `[scan] file ${event.filesProcessed}/${event.totalFiles} current=${event.file} events=${event.eventsParsed}`,
284
286
  );
285
287
  return;
286
288
  case 'done':
287
289
  console.log(
288
- `[catch-up] complete files=${event.filesProcessed}/${event.totalFiles} events=${event.eventsParsed} uploaded_batches=${event.batchesUploaded} remaining_batches=${event.remainingQueuedBatches} remaining_events=${event.remainingQueuedEvents} duration=${formatDuration(event.durationMs)}`,
290
+ `[scan] complete files=${event.filesProcessed}/${event.totalFiles} events=${event.eventsParsed} pending_batches=${event.pendingBatches} duration=${formatDuration(event.durationMs)}`,
289
291
  );
290
292
  return;
291
293
  case 'error':
292
294
  console.error(
293
- `[catch-up] failed stage=${event.stage} message=${event.message} remaining_batches=${
294
- event.bufferingBatchCount +
295
- event.pendingBatchCount +
296
- event.retryingBatchCount
297
- } remaining_events=${event.queuedEvents}`,
295
+ `[scan] failed stage=${event.stage} message=${event.message}`,
298
296
  );
299
297
  return;
300
298
  }
@@ -308,17 +306,21 @@ function wrapCatchUpFailure(error) {
308
306
  );
309
307
  }
310
308
 
311
- function printPathHint(runtime) {
312
- const linkDir = path.dirname(runtime.homeBinLink);
313
- const dirs = (process.env.PATH || '').split(path.delimiter);
314
- if (dirs.includes(linkDir)) return;
315
- console.log('');
316
- console.log(
317
- `Note: ${linkDir} is not in your PATH. To use \`${CLI_NAME}\` directly, run:`,
309
+ function wrapUsageFailure(error) {
310
+ return new CliOperationalError(
311
+ `Usage query failed: ${
312
+ error instanceof Error ? error.message : String(error)
313
+ }`,
318
314
  );
319
- console.log(` export PATH="${linkDir}:$PATH"`);
320
- console.log(
321
- 'You can add this line to ~/.zshrc or ~/.bashrc to make it permanent.',
315
+ }
316
+
317
+ async function resolveAutoStartOnLogin(runtime, options) {
318
+ if (options.yes) {
319
+ return runtime.autoStartOnLogin;
320
+ }
321
+ return cliDeps.promptConfirm(
322
+ 'Start automatically when you log in on this Mac?',
323
+ runtime.autoStartOnLogin,
322
324
  );
323
325
  }
324
326
 
@@ -340,7 +342,7 @@ async function runInit(options) {
340
342
  nodePath: process.execPath,
341
343
  });
342
344
 
343
- const manager = cliDeps.createServiceManager(runtime);
345
+ let manager = cliDeps.createServiceManager(runtime);
344
346
  manager.stop();
345
347
 
346
348
  const uploader = cliDeps.createUploader(runtime);
@@ -351,25 +353,137 @@ async function runInit(options) {
351
353
  console.log(`Install root: ${runtime.installRoot}`);
352
354
  console.log(`Config file: ${runtime.configFile}`);
353
355
  printIdentity(identity);
354
- console.log('Starting foreground historical catch-up.');
356
+ console.log('Scanning local Codex sessions...');
355
357
 
358
+ let scanResult;
356
359
  try {
357
- await uploader.runForegroundCatchUp({
360
+ scanResult = await uploader.runForegroundCatchUp({
358
361
  onProgress: printCatchUpProgress,
359
362
  });
360
363
  } catch (error) {
361
364
  throw wrapCatchUpFailure(error);
362
365
  }
363
366
 
367
+ runtime.autoStartOnLogin = await resolveAutoStartOnLogin(runtime, options);
368
+ runtime = saveRuntimeConfig(runtime);
369
+ manager = cliDeps.createServiceManager(runtime);
364
370
  manager.start();
365
371
  console.log(`${PRODUCT_NAME} initialized and started.`);
372
+ console.log(
373
+ runtime.autoStartOnLogin
374
+ ? 'Login auto-start is enabled.'
375
+ : 'Login auto-start is disabled for future logins.',
376
+ );
377
+ if (scanResult.pendingBatches > 0) {
378
+ console.log(
379
+ `Background service is uploading ${scanResult.pendingBatches} batch(es) with ${scanResult.pendingEvents} event(s).`,
380
+ );
381
+ }
366
382
  console.log(`Use \`${CLI_NAME} status\` to check the local service.`);
367
- printPathHint(runtime);
383
+ const linkDir = path.dirname(runtime.homeBinLink);
384
+ const pathDirs = (process.env.PATH || '').split(path.delimiter);
385
+ if (!pathDirs.includes(linkDir)) {
386
+ cliDeps.ensurePathInShellConfigs(linkDir);
387
+ console.log(`Open a new terminal to use \`${CLI_NAME}\` directly.`);
388
+ }
368
389
  } finally {
369
390
  uploader.close();
370
391
  }
371
392
  }
372
393
 
394
+ function formatCount(value) {
395
+ return Number(value ?? 0).toLocaleString('en-US');
396
+ }
397
+
398
+ function renderTable(headers, rows, alignments = []) {
399
+ const widths = headers.map((header, index) =>
400
+ Math.max(
401
+ header.length,
402
+ ...rows.map((row) => String(row[index] ?? '').length),
403
+ ),
404
+ );
405
+ const formatCell = (cell, index) => {
406
+ const text = String(cell ?? '');
407
+ return alignments[index] === 'right'
408
+ ? text.padStart(widths[index])
409
+ : text.padEnd(widths[index]);
410
+ };
411
+ const formatRow = (row) =>
412
+ row
413
+ .map((cell, index) => formatCell(cell, index))
414
+ .join(' ')
415
+ .trimEnd();
416
+ return [
417
+ formatRow(headers),
418
+ ...rows.map((row) => formatRow(row)),
419
+ ].join('\n');
420
+ }
421
+
422
+ function formatUsageDate(dateString) {
423
+ const [year, month, day] = dateString.split('-').map(Number);
424
+ return new Intl.DateTimeFormat('en-US', {
425
+ timeZone: 'UTC',
426
+ month: 'short',
427
+ day: 'numeric',
428
+ year: 'numeric',
429
+ }).format(new Date(Date.UTC(year, month - 1, day)));
430
+ }
431
+
432
+ function printUsageReport(report) {
433
+ if (report.tokenEventCount === 0) {
434
+ console.log('No local Codex usage found for selected range.');
435
+ return;
436
+ }
437
+
438
+ const rows = report.days.map((row) => [
439
+ formatUsageDate(row.date),
440
+ row.models.join(', '),
441
+ formatCount(row.inputTokens),
442
+ formatCount(row.outputTokens),
443
+ formatCount(row.reasoningOutputTokens),
444
+ formatCount(row.cachedInputTokens),
445
+ formatCount(row.totalTokens),
446
+ ]);
447
+ rows.push([
448
+ 'Total',
449
+ '',
450
+ formatCount(report.summary.inputTokens),
451
+ formatCount(report.summary.outputTokens),
452
+ formatCount(report.summary.reasoningOutputTokens),
453
+ formatCount(report.summary.cachedInputTokens),
454
+ formatCount(report.summary.totalTokens),
455
+ ]);
456
+
457
+ console.log(
458
+ renderTable(
459
+ ['Date', 'Models', 'Input', 'Output', 'Reasoning', 'Cache Read', 'Total Tokens'],
460
+ rows,
461
+ ['left', 'left', 'right', 'right', 'right', 'right', 'right'],
462
+ ),
463
+ );
464
+ }
465
+
466
+ async function runUsage(options) {
467
+ const runtime = mergeRuntimeConfig(options.configFile, runtimeOverrides(options));
468
+
469
+ console.log('Scanning local Codex usage...');
470
+
471
+ let report;
472
+ try {
473
+ report = await collectLocalUsage({
474
+ sessionsDir: runtime.sessionsDir,
475
+ period: options.period,
476
+ from: options.from,
477
+ to: options.to,
478
+ timeZone: getLocalTimeZone(),
479
+ });
480
+ } catch (error) {
481
+ throw wrapUsageFailure(error);
482
+ }
483
+
484
+ printUsageReport(report);
485
+ }
486
+
373
487
  async function runBind(options) {
374
488
  const runtime = mergeRuntimeConfig(options.configFile, runtimeOverrides(options));
375
489
  const uploader = cliDeps.createUploader(runtime);
@@ -403,6 +517,7 @@ async function runInternalWorker(options) {
403
517
  function loadQueueStats(stateDbPath) {
404
518
  if (!fs.existsSync(stateDbPath)) {
405
519
  return {
520
+ collectorId: null,
406
521
  bufferingBatchCount: 0,
407
522
  pendingBatchCount: 0,
408
523
  retryingBatchCount: 0,
@@ -414,7 +529,11 @@ function loadQueueStats(stateDbPath) {
414
529
  }
415
530
  const stateDb = new StateDb(stateDbPath);
416
531
  try {
417
- return stateDb.getQueueStats();
532
+ const identity = stateDb.getIdentity();
533
+ return {
534
+ collectorId: identity.collectorId ?? null,
535
+ ...stateDb.getQueueStats(),
536
+ };
418
537
  } finally {
419
538
  stateDb.close();
420
539
  }
@@ -429,10 +548,13 @@ function printStatus(runtime, status) {
429
548
  ['Config exists', payload.configExists ? 'yes' : 'no'],
430
549
  ['Loaded', payload.loaded ? 'yes' : 'no'],
431
550
  ['Running', payload.running ? 'yes' : 'no'],
551
+ ['Auto start on login', payload.autoStartOnLogin ? 'yes' : 'no'],
432
552
  ['PID', payload.pid ?? '-'],
433
553
  ['State', payload.state ?? '-'],
434
554
  ['Last exit code', payload.lastExitCode ?? '-'],
435
- ['Backend URL', payload.backendUrl ?? '-'],
555
+ ['Collector ID', payload.collectorId ?? '-'],
556
+ ['Upload URL', payload.backendUrl ? `${payload.backendUrl}/codex-usage/upload` : '-'],
557
+ ['Register URL', payload.backendUrl ? `${payload.backendUrl}/codex-usage/collectors/register` : '-'],
436
558
  ['Interval', `${payload.intervalSeconds}s`],
437
559
  ['Buffering batches', payload.bufferingBatchCount],
438
560
  ['Pending batches', payload.pendingBatchCount],
@@ -450,7 +572,8 @@ function printStatus(runtime, status) {
450
572
  ['State DB', payload.stateDbPath],
451
573
  ['Stdout log', payload.stdoutLogPath],
452
574
  ['Stderr log', payload.stderrLogPath],
453
- ['Plist', payload.plistPath],
575
+ ['Service plist', payload.plistPath],
576
+ ['LaunchAgent plist', payload.launchAgentPath],
454
577
  ['Label', payload.label],
455
578
  ];
456
579
  for (const [label, value] of lines) {
@@ -479,7 +602,7 @@ async function runClear(options) {
479
602
  }
480
603
 
481
604
  if (!options.yes) {
482
- const confirmed = await promptConfirm(
605
+ const confirmed = await cliDeps.promptConfirm(
483
606
  'Clear local backfill state and queued uploads?',
484
607
  false,
485
608
  );
@@ -550,6 +673,7 @@ function runLifecycleCommand(action, options) {
550
673
  }
551
674
  case 'uninstall':
552
675
  manager.uninstall();
676
+ cliDeps.removePathFromShellConfigs();
553
677
  fs.rmSync(runtime.installRoot, { recursive: true, force: true });
554
678
  console.log(`${PRODUCT_NAME} uninstalled from ${runtime.installRoot}`);
555
679
  return;
@@ -574,6 +698,7 @@ export async function main(argv = process.argv.slice(2)) {
574
698
  'stop',
575
699
  'restart',
576
700
  'status',
701
+ 'usage',
577
702
  'logs',
578
703
  'uninstall',
579
704
  'run',
@@ -594,6 +719,9 @@ export async function main(argv = process.argv.slice(2)) {
594
719
  case 'clear':
595
720
  await runClear(options);
596
721
  return 0;
722
+ case 'usage':
723
+ await runUsage(options);
724
+ return 0;
597
725
  case 'run':
598
726
  await runInternalWorker(options);
599
727
  return 0;
package/src/collector.js CHANGED
@@ -5,11 +5,12 @@ import { createInterface } from 'node:readline';
5
5
  import { loadCodexAuthIdentity, identityIsBound, promptValue } from './auth.js';
6
6
  import { RolloutParser } from './parser.js';
7
7
  import { StateDb } from './state-db.js';
8
- import { nowTs, sleep, stableStringify } from './utils.js';
8
+ import { nowTs, sleep, stableStringify, readPersistentCollectorId, writePersistentCollectorId } from './utils.js';
9
9
  import {
10
10
  ARCHIVED_SESSIONS_SOURCE_ROOT,
11
11
  MAX_BATCH_BYTES,
12
12
  MAX_EVENTS_PER_BATCH,
13
+ PERSISTENT_COLLECTOR_ID_PATH,
13
14
  REGISTER_REFRESH_SECONDS,
14
15
  SCAN_CHUNK_MAX_BYTES,
15
16
  SCAN_CHUNK_MAX_EVENTS,
@@ -23,6 +24,7 @@ export class CodexUsageUploader {
23
24
  backendUrl,
24
25
  intervalSeconds,
25
26
  codexAuthPath,
27
+ persistentCollectorIdPath = PERSISTENT_COLLECTOR_ID_PATH,
26
28
  scanChunkMaxEvents = SCAN_CHUNK_MAX_EVENTS,
27
29
  scanChunkMaxBytes = SCAN_CHUNK_MAX_BYTES,
28
30
  }) {
@@ -31,6 +33,7 @@ export class CodexUsageUploader {
31
33
  this.backendUrl = backendUrl?.replace(/\/+$/, '') || null;
32
34
  this.intervalSeconds = intervalSeconds;
33
35
  this.codexAuthPath = codexAuthPath;
36
+ this.persistentCollectorIdPath = persistentCollectorIdPath;
34
37
  this.scanChunkMaxEvents = scanChunkMaxEvents;
35
38
  this.scanChunkMaxBytes = scanChunkMaxBytes;
36
39
  this.identity = this.ensureIdentity();
@@ -43,10 +46,12 @@ export class CodexUsageUploader {
43
46
  ensureIdentity() {
44
47
  const identity = this.stateDb.getIdentity();
45
48
  if (!identity.collectorId) {
46
- identity.collectorId = `${Date.now().toString(16)}${Math.random()
49
+ const persisted = readPersistentCollectorId(this.persistentCollectorIdPath);
50
+ identity.collectorId = persisted ?? `${Date.now().toString(16)}${Math.random()
47
51
  .toString(16)
48
52
  .slice(2, 14)}`;
49
53
  }
54
+ writePersistentCollectorId(this.persistentCollectorIdPath, identity.collectorId);
50
55
  if (!identity.deviceId) {
51
56
  identity.deviceId = `${os.hostname()}-${Math.random()
52
57
  .toString(16)
@@ -465,7 +470,7 @@ export class CodexUsageUploader {
465
470
  const response = await fetch(`${this.backendUrl}${apiPath}`, {
466
471
  method: 'POST',
467
472
  headers: { 'Content-Type': 'application/json' },
468
- body: stableStringify(payload),
473
+ body: JSON.stringify(payload),
469
474
  });
470
475
  if (!response.ok) {
471
476
  const text = await response.text();
@@ -489,7 +494,7 @@ export class CodexUsageUploader {
489
494
  this.stateDb.setCheckpoint('last_register_at', String(nowTs()));
490
495
  }
491
496
 
492
- async flushPendingBatches({ failFast = false, onBatchUploaded } = {}) {
497
+ async flushPendingBatches({ failFast = false, concurrency = 5, onBatchUploaded } = {}) {
493
498
  if (!this.backendUrl) {
494
499
  return { uploadedBatches: 0, failedBatches: 0, lastError: null };
495
500
  }
@@ -509,42 +514,70 @@ export class CodexUsageUploader {
509
514
  let uploadedBatches = 0;
510
515
  let failedBatches = 0;
511
516
  let lastError = null;
517
+ const collectorBody = this.collectorRequestBody();
512
518
 
513
- for (const row of this.stateDb.iterDuePendingBatches()) {
519
+ const rows = this.stateDb.iterDuePendingBatches();
520
+
521
+ const uploadOne = async (row) => {
514
522
  const payload = this.sanitizeUploadPayload(JSON.parse(row.payload_json));
515
523
  const requestBody = {
516
524
  idempotencyKey: row.batch_key,
517
- collector: this.collectorRequestBody(),
525
+ collector: collectorBody,
518
526
  payloadSizeBytes: Number(row.payload_bytes),
519
527
  sessions: payload.sessions ?? [],
520
528
  turns: payload.turns ?? [],
521
529
  events: payload.events ?? [],
522
530
  };
531
+ await this.postJson('/codex-usage/upload', requestBody);
532
+ };
533
+
534
+ const pending = new Set();
535
+ let rowIndex = 0;
536
+
537
+ const enqueue = () => {
538
+ while (pending.size < concurrency && rowIndex < rows.length) {
539
+ const row = rows[rowIndex++];
540
+ const task = uploadOne(row)
541
+ .then(() => {
542
+ this.stateDb.markBatchUploaded(row.id);
543
+ uploadedBatches += 1;
544
+ onBatchUploaded?.({ row, uploadedBatches });
545
+ })
546
+ .catch((error) => {
547
+ this.stateDb.markBatchFailed(
548
+ row.id,
549
+ Number(row.attempt_count) + 1,
550
+ error instanceof Error ? error.message : String(error),
551
+ );
552
+ failedBatches += 1;
553
+ lastError = error;
554
+ if (failFast) {
555
+ throw error;
556
+ }
557
+ })
558
+ .finally(() => {
559
+ pending.delete(task);
560
+ });
561
+ pending.add(task);
562
+ }
563
+ };
564
+
565
+ enqueue();
566
+ while (pending.size > 0) {
523
567
  try {
524
- await this.postJson('/codex-usage/upload', requestBody);
525
- this.stateDb.markBatchUploaded(row.id);
526
- uploadedBatches += 1;
527
- onBatchUploaded?.({
528
- row,
529
- uploadedBatches,
530
- queueStats: this.getQueueStats(),
531
- });
532
- } catch (error) {
533
- this.stateDb.markBatchFailed(
534
- row.id,
535
- Number(row.attempt_count) + 1,
536
- error instanceof Error ? error.message : String(error),
537
- );
538
- failedBatches += 1;
539
- lastError = error;
540
- if (failFast) {
541
- throw new Error(
542
- `Upload batch ${row.batch_key} failed: ${
543
- error instanceof Error ? error.message : String(error)
544
- }`,
545
- );
546
- }
568
+ await Promise.race(pending);
569
+ } catch {
570
+ if (failFast) break;
547
571
  }
572
+ enqueue();
573
+ }
574
+
575
+ if (failFast && lastError) {
576
+ throw new Error(
577
+ `Upload batch failed: ${
578
+ lastError instanceof Error ? lastError.message : String(lastError)
579
+ }`,
580
+ );
548
581
  }
549
582
 
550
583
  return { uploadedBatches, failedBatches, lastError };
@@ -578,15 +611,12 @@ export class CodexUsageUploader {
578
611
  filesProcessed: 0,
579
612
  eventsParsed: 0,
580
613
  batchesQueued: 0,
581
- batchesUploaded: 0,
582
- ...this.getQueueStats(),
583
614
  });
584
615
 
585
616
  let scanResult;
586
617
  try {
587
618
  scanResult = await this.scanSnapshotEntries(backfillSnapshot, {
588
619
  onFileProcessed: ({ entry, filesProcessed, totals }) => {
589
- const queueStats = this.getQueueStats();
590
620
  onProgress?.({
591
621
  phase: 'file',
592
622
  file: entry.progressPath ?? entry.relpath,
@@ -594,8 +624,6 @@ export class CodexUsageUploader {
594
624
  filesProcessed,
595
625
  eventsParsed: totals.events,
596
626
  batchesQueued: totals.batchesQueued,
597
- batchesUploaded: 0,
598
- ...queueStats,
599
627
  });
600
628
  },
601
629
  });
@@ -604,44 +632,13 @@ export class CodexUsageUploader {
604
632
  phase: 'error',
605
633
  stage: 'scan',
606
634
  message: error instanceof Error ? error.message : String(error),
607
- ...this.getQueueStats(),
608
635
  });
609
636
  throw error;
610
637
  }
611
638
 
612
639
  scanResult.batchesQueued += this.stateDb.sealStaleBatches(true);
613
- this.stateDb.markAllPendingDue();
614
640
 
615
- let flushResult;
616
- try {
617
- flushResult = await this.flushPendingBatches({
618
- failFast: true,
619
- onBatchUploaded: ({ row, uploadedBatches, queueStats }) => {
620
- onProgress?.({
621
- phase: 'upload',
622
- batchKey: row.batch_key,
623
- totalFiles,
624
- filesProcessed: totalFiles,
625
- eventsParsed: scanResult.events,
626
- batchesQueued: scanResult.batchesQueued,
627
- batchesUploaded: uploadedBatches,
628
- ...queueStats,
629
- });
630
- },
631
- });
632
- } catch (error) {
633
- onProgress?.({
634
- phase: 'error',
635
- stage: 'upload',
636
- message: error instanceof Error ? error.message : String(error),
637
- totalFiles,
638
- filesProcessed: totalFiles,
639
- eventsParsed: scanResult.events,
640
- batchesQueued: scanResult.batchesQueued,
641
- ...this.getQueueStats(),
642
- });
643
- throw error;
644
- }
641
+ await this.ensureRemoteRegistration();
645
642
 
646
643
  const queueStats = this.getQueueStats();
647
644
  const result = {
@@ -651,14 +648,10 @@ export class CodexUsageUploader {
651
648
  sessionsParsed: scanResult.sessions,
652
649
  turnsParsed: scanResult.turns,
653
650
  batchesQueued: scanResult.batchesQueued,
654
- batchesUploaded: flushResult.uploadedBatches,
655
- remainingQueuedBatches:
656
- queueStats.bufferingBatchCount +
657
- queueStats.pendingBatchCount +
658
- queueStats.retryingBatchCount,
659
- remainingQueuedEvents: queueStats.queuedEvents,
651
+ pendingBatches:
652
+ queueStats.pendingBatchCount + queueStats.retryingBatchCount,
653
+ pendingEvents: queueStats.queuedEvents,
660
654
  durationMs: Date.now() - startedAt,
661
- ...queueStats,
662
655
  };
663
656
 
664
657
  onProgress?.({
package/src/constants.js CHANGED
@@ -20,11 +20,20 @@ export const DEFAULT_LOCAL_BIN_PATH = path.join(DEFAULT_LOCAL_BIN_DIR, CLI_NAME)
20
20
  export const DEFAULT_HOME_BIN_LINK = path.join(os.homedir(), 'bin', CLI_NAME);
21
21
  export const DEFAULT_LAUNCHD_LABEL = 'com.token-dashboard.codex-usage-uploader';
22
22
  export const DEFAULT_PLIST_PATH = path.join(
23
+ DEFAULT_INSTALL_ROOT,
24
+ 'launchd',
25
+ `${DEFAULT_LAUNCHD_LABEL}.plist`,
26
+ );
27
+ export const DEFAULT_LAUNCH_AGENT_PATH = path.join(
23
28
  os.homedir(),
24
29
  'Library',
25
30
  'LaunchAgents',
26
31
  `${DEFAULT_LAUNCHD_LABEL}.plist`,
27
32
  );
33
+ export const PERSISTENT_COLLECTOR_ID_PATH = path.join(
34
+ os.homedir(),
35
+ '.codex-usage-uploader-collector-id',
36
+ );
28
37
  export const DEFAULT_BACKEND_URL = 'http://101.126.66.51:8086';
29
38
  export const SCAN_CHUNK_MAX_EVENTS = 50;
30
39
  export const SCAN_CHUNK_MAX_BYTES = 256 * 1024;
package/src/install.js CHANGED
@@ -1,10 +1,17 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { CLI_NAME } from './constants.js';
6
7
  import { saveRuntimeConfig } from './runtime-config.js';
7
8
 
9
+ const SHELL_CONFIG_MARKER = '# Added by codex-usage-uploader';
10
+ const SHELL_CONFIG_FILES = [
11
+ path.join(os.homedir(), '.zshrc'),
12
+ path.join(os.homedir(), '.bash_profile'),
13
+ ];
14
+
8
15
  export function findPackageRoot(startUrl = import.meta.url) {
9
16
  let current = path.dirname(fileURLToPath(startUrl));
10
17
  while (true) {
@@ -99,3 +106,45 @@ export function ensureHomeBinLink(runtime) {
99
106
  }
100
107
  fs.symlinkSync(runtime.localBinPath, runtime.homeBinLink);
101
108
  }
109
+
110
+ export function ensurePathInShellConfigs(binDir) {
111
+ const exportLine = `export PATH="${binDir}:$PATH"`;
112
+ for (const configFile of SHELL_CONFIG_FILES) {
113
+ try {
114
+ if (!fs.existsSync(configFile)) continue;
115
+ const content = fs.readFileSync(configFile, 'utf8');
116
+ if (content.includes(SHELL_CONFIG_MARKER)) continue;
117
+ const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
118
+ fs.appendFileSync(configFile, `${prefix}\n${SHELL_CONFIG_MARKER}\n${exportLine}\n`);
119
+ } catch {
120
+ // best-effort, ignore errors
121
+ }
122
+ }
123
+ }
124
+
125
+ export function removePathFromShellConfigs() {
126
+ for (const configFile of SHELL_CONFIG_FILES) {
127
+ try {
128
+ if (!fs.existsSync(configFile)) continue;
129
+ const content = fs.readFileSync(configFile, 'utf8');
130
+ if (!content.includes(SHELL_CONFIG_MARKER)) continue;
131
+ const lines = content.split('\n');
132
+ const filtered = [];
133
+ for (let i = 0; i < lines.length; i++) {
134
+ if (lines[i] === SHELL_CONFIG_MARKER) {
135
+ if (i + 1 < lines.length && lines[i + 1].startsWith('export PATH=')) {
136
+ i += 1;
137
+ }
138
+ if (filtered.length > 0 && filtered[filtered.length - 1] === '') {
139
+ filtered.pop();
140
+ }
141
+ continue;
142
+ }
143
+ filtered.push(lines[i]);
144
+ }
145
+ fs.writeFileSync(configFile, filtered.join('\n'));
146
+ } catch {
147
+ // best-effort, ignore errors
148
+ }
149
+ }
150
+ }
package/src/launchd.js CHANGED
@@ -96,21 +96,37 @@ export class LaunchdServiceManager {
96
96
  if (!this.runtime.entryFile || !fs.existsSync(this.runtime.entryFile)) {
97
97
  throw new Error(`installed runtime entry not found: ${this.runtime.entryFile || '(empty)'}`);
98
98
  }
99
+ const plist = buildLaunchdPlist(this.runtime);
99
100
  fs.mkdirSync(path.dirname(this.runtime.plistPath), { recursive: true });
100
101
  fs.mkdirSync(path.dirname(this.runtime.stdoutLogPath), { recursive: true });
101
- fs.writeFileSync(this.runtime.plistPath, buildLaunchdPlist(this.runtime));
102
+ fs.writeFileSync(this.runtime.plistPath, plist);
103
+ this.syncLaunchAgent(plist);
104
+ }
105
+
106
+ syncLaunchAgent(plist) {
107
+ const launchAgentPath = this.runtime.launchAgentPath;
108
+ if (!launchAgentPath) {
109
+ return;
110
+ }
111
+ if (this.runtime.autoStartOnLogin) {
112
+ fs.mkdirSync(path.dirname(launchAgentPath), { recursive: true });
113
+ fs.writeFileSync(launchAgentPath, plist);
114
+ return;
115
+ }
116
+ if (fs.existsSync(launchAgentPath)) {
117
+ fs.rmSync(launchAgentPath, { force: true });
118
+ }
102
119
  }
103
120
 
104
121
  start() {
105
122
  this.ensurePlist();
106
123
  this.stop();
107
124
  this.run(['launchctl', 'bootstrap', this.domainTarget(), this.runtime.plistPath]);
108
- this.run(['launchctl', 'kickstart', '-k', this.serviceTarget()], { check: false });
109
125
  }
110
126
 
111
127
  stop() {
112
128
  this.ensureMacos();
113
- this.run(['launchctl', 'bootout', this.domainTarget(), this.runtime.plistPath], { check: false });
129
+ this.run(['launchctl', 'bootout', this.serviceTarget()], { check: false });
114
130
  }
115
131
 
116
132
  restart() {
@@ -137,6 +153,9 @@ export class LaunchdServiceManager {
137
153
  if (fs.existsSync(this.runtime.plistPath)) {
138
154
  fs.unlinkSync(this.runtime.plistPath);
139
155
  }
156
+ if (this.runtime.launchAgentPath && fs.existsSync(this.runtime.launchAgentPath)) {
157
+ fs.unlinkSync(this.runtime.launchAgentPath);
158
+ }
140
159
  if (fs.existsSync(this.runtime.homeBinLink) && fs.lstatSync(this.runtime.homeBinLink).isSymbolicLink()) {
141
160
  try {
142
161
  const target = fs.realpathSync.native(this.runtime.homeBinLink);
@@ -0,0 +1,342 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import {
5
+ ARCHIVED_SESSIONS_SOURCE_ROOT,
6
+ SESSIONS_SOURCE_ROOT,
7
+ } from './constants.js';
8
+ import { RolloutParser } from './parser.js';
9
+
10
+ const LOCAL_USAGE_COLLECTOR = Object.freeze({ collectorId: 'local-usage' });
11
+ const VALID_PERIODS = new Set(['today', '7d', '30d', 'all']);
12
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
13
+
14
+ export function getLocalTimeZone() {
15
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
16
+ }
17
+
18
+ export function resolveUsageQuery({
19
+ period,
20
+ from,
21
+ to,
22
+ now = Date.now(),
23
+ timeZone = getLocalTimeZone(),
24
+ } = {}) {
25
+ if (period && (from || to)) {
26
+ throw new Error('--period cannot be combined with --from or --to');
27
+ }
28
+
29
+ if (period && !VALID_PERIODS.has(period)) {
30
+ throw new Error(`--period must be one of: today, 7d, 30d, all`);
31
+ }
32
+
33
+ const today = formatDateInTimeZone(now, timeZone);
34
+
35
+ if (period === 'today') {
36
+ return {
37
+ mode: 'today',
38
+ from: today,
39
+ to: today,
40
+ trendFrom: today,
41
+ trendTo: today,
42
+ };
43
+ }
44
+
45
+ if (period === '7d') {
46
+ return {
47
+ mode: '7d',
48
+ from: addDays(today, -6),
49
+ to: today,
50
+ trendFrom: addDays(today, -6),
51
+ trendTo: today,
52
+ };
53
+ }
54
+
55
+ if (period === '30d') {
56
+ return {
57
+ mode: '30d',
58
+ from: addDays(today, -29),
59
+ to: today,
60
+ trendFrom: addDays(today, -29),
61
+ trendTo: today,
62
+ };
63
+ }
64
+
65
+ if (period === 'all' || (!period && !from && !to)) {
66
+ return {
67
+ mode: 'all',
68
+ from: null,
69
+ to: null,
70
+ trendFrom: addDays(today, -29),
71
+ trendTo: today,
72
+ };
73
+ }
74
+
75
+ const normalizedFrom = from == null ? null : normalizeDateString(from, '--from');
76
+ const normalizedTo = to == null ? null : normalizeDateString(to, '--to');
77
+
78
+ if (normalizedFrom && normalizedTo && normalizedFrom > normalizedTo) {
79
+ throw new Error('--from cannot be later than --to');
80
+ }
81
+
82
+ return {
83
+ mode: 'custom',
84
+ from: normalizedFrom,
85
+ to: normalizedTo,
86
+ trendFrom: normalizedFrom,
87
+ trendTo: normalizedTo,
88
+ };
89
+ }
90
+
91
+ export async function collectLocalUsage({
92
+ sessionsDir,
93
+ period,
94
+ from,
95
+ to,
96
+ now = Date.now(),
97
+ timeZone = getLocalTimeZone(),
98
+ includeArchived = true,
99
+ } = {}) {
100
+ const range = resolveUsageQuery({ period, from, to, now, timeZone });
101
+ const archivedSessionsDir = path.join(
102
+ path.dirname(sessionsDir),
103
+ ARCHIVED_SESSIONS_SOURCE_ROOT,
104
+ );
105
+
106
+ const roots = [
107
+ { sourceRoot: SESSIONS_SOURCE_ROOT, dir: sessionsDir },
108
+ ];
109
+ if (includeArchived) {
110
+ roots.push({
111
+ sourceRoot: ARCHIVED_SESSIONS_SOURCE_ROOT,
112
+ dir: archivedSessionsDir,
113
+ });
114
+ }
115
+
116
+ const entries = roots.flatMap((root) =>
117
+ iterRolloutFiles(root.dir).map((filePath) => ({
118
+ ...root,
119
+ filePath,
120
+ relpath: path.relative(root.dir, filePath).split(path.sep).join('/'),
121
+ })),
122
+ );
123
+
124
+ entries.sort((left, right) => left.filePath.localeCompare(right.filePath));
125
+
126
+ const summary = emptyUsageRow();
127
+ const dayMap = new Map();
128
+ const trendMap = new Map();
129
+ const modelMap = new Map();
130
+ let tokenEventCount = 0;
131
+
132
+ for (const entry of entries) {
133
+ const parser = new RolloutParser(
134
+ LOCAL_USAGE_COLLECTOR,
135
+ `${entry.sourceRoot}/${entry.relpath}`,
136
+ );
137
+ const input = fs.createReadStream(entry.filePath, { encoding: 'utf8' });
138
+ const rl = createInterface({ input, crlfDelay: Infinity });
139
+ let lineNo = 0;
140
+
141
+ try {
142
+ for await (const line of rl) {
143
+ lineNo += 1;
144
+ if (!line) continue;
145
+ const parsed = parser.processLine(lineNo, line);
146
+ for (const event of parsed.events) {
147
+ const eventDate = formatDateInTimeZone(event.timestamp, timeZone);
148
+ if (!isWithinDateRange(eventDate, range.from, range.to)) {
149
+ continue;
150
+ }
151
+
152
+ const usage = usageFromEvent(event);
153
+ tokenEventCount += 1;
154
+ accumulateUsage(summary, usage);
155
+
156
+ const currentDay = dayMap.get(eventDate) ?? {
157
+ date: eventDate,
158
+ eventCount: 0,
159
+ models: new Set(),
160
+ ...emptyUsageRow(),
161
+ };
162
+ currentDay.eventCount += 1;
163
+ currentDay.models.add(usage.model);
164
+ accumulateUsage(currentDay, usage);
165
+ dayMap.set(eventDate, currentDay);
166
+
167
+ const currentModel = modelMap.get(usage.model) ?? {
168
+ model: usage.model,
169
+ eventCount: 0,
170
+ ...emptyUsageRow(),
171
+ };
172
+ currentModel.eventCount += 1;
173
+ accumulateUsage(currentModel, usage);
174
+ modelMap.set(usage.model, currentModel);
175
+
176
+ if (isWithinDateRange(eventDate, range.trendFrom, range.trendTo)) {
177
+ const currentDate = trendMap.get(eventDate) ?? {
178
+ date: eventDate,
179
+ eventCount: 0,
180
+ ...emptyUsageRow(),
181
+ };
182
+ currentDate.eventCount += 1;
183
+ accumulateUsage(currentDate, usage);
184
+ trendMap.set(eventDate, currentDate);
185
+ }
186
+ }
187
+ }
188
+ } finally {
189
+ rl.close();
190
+ }
191
+ }
192
+
193
+ const days = [...dayMap.values()]
194
+ .sort((left, right) => left.date.localeCompare(right.date))
195
+ .map((row) => ({
196
+ ...row,
197
+ models: [...row.models].sort((left, right) => left.localeCompare(right)),
198
+ }));
199
+ const trend = materializeTrendRows(range, trendMap);
200
+ const byModel = [...modelMap.values()].sort((left, right) => {
201
+ if (right.totalTokens !== left.totalTokens) {
202
+ return right.totalTokens - left.totalTokens;
203
+ }
204
+ return left.model.localeCompare(right.model);
205
+ });
206
+
207
+ return {
208
+ scope: 'local-machine',
209
+ timezone: timeZone,
210
+ range,
211
+ sources: {
212
+ sessionsDir,
213
+ archivedSessionsDir,
214
+ includeArchived,
215
+ },
216
+ filesScanned: entries.length,
217
+ tokenEventCount,
218
+ summary,
219
+ days,
220
+ trend,
221
+ byModel,
222
+ };
223
+ }
224
+
225
+ function iterRolloutFiles(rootDir) {
226
+ if (!fs.existsSync(rootDir)) return [];
227
+ const files = [];
228
+ const walk = (dir) => {
229
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
230
+ const fullPath = path.join(dir, entry.name);
231
+ if (entry.isDirectory()) {
232
+ walk(fullPath);
233
+ } else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) {
234
+ files.push(fullPath);
235
+ }
236
+ }
237
+ };
238
+ walk(rootDir);
239
+ return files;
240
+ }
241
+
242
+ function emptyUsageRow() {
243
+ return {
244
+ inputTokens: 0,
245
+ cachedInputTokens: 0,
246
+ outputTokens: 0,
247
+ reasoningOutputTokens: 0,
248
+ totalTokens: 0,
249
+ };
250
+ }
251
+
252
+ function materializeTrendRows(range, trendMap) {
253
+ const rows = [...trendMap.values()].sort((left, right) =>
254
+ left.date.localeCompare(right.date),
255
+ );
256
+ if (rows.length === 0) {
257
+ return [];
258
+ }
259
+ if (!range.trendFrom || !range.trendTo || range.trendFrom > range.trendTo) {
260
+ return rows;
261
+ }
262
+ const materialized = [];
263
+ for (let date = range.trendFrom; date <= range.trendTo; date = addDays(date, 1)) {
264
+ materialized.push(
265
+ trendMap.get(date) ?? {
266
+ date,
267
+ eventCount: 0,
268
+ ...emptyUsageRow(),
269
+ },
270
+ );
271
+ }
272
+ return materialized;
273
+ }
274
+
275
+ function usageFromEvent(event) {
276
+ return {
277
+ model: event.model ?? '(unknown)',
278
+ inputTokens: numberOrZero(event.lastInputTokens),
279
+ cachedInputTokens: numberOrZero(event.lastCachedInputTokens),
280
+ outputTokens: numberOrZero(event.lastOutputTokens),
281
+ reasoningOutputTokens: numberOrZero(event.lastReasoningOutputTokens),
282
+ totalTokens: numberOrZero(event.lastTotalTokens),
283
+ };
284
+ }
285
+
286
+ function accumulateUsage(target, usage) {
287
+ target.inputTokens += usage.inputTokens;
288
+ target.cachedInputTokens += usage.cachedInputTokens;
289
+ target.outputTokens += usage.outputTokens;
290
+ target.reasoningOutputTokens += usage.reasoningOutputTokens;
291
+ target.totalTokens += usage.totalTokens;
292
+ }
293
+
294
+ function numberOrZero(value) {
295
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
296
+ }
297
+
298
+ function formatDateInTimeZone(timestampMs, timeZone) {
299
+ const parts = new Intl.DateTimeFormat('en-US', {
300
+ timeZone,
301
+ year: 'numeric',
302
+ month: '2-digit',
303
+ day: '2-digit',
304
+ }).formatToParts(new Date(timestampMs));
305
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
306
+ return `${values.year}-${values.month}-${values.day}`;
307
+ }
308
+
309
+ function normalizeDateString(value, label) {
310
+ if (!DATE_RE.test(String(value))) {
311
+ throw new Error(`${label} must be in YYYY-MM-DD format`);
312
+ }
313
+ const [yearRaw, monthRaw, dayRaw] = String(value).split('-');
314
+ const year = Number(yearRaw);
315
+ const month = Number(monthRaw);
316
+ const day = Number(dayRaw);
317
+ const normalized = new Date(Date.UTC(year, month - 1, day));
318
+ if (
319
+ Number.isNaN(normalized.getTime()) ||
320
+ normalized.getUTCFullYear() !== year ||
321
+ normalized.getUTCMonth() !== month - 1 ||
322
+ normalized.getUTCDate() !== day
323
+ ) {
324
+ throw new Error(`${label} must be a valid calendar date`);
325
+ }
326
+ return `${yearRaw}-${monthRaw}-${dayRaw}`;
327
+ }
328
+
329
+ function addDays(dateString, deltaDays) {
330
+ const [year, month, day] = dateString.split('-').map(Number);
331
+ const shifted = new Date(Date.UTC(year, month - 1, day + deltaDays));
332
+ const nextYear = shifted.getUTCFullYear();
333
+ const nextMonth = String(shifted.getUTCMonth() + 1).padStart(2, '0');
334
+ const nextDay = String(shifted.getUTCDate()).padStart(2, '0');
335
+ return `${nextYear}-${nextMonth}-${nextDay}`;
336
+ }
337
+
338
+ function isWithinDateRange(date, from, to) {
339
+ if (from && date < from) return false;
340
+ if (to && date > to) return false;
341
+ return true;
342
+ }
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_CURRENT_APP_DIR,
10
10
  DEFAULT_HOME_BIN_LINK,
11
11
  DEFAULT_INSTALL_ROOT,
12
+ DEFAULT_LAUNCH_AGENT_PATH,
12
13
  DEFAULT_LAUNCHD_LABEL,
13
14
  DEFAULT_LOCAL_BIN_DIR,
14
15
  DEFAULT_LOCAL_BIN_PATH,
@@ -26,6 +27,7 @@ export function defaultRuntimeConfig(configFile = DEFAULT_CONFIG_FILE) {
26
27
  const installRoot = path.dirname(configFile);
27
28
  const appRoot = path.join(installRoot, 'app');
28
29
  const currentAppDir = path.join(appRoot, 'current');
30
+ const launchdLabel = DEFAULT_LAUNCHD_LABEL;
29
31
  return {
30
32
  configFile,
31
33
  installRoot,
@@ -44,8 +46,13 @@ export function defaultRuntimeConfig(configFile = DEFAULT_CONFIG_FILE) {
44
46
  homeBinLink: DEFAULT_HOME_BIN_LINK,
45
47
  stdoutLogPath: path.join(installRoot, 'logs', path.basename(DEFAULT_STDOUT_LOG_PATH)),
46
48
  stderrLogPath: path.join(installRoot, 'logs', path.basename(DEFAULT_STDERR_LOG_PATH)),
47
- launchdLabel: DEFAULT_LAUNCHD_LABEL,
48
- plistPath: DEFAULT_PLIST_PATH,
49
+ launchdLabel,
50
+ plistPath: path.join(installRoot, 'launchd', `${launchdLabel}.plist`),
51
+ launchAgentPath: path.join(
52
+ path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
53
+ `${launchdLabel}.plist`,
54
+ ),
55
+ autoStartOnLogin: true,
49
56
  };
50
57
  }
51
58
 
@@ -65,6 +72,17 @@ export function normalizeRuntimeConfig(runtime) {
65
72
  const appRoot = runtime.appRoot || path.join(installRoot, 'app');
66
73
  const currentAppDir = runtime.currentAppDir || path.join(appRoot, 'current');
67
74
  const localBinDir = runtime.localBinDir || DEFAULT_LOCAL_BIN_DIR;
75
+ const launchdLabel = runtime.launchdLabel || DEFAULT_LAUNCHD_LABEL;
76
+ const defaultPlistPath = path.join(installRoot, 'launchd', `${launchdLabel}.plist`);
77
+ const defaultLaunchAgentPath = path.join(
78
+ path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
79
+ `${launchdLabel}.plist`,
80
+ );
81
+ const legacyLaunchAgentPath = path.join(
82
+ path.dirname(DEFAULT_LAUNCH_AGENT_PATH),
83
+ `${launchdLabel}.plist`,
84
+ );
85
+ const rawPlistPath = runtime.plistPath || defaultPlistPath;
68
86
  return {
69
87
  ...runtime,
70
88
  configFile: runtime.configFile || DEFAULT_CONFIG_FILE,
@@ -84,8 +102,14 @@ export function normalizeRuntimeConfig(runtime) {
84
102
  homeBinLink: runtime.homeBinLink || DEFAULT_HOME_BIN_LINK,
85
103
  stdoutLogPath: runtime.stdoutLogPath || path.join(installRoot, 'logs', 'stdout.log'),
86
104
  stderrLogPath: runtime.stderrLogPath || path.join(installRoot, 'logs', 'stderr.log'),
87
- launchdLabel: runtime.launchdLabel || DEFAULT_LAUNCHD_LABEL,
88
- plistPath: runtime.plistPath || DEFAULT_PLIST_PATH,
105
+ launchdLabel,
106
+ plistPath:
107
+ rawPlistPath === legacyLaunchAgentPath
108
+ ? defaultPlistPath
109
+ : rawPlistPath,
110
+ launchAgentPath: runtime.launchAgentPath || defaultLaunchAgentPath,
111
+ autoStartOnLogin:
112
+ runtime.autoStartOnLogin === undefined ? true : Boolean(runtime.autoStartOnLogin),
89
113
  };
90
114
  }
91
115
 
@@ -112,6 +136,8 @@ export function saveRuntimeConfig(runtime) {
112
136
  stderrLogPath: normalized.stderrLogPath,
113
137
  launchdLabel: normalized.launchdLabel,
114
138
  plistPath: normalized.plistPath,
139
+ launchAgentPath: normalized.launchAgentPath,
140
+ autoStartOnLogin: normalized.autoStartOnLogin,
115
141
  })}\n`);
116
142
  return normalized;
117
143
  }
@@ -136,7 +162,9 @@ export function formatStatusOutput(runtime, status) {
136
162
  stdoutLogPath: runtime.stdoutLogPath,
137
163
  stderrLogPath: runtime.stderrLogPath,
138
164
  plistPath: runtime.plistPath,
165
+ launchAgentPath: runtime.launchAgentPath,
139
166
  label: runtime.launchdLabel,
167
+ autoStartOnLogin: runtime.autoStartOnLogin,
140
168
  onlineThresholdSeconds: STATUS_ONLINE_THRESHOLD_SECONDS,
141
169
  };
142
170
  }
package/src/utils.js CHANGED
@@ -51,3 +51,18 @@ export function computeEventUid(collectorId, relpath, lineNo) {
51
51
  const raw = Buffer.from(`${collectorId}|${relpath}|${lineNo}`, 'utf8');
52
52
  return createHash('sha1').update(raw).digest('hex');
53
53
  }
54
+
55
+ export function readPersistentCollectorId(filePath) {
56
+ try {
57
+ const content = fs.readFileSync(filePath, 'utf8').trim();
58
+ return content || null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ export function writePersistentCollectorId(filePath, id) {
65
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
66
+ fs.writeFileSync(tmpPath, id, 'utf8');
67
+ fs.renameSync(tmpPath, filePath);
68
+ }