cc-costline 0.4.2 → 0.5.0

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.es.md CHANGED
@@ -54,10 +54,10 @@ cc-costline config --period both # Mostrar ambos períodos
54
54
  ## Cómo funciona
55
55
 
56
56
  1. `install` configura `~/.claude/settings.json` — establece el comando de statusline y añade hooks de fin de sesión. Tu configuración existente se conserva.
57
- 2. `render` es llamado por Claude Code en cada turno de conversación. Lee el JSON de stdin para datos de sesión, luego actualiza todas las fuentes de datos en línea cuando su caché expira (TTL unificado de 2 minutos):
58
- - **Costo local**: escanea `~/.claude/projects/**/*.jsonl`, aplica precios por modelo → `~/.cc-costline/cache.json`
59
- - **Límites de uso**: obtiene de `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`
60
- - **Ranking ccclub**: obtiene de `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
57
+ 2. `render` es llamado por Claude Code en cada turno de conversación. Lee el JSON de stdin para datos de sesión, luego actualiza las fuentes de datos con TTLs separados:
58
+ - **Costo local** (TTL 2 min): escanea `~/.claude/projects/**/*.jsonl`, aplica precios por modelo → `~/.cc-costline/cache.json`
59
+ - **Límites de uso** (retry 5 min, sensible al token): obtiene de `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`. Detecta la rotación del token OAuth para reintentar inmediatamente (nuevo token = nueva cuota de límite). Los datos obsoletos persisten ante fallos.
60
+ - **Ranking ccclub** (retry 5 min): obtiene de `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
61
61
  3. `refresh` también puede ejecutarse manualmente o mediante hooks de fin de sesión para precalentar la caché.
62
62
 
63
63
  <details>
package/README.fr.md CHANGED
@@ -54,10 +54,10 @@ cc-costline config --period both # Afficher les deux périodes
54
54
  ## Fonctionnement
55
55
 
56
56
  1. `install` configure `~/.claude/settings.json` — définit la commande statusline et ajoute des hooks de fin de session. Vos paramètres existants sont préservés.
57
- 2. `render` est appelé par Claude Code à chaque tour de conversation. Il lit le JSON stdin pour les données de session, puis rafraîchit toutes les sources de données en ligne lorsque leur cache expire (TTL unifié de 2 minutes) :
58
- - **Coût local** : parcourt `~/.claude/projects/**/*.jsonl`, applique la tarification par modèle → `~/.cc-costline/cache.json`
59
- - **Limites d'utilisation** : récupère depuis `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`
60
- - **Rang ccclub** : récupère depuis `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
57
+ 2. `render` est appelé par Claude Code à chaque tour de conversation. Il lit le JSON stdin pour les données de session, puis rafraîchit les sources de données avec des TTL séparés :
58
+ - **Coût local** (TTL 2 min) : parcourt `~/.claude/projects/**/*.jsonl`, applique la tarification par modèle → `~/.cc-costline/cache.json`
59
+ - **Limites d'utilisation** (retry 5 min, sensible au token) : récupère depuis `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`. Détecte la rotation du token OAuth pour relancer immédiatement (nouveau token = nouveau quota de débit). Les données périmées persistent en cas d'échec.
60
+ - **Rang ccclub** (retry 5 min) : récupère depuis `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
61
61
  3. `refresh` peut aussi être exécuté manuellement ou via les hooks de fin de session pour préchauffer le cache.
62
62
 
63
63
  <details>
package/README.ja.md CHANGED
@@ -54,10 +54,10 @@ cc-costline config --period both # 両方の期間を表示
54
54
  ## 仕組み
55
55
 
56
56
  1. `install` は `~/.claude/settings.json` を設定 — ステータスラインコマンドとセッション終了フックを追加します。既存の設定は保持されます。
57
- 2. `render` は毎回の対話時に Claude Code から呼び出され、stdin JSON からセッションデータを読み取り、すべてのデータソースをキャッシュ期限切れ時にインラインで更新します(統一 2 分 TTL):
58
- - **ローカルコスト**:`~/.claude/projects/**/*.jsonl` をスキャン、モデル別価格を適用 → `~/.cc-costline/cache.json`
59
- - **使用率**:`api.anthropic.com/api/oauth/usage` から取得 → `/tmp/sl-claude-usage`
60
- - **ccclub ランキング**:`ccclub.dev/api/rank` から取得 → `/tmp/sl-ccclub-rank`
57
+ 2. `render` は毎回の対話時に Claude Code から呼び出され、stdin JSON からセッションデータを読み取り、各データソースを個別の TTL でインライン更新します:
58
+ - **ローカルコスト**(2 分 TTL):`~/.claude/projects/**/*.jsonl` をスキャン、モデル別価格を適用 → `~/.cc-costline/cache.json`
59
+ - **使用率**(5 分リトライ、トークンローテーション検知):`api.anthropic.com/api/oauth/usage` から取得 → `/tmp/sl-claude-usage`。OAuth トークンのローテーションを検知し、即座にリトライ(新トークン=新レート制限枠)。API 失敗時も過去のデータを保持。
60
+ - **ccclub ランキング**(5 分リトライ):`ccclub.dev/api/rank` から取得 → `/tmp/sl-ccclub-rank`
61
61
  3. `refresh` は手動実行やセッション終了フックによるキャッシュウォームアップにも使用できます。
