ccclub 0.3.2 → 0.3.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.
Files changed (2) hide show
  1. package/dist/index.js +879 -104
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command, Option } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
- import { createInterface } from "readline/promises";
7
+ import { createInterface as createInterface2 } from "readline/promises";
8
8
  import { stdin, stdout } from "process";
9
9
  import chalk4 from "chalk";
10
10
  import ora2 from "ora";
@@ -17,11 +17,31 @@ import { existsSync } from "fs";
17
17
  import { randomBytes } from "crypto";
18
18
  import { execSync } from "child_process";
19
19
 
20
+ // ../shared/dist/types.js
21
+ var AGENT_SOURCES = ["claude", "codex", "opencode", "amp", "pi"];
22
+ var AGENT_LABELS = {
23
+ claude: "Claude",
24
+ codex: "Codex",
25
+ opencode: "OpenCode",
26
+ amp: "Amp",
27
+ pi: "pi-agent"
28
+ };
29
+
20
30
  // ../shared/dist/constants.js
21
31
  var BLOCK_DURATION_MIN = 30;
22
32
  var BLOCK_DURATION_MS = BLOCK_DURATION_MIN * 60 * 1e3;
23
33
  var DEFAULT_API_URL = "https://ccclub.dev";
24
34
  var CLAUDE_PROJECTS_DIR = ".claude/projects";
35
+ var CLAUDE_CONFIG_PROJECTS_DIR = ".config/claude/projects";
36
+ var CLAUDE_CONFIG_DIR_ENV = "CLAUDE_CONFIG_DIR";
37
+ var CODEX_HOME_ENV = "CODEX_HOME";
38
+ var OPENCODE_DATA_DIR_ENV = "OPENCODE_DATA_DIR";
39
+ var AMP_DATA_DIR_ENV = "AMP_DATA_DIR";
40
+ var PI_AGENT_DIR_ENV = "PI_AGENT_DIR";
41
+ var DEFAULT_CODEX_DIR = ".codex";
42
+ var DEFAULT_OPENCODE_DIR = ".local/share/opencode";
43
+ var DEFAULT_AMP_DIR = ".local/share/amp";
44
+ var DEFAULT_PI_AGENT_SESSIONS_DIR = ".pi/agent/sessions";
25
45
  var CCCLUB_CONFIG_DIR = ".ccclub";
26
46
  var PLAN_PRICES = {
27
47
  pro: 20,
@@ -47,12 +67,25 @@ var MODEL_PRICING = {
47
67
  "claude-3-5-sonnet-20241022": { input: 3, output: 15, cacheCreation: 3.75, cacheRead: 0.3 },
48
68
  // Haiku
49
69
  "claude-haiku-4-5-20251001": { input: 1, output: 5, cacheCreation: 1.25, cacheRead: 0.1 },
50
- "claude-3-5-haiku-20241022": { input: 0.8, output: 4, cacheCreation: 1, cacheRead: 0.08 }
70
+ "claude-3-5-haiku-20241022": { input: 0.8, output: 4, cacheCreation: 1, cacheRead: 0.08 },
71
+ // OpenAI GPT family fallbacks. Many agent logs provide exact costs; these are best-effort
72
+ // estimates for sources that only expose tokens.
73
+ "gpt-5": { input: 1.25, output: 10, cacheCreation: 0, cacheRead: 0.125 },
74
+ "gpt-5-mini": { input: 0.25, output: 2, cacheCreation: 0, cacheRead: 0.025 },
75
+ "gpt-5-nano": { input: 0.05, output: 0.4, cacheCreation: 0, cacheRead: 5e-3 }
51
76
  };
52
77
  var FAMILY_FALLBACK = {
53
78
  opus: MODEL_PRICING["claude-opus-4-6"],
54
79
  sonnet: MODEL_PRICING["claude-sonnet-4-5-20250929"],
55
- haiku: MODEL_PRICING["claude-haiku-4-5-20251001"]
80
+ haiku: MODEL_PRICING["claude-haiku-4-5-20251001"],
81
+ "gpt-5-nano": MODEL_PRICING["gpt-5-nano"],
82
+ "gpt-5-mini": MODEL_PRICING["gpt-5-mini"],
83
+ "gpt-5": MODEL_PRICING["gpt-5"],
84
+ gpt: MODEL_PRICING["gpt-5"],
85
+ o3: MODEL_PRICING["gpt-5"],
86
+ o4: MODEL_PRICING["gpt-5"],
87
+ gemini: { input: 1.25, output: 10, cacheCreation: 0, cacheRead: 0.125 },
88
+ deepseek: { input: 0.27, output: 1.1, cacheCreation: 0, cacheRead: 0.07 }
56
89
  };
57
90
  function getPricing(model) {
58
91
  if (MODEL_PRICING[model])
@@ -64,9 +97,9 @@ function getPricing(model) {
64
97
  }
65
98
  return FAMILY_FALLBACK.sonnet;
66
99
  }
67
- function calculateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) {
100
+ function calculateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, reasoningTokens = 0) {
68
101
  const pricing = getPricing(model);
69
- return (inputTokens * pricing.input + outputTokens * pricing.output + cacheCreationTokens * pricing.cacheCreation + cacheReadTokens * pricing.cacheRead) / 1e6;
102
+ return (inputTokens * pricing.input + (outputTokens + reasoningTokens) * pricing.output + cacheCreationTokens * pricing.cacheCreation + cacheReadTokens * pricing.cacheRead) / 1e6;
70
103
  }
71
104
 
72
105
  // src/config.ts
@@ -204,81 +237,721 @@ function isHookInstalled() {
204
237
  }
205
238
  }
206
239
 