62
62
 
63
63
  <details>
package/README.md CHANGED
@@ -54,10 +54,10 @@ cc-costline config --period both # Show both periods
54
54
  ## How it works
55
55
 
56
56
  1. `install` configures `~/.claude/settings.json` — sets the statusline command and adds session-end hooks. Your existing settings are preserved.
57
- 2. `render` is called by Claude Code on every turn. It reads stdin JSON for session data, then refreshes all data sources inline when their cache expires (2-minute TTL):
58
- - **Local cost**: scans `~/.claude/projects/**/*.jsonl`, applies per-model pricing → `~/.cc-costline/cache.json`
59
- - **Usage limits**: fetches `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`
60
- - **ccclub rank**: fetches `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
57
+ 2. `render` is called by Claude Code on every turn. It reads stdin JSON for session data, then refreshes data sources inline with separate TTLs:
58
+ - **Local cost** (2-min TTL): scans `~/.claude/projects/**/*.jsonl`, applies per-model pricing → `~/.cc-costline/cache.json`
59
+ - **Usage limits** (5-min retry, token-aware): fetches `api.anthropic.com/api/oauth/usage` → `/tmp/sl-claude-usage`. Detects OAuth token rotation to retry immediately with fresh rate limit quota. Stale data persists across failures.
60
+ - **ccclub rank** (5-min retry): fetches `ccclub.dev/api/rank` → `/tmp/sl-ccclub-rank`
61
61
  3. `refresh` can also be run manually or via session-end hooks to warm the cost cache.
62
62
 
63
63
  <details>
package/README.zh-CN.md CHANGED
@@ -54,10 +54,10 @@ cc-costline config --period both # 同时显示两个周期
54
54
  ## 工作原理
55
55
 
56
56
  1. `install` 配置 `~/.claude/settings.json` — 设置状态栏命令并添加会话结束 hook。你的现有设置会被保留。
57
- 2. `render` 在每次对话时被 Claude Code 调用,读取 stdin JSON 获取会话数据,然后按需刷新所有数据源(统一 2 分钟 TTL):
58
- - **本地费用**:扫描 `~/.claude/projects/**/*.jsonl`,按模型定价计算 → `~/.cc-costline/cache.json`
59
- - **使用率**:从 `api.anthropic.com/api/oauth/usage` 获取 → `/tmp/sl-claude-usage`
60
- - **ccclub 排名**:从 `ccclub.dev/api/rank` 获取 → `/tmp/sl-ccclub-rank`
57
+ 2. `render` 在每次对话时被 Claude Code 调用,读取 stdin JSON 获取会话数据,然后按独立 TTL 刷新各数据源:
58
+ - **本地费用**(2 分钟 TTL):扫描 `~/.claude/projects/**/*.jsonl`,按模型定价计算 → `~/.cc-costline/cache.json`
59
+ - **使用率**(5 分钟重试,感知 token 轮换):从 `api.anthropic.com/api/oauth/usage` 获取 → `/tmp/sl-claude-usage`。检测 OAuth token 轮换后立即重试(新 token 有新的速率配额)。API 失败时保留历史数据。
60
+ - **ccclub 排名**(5 分钟重试):从 `ccclub.dev/api/rank` 获取 → `/tmp/sl-ccclub-rank`
61
61
  3. `refresh` 也可以手动运行或通过会话结束 hook 预热缓存。
62
62
 
63
63
  <details>
package/dist/cache.d.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  declare const CACHE_DIR: string;
2
+ export interface FileCostEntry {
3
+ mtimeMs: number;
4
+ size: number;
5
+ byDay: Record<string, number>;
6
+ }
2
7
  export interface CacheData {
3
8
  cost7d: number;
4
9
  cost30d: number;
5
10
  updatedAt: string;
11
+ files?: Record<string, FileCostEntry>;
6
12
  }
7
13
  export interface ConfigData {
8
14
  period: "7d" | "30d" | "both";
package/dist/cli.js CHANGED
@@ -6,8 +6,9 @@ import { fileURLToPath } from "node:url";
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
8
8
  import { collectCosts } from "./collector.js";
9
- import { writeCache, writeConfig, readConfig, CACHE_DIR } from "./cache.js";
9
+ import { readCache, writeCache, writeConfig, readConfig, CACHE_DIR } from "./cache.js";
10
10
  import { render } from "./statusline.js";
11
+ import { refreshAll } from "./refresh.js";
11
12
  const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
12
13
  const RENDER_COMMAND = "cc-costline render";
13
14
  const REFRESH_COMMAND = "cc-costline refresh";
@@ -115,11 +116,13 @@ function cmdConfig(args) {
115
116
  console.log(`✓ Period set to: ${period}`);
116
117
  }
117
118
  function cmdRefresh() {
118
- const result = collectCosts();
119
+ const prev = readCache();
120
+ const result = collectCosts(undefined, prev?.files);
119
121
  writeCache({
120
122
  cost7d: result.cost7d,
121
123
  cost30d: result.cost30d,
122
124
  updatedAt: new Date().toISOString(),
125
+ files: result.files,
123
126
  });
124
127
  console.log(`✓ Cache updated — 7d: $${result.cost7d.toFixed(2)} | 30d: $${result.cost30d.toFixed(2)}`);
125
128
  }
@@ -131,6 +134,10 @@ function cmdRender() {
131
134
  if (output)
132
135
  process.stdout.write(output);
133
136
  }
137
+ function cmdRefreshBg(args) {
138
+ const transcriptPath = args[0] || "";
139
+ refreshAll(transcriptPath);
140
+ }
134
141
  // ─── Main ─────────────────────────────────────────────────
135
142
  const args = process.argv.slice(2);
136
143
  const command = args[0];
@@ -147,6 +154,9 @@ switch (command) {
147
154
  case "refresh":
148
155
  cmdRefresh();
149
156
  break;
157
+ case "refresh-bg":
158
+ cmdRefreshBg(args.slice(1));
159
+ break;
150
160
  case "render":
151
161
  cmdRender();
152
162
  break;
@@ -1,6 +1,12 @@
1
+ import type { FileCostEntry } from "./cache.js";
1
2
  interface CollectResult {
2
3
  cost7d: number;
3
4
  cost30d: number;
5
+ files: Record<string, FileCostEntry>;
4
6
  }
5
- export declare function collectCosts(baseDir?: string): CollectResult;
7
+ /**
8
+ * Scan all jsonl files under projectsDir and compute cost7d/cost30d.
9
+ * Reuses prevFiles entries whose mtime+size haven't changed (incremental scan).
10
+ */
11
+ export declare function collectCosts(baseDir?: string, prevFiles?: Record<string, FileCostEntry>): CollectResult;
6
12
  export {};
package/dist/collector.js CHANGED
@@ -31,63 +31,106 @@ function findJsonlFiles(dir) {
31
31
  }
32
32
  return results;
33
33
  }
34
- export function collectCosts(baseDir) {
34
+ function dayKey(ms) {
35
+ const d = new Date(ms);
36
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
37
+ }
38
+ function dayStartMs(key) {
39
+ return new Date(key + "T00:00:00Z").getTime();
40
+ }
41
+ /** Parse a single jsonl file and bucket per-entry cost by UTC day. */
42
+ function parseFile(file, mtimeMs, size, cutoffMs) {
43
+ const byDay = {};
44
+ let content;
45
+ try {
46
+ content = readFileSync(file, "utf-8");
47
+ }
48
+ catch {
49
+ return { mtimeMs, size, byDay };
50
+ }
51
+ const seen = new Set();
52
+ const lines = content.split("\n");
53
+ for (const line of lines) {
54
+ if (!line.trim())
55
+ continue;
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(line);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ if (parsed.type !== "assistant" || !parsed.message?.usage)
64
+ continue;
65
+ const ts = new Date(parsed.timestamp).getTime();
66
+ if (isNaN(ts) || ts < cutoffMs)
67
+ continue;
68
+ const usage = parsed.message.usage;
69
+ const requestId = parsed.requestId || "";
70
+ const sessionId = parsed.sessionId || "";
71
+ const dedupeKey = requestId
72
+ ? `${sessionId}:${requestId}`
73
+ : `${sessionId}:${parsed.timestamp}:${parsed.message.model}:${usage.input_tokens}:${usage.output_tokens}:${usage.cache_creation_input_tokens || 0}:${usage.cache_read_input_tokens || 0}`;
74
+ if (seen.has(dedupeKey))
75
+ continue;
76
+ seen.add(dedupeKey);
77
+ const cost = calculateCost(parsed.message.model || "unknown", usage.input_tokens || 0, usage.output_tokens || 0, usage.cache_creation_input_tokens || 0, usage.cache_read_input_tokens || 0);
78
+ const day = dayKey(ts);
79
+ byDay[day] = (byDay[day] || 0) + cost;
80
+ }
81
+ return { mtimeMs, size, byDay };
82
+ }
83
+ /**
84
+ * Scan all jsonl files under projectsDir and compute cost7d/cost30d.
85
+ * Reuses prevFiles entries whose mtime+size haven't changed (incremental scan).
86
+ */
87
+ export function collectCosts(baseDir, prevFiles) {
35
88
  const projectsDir = baseDir || join(homedir(), CLAUDE_PROJECTS_DIR);
36
89
  const files = findJsonlFiles(projectsDir);
37
90
  if (files.length === 0) {
38
- return { cost7d: 0, cost30d: 0 };
91
+ return { cost7d: 0, cost30d: 0, files: {} };
39
92
  }
40
93
  const now = Date.now();
41
94
  const cutoff7d = now - 7 * 24 * 60 * 60 * 1000;
42
95
  const cutoff30d = now - 30 * 24 * 60 * 60 * 1000;
96
+ const prev = prevFiles || {};
97
+ const out = {};
43
98
  let cost7d = 0;
44
99
  let cost30d = 0;
45
- // Deduplication set (same as ccclub)
46
- const seen = new Set();
47
100
  for (const file of files) {
48
- let content;
101
+ let stat;
49
102
  try {
50
- content = readFileSync(file, "utf-8");
103
+ stat = statSync(file);
51
104
  }
52
105
  catch {
53
106
  continue;
54
107
  }
55
- const lines = content.split("\n");
56
- for (const line of lines) {
57
- if (!line.trim())
58
- continue;
59
- let parsed;
60
- try {
61
- parsed = JSON.parse(line);
62
- }
63
- catch {
64
- continue;
108
+ // Files not touched in the last 30d cannot contribute to either window
109
+ // (jsonl is append-only, so all entries inside are ≤ mtime).
110
+ if (stat.mtimeMs < cutoff30d)
111
+ continue;
112
+ let entry;
113
+ const cached = prev[file];
114
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
115
+ // Prune day buckets that have slid past the 30d window to keep cache from growing
116
+ const pruned = {};
117
+ for (const [day, cost] of Object.entries(cached.byDay)) {
118
+ if (dayStartMs(day) >= cutoff30d)
119
+ pruned[day] = cost;
65
120
  }
66
- if (parsed.type !== "assistant" || !parsed.message?.usage)
67
- continue;
68
- const ts = new Date(parsed.timestamp).getTime();
69
- if (isNaN(ts) || ts < cutoff30d)
70
- continue;
71
- const usage = parsed.message.usage;
72
- const requestId = parsed.requestId || "";
73
- const sessionId = parsed.sessionId || "";
74
- const dedupeKey = requestId
75
- ? `${sessionId}:${requestId}`
76
- : `${sessionId}:${parsed.timestamp}:${parsed.message.model}:${usage.input_tokens}:${usage.output_tokens}:${usage.cache_creation_input_tokens || 0}:${usage.cache_read_input_tokens || 0}`;
77
- if (seen.has(dedupeKey))
78
- continue;
79
- seen.add(dedupeKey);
80
- const model = parsed.message.model || "unknown";
81
- const inputTokens = usage.input_tokens || 0;
82
- const outputTokens = usage.output_tokens || 0;
83
- const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
84
- const cacheReadTokens = usage.cache_read_input_tokens || 0;
85
- const cost = calculateCost(model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens);
86
- cost30d += cost;
87
- if (ts >= cutoff7d) {
121
+ entry = { mtimeMs: cached.mtimeMs, size: cached.size, byDay: pruned };
122
+ }
123
+ else {
124
+ entry = parseFile(file, stat.mtimeMs, stat.size, cutoff30d);
125
+ }
126
+ out[file] = entry;
127
+ for (const [day, cost] of Object.entries(entry.byDay)) {
128
+ const ms = dayStartMs(day);
129
+ if (ms >= cutoff30d)
130
+ cost30d += cost;
131
+ if (ms >= cutoff7d)
88
132
  cost7d += cost;
89
- }
90
133
  }
91
134
  }
92
- return { cost7d, cost30d };
135
+ return { cost7d, cost30d, files: out };
93
136
  }
@@ -0,0 +1 @@
1
+ export declare function refreshAll(transcriptPath?: string): void;
@@ -0,0 +1,226 @@
1
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, statSync, utimesSync, closeSync, openSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { execSync } from "node:child_process";
5
+ import { readCache, writeCache } from "./cache.js";
6
+ import { collectCosts } from "./collector.js";
7
+ import { shouldRefreshLocalCostCache } from "./statusline.js";
8
+ // Anthropic usage API: strict per-token rate limits (~5 req/token). NEVER shrink.
9
+ const ANTHROPIC_TTL_MS = 300_000;
10
+ // ccclub rank: self-hosted, no strict limits — refresh more aggressively for visible data.
11
+ const CCCLUB_TTL_MS = 90_000;
12
+ const REFRESH_LOCK = "/tmp/sl-refresh.lock";
13
+ const REFRESH_LAST = "/tmp/sl-refresh.last";
14
+ const LOCK_STALE_MS = 60_000;
15
+ function acquireLock() {
16
+ try {
17
+ if (existsSync(REFRESH_LOCK)) {
18
+ try {
19
+ const stat = statSync(REFRESH_LOCK);
20
+ if (Date.now() - stat.mtimeMs < LOCK_STALE_MS)
21
+ return false;
22
+ unlinkSync(REFRESH_LOCK);
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ const fd = openSync(REFRESH_LOCK, "wx");
29
+ try {
30
+ writeFileSync(fd, String(process.pid));
31
+ }
32
+ finally {
33
+ closeSync(fd);
34
+ }
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ function releaseLock() {
42
+ try {
43
+ unlinkSync(REFRESH_LOCK);
44
+ }
45
+ catch { }
46
+ try {
47
+ const now = new Date();
48
+ if (!existsSync(REFRESH_LAST)) {
49
+ writeFileSync(REFRESH_LAST, "");
50
+ }
51
+ utimesSync(REFRESH_LAST, now, now);
52
+ }
53
+ catch { }
54
+ }
55
+ function refreshLocalCost(transcriptPath) {
56
+ const cache = readCache();
57
+ if (!shouldRefreshLocalCostCache(cache, transcriptPath))
58
+ return;
59
+ try {
60
+ const result = collectCosts(undefined, cache?.files);
61
+ // Don't overwrite valid cache with zeros (directory read failure)
62
+ if (result.cost7d > 0 || result.cost30d > 0 || !cache) {
63
+ writeCache({
64
+ cost7d: result.cost7d,
65
+ cost30d: result.cost30d,
66
+ updatedAt: new Date().toISOString(),
67
+ files: result.files,
68
+ });
69
+ }
70
+ }
71
+ catch { }
72
+ }
73
+ function refreshClaudeUsage() {
74
+ const cacheFile = "/tmp/sl-claude-usage";
75
+ const hitFile = "/tmp/sl-claude-usage-hit";
76
+ const now = Date.now();
77
+ let staleData = null;
78
+ let lastAttempt = 0;
79
+ let cachedTokenPrefix = "";
80
+ if (existsSync(cacheFile)) {
81
+ try {
82
+ const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
83
+ staleData = cached.data ?? null;
84
+ lastAttempt = cached.lastAttempt || 0;
85
+ cachedTokenPrefix = cached.tokenPrefix || "";
86
+ }
87
+ catch { }
88
+ }
89
+ let accessToken = "";
90
+ try {
91
+ const username = process.env.USER || process.env.USERNAME;
92
+ const keychainCmd = `security find-generic-password -s "Claude Code-credentials" -a "${username}" -w 2>/dev/null`;
93
+ const credentialsJSON = execSync(keychainCmd, { encoding: "utf-8", timeout: 2000 }).trim();
94
+ if (!credentialsJSON)
95
+ return;
96
+ const credentials = JSON.parse(credentialsJSON);
97
+ accessToken = credentials.claudeAiOauth?.accessToken || "";
98
+ if (!accessToken)
99
+ return;
100
+ const expiresAt = credentials.claudeAiOauth?.expiresAt;
101
+ if (expiresAt && now > expiresAt)
102
+ return;
103
+ }
104
+ catch {
105
+ return;
106
+ }
107
+ const currentTokenPrefix = accessToken.slice(-20);
108
+ const tokenChanged = cachedTokenPrefix && currentTokenPrefix !== cachedTokenPrefix;
109
+ // Skip if cache is fresh and token hasn't rotated
110
+ if (!tokenChanged && lastAttempt && now - lastAttempt < ANTHROPIC_TTL_MS)
111
+ return;
112
+ // Mark attempt before HTTP — protects against repeated failures hammering the API
113
+ try {
114
+ writeFileSync(cacheFile, JSON.stringify({ data: staleData, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
115
+ }
116
+ catch { }
117
+ try {
118
+ const apiUrl = "https://api.anthropic.com/api/oauth/usage";
119
+ const curlCmd = `curl -sf "${apiUrl}" -H "Authorization: Bearer ${accessToken}" -H "anthropic-beta: oauth-2025-04-20"`;
120
+ const response = execSync(curlCmd, { encoding: "utf-8", timeout: 5000 });
121
+ if (!response)
122
+ return;
123
+ const data = JSON.parse(response);
124
+ try {
125
+ writeFileSync("/tmp/sl-claude-usage-raw", JSON.stringify(data, null, 2), "utf-8");
126
+ }
127
+ catch { }
128
+ const parseUtil = (val) => {
129
+ if (typeof val === "number")
130
+ return Math.round(val);
131
+ if (typeof val === "string")
132
+ return Math.round(parseFloat(val.replace("%", "")));
133
+ return 0;
134
+ };
135
+ const fiveHour = parseUtil(data.five_hour?.utilization);
136
+ const sevenDay = parseUtil(data.seven_day?.utilization);
137
+ let fiveHourResetsAt;
138
+ const resetsAtRaw = data.five_hour?.resets_at ?? data.five_hour?.reset_at ?? data.five_hour?.next_reset;
139
+ if (resetsAtRaw) {
140
+ const ts = typeof resetsAtRaw === "string" ? new Date(resetsAtRaw).getTime() : resetsAtRaw * 1000;
141
+ if (!isNaN(ts) && ts > now)
142
+ fiveHourResetsAt = ts;
143
+ }
144
+ if (fiveHour >= 100) {
145
+ if (!fiveHourResetsAt) {
146
+ if (existsSync(hitFile)) {
147
+ const hitTime = parseFloat(readFileSync(hitFile, "utf-8").trim());
148
+ if (!isNaN(hitTime))
149
+ fiveHourResetsAt = hitTime + 5 * 3600 * 1000;
150
+ }
151
+ else {
152
+ writeFileSync(hitFile, String(now), "utf-8");
153
+ fiveHourResetsAt = now + 5 * 3600 * 1000;
154
+ }
155
+ }
156
+ }
157
+ else {
158
+ try {
159
+ if (existsSync(hitFile))
160
+ unlinkSync(hitFile);
161
+ }
162
+ catch { }
163
+ }
164
+ const result = { fiveHour, sevenDay };
165
+ if (fiveHourResetsAt)
166
+ result.fiveHourResetsAt = fiveHourResetsAt;
167
+ writeFileSync(cacheFile, JSON.stringify({ data: result, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
168
+ }
169
+ catch { }
170
+ }
171
+ function refreshCcclubRank() {
172
+ const configPath = join(homedir(), ".ccclub", "config.json");
173
+ if (!existsSync(configPath))
174
+ return;
175
+ const cacheFile = "/tmp/sl-ccclub-rank";
176
+ const now = Date.now();
177
+ let staleData = null;
178
+ let lastAttempt = 0;
179
+ if (existsSync(cacheFile)) {
180
+ try {
181
+ const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
182
+ staleData = cached.data ?? null;
183
+ lastAttempt = cached.lastAttempt || cached.timestamp || 0;
184
+ if (now - lastAttempt < CCCLUB_TTL_MS)
185
+ return;
186
+ }
187
+ catch { }
188
+ }
189
+ // Mark attempt before HTTP
190
+ try {
191
+ writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: staleData ? (lastAttempt || now) : 0, lastAttempt: now }), "utf-8");
192
+ }
193
+ catch { }
194
+ try {
195
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
196
+ const code = config.groups?.[0];
197
+ const userId = config.userId;
198
+ if (!code || !userId)
199
+ return;
200
+ const tz = -(new Date()).getTimezoneOffset();
201
+ const url = `${config.apiUrl}/api/rank/${code}?period=today&tz=${tz}`;
202
+ const response = execSync(`curl -sf "${url}"`, { encoding: "utf-8", timeout: 5000 });
203
+ if (!response)
204
+ return;
205
+ const data = JSON.parse(response);
206
+ const rankings = data.rankings || [];
207
+ const me = rankings.find((r) => r.userId === userId);
208
+ if (!me)
209
+ return;
210
+ const result = { rank: me.rank, total: rankings.length, cost: me.costUSD };
211
+ writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: now, lastAttempt: now }), "utf-8");
212
+ }
213
+ catch { }
214
+ }
215
+ export function refreshAll(transcriptPath = "") {
216
+ if (!acquireLock())
217
+ return;
218
+ try {
219
+ refreshLocalCost(transcriptPath);
220
+ refreshClaudeUsage();
221
+ refreshCcclubRank();
222
+ }
223
+ finally {
224
+ releaseLock();
225
+ }
226
+ }
@@ -1,13 +1,13 @@
1
- import { readFileSync, existsSync, statSync, writeFileSync, unlinkSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { homedir } from "node:os";
4
- import { execSync } from "node:child_process";
5
- import { readCache, writeCache, readConfig } from "./cache.js";
6
- import { collectCosts } from "./collector.js";
7
- // TTL for local cost cache (2 minutes)
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { readCache, readConfig } from "./cache.js";
4
+ // TTL for local cost cache (2 minutes) — used by refresh-bg to decide whether to rescan jsonl
8
5
  const CACHE_TTL_MS = 120_000;
9
- // TTL for external API retry throttle (5 minutes) usage API has strict per-token rate limits (~5 req/token)
10
- const API_RETRY_TTL_MS = 300_000;
6
+ // Throttle: render only spawns a background refresh if the last one finished > 30s ago.
7
+ // refresh-bg internally honors per-API TTLs (Anthropic 5min, ccclub 90s) so this is just
8
+ // a coarse "don't fork node every turn" gate.
9
+ const REFRESH_SPAWN_THROTTLE_MS = 30_000;
10
+ const REFRESH_LAST_MARKER = "/tmp/sl-refresh.last";
11
11
  // ANSI colors (matching original statusline.sh)
12
12
  const FG_GRAY = "\x1b[38;5;245m";
13
13
  const FG_GRAY_DIM = "\x1b[38;5;102m";
@@ -67,53 +67,6 @@ export function shouldRefreshLocalCostCache(cache, transcriptPath = "", now = Da
67
67
  }
68
68
  return now - cacheUpdatedAt >= CACHE_TTL_MS;
69
69
  }
70
- // ccclub rank fetcher — split cache: data persists, retry throttled
71
- function getCcclubRank() {
72
- const configPath = join(homedir(), ".ccclub", "config.json");
73
- if (!existsSync(configPath))
74
- return null;
75
- const cacheFile = "/tmp/sl-ccclub-rank";
76
- const now = Date.now();
77
- let staleData = null;
78
- let lastAttempt = 0;
79
- if (existsSync(cacheFile)) {
80
- try {
81
- const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
82
- staleData = cached.data ?? null;
83
- lastAttempt = cached.lastAttempt || cached.timestamp || 0;
84
- if (now - lastAttempt < API_RETRY_TTL_MS)
85
- return staleData;
86
- }
87
- catch { }
88
- }
89
- try {
90
- writeFileSync(cacheFile, JSON.stringify({ data: staleData, timestamp: staleData ? (lastAttempt || now) : 0, lastAttempt: now }), "utf-8");
91
- }
92
- catch { }
93
- try {
94
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
95
- const code = config.groups?.[0];
96
- const userId = config.userId;
97
- if (!code || !userId)
98
- return staleData;
99
- const tz = -(new Date()).getTimezoneOffset();
100
- const url = `${config.apiUrl}/api/rank/${code}?period=today&tz=${tz}`;
101
- const response = execSync(`curl -sf "${url}"`, { encoding: "utf-8", timeout: 5000 });
102
- if (!response)
103
- return staleData;
104
- const data = JSON.parse(response);
105
- const rankings = data.rankings || [];
106
- const me = rankings.find((r) => r.userId === userId);
107
- if (!me)
108
- return staleData;
109
- const result = { rank: me.rank, total: rankings.length, cost: me.costUSD };
110
- writeFileSync(cacheFile, JSON.stringify({ data: result, timestamp: now, lastAttempt: now }), "utf-8");
111
- return result;
112
- }
113
- catch {
114
- return staleData;
115
- }
116
- }
117
70
  export function rankColor(rank) {
118
71
  if (rank === 1)
119
72
  return FG_YELLOW;
@@ -123,114 +76,45 @@ export function rankColor(rank) {
123
76
  return FG_ORANGE;
124
77
  return FG_CYAN;
125
78
  }
126
- // Claude usage fetcher token-aware cache: detects token rotation to retry immediately
127
- function getClaudeUsage() {
128
- const cacheFile = "/tmp/sl-claude-usage";
129
- const hitFile = "/tmp/sl-claude-usage-hit";
130
- const now = Date.now();
131
- // Cache format: { data, lastAttempt, tokenPrefix (first 20 chars of token) }
132
- let staleData = null;
133
- let lastAttempt = 0;
134
- let cachedTokenPrefix = "";
135
- if (existsSync(cacheFile)) {
136
- try {
137
- const cached = JSON.parse(readFileSync(cacheFile, "utf-8"));
138
- staleData = cached.data ?? null;
139
- lastAttempt = cached.lastAttempt || 0;
140
- cachedTokenPrefix = cached.tokenPrefix || "";
141
- }
142
- catch { }
79
+ // Read-only: usage data from /tmp cache. Refresh is done by `cc-costline refresh-bg`.
80
+ function readUsageCache() {
81
+ try {
82
+ const cached = JSON.parse(readFileSync("/tmp/sl-claude-usage", "utf-8"));
83
+ return cached.data ?? null;
143
84
  }
144
- // Get current token
145
- let accessToken = "";
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ // Read-only: ccclub rank from /tmp cache.
90
+ function readRankCache() {
146
91
  try {
147
- const username = process.env.USER || process.env.USERNAME;
148
- const keychainCmd = `security find-generic-password -s "Claude Code-credentials" -a "${username}" -w 2>/dev/null`;
149
- const credentialsJSON = execSync(keychainCmd, { encoding: "utf-8", timeout: 2000 }).trim();
150
- if (!credentialsJSON)
151
- return staleData;
152
- const credentials = JSON.parse(credentialsJSON);
153
- accessToken = credentials.claudeAiOauth?.accessToken || "";
154
- if (!accessToken)
155
- return staleData;
156
- const expiresAt = credentials.claudeAiOauth?.expiresAt;
157
- if (expiresAt && now > expiresAt)
158
- return staleData;
92
+ const cached = JSON.parse(readFileSync("/tmp/sl-ccclub-rank", "utf-8"));
93
+ return cached.data ?? null;
159
94
  }
160
95
  catch {
161
- return staleData;
96
+ return null;
162
97
  }
163
- const currentTokenPrefix = accessToken.slice(-20);
164
- const tokenChanged = cachedTokenPrefix && currentTokenPrefix !== cachedTokenPrefix;
165
- // Throttle: skip API call unless token rotated or TTL expired
166
- if (!tokenChanged && lastAttempt && now - lastAttempt < API_RETRY_TTL_MS)
167
- return staleData;
168
- // Mark attempt and store token prefix
98
+ }
99
+ // Spawn detached `cc-costline refresh-bg` subprocess. Render does NOT wait for it.
100
+ // The subprocess uses a lockfile to prevent concurrent refresh across multiple Claude Code windows.
101
+ function maybeSpawnRefresh(transcriptPath) {
102
+ if (process.env.CC_COSTLINE_NO_SPAWN)
103
+ return;
104
+ const entry = process.argv[1] || "";
105
+ if (!/cc-costline|cli\.js$/.test(entry))
106
+ return;
169
107
  try {
170
- writeFileSync(cacheFile, JSON.stringify({ data: staleData, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
108
+ const stat = statSync(REFRESH_LAST_MARKER);
109
+ if (Date.now() - stat.mtimeMs < REFRESH_SPAWN_THROTTLE_MS)
110
+ return;
171
111
  }
172
112
  catch { }
173
113
  try {
174
- const apiUrl = "https://api.anthropic.com/api/oauth/usage";
175
- const curlCmd = `curl -sf "${apiUrl}" -H "Authorization: Bearer ${accessToken}" -H "anthropic-beta: oauth-2025-04-20"`;
176
- const response = execSync(curlCmd, { encoding: "utf-8", timeout: 5000 });
177
- if (!response)
178
- return staleData;
179
- const data = JSON.parse(response);
180
- // Debug: save raw API response for diagnosis
181
- try {
182
- writeFileSync("/tmp/sl-claude-usage-raw", JSON.stringify(data, null, 2), "utf-8");
183
- }
184
- catch { }
185
- const parseUtil = (val) => {
186
- if (typeof val === "number")
187
- return Math.round(val);
188
- if (typeof val === "string")
189
- return Math.round(parseFloat(val.replace("%", "")));
190
- return 0;
191
- };
192
- const fiveHour = parseUtil(data.five_hour?.utilization);
193
- const sevenDay = parseUtil(data.seven_day?.utilization);
194
- let fiveHourResetsAt;
195
- // Strategy 1: Use reset time from API if available
196
- const resetsAtRaw = data.five_hour?.resets_at ?? data.five_hour?.reset_at ?? data.five_hour?.next_reset;
197
- if (resetsAtRaw) {
198
- const ts = typeof resetsAtRaw === "string" ? new Date(resetsAtRaw).getTime() : resetsAtRaw * 1000;
199
- if (!isNaN(ts) && ts > now)
200
- fiveHourResetsAt = ts;
201
- }
202
- // Strategy 2: Fallback - track when we first saw 100%
203
- if (fiveHour >= 100) {
204
- if (!fiveHourResetsAt) {
205
- if (existsSync(hitFile)) {
206
- const hitTime = parseFloat(readFileSync(hitFile, "utf-8").trim());
207
- if (!isNaN(hitTime)) {
208
- fiveHourResetsAt = hitTime + 5 * 3600 * 1000;
209
- }
210
- }
211
- else {
212
- writeFileSync(hitFile, String(now), "utf-8");
213
- fiveHourResetsAt = now + 5 * 3600 * 1000;
214
- }
215
- }
216
- }
217
- else {
218
- // Usage dropped below 100%, clear hit tracker
219
- try {
220
- if (existsSync(hitFile))
221
- unlinkSync(hitFile);
222
- }
223
- catch { }
224
- }
225
- const result = { fiveHour, sevenDay };
226
- if (fiveHourResetsAt)
227
- result.fiveHourResetsAt = fiveHourResetsAt;
228
- writeFileSync(cacheFile, JSON.stringify({ data: result, lastAttempt: now, tokenPrefix: currentTokenPrefix }), "utf-8");
229
- return result;
230
- }
231
- catch {
232
- return staleData;
114
+ const child = spawn(process.execPath, [entry, "refresh-bg", transcriptPath], { detached: true, stdio: "ignore" });
115
+ child.unref();
233
116
  }
117
+ catch { }
234
118
  }
235
119
  export function render(input) {
236
120
  let data;
@@ -245,7 +129,7 @@ export function render(input) {
245
129
  const model = (data.model?.display_name ?? "—").replace(/\s*\((\d+[KMB])\s+context\)/i, " ($1)");
246
130
  const contextPct = Math.floor(data.context_window?.used_percentage ?? 0);
247
131
  const transcriptPath = data.transcript_path ?? "";
248
- // Token stats from transcript
132
+ // Token stats from transcript (synchronous — small per-session file, typically < 1ms)
249
133
  let totalTokens = 0;
250
134
  if (transcriptPath) {
251
135
  try {
@@ -265,22 +149,13 @@ export function render(input) {
265
149
  }
266
150
  catch { }
267
151
  }
268
- // Refresh local cost cache if stale
269
- let cache = readCache();
270
- if (shouldRefreshLocalCostCache(cache, transcriptPath)) {
271
- try {
272
- const result = collectCosts();
273
- // Don't overwrite valid cache with zeros (directory read failure)
274
- if (result.cost7d > 0 || result.cost30d > 0 || !cache) {
275
- const newCache = { cost7d: result.cost7d, cost30d: result.cost30d, updatedAt: new Date().toISOString() };
276
- writeCache(newCache);
277
- cache = newCache;
278
- }
279
- }
280
- catch { }
281
- }
152
+ // All external data is read-only here. refresh-bg writes these caches in the background.
153
+ const cache = readCache();
282
154
  const config = readConfig();
283
- const claudeUsage = getClaudeUsage();
155
+ const claudeUsage = readUsageCache();
156
+ const ccclubRank = readRankCache();
157
+ // Fire-and-forget background refresh (throttled to once per 30s)
158
+ maybeSpawnRefresh(transcriptPath);
284
159
  const g = FG_GRAY_DIM;
285
160
  const y = FG_YELLOW;
286
161
  const m = FG_MODEL;
@@ -319,7 +194,6 @@ export function render(input) {
319
194
  segments.push(usageParts.join(` ${g}·${r} `));
320
195
  }
321
196
  // #2 $53.6
322
- const ccclubRank = getCcclubRank();
323
197
  if (ccclubRank) {
324
198
  const rc = rankColor(ccclubRank.rank);
325
199
  segments.push(`${rc}#${ccclubRank.rank} ${formatCost(ccclubRank.cost)}${r}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-costline",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Enhanced statusline for Claude Code with cost tracking, usage limits, and leaderboard",
5
5
  "type": "module",
6
6
  "bin": {