240
+ // src/heartbeat.ts
241
+ import { writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
242
+ import { join as join3 } from "path";
243
+ import { homedir as homedir3 } from "os";
244
+ import { existsSync as existsSync3 } from "fs";
245
+ import { execFile } from "child_process";
246
+ var PLIST_NAME = "dev.ccclub.sync";
247
+ var LAUNCH_AGENTS_DIR = join3(homedir3(), "Library", "LaunchAgents");
248
+ var PLIST_PATH = join3(LAUNCH_AGENTS_DIR, `${PLIST_NAME}.plist`);
249
+ function getPlist() {
250
+ const logPath = join3(homedir3(), ".ccclub", "sync.log");
251
+ return `<?xml version="1.0" encoding="UTF-8"?>
252
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
253
+ <plist version="1.0">
254
+ <dict>
255
+ <key>Label</key>
256
+ <string>${PLIST_NAME}</string>
257
+ <key>ProgramArguments</key>
258
+ <array>
259
+ <string>/usr/bin/env</string>
260
+ <string>npx</string>
261
+ <string>ccclub</string>
262
+ <string>sync</string>
263
+ <string>--silent</string>
264
+ </array>
265
+ <key>StartInterval</key>
266
+ <integer>300</integer>
267
+ <key>StandardOutPath</key>
268
+ <string>${logPath}</string>
269
+ <key>StandardErrorPath</key>
270
+ <string>${logPath}</string>
271
+ <key>RunAtLoad</key>
272
+ <true/>
273
+ <key>EnvironmentVariables</key>
274
+ <dict>
275
+ <key>PATH</key>
276
+ <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
277
+ </dict>
278
+ </dict>
279
+ </plist>`;
280
+ }
281
+ async function installHeartbeat() {
282
+ if (process.platform !== "darwin") {
283
+ return false;
284
+ }
285
+ if (existsSync3(PLIST_PATH)) {
286
+ return true;
287
+ }
288
+ if (!existsSync3(LAUNCH_AGENTS_DIR)) {
289
+ await mkdir3(LAUNCH_AGENTS_DIR, { recursive: true });
290
+ }
291
+ await writeFile3(PLIST_PATH, getPlist());
292
+ try {
293
+ await new Promise((resolve2, reject) => {
294
+ execFile("launchctl", ["load", PLIST_PATH], (err) => err ? reject(err) : resolve2());
295
+ });
296
+ } catch {
297
+ }
298
+ return true;
299
+ }
300
+ function isHeartbeatInstalled() {
301
+ return existsSync3(PLIST_PATH);
302
+ }
303
+
207
304
  // src/commands/sync.ts
208
- import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
209
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
210
- import { join as join5 } from "path";
211
- import { homedir as homedir5 } from "os";
305
+ import { readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
306
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
307
+ import { join as join11 } from "path";
308
+ import { homedir as homedir11 } from "os";
212
309
  import chalk2 from "chalk";
213
310
  import ora from "ora";
214
311
 
215
- // src/collector.ts
216
- import { readFile as readFile3 } from "fs/promises";
217
- import { join as join3 } from "path";
218
- import { homedir as homedir3 } from "os";
312
+ // src/sources/amp.ts
313
+ import { join as join5 } from "path";
314
+ import { homedir as homedir5 } from "os";
315
+
316
+ // src/sources/shared.ts
317
+ import { createReadStream } from "fs";
318
+ import { stat, readFile as readFile3 } from "fs/promises";
319
+ import { homedir as homedir4 } from "os";
320
+ import { resolve, join as join4 } from "path";
321
+ import { createInterface } from "readline";
219
322
  import { glob } from "glob";
220
- async function collectUsageEntries() {
221
- const projectsDir = join3(homedir3(), CLAUDE_PROJECTS_DIR);
222
- const files = await glob("**/*.jsonl", { cwd: projectsDir, absolute: true });
223
- if (files.length === 0) {
224
- return { entries: [], humanTurns: [] };
323
+ function resolveHomePath(path) {
324
+ if (path === "~") return homedir4();
325
+ if (path.startsWith("~/")) return join4(homedir4(), path.slice(2));
326
+ return path;
327
+ }
328
+ function parsePathList(value, fallback) {
329
+ const raw = value?.trim() ? value.split(",").map((p) => p.trim()).filter(Boolean) : fallback;
330
+ return Array.from(new Set(raw.map((p) => resolve(resolveHomePath(p)))));
331
+ }
332
+ async function existingDirectories(paths) {
333
+ const results = await Promise.all(
334
+ paths.map(async (path) => {
335
+ try {
336
+ return (await stat(path)).isDirectory() ? path : null;
337
+ } catch {
338
+ return null;
339
+ }
340
+ })
341
+ );
342
+ return results.filter((path) => path !== null);
343
+ }
344
+ async function globFiles(directories, pattern) {
345
+ const groups = await Promise.all(
346
+ directories.map((cwd) => glob(pattern, { cwd, absolute: true }).catch(() => []))
347
+ );
348
+ return groups.flat().sort();
349
+ }
350
+ async function readJsonFile(file) {
351
+ try {
352
+ return JSON.parse(await readFile3(file, "utf-8"));
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+ async function readJsonlFile(file, onValue) {
358
+ const stream = createReadStream(file, { encoding: "utf-8" });
359
+ const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
360
+ for await (const line of rl) {
361
+ const trimmed = line.trim();
362
+ if (!trimmed) continue;
363
+ try {
364
+ await onValue(JSON.parse(trimmed), trimmed);
365
+ } catch {
366
+ }
367
+ }
368
+ }
369
+ function asRecord(value) {
370
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
371
+ }
372
+ function asNumber(value) {
373
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
374
+ }
375
+ function asString(value) {
376
+ return typeof value === "string" && value.trim() ? value : void 0;
377
+ }
378
+ function toIsoTimestamp(value) {
379
+ if (typeof value === "string") {
380
+ const date = new Date(value);
381
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
382
+ }
383
+ if (typeof value === "number" && Number.isFinite(value)) {
384
+ const ms = value < 1e10 ? value * 1e3 : value;
385
+ const date = new Date(ms);
386
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
387
+ }
388
+ return null;
389
+ }
390
+
391
+ // src/sources/amp.ts
392
+ function getAmpDirs() {
393
+ const dirs = parsePathList(process.env[AMP_DATA_DIR_ENV], [join5(homedir5(), DEFAULT_AMP_DIR)]);
394
+ return existingDirectories(dirs);
395
+ }
396
+ function getAmpCacheTokens(messages, toMessageId) {
397
+ if (!Array.isArray(messages)) return { cacheCreationTokens: 0, cacheReadTokens: 0 };
398
+ for (const message of messages) {
399
+ const record = asRecord(message);
400
+ if (record?.role !== "assistant" || asNumber(record.messageId) !== toMessageId) continue;
401
+ const usage = asRecord(record.usage);
402
+ return {
403
+ cacheCreationTokens: asNumber(usage?.cacheCreationInputTokens),
404
+ cacheReadTokens: asNumber(usage?.cacheReadInputTokens)
405
+ };
406
+ }
407
+ return { cacheCreationTokens: 0, cacheReadTokens: 0 };
408
+ }
409
+ function parseAmpThread(data) {
410
+ const source = "amp";
411
+ const thread = asRecord(data);
412
+ const threadId = asString(thread?.id) ?? "unknown";
413
+ const usageLedger = asRecord(thread?.usageLedger);
414
+ const events = usageLedger?.events;
415
+ if (!Array.isArray(events)) return [];
416
+ const entries = [];
417
+ for (const rawEvent of events) {
418
+ const event = asRecord(rawEvent);
419
+ if (event == null) continue;
420
+ const timestamp = toIsoTimestamp(event.timestamp);
421
+ const model = asString(event.model);
422
+ const tokens = asRecord(event.tokens);
423
+ if (timestamp == null || model == null || tokens == null) continue;
424
+ const inputTokens = asNumber(tokens.input);
425
+ const outputTokens = asNumber(tokens.output);
426
+ const toMessageId = asNumber(event.toMessageId);
427
+ const { cacheCreationTokens, cacheReadTokens } = getAmpCacheTokens(thread?.messages, toMessageId);
428
+ if (inputTokens === 0 && outputTokens === 0 && cacheCreationTokens === 0 && cacheReadTokens === 0) {
429
+ continue;
430
+ }
431
+ const requestId = [
432
+ source,
433
+ threadId,
434
+ timestamp,
435
+ model,
436
+ inputTokens,
437
+ outputTokens,
438
+ cacheCreationTokens,
439
+ cacheReadTokens,
440
+ toMessageId
441
+ ].join(":");
442
+ entries.push({
443
+ source,
444
+ timestamp,
445
+ sessionId: threadId,
446
+ requestId,
447
+ model,
448
+ inputTokens,
449
+ outputTokens,
450
+ cacheCreationTokens,
451
+ cacheReadTokens,
452
+ totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens,
453
+ costUSD: calculateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens)
454
+ });
225
455
  }
456
+ return entries;
457
+ }
458
+ async function collectAmpUsage() {
459
+ const source = "amp";
460
+ const dirs = await getAmpDirs();
461
+ const files = await globFiles(dirs.map((dir) => join5(dir, "threads")), "**/*.json");
226
462
  const entries = [];
227
- const humanTurns = [];
463
+ const turns = [];
228
464
  const seen = /* @__PURE__ */ new Set();
229
- const seenHuman = /* @__PURE__ */ new Set();
230
465
  for (const file of files) {
231
- const content = await readFile3(file, "utf-8");
232
- const lines = content.split("\n").filter((l) => l.trim());
233
- for (const line of lines) {
234
- let parsed;
235
- try {
236
- parsed = JSON.parse(line);
237
- } catch {
238
- continue;
239
- }
240
- if (parsed.type === "user" && parsed.timestamp) {
241
- const content2 = parsed.message?.content;
242
- const isToolResult = Array.isArray(content2) && content2.length > 0 && content2.every((c) => c.type === "tool_result");
243
- if (!isToolResult) {
244
- const humanKey = `${parsed.sessionId || ""}:${parsed.timestamp}`;
245
- if (!seenHuman.has(humanKey)) {
246
- seenHuman.add(humanKey);
247
- humanTurns.push(parsed.timestamp);
248
- }
466
+ for (const entry of parseAmpThread(await readJsonFile(file))) {
467
+ const key = entry.requestId ?? `${source}:${entry.sessionId}:${entry.timestamp}`;
468
+ if (seen.has(key)) continue;
469
+ seen.add(key);
470
+ entries.push(entry);
471
+ turns.push({ source, timestamp: entry.timestamp, key });
472
+ }
473
+ }
474
+ return { source, entries, turns, files: files.length, warnings: [] };
475
+ }
476
+ var ampCollector = {
477
+ source: "amp",
478
+ label: "Amp",
479
+ collect: collectAmpUsage
480
+ };
481
+
482
+ // src/sources/claude.ts
483
+ import { join as join6, basename } from "path";
484
+ import { homedir as homedir6 } from "os";
485
+ function isProjectsPath(path) {
486
+ return basename(path) === "projects";
487
+ }
488
+ async function getClaudeProjectDirs() {
489
+ const envPaths = process.env[CLAUDE_CONFIG_DIR_ENV];
490
+ const basePaths = parsePathList(
491
+ envPaths,
492
+ [join6(homedir6(), CLAUDE_CONFIG_PROJECTS_DIR), join6(homedir6(), CLAUDE_PROJECTS_DIR)]
493
+ );
494
+ const candidates = envPaths?.trim() ? basePaths.flatMap((path) => isProjectsPath(path) ? [path] : [join6(path, "projects"), path]) : basePaths;
495
+ return existingDirectories(Array.from(new Set(candidates)));
496
+ }
497
+ function isClaudeUsageEntry(value) {
498
+ const entry = value;
499
+ return entry?.type === "assistant" && typeof entry.timestamp === "string" && entry.message?.usage != null;
500
+ }
501
+ function isClaudeHumanTurn(value) {
502
+ const entry = value;
503
+ if (entry?.type !== "user" || typeof entry.timestamp !== "string") return false;
504
+ const content = entry.message?.content;
505
+ return !(Array.isArray(content) && content.length > 0 && content.every((item) => asRecord(item)?.type === "tool_result"));
506
+ }
507
+ async function collectClaudeUsage() {
508
+ const source = "claude";
509
+ const projectDirs = await getClaudeProjectDirs();
510
+ const files = await globFiles(projectDirs, "**/*.jsonl");
511
+ const entries = [];
512
+ const turns = [];
513
+ const seen = /* @__PURE__ */ new Set();
514
+ const seenTurns = /* @__PURE__ */ new Set();
515
+ for (const file of files) {
516
+ await readJsonlFile(file, (value) => {
517
+ if (isClaudeHumanTurn(value)) {
518
+ const timestamp2 = toIsoTimestamp(value.timestamp);
519
+ if (timestamp2 == null) return;
520
+ const sessionId2 = value.sessionId || "";
521
+ const key = `${source}:${sessionId2}:${timestamp2}`;
522
+ if (!seenTurns.has(key)) {
523
+ seenTurns.add(key);
524
+ turns.push({ source, timestamp: timestamp2, key });
249
525
  }
250
- continue;
251
- }
252
- if (parsed.type !== "assistant" || !parsed.message?.usage) {
253
- continue;
526
+ return;
254
527
  }
255
- const usage = parsed.message.usage;
256
- const requestId = parsed.requestId || "";
257
- const sessionId = parsed.sessionId || "";
258
- const dedupeKey = requestId ? `${sessionId}:${requestId}` : `${sessionId}:${parsed.timestamp}:${usage.input_tokens}:${usage.output_tokens}`;
259
- if (seen.has(dedupeKey)) continue;
528
+ if (!isClaudeUsageEntry(value)) return;
529
+ const timestamp = toIsoTimestamp(value.timestamp);
530
+ if (timestamp == null) return;
531
+ const usage = value.message.usage;
532
+ const sessionId = value.sessionId || "";
533
+ const requestId = asString(value.requestId);
534
+ const dedupeKey = requestId ? `${source}:${sessionId}:${requestId}` : [
535
+ source,
536
+ sessionId,
537
+ timestamp,
538
+ usage.input_tokens,
539
+ usage.output_tokens,
540
+ usage.cache_creation_input_tokens ?? 0,
541
+ usage.cache_read_input_tokens ?? 0
542
+ ].join(":");
543
+ if (seen.has(dedupeKey)) return;
260
544
  seen.add(dedupeKey);
261
545
  const inputTokens = usage.input_tokens || 0;
262
546
  const outputTokens = usage.output_tokens || 0;
263
547
  const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
264
548
  const cacheReadTokens = usage.cache_read_input_tokens || 0;
549
+ const model = value.message.model || "unknown";
550
+ const costUSD = value.costUSD && value.costUSD > 0 ? value.costUSD : calculateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens);
265
551
  entries.push({
266
- timestamp: parsed.timestamp,
552
+ source,
553
+ timestamp,
267
554
  sessionId,
268
555
  requestId,
269
- model: parsed.message.model || "unknown",
556
+ model,
270
557
  inputTokens,
271
558
  outputTokens,
272
559
  cacheCreationTokens,
273
560
  cacheReadTokens,
274
561
  totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens,
275
- costUSD: parsed.costUSD || 0
562
+ costUSD
276
563
  });
564
+ });
565
+ }
566
+ return { source, entries, turns, files: files.length, warnings: [] };
567
+ }
568
+ var claudeCollector = {
569
+ source: "claude",
570
+ label: "Claude",
571
+ collect: collectClaudeUsage
572
+ };
573
+
574
+ // src/sources/codex.ts
575
+ import { join as join7, relative, sep } from "path";
576
+ import { homedir as homedir7 } from "os";
577
+ function getCodexSessionDirs() {
578
+ const homes = parsePathList(process.env[CODEX_HOME_ENV], [join7(homedir7(), DEFAULT_CODEX_DIR)]);
579
+ return existingDirectories(homes.map((home) => join7(home, "sessions")));
580
+ }
581
+ function normalizeRawUsage(value) {
582
+ const record = asRecord(value);
583
+ if (record == null) return null;
584
+ const inputTokens = asNumber(record.input_tokens);
585
+ const cachedInputTokens = asNumber(record.cached_input_tokens ?? record.cache_read_input_tokens);
586
+ const outputTokens = asNumber(record.output_tokens);
587
+ const reasoningTokens = asNumber(record.reasoning_output_tokens);
588
+ const fallbackTotal = inputTokens + outputTokens + reasoningTokens;
589
+ const totalTokens = asNumber(record.total_tokens) || fallbackTotal;
590
+ return { inputTokens, cachedInputTokens, outputTokens, reasoningTokens, totalTokens };
591
+ }
592
+ function subtractUsage(current, previous) {
593
+ return {
594
+ inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0),
595
+ cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0),
596
+ outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0),
597
+ reasoningTokens: Math.max(current.reasoningTokens - (previous?.reasoningTokens ?? 0), 0),
598
+ totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0)
599
+ };
600
+ }
601
+ function extractModelFromPayload(payload) {
602
+ const payloadRecord = asRecord(payload);
603
+ const info = asRecord(payloadRecord?.info);
604
+ const metadata = asRecord(info?.metadata) ?? asRecord(payloadRecord?.metadata);
605
+ return asString(info?.model) ?? asString(info?.model_name) ?? asString(metadata?.model) ?? asString(payloadRecord?.model) ?? asString(payloadRecord?.model_name);
606
+ }
607
+ function sessionIdForFile(sessionDir, file) {
608
+ return relative(sessionDir, file).split(sep).join("/").replace(/\.jsonl$/i, "");
609
+ }
610
+ async function collectCodexUsage() {
611
+ const source = "codex";
612
+ const sessionDirs = await getCodexSessionDirs();
613
+ const files = await globFiles(sessionDirs, "**/*.jsonl");
614
+ const entries = [];
615
+ const turns = [];
616
+ const seen = /* @__PURE__ */ new Set();
617
+ for (const sessionDir of sessionDirs) {
618
+ const sessionFiles = files.filter((file) => file.startsWith(`${sessionDir}${sep}`));
619
+ for (const file of sessionFiles) {
620
+ const sessionId = sessionIdForFile(sessionDir, file);
621
+ let previousTotal = null;
622
+ let currentModel;
623
+ await readJsonlFile(file, (value) => {
624
+ const record = asRecord(value);
625
+ if (record == null) return;
626
+ const payload = asRecord(record.payload);
627
+ const type = asString(record.type);
628
+ if (type === "turn_context") {
629
+ currentModel = extractModelFromPayload(payload) ?? currentModel;
630
+ return;
631
+ }
632
+ if (type !== "event_msg" || payload?.type !== "token_count") return;
633
+ const timestamp = toIsoTimestamp(record.timestamp);
634
+ if (timestamp == null) return;
635
+ const info = asRecord(payload.info);
636
+ const lastUsage = normalizeRawUsage(info?.last_token_usage);
637
+ const totalUsage = normalizeRawUsage(info?.total_token_usage);
638
+ const rawUsage = lastUsage ?? (totalUsage == null ? null : subtractUsage(totalUsage, previousTotal));
639
+ if (totalUsage != null) previousTotal = totalUsage;
640
+ if (rawUsage == null) return;
641
+ currentModel = extractModelFromPayload(payload) ?? currentModel;
642
+ const model = currentModel ?? "gpt-5";
643
+ const cacheReadTokens = Math.min(rawUsage.cachedInputTokens, rawUsage.inputTokens);
644
+ const inputTokens = Math.max(rawUsage.inputTokens - cacheReadTokens, 0);
645
+ const totalTokens = rawUsage.totalTokens > 0 ? rawUsage.totalTokens : inputTokens + cacheReadTokens + rawUsage.outputTokens + rawUsage.reasoningTokens;
646
+ if (inputTokens === 0 && cacheReadTokens === 0 && rawUsage.outputTokens === 0 && rawUsage.reasoningTokens === 0) {
647
+ return;
648
+ }
649
+ const dedupeKey = [
650
+ source,
651
+ sessionId,
652
+ timestamp,
653
+ model,
654
+ inputTokens,
655
+ cacheReadTokens,
656
+ rawUsage.outputTokens,
657
+ rawUsage.reasoningTokens,
658
+ totalTokens
659
+ ].join(":");
660
+ if (seen.has(dedupeKey)) return;
661
+ seen.add(dedupeKey);
662
+ entries.push({
663
+ source,
664
+ timestamp,
665
+ sessionId,
666
+ requestId: dedupeKey,
667
+ model,
668
+ inputTokens,
669
+ outputTokens: rawUsage.outputTokens,
670
+ cacheCreationTokens: 0,
671
+ cacheReadTokens,
672
+ reasoningTokens: rawUsage.reasoningTokens,
673
+ totalTokens,
674
+ costUSD: calculateCost(model, inputTokens, rawUsage.outputTokens, 0, cacheReadTokens, rawUsage.reasoningTokens)
675
+ });
676
+ turns.push({ source, timestamp, key: dedupeKey });
677
+ });
678
+ }
679
+ }
680
+ return { source, entries, turns, files: files.length, warnings: [] };
681
+ }
682
+ var codexCollector = {
683
+ source: "codex",
684
+ label: "Codex",
685
+ collect: collectCodexUsage
686
+ };
687
+
688
+ // src/sources/opencode.ts
689
+ import { join as join8 } from "path";
690
+ import { homedir as homedir8 } from "os";
691
+ function getOpenCodeDirs() {
692
+ const dirs = parsePathList(process.env[OPENCODE_DATA_DIR_ENV], [join8(homedir8(), DEFAULT_OPENCODE_DIR)]);
693
+ return existingDirectories(dirs);
694
+ }
695
+ function parseOpenCodeMessage(row) {
696
+ const source = "opencode";
697
+ const record = asRecord(row.data);
698
+ if (record == null) return null;
699
+ const tokens = asRecord(record.tokens);
700
+ const cache = asRecord(tokens?.cache);
701
+ const model = asString(record.modelID) ?? asString(record.model) ?? "unknown";
702
+ const providerID = asString(record.providerID) ?? "unknown";
703
+ if (model === "unknown" || tokens == null) return null;
704
+ const time = asRecord(record.time);
705
+ const timestamp = toIsoTimestamp(time?.created ?? time?.completed);
706
+ if (timestamp == null) return null;
707
+ const inputTokens = asNumber(tokens.input);
708
+ const outputTokens = asNumber(tokens.output);
709
+ const reasoningTokens = asNumber(tokens.reasoning);
710
+ const cacheCreationTokens = asNumber(cache?.write);
711
+ const cacheReadTokens = asNumber(cache?.read);
712
+ if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0 && cacheCreationTokens === 0 && cacheReadTokens === 0) {
713
+ return null;
714
+ }
715
+ const sessionId = row.sessionId ?? asString(record.sessionID) ?? "unknown";
716
+ const costUSD = asNumber(record.cost) || calculateCost(
717
+ model,
718
+ inputTokens,
719
+ outputTokens,
720
+ cacheCreationTokens,
721
+ cacheReadTokens,
722
+ reasoningTokens
723
+ );
724
+ return {
725
+ source,
726
+ timestamp,
727
+ sessionId,
728
+ requestId: row.id,
729
+ model: providerID === "unknown" ? model : `${providerID}/${model}`,
730
+ inputTokens,
731
+ outputTokens,
732
+ cacheCreationTokens,
733
+ cacheReadTokens,
734
+ reasoningTokens,
735
+ totalTokens: inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens + reasoningTokens,
736
+ costUSD
737
+ };
738
+ }
739
+ async function loadOpenCodeJsonRows(openCodeDirs) {
740
+ const messageDirs = openCodeDirs.map((dir) => join8(dir, "storage", "message"));
741
+ const files = await globFiles(await existingDirectories(messageDirs), "**/*.json");
742
+ const rows = [];
743
+ for (const file of files) {
744
+ const data = await readJsonFile(file);
745
+ const record = asRecord(data);
746
+ const id = asString(record?.id);
747
+ if (id == null) continue;
748
+ rows.push({ id, sessionId: asString(record?.sessionID), data });
749
+ }
750
+ return rows;
751
+ }
752
+ async function loadNodeSqlite() {
753
+ try {
754
+ return await import("sqlite");
755
+ } catch {
756
+ return null;
757
+ }
758
+ }
759
+ async function loadOpenCodeDbRows(openCodeDirs) {
760
+ const dbFiles = [
761
+ ...await globFiles(openCodeDirs, "opencode.db"),
762
+ ...await globFiles(openCodeDirs, "opencode-*.db")
763
+ ];
764
+ if (dbFiles.length === 0) return [];
765
+ const sqlite = await loadNodeSqlite();
766
+ if (sqlite == null) return [];
767
+ const rows = [];
768
+ for (const dbFile of Array.from(new Set(dbFiles))) {
769
+ let db;
770
+ try {
771
+ db = new sqlite.DatabaseSync(dbFile, { readOnly: true });
772
+ const result = db.prepare("SELECT id, session_id, data FROM message").all();
773
+ for (const raw of result) {
774
+ const id = asString(raw.id);
775
+ const dataText = asString(raw.data);
776
+ if (id == null || dataText == null) continue;
777
+ try {
778
+ rows.push({
779
+ id,
780
+ sessionId: asString(raw.session_id),
781
+ data: JSON.parse(dataText)
782
+ });
783
+ } catch {
784
+ }
785
+ }
786
+ } catch {
787
+ } finally {
788
+ try {
789
+ db?.close();
790
+ } catch {
791
+ }
277
792
  }
278
793
  }
279
- entries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
280
- humanTurns.sort();
281
- return { entries, humanTurns };
794
+ return rows;
795
+ }
796
+ async function collectOpenCodeUsage() {
797
+ const source = "opencode";
798
+ const openCodeDirs = await getOpenCodeDirs();
799
+ const [jsonRows, dbRows] = await Promise.all([
800
+ loadOpenCodeJsonRows(openCodeDirs),
801
+ loadOpenCodeDbRows(openCodeDirs)
802
+ ]);
803
+ const rows = [...dbRows, ...jsonRows];
804
+ const entries = [];
805
+ const turns = [];
806
+ const seen = /* @__PURE__ */ new Set();
807
+ for (const row of rows) {
808
+ if (seen.has(row.id)) continue;
809
+ seen.add(row.id);
810
+ const entry = parseOpenCodeMessage(row);
811
+ if (entry == null) continue;
812
+ entries.push(entry);
813
+ turns.push({ source, timestamp: entry.timestamp, key: `${source}:${row.id}` });
814
+ }
815
+ return {
816
+ source,
817
+ entries,
818
+ turns,
819
+ files: jsonRows.length + dbRows.length,
820
+ warnings: []
821
+ };
822
+ }
823
+ var openCodeCollector = {
824
+ source: "opencode",
825
+ label: "OpenCode",
826
+ collect: collectOpenCodeUsage
827
+ };
828
+
829
+ // src/sources/pi.ts
830
+ import { basename as basename2, join as join9 } from "path";
831
+ import { homedir as homedir9 } from "os";
832
+ function getPiSessionDirs() {
833
+ const dirs = parsePathList(process.env[PI_AGENT_DIR_ENV], [join9(homedir9(), DEFAULT_PI_AGENT_SESSIONS_DIR)]);
834
+ return existingDirectories(dirs);
835
+ }
836
+ function extractSessionId(file) {
837
+ const name = basename2(file, ".jsonl");
838
+ const index = name.indexOf("_");
839
+ return index === -1 ? name : name.slice(index + 1);
840
+ }
841
+ function extractProject(file) {
842
+ const parts = file.split(/[\\/]/g);
843
+ const sessionsIndex = parts.findIndex((part) => part === "sessions");
844
+ return sessionsIndex >= 0 ? parts[sessionsIndex + 1] ?? "unknown" : "unknown";
845
+ }
846
+ function normalizePiModel(model) {
847
+ return model == null ? "unknown" : `[pi] ${model}`;
848
+ }
849
+ async function collectPiUsage() {
850
+ const source = "pi";
851
+ const dirs = await getPiSessionDirs();
852
+ const files = await globFiles(dirs, "**/*.jsonl");
853
+ const entries = [];
854
+ const turns = [];
855
+ const seen = /* @__PURE__ */ new Set();
856
+ for (const file of files) {
857
+ const sessionId = extractSessionId(file);
858
+ const project = extractProject(file);
859
+ await readJsonlFile(file, (value) => {
860
+ const record = asRecord(value);
861
+ const message = asRecord(record?.message);
862
+ const usage = asRecord(message?.usage);
863
+ const timestamp = toIsoTimestamp(record?.timestamp);
864
+ if (timestamp == null || usage == null || message?.role !== "assistant") return;
865
+ const inputTokens = asNumber(usage.input);
866
+ const outputTokens = asNumber(usage.output);
867
+ const cacheReadTokens = asNumber(usage.cacheRead);
868
+ const cacheCreationTokens = asNumber(usage.cacheWrite);
869
+ if (inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheCreationTokens === 0) {
870
+ return;
871
+ }
872
+ const cost = asRecord(usage.cost);
873
+ const model = normalizePiModel(asString(message.model));
874
+ const totalTokens = asNumber(usage.totalTokens) || inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
875
+ const key = [
876
+ source,
877
+ project,
878
+ sessionId,
879
+ timestamp,
880
+ model,
881
+ inputTokens,
882
+ outputTokens,
883
+ cacheCreationTokens,
884
+ cacheReadTokens,
885
+ totalTokens
886
+ ].join(":");
887
+ if (seen.has(key)) return;
888
+ seen.add(key);
889
+ entries.push({
890
+ source,
891
+ timestamp,
892
+ sessionId,
893
+ requestId: key,
894
+ model,
895
+ inputTokens,
896
+ outputTokens,
897
+ cacheCreationTokens,
898
+ cacheReadTokens,
899
+ totalTokens,
900
+ costUSD: asNumber(cost?.total)
901
+ });
902
+ turns.push({ source, timestamp, key });
903
+ });
904
+ }
905
+ return { source, entries, turns, files: files.length, warnings: [] };
906
+ }
907
+ var piCollector = {
908
+ source: "pi",
909
+ label: "pi-agent",
910
+ collect: collectPiUsage
911
+ };
912
+
913
+ // src/sources/index.ts
914
+ var COLLECTORS = {
915
+ claude: claudeCollector,
916
+ codex: codexCollector,
917
+ opencode: openCodeCollector,
918
+ amp: ampCollector,
919
+ pi: piCollector
920
+ };
921
+ function isAgentSource(value) {
922
+ return AGENT_SOURCES.includes(value);
923
+ }
924
+ function parseSources(value) {
925
+ if (value == null || value.trim() === "") return [...AGENT_SOURCES];
926
+ const sources = value.split(",").map((source) => source.trim().toLowerCase()).filter((source) => isAgentSource(source));
927
+ return sources.length > 0 ? Array.from(new Set(sources)) : [...AGENT_SOURCES];
928
+ }
929
+ async function collectAllUsageEntries(options) {
930
+ const selectedSources = options?.sources ?? parseSources(process.env.CCCLUB_SOURCES);
931
+ const results = await Promise.all(
932
+ selectedSources.map(async (source) => {
933
+ const collector = COLLECTORS[source];
934
+ try {
935
+ return await collector.collect();
936
+ } catch (error) {
937
+ return {
938
+ source,
939
+ entries: [],
940
+ turns: [],
941
+ files: 0,
942
+ warnings: [`${AGENT_LABELS[source]}: ${error instanceof Error ? error.message : String(error)}`]
943
+ };
944
+ }
945
+ })
946
+ );
947
+ const entries = results.flatMap((result) => result.entries).sort(
948
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
949
+ );
950
+ const humanTurns = results.flatMap((result) => result.turns).sort(
951
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
952
+ );
953
+ const warnings = results.flatMap((result) => result.warnings);
954
+ return { entries, humanTurns, sources: results, warnings };
282
955
  }
283
956
 
284
957
  // src/aggregator.ts
@@ -288,9 +961,9 @@ function floorToBlock(date) {
288
961
  floored.setUTCMinutes(min - min % 30, 0, 0);
289
962
  return floored;
290
963
  }
291
- function aggregateToBlocks(entries, humanTurns = []) {
964
+ function aggregateSourceToBlocks(source, entries, humanTurns) {
292
965
  if (entries.length === 0) return [];
293
- const humanTurnMs = humanTurns.map((t) => new Date(t).getTime());
966
+ const humanTurnMs = humanTurns.map((t) => new Date(t.timestamp).getTime());
294
967
  const blocks = [];
295
968
  let blockStart = floorToBlock(new Date(entries[0].timestamp));
296
969
  let blockEnd = new Date(blockStart.getTime() + BLOCK_DURATION_MS);
@@ -315,6 +988,7 @@ function aggregateToBlocks(entries, humanTurns = []) {
315
988
  let outputTokens = 0;
316
989
  let cacheCreationTokens = 0;
317
990
  let cacheReadTokens = 0;
991
+ let reasoningTokens = 0;
318
992
  let totalTokens = 0;
319
993
  let costUSD = 0;
320
994
  for (const entry of currentBlock) {
@@ -322,6 +996,7 @@ function aggregateToBlocks(entries, humanTurns = []) {
322
996
  outputTokens += entry.outputTokens;
323
997
  cacheCreationTokens += entry.cacheCreationTokens;
324
998
  cacheReadTokens += entry.cacheReadTokens;
999
+ reasoningTokens += entry.reasoningTokens || 0;
325
1000
  totalTokens += entry.totalTokens;
326
1001
  models.add(entry.model);
327
1002
  if (entry.costUSD > 0) {
@@ -332,17 +1007,20 @@ function aggregateToBlocks(entries, humanTurns = []) {
332
1007
  entry.inputTokens,
333
1008
  entry.outputTokens,
334
1009
  entry.cacheCreationTokens,
335
- entry.cacheReadTokens
1010
+ entry.cacheReadTokens,
1011
+ entry.reasoningTokens || 0
336
1012
  );
337
1013
  }
338
1014
  }
339
1015
  blocks.push({
1016
+ source,
340
1017
  blockStart: blockStart.toISOString(),
341
1018
  blockEnd: blockEnd.toISOString(),
342
1019
  inputTokens,
343
1020
  outputTokens,
344
1021
  cacheCreationTokens,
345
1022
  cacheReadTokens,
1023
+ reasoningTokens,
346
1024
  totalTokens,
347
1025
  costUSD: Math.round(costUSD * 1e4) / 1e4,
348
1026
  models: Array.from(models),
@@ -363,6 +1041,34 @@ function aggregateToBlocks(entries, humanTurns = []) {
363
1041
  flushBlock();
364
1042
  return blocks;
365
1043
  }
1044
+ function aggregateToBlocks(entries, humanTurns = []) {
1045
+ if (entries.length === 0) return [];
1046
+ const entriesBySource = /* @__PURE__ */ new Map();
1047
+ for (const entry of entries) {
1048
+ const group = entriesBySource.get(entry.source) ?? [];
1049
+ group.push(entry);
1050
+ entriesBySource.set(entry.source, group);
1051
+ }
1052
+ const turnsBySource = /* @__PURE__ */ new Map();
1053
+ for (const turn of humanTurns) {
1054
+ const group = turnsBySource.get(turn.source) ?? [];
1055
+ group.push(turn);
1056
+ turnsBySource.set(turn.source, group);
1057
+ }
1058
+ const blocks = [];
1059
+ for (const [source, sourceEntries] of entriesBySource) {
1060
+ blocks.push(
1061
+ ...aggregateSourceToBlocks(
1062
+ source,
1063
+ sourceEntries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()),
1064
+ turnsBySource.get(source) ?? []
1065
+ )
1066
+ );
1067
+ }
1068
+ return blocks.sort(
1069
+ (a, b) => new Date(a.blockStart).getTime() - new Date(b.blockStart).getTime() || (a.source ?? "claude").localeCompare(b.source ?? "claude")
1070
+ );
1071
+ }
366
1072
 
367
1073
  // src/fetch-error.ts
368
1074
  import chalk from "chalk";
@@ -394,15 +1100,15 @@ function formatFetchError(err) {
394
1100
  // src/usage-limits.ts
395
1101
  import { execSync as execSync2, exec } from "child_process";
396
1102
  import { promisify } from "util";
397
- import { userInfo as userInfo2, homedir as homedir4 } from "os";
398
- import { join as join4 } from "path";
1103
+ import { userInfo as userInfo2, homedir as homedir10 } from "os";
1104
+ import { join as join10 } from "path";
399
1105
  import { readFileSync as readFileSync2, writeFileSync } from "fs";
400
1106
  var execAsync = promisify(exec);
401
1107
  var debug = (...args) => {
402
1108
  if (process.env.CCCLUB_DEBUG) console.error("[usage-debug]", ...args);
403
1109
  };
404
1110
  var CACHE_TTL_MS = 5 * 60 * 1e3;
405
- var CACHE_PATH = join4(homedir4(), CCCLUB_CONFIG_DIR, "usage-cache.json");
1111
+ var CACHE_PATH = join10(homedir10(), CCCLUB_CONFIG_DIR, "usage-cache.json");
406
1112
  function readCache(allowStale = false) {
407
1113
  try {
408
1114
  const raw = readFileSync2(CACHE_PATH, "utf-8");
@@ -491,13 +1197,16 @@ async function fetchUsageLimits() {
491
1197
  }
492
1198
 
493
1199
  // src/commands/sync.ts
494
- var SYNC_FORMAT_VERSION = "6";
1200
+ var SYNC_FORMAT_VERSION = "7";
495
1201
  function getSyncVersionPath() {
496
- return join5(homedir5(), CCCLUB_CONFIG_DIR, "sync-version");
1202
+ return join11(homedir11(), CCCLUB_CONFIG_DIR, "sync-version");
1203
+ }
1204
+ function getLastSyncBySourcePath() {
1205
+ return join11(homedir11(), CCCLUB_CONFIG_DIR, "last-sync-sources.json");
497
1206
  }
498
1207
  function needsFullSync() {
499
1208
  const path = getSyncVersionPath();
500
- if (!existsSync3(path)) return true;
1209
+ if (!existsSync4(path)) return true;
501
1210
  try {
502
1211
  const stored = readFileSync3(path, "utf-8").trim();
503
1212
  return stored !== SYNC_FORMAT_VERSION;
@@ -509,7 +1218,7 @@ var THROTTLE_MS = 5 * 60 * 1e3;
509
1218
  async function syncCommand(options) {
510
1219
  const timePath = getLastSyncTimePath();
511
1220
  if (options.silent && !options.full) {
512
- if (existsSync3(timePath)) {
1221
+ if (existsSync4(timePath)) {
513
1222
  try {
514
1223
  const ts = parseInt(readFileSync3(timePath, "utf-8").trim(), 10);
515
1224
  if (Date.now() - ts < THROTTLE_MS) return;
@@ -542,31 +1251,47 @@ async function doSync(firstSync = false, silent = false) {
542
1251
  } : console.log;
543
1252
  const spinner = silent ? null : ora("Collecting usage data...").start();
544
1253
  try {
545
- const [{ entries, humanTurns }, usageSnapshot] = await Promise.all([
546
- collectUsageEntries(),
1254
+ const [{ entries, humanTurns, sources, warnings }, usageSnapshot] = await Promise.all([
1255
+ collectAllUsageEntries(),
547
1256
  fetchUsageLimits().catch(() => null)
548
1257
  ]);
549
- if (spinner) spinner.text = `Found ${entries.length} entries`;
1258
+ const activeSources = sources.filter((source) => source.entries.length > 0).map((source) => AGENT_LABELS[source.source]);
1259
+ if (spinner) spinner.text = `Found ${entries.length} entries${activeSources.length > 0 ? ` from ${activeSources.join(", ")}` : ""}`;
550
1260
  if (entries.length === 0) {
551
- if (spinner) spinner.warn("No usage data found in ~/.claude/projects/");
1261
+ if (spinner) spinner.warn("No usage data found for supported coding agents");
1262
+ if (!silent && warnings.length > 0) {
1263
+ for (const warning of warnings) log(chalk2.dim(` ${warning}`));
1264
+ }
552
1265
  return;
553
1266
  }
554
1267
  const allBlocks = aggregateToBlocks(entries, humanTurns);
555
1268
  const lastSyncPath = getLastSyncPath();
556
1269
  let lastSync = null;
557
- if (existsSync3(lastSyncPath)) {
1270
+ if (existsSync4(lastSyncPath)) {
558
1271
  lastSync = (await readFile4(lastSyncPath, "utf-8")).trim() || null;
559
1272
  }
1273
+ let lastSyncBySource = {};
1274
+ if (existsSync4(getLastSyncBySourcePath())) {
1275
+ try {
1276
+ lastSyncBySource = JSON.parse(await readFile4(getLastSyncBySourcePath(), "utf-8"));
1277
+ } catch {
1278
+ lastSyncBySource = {};
1279
+ }
1280
+ }
560
1281
  let blocksToSync;
561
1282
  if (lastSync && !firstSync) {
562
- const lastSyncTime = new Date(lastSync).getTime();
563
- blocksToSync = allBlocks.filter((b) => new Date(b.blockStart).getTime() >= lastSyncTime);
1283
+ blocksToSync = allBlocks.filter((b) => {
1284
+ const source = b.source ?? "claude";
1285
+ const sourceLastSync = lastSyncBySource[source] ?? lastSync;
1286
+ return new Date(b.blockStart).getTime() >= new Date(sourceLastSync).getTime();
1287
+ });
564
1288
  } else {
565
1289
  blocksToSync = allBlocks;
566
1290
  }
567
1291
  if (blocksToSync.length === 0) {
568
1292
  if (spinner) spinner.succeed("Already up to date");
569
- await writeFile3(getSyncVersionPath(), SYNC_FORMAT_VERSION);
1293
+ await writeFile4(getLastSyncBySourcePath(), JSON.stringify(getLatestBlockStartBySource(allBlocks), null, 2));
1294
+ await writeFile4(getSyncVersionPath(), SYNC_FORMAT_VERSION);
570
1295
  if (usageSnapshot) {
571
1296
  fetch(`${config.apiUrl}/api/usage`, {
572
1297
  method: "POST",
@@ -598,27 +1323,42 @@ async function doSync(firstSync = false, silent = false) {
598
1323
  }
599
1324
  const data = await res.json();
600
1325
  const latest = blocksToSync[blocksToSync.length - 1];
601
- await writeFile3(lastSyncPath, latest.blockStart);
602
- await writeFile3(getSyncVersionPath(), SYNC_FORMAT_VERSION);
603
- await writeFile3(getLastSyncTimePath(), String(Date.now()));
1326
+ await writeFile4(lastSyncPath, latest.blockStart);
1327
+ await writeFile4(getLastSyncBySourcePath(), JSON.stringify(getLatestBlockStartBySource(allBlocks), null, 2));
1328
+ await writeFile4(getSyncVersionPath(), SYNC_FORMAT_VERSION);
1329
+ await writeFile4(getLastSyncTimePath(), String(Date.now()));
604
1330
  const totalTokens = blocksToSync.reduce((s, b) => s + b.totalTokens, 0);
605
1331
  const totalCost = blocksToSync.reduce((s, b) => s + b.costUSD, 0);
606
1332
  if (spinner) {
607
1333
  spinner.succeed(`Synced ${data.synced} blocks`);
608
1334
  log(chalk2.dim(` Tokens: ${totalTokens.toLocaleString()} Cost: $${totalCost.toFixed(4)}`));
1335
+ if (warnings.length > 0) {
1336
+ for (const warning of warnings) log(chalk2.dim(` ${warning}`));
1337
+ }
609
1338
  }
610
1339
  } catch (err) {
611
1340
  if (spinner) spinner.fail(`Sync error: ${formatFetchError(err)}`);
612
1341
  if (silent) throw err;
613
1342
  }
614
1343
  }
1344
+ function getLatestBlockStartBySource(blocks) {
1345
+ const latest = {};
1346
+ for (const block of blocks) {
1347
+ const source = block.source ?? "claude";
1348
+ if (!AGENT_SOURCES.includes(source)) continue;
1349
+ if (latest[source] == null || block.blockStart > latest[source]) {
1350
+ latest[source] = block.blockStart;
1351
+ }
1352
+ }
1353
+ return latest;
1354
+ }
615
1355
 
616
1356
  // src/global-install.ts
617
1357
  import { exec as exec2 } from "child_process";
618
1358
  import chalk3 from "chalk";
619
1359
  function run(cmd) {
620
- return new Promise((resolve) => {
621
- exec2(cmd, (err, stdout5) => resolve(err ? "" : stdout5.trim()));
1360
+ return new Promise((resolve2) => {
1361
+ exec2(cmd, (err, stdout5) => resolve2(err ? "" : stdout5.trim()));
622
1362
  });
623
1363
  }
624
1364
  async function ensureGlobalInstall() {
@@ -645,10 +1385,14 @@ async function initCommand() {
645
1385
  const hookOk = await installHook();
646
1386
  if (hookOk) console.log(chalk4.green(" Auto-sync hook installed!"));
647
1387
  }
1388
+ if (!isHeartbeatInstalled()) {
1389
+ const heartbeatOk = await installHeartbeat();
1390
+ if (heartbeatOk) console.log(chalk4.green(" Background sync installed!"));
1391
+ }
648
1392
  console.log(chalk4.dim('\n Run "ccclub" to see the leaderboard'));
649
1393
  return;
650
1394
  }
651
- const rl = createInterface({ input: stdin, output: stdout });
1395
+ const rl = createInterface2({ input: stdin, output: stdout });
652
1396
  try {
653
1397
  const defaultName = getDefaultDisplayName();
654
1398
  const prompt = defaultName ? chalk4.bold(`Your display name (${defaultName}): `) : chalk4.bold("Your display name: ");
@@ -686,11 +1430,17 @@ async function initCommand() {
686
1430
  displayName: displayName.trim(),
687
1431
  groups: [data.groupCode]
688
1432
  });
689
- const hookOk = await installHook();
1433
+ const [hookOk, heartbeatOk] = await Promise.all([
1434
+ installHook(),
1435
+ installHeartbeat()
1436
+ ]);
690
1437
  spinner.succeed("ccclub initialized!");
691
1438
  if (!hookOk) {
692
1439
  console.log(chalk4.dim(' Tip: run "ccclub hook" to set up auto-sync'));
693
1440
  }
1441
+ if (!heartbeatOk) {
1442
+ console.log(chalk4.dim(' Tip: run "ccclub sync" manually to refresh non-Claude agent usage'));
1443
+ }
694
1444
  console.log("");
695
1445
  console.log(chalk4.bold(" Invite friends to compete:"));
696
1446
  console.log("");
@@ -712,7 +1462,7 @@ function printQuickStart() {
712
1462
  }
713
1463
 
714
1464
  // src/commands/join.ts
715
- import { createInterface as createInterface2 } from "readline/promises";
1465
+ import { createInterface as createInterface3 } from "readline/promises";
716
1466
  import { stdin as stdin2, stdout as stdout2 } from "process";
717
1467
  import chalk5 from "chalk";
718
1468
  import ora3 from "ora";
@@ -725,7 +1475,7 @@ async function joinCommand(inviteCode) {
725
1475
  token = config.token;
726
1476
  displayName = config.displayName;
727
1477
  } else {
728
- const rl = createInterface2({ input: stdin2, output: stdout2 });
1478
+ const rl = createInterface3({ input: stdin2, output: stdout2 });
729
1479
  try {
730
1480
  const defaultName = getDefaultDisplayName();
731
1481
  const prompt = defaultName ? chalk5.bold(`Your display name (${defaultName}): `) : chalk5.bold("Your display name: ");
@@ -791,16 +1541,16 @@ import Table from "cli-table3";
791
1541
  import ora4 from "ora";
792
1542
 
793
1543
  // src/update-check.ts
794
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
795
- import { join as join6 } from "path";
796
- import { homedir as homedir6 } from "os";
1544
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync5 } from "fs";
1545
+ import { join as join12 } from "path";
1546
+ import { homedir as homedir12 } from "os";
797
1547
  var CHECK_INTERVAL_MS = 12 * 60 * 60 * 1e3;
798
- var CHECK_FILE = join6(homedir6(), CCCLUB_CONFIG_DIR, "last-update-check");
1548
+ var CHECK_FILE = join12(homedir12(), CCCLUB_CONFIG_DIR, "last-update-check");
799
1549
  var pendingCheck = null;
800
1550
  function startUpdateCheck(currentVersion) {
801
1551
  if (process.argv.includes("--silent") || process.argv.includes("-s")) return;
802
1552
  try {
803
- if (existsSync4(CHECK_FILE)) {
1553
+ if (existsSync5(CHECK_FILE)) {
804
1554
  const ts = parseInt(readFileSync4(CHECK_FILE, "utf-8").trim(), 10);
805
1555
  if (Date.now() - ts < CHECK_INTERVAL_MS) return;
806
1556
  }
@@ -836,6 +1586,7 @@ var ACTIVE_THRESHOLD_MS = 15 * 60 * 1e3;
836
1586
  async function rankCommand(options) {
837
1587
  const config = await requireConfig();
838
1588
  if (!isHookInstalled()) await installHook();
1589
+ if (!isHeartbeatInstalled()) await installHeartbeat();
839
1590
  if (needsFullSync()) {
840
1591
  await doSync(true, true);
841
1592
  }
@@ -932,7 +1683,7 @@ async function rankCommand(options) {
932
1683
  if (activityData) renderActivity(activityData, range);
933
1684
  if (i < groupResults.length - 1) console.log("");
934
1685
  }
935
- console.log(chalk6.dim("\n Tokens = input + output ") + chalk6.yellow("(cache excluded)") + chalk6.dim(". Use ") + chalk6.white("--cache") + chalk6.dim(" to include cache tokens."));
1686
+ console.log(chalk6.dim("\n Tokens = input + output + reasoning ") + chalk6.yellow("(cache excluded)") + chalk6.dim(". Use ") + chalk6.white("--cache") + chalk6.dim(" to include cache tokens."));
936
1687
  const update = await getUpdateResult();
937
1688
  if (update) {
938
1689
  console.log(chalk6.yellow("\n Update available") + chalk6.dim(`: ${update.current} \u2192 ${update.latest} Run `) + chalk6.cyan("npm i -g ccclub@latest"));
@@ -978,14 +1729,21 @@ function printGroup(data, code, period, config, showCache = false, showAll = fal
978
1729
  const hiddenCount = data.rankings.length - activeRankings.length;
979
1730
  const hasPlan = activeRankings.some((r) => r.plan);
980
1731
  const hasUsage = activeRankings.some((r) => r.usageSnapshot);
1732
+ const hasAgents = activeRankings.some(
1733
+ (r) => r.agents && r.agents.length > 0 && !(r.agents.length === 1 && r.agents[0] === "claude")
1734
+ );
981
1735
  const head = ["#", "Name", "Cost", "Tokens"];
982
1736
  const hasActive = activeRankings.some((r) => r.lastSync && now - new Date(r.lastSync).getTime() < ACTIVE_THRESHOLD_MS);
983
1737
  const widths = [5, hasActive ? 30 : 20, 12, 10];
1738
+ if (hasAgents) {
1739
+ head.splice(2, 0, "Agents");
1740
+ widths.splice(2, 0, 16);
1741
+ }
984
1742
  if (hasPlan) {
985
1743
  head.push("Monthly ROI");
986
1744
  widths.push(15);
987
1745
  }
988
- head.push("Chats", "$/Chat");
1746
+ head.push("Turns", "$/Turn");
989
1747
  widths.push(8, 9);
990
1748
  if (hasUsage) {
991
1749
  head.push("Usage 7d");
@@ -998,7 +1756,7 @@ function printGroup(data, code, period, config, showCache = false, showAll = fal
998
1756
  });
999
1757
  for (const entry of activeRankings) {
1000
1758
  const isMe = entry.userId === config.userId;
1001
- const tokens = showCache ? entry.totalTokens : entry.inputTokens + entry.outputTokens;
1759
+ const tokens = showCache ? entry.totalTokens : entry.inputTokens + entry.outputTokens + (entry.reasoningTokens || 0);
1002
1760
  const marker = isMe ? chalk6.green("\u2192") : " ";
1003
1761
  const isActive = entry.lastSync && now - new Date(entry.lastSync).getTime() < ACTIVE_THRESHOLD_MS;
1004
1762
  const id = (s) => s;
@@ -1007,10 +1765,12 @@ function printGroup(data, code, period, config, showCache = false, showAll = fal
1007
1765
  const displayName = isActive ? `${entry.displayName} ${chalk6.green("(active)")}` : entry.displayName;
1008
1766
  const row = [
1009
1767
  `${marker}${c(String(entry.rank))}`,
1010
- nameC(displayName),
1011
- c(`$${entry.costUSD.toFixed(2)}`),
1012
- c(formatTokens(tokens))
1768
+ nameC(displayName)
1013
1769
  ];
1770
+ if (hasAgents) {
1771
+ row.push(c(formatAgents(entry.agents)));
1772
+ }
1773
+ row.push(c(`$${entry.costUSD.toFixed(2)}`), c(formatTokens(tokens)));
1014
1774
  if (hasPlan) {
1015
1775
  if (entry.plan && entry.plan !== "api") {
1016
1776
  const price = PLAN_PRICES[entry.plan];
@@ -1052,6 +1812,10 @@ function printGroup(data, code, period, config, showCache = false, showAll = fal
1052
1812
  }
1053
1813
  }
1054
1814
  }
1815
+ function formatAgents(agents) {
1816
+ if (!agents || agents.length === 0) return "\u2014";
1817
+ return agents.map((agent) => AGENT_LABELS[agent] ?? agent).join(", ");
1818
+ }
1055
1819
  var SPARK_CHARS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587";
1056
1820
  function renderActivity(data, range) {
1057
1821
  const active = data.series.filter((s) => s.blocks.length > 0);
@@ -1219,38 +1983,49 @@ async function showDataCommand() {
1219
1983
  console.log(chalk8.bold("\n What ccclub uploads:\n"));
1220
1984
  console.log(chalk8.dim(" Only aggregated 30-minute block summaries. No conversation content,"));
1221
1985
  console.log(chalk8.dim(" no file paths, no project names, no session details.\n"));
1222
- const { entries, humanTurns } = await collectUsageEntries();
1986
+ const { entries, humanTurns, sources, warnings } = await collectAllUsageEntries();
1223
1987
  const blocks = aggregateToBlocks(entries, humanTurns);
1224
1988
  if (blocks.length === 0) {
1225
- console.log(chalk8.yellow(" No usage data found in ~/.claude/projects/"));
1989
+ console.log(chalk8.yellow(" No usage data found for supported coding agents."));
1990
+ for (const warning of warnings) console.log(chalk8.dim(` ${warning}`));
1226
1991
  return;
1227
1992
  }
1228
1993
  console.log(chalk8.dim(` Total entries found: ${entries.length}`));
1229
1994
  console.log(chalk8.dim(` Aggregated into: ${blocks.length} blocks
1230
1995
  `));
1996
+ console.log(chalk8.bold(" Sources:\n"));
1997
+ for (const source of sources.filter((s) => s.entries.length > 0)) {
1998
+ console.log(chalk8.dim(` ${AGENT_LABELS[source.source]}: ${source.entries.length.toLocaleString()} entries from ${source.files.toLocaleString()} files/records`));
1999
+ }
2000
+ console.log("");
1231
2001
  const recent = blocks.slice(-5);
1232
2002
  console.log(chalk8.bold(" Last 5 blocks (this is exactly what gets uploaded):\n"));
1233
2003
  for (const block of recent) {
1234
- console.log(chalk8.cyan(` ${block.blockStart.slice(0, 16)} \u2192 ${block.blockEnd.slice(11, 16)}`));
2004
+ console.log(chalk8.cyan(` ${AGENT_LABELS[block.source ?? "claude"]} \xB7 ${block.blockStart.slice(0, 16)} \u2192 ${block.blockEnd.slice(11, 16)}`));
1235
2005
  console.log(chalk8.dim(` input: ${block.inputTokens.toLocaleString()} output: ${block.outputTokens.toLocaleString()} cache_create: ${block.cacheCreationTokens.toLocaleString()} cache_read: ${block.cacheReadTokens.toLocaleString()}`));
1236
- console.log(chalk8.dim(` cost: $${block.costUSD.toFixed(4)} calls: ${block.entryCount} models: ${block.models.join(", ")}`));
2006
+ if (block.reasoningTokens) {
2007
+ console.log(chalk8.dim(` reasoning: ${block.reasoningTokens.toLocaleString()}`));
2008
+ }
2009
+ console.log(chalk8.dim(` cost: $${block.costUSD.toFixed(4)} calls: ${block.entryCount} turns: ${block.chatCount || 0} models: ${block.models.join(", ")}`));
1237
2010
  }
1238
2011
  const totalInput = blocks.reduce((s, b) => s + b.inputTokens, 0);
1239
2012
  const totalOutput = blocks.reduce((s, b) => s + b.outputTokens, 0);
2013
+ const totalReasoning = blocks.reduce((s, b) => s + (b.reasoningTokens || 0), 0);
2014
+ const totalCache = blocks.reduce((s, b) => s + b.cacheCreationTokens + b.cacheReadTokens, 0);
1240
2015
  const totalCost = blocks.reduce((s, b) => s + b.costUSD, 0);
1241
2016
  console.log(chalk8.bold(`
1242
- All-time total: ${(totalInput + totalOutput).toLocaleString()} tokens \xB7 $${totalCost.toFixed(2)}`));
1243
- console.log(chalk8.dim(` input: ${totalInput.toLocaleString()} output: ${totalOutput.toLocaleString()}`));
2017
+ All-time total: ${(totalInput + totalOutput + totalReasoning).toLocaleString()} non-cache tokens \xB7 $${totalCost.toFixed(2)}`));
2018
+ console.log(chalk8.dim(` input: ${totalInput.toLocaleString()} output: ${totalOutput.toLocaleString()} reasoning: ${totalReasoning.toLocaleString()} cache: ${totalCache.toLocaleString()}`));
1244
2019
  }
1245
2020
 
1246
2021
  // src/commands/group.ts
1247
- import { createInterface as createInterface3 } from "readline/promises";
2022
+ import { createInterface as createInterface4 } from "readline/promises";
1248
2023
  import { stdin as stdin3, stdout as stdout3 } from "process";
1249
2024
  import chalk9 from "chalk";
1250
2025
  import ora6 from "ora";
1251
2026
  async function createGroupCommand() {
1252
2027
  const config = await requireConfig();
1253
- const rl = createInterface3({ input: stdin3, output: stdout3 });
2028
+ const rl = createInterface4({ input: stdin3, output: stdout3 });
1254
2029
  try {
1255
2030
  const name = await rl.question(chalk9.bold("Group name: "));
1256
2031
  if (!name.trim()) {
@@ -1294,7 +2069,7 @@ async function createGroupCommand() {
1294
2069
  }
1295
2070
 
1296
2071
  // src/commands/leave.ts
1297
- import { createInterface as createInterface4 } from "readline/promises";
2072
+ import { createInterface as createInterface5 } from "readline/promises";
1298
2073
  import { stdin as stdin4, stdout as stdout4 } from "process";
1299
2074
  import chalk10 from "chalk";
1300
2075
  import ora7 from "ora";
@@ -1318,7 +2093,7 @@ async function leaveCommand(code) {
1318
2093
  for (let i = 0; i < config.groups.length; i++) {
1319
2094
  console.log(` ${i + 1}. ${config.groups[i]}`);
1320
2095
  }
1321
- const rl2 = createInterface4({ input: stdin4, output: stdout4 });
2096
+ const rl2 = createInterface5({ input: stdin4, output: stdout4 });
1322
2097
  try {
1323
2098
  const input = await rl2.question(chalk10.bold("\n Leave which group? (number): "));
1324
2099
  const idx = parseInt(input.trim(), 10) - 1;
@@ -1331,7 +2106,7 @@ async function leaveCommand(code) {
1331
2106
  rl2.close();
1332
2107
  }
1333
2108
  }
1334
- const rl = createInterface4({ input: stdin4, output: stdout4 });
2109
+ const rl = createInterface5({ input: stdin4, output: stdout4 });
1335
2110
  try {
1336
2111
  const answer = await rl.question(chalk10.bold(` Leave group ${targetCode}? [y/N] `));
1337
2112
  if (answer.trim().toLowerCase() !== "y") {
@@ -1385,10 +2160,10 @@ async function hookCommand() {
1385
2160
  }
1386
2161
 
1387
2162
  // src/index.ts
1388
- var VERSION = "0.3.2";
2163
+ var VERSION = "0.3.3";
1389
2164
  startUpdateCheck(VERSION);
1390
2165
  var program = new Command();
1391
- program.name("ccclub").description("Claude Code leaderboard among friends").version(VERSION, "-v, -V, --version");
2166
+ program.name("ccclub").description("Coding agent usage leaderboard among friends").version(VERSION, "-v, -V, --version");
1392
2167
  program.command("rank", { isDefault: true, hidden: true }).description("Show leaderboard").option("-d, --days [days]", "Time window: 1 | 7 | 30 | all (default: today)").addOption(new Option("-p, --period [period]").hideHelp()).option("-g, --group <code>", "Group invite code").option("--global", "Show global public ranking").option("--cache", "Include cache tokens in count").option("--all", "Show all members including those with no activity").action(rankCommand);
1393
2168
  program.command("init").description("Create a group and get started (first-time setup)").action(initCommand);
1394
2169
  program.command("join").description("Join a group with a 6-letter invite code").argument("[invite-code]", "6-character invite code").action((code) => {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "ccclub",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
- "description": "Claude Code leaderboard among friends",
5
+ "description": "Coding agent usage leaderboard among friends",
6
6
  "bin": {
7
7
  "ccclub": "dist/index.js"
8
8
  },