handmux 0.5.3 → 0.7.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/src/usage.js ADDED
@@ -0,0 +1,94 @@
1
+ // Usage/quota reader for the phone's Usage page. Purely reads what each agent already puts on disk — no
2
+ // API calls, no credentials:
3
+ // • Claude — the snapshot the statusLine capturer writes to ~/.handmux/claude-usage.json. Claude Code's
4
+ // statusLine stdin is the ONLY documented local source of the 5h/weekly rate-limit % (see
5
+ // server/hooks/handmux-statusline.cjs). Absent until the user opts the capturer in → returns null.
6
+ // • Codex — the newest rollout's most recent `token_count` event, which carries `rate_limits` (used %,
7
+ // reset, window) and cumulative token usage. Always available once Codex has run, no wiring needed.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import { pocketHome } from './cli/state.js';
12
+
13
+ export function claudeUsagePath(home = homedir()) { return path.join(pocketHome(home), 'claude-usage.json'); }
14
+ export function codexSessionsDir(home = homedir()) { return path.join(home, '.codex', 'sessions'); }
15
+
16
+ // Claude: read the statusLine snapshot. null if the capturer isn't wired / never populated it.
17
+ export function readClaudeUsage(home = homedir()) {
18
+ try {
19
+ const snap = JSON.parse(fs.readFileSync(claudeUsagePath(home), 'utf8'));
20
+ return (snap && typeof snap === 'object' && !Array.isArray(snap)) ? snap : null;
21
+ } catch { return null; }
22
+ }
23
+
24
+ // The rollout tree is date-nested (sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl) and every path segment
25
+ // sorts lexically = chronologically, so the newest rollout is the lexically-largest entry at each level —
26
+ // found without walking the whole tree.
27
+ function newestRollout(dir) {
28
+ const maxEntry = (d, pred) => {
29
+ let names;
30
+ try { names = fs.readdirSync(d); } catch { return null; }
31
+ names = names.filter((n) => !n.startsWith('.') && (!pred || pred(n))).sort();
32
+ return names.length ? names[names.length - 1] : null;
33
+ };
34
+ const y = maxEntry(dir); if (!y) return null;
35
+ const m = maxEntry(path.join(dir, y)); if (!m) return null;
36
+ const d = maxEntry(path.join(dir, y, m)); if (!d) return null;
37
+ const dayDir = path.join(dir, y, m, d);
38
+ const f = maxEntry(dayDir, (n) => n.startsWith('rollout-') && n.endsWith('.jsonl'));
39
+ return f ? path.join(dayDir, f) : null;
40
+ }
41
+
42
+ // One Codex rate-limit window → our shape, or null if absent (secondary is often null on plans without it).
43
+ function codexWindow(w) {
44
+ if (!w || typeof w.used_percent !== 'number') return null;
45
+ return {
46
+ usedPercent: w.used_percent,
47
+ windowMinutes: typeof w.window_minutes === 'number' ? w.window_minutes : null,
48
+ resetsAt: typeof w.resets_at === 'number' ? w.resets_at : null,
49
+ };
50
+ }
51
+
52
+ // Codex: scan the newest rollout from the end for the last `token_count` event (carries the account-wide
53
+ // rate_limits + the session's cumulative tokens). null if Codex hasn't run or the rollout has none yet.
54
+ export function readCodexUsage(home = homedir()) {
55
+ const f = newestRollout(codexSessionsDir(home));
56
+ if (!f) return null;
57
+ let lines;
58
+ try { lines = fs.readFileSync(f, 'utf8').split('\n'); } catch { return null; }
59
+ for (let i = lines.length - 1; i >= 0; i--) {
60
+ const ln = lines[i];
61
+ if (!ln || ln.indexOf('token_count') === -1) continue;
62
+ let rec; try { rec = JSON.parse(ln); } catch { continue; }
63
+ const p = rec.payload;
64
+ if (!p || p.type !== 'token_count') continue;
65
+ const info = p.info || {};
66
+ const tu = info.total_token_usage || {};
67
+ const rl = p.rate_limits || {};
68
+ return {
69
+ updatedAt: Date.parse(rec.timestamp) || null,
70
+ rateLimits: { primary: codexWindow(rl.primary), secondary: codexWindow(rl.secondary) },
71
+ tokens: {
72
+ total: tu.total_tokens ?? null,
73
+ input: tu.input_tokens ?? null,
74
+ cachedInput: tu.cached_input_tokens ?? null,
75
+ output: tu.output_tokens ?? null,
76
+ reasoning: tu.reasoning_output_tokens ?? null,
77
+ },
78
+ contextWindow: typeof info.model_context_window === 'number' ? info.model_context_window : null,
79
+ };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ export function getUsage(home = homedir()) {
85
+ return { claude: readClaudeUsage(home), codex: readCodexUsage(home) };
86
+ }
87
+
88
+ // Small TTL cache so a phone that re-polls doesn't rescan the rollout every few seconds.
89
+ let _cache = { at: 0, home: null, data: null };
90
+ export function getUsageCached(home = homedir(), { ttlMs = 15000, now = Date.now() } = {}) {
91
+ if (_cache.data && _cache.home === home && (now - _cache.at) < ttlMs) return _cache.data;
92
+ _cache = { at: now, home, data: getUsage(home) };
93
+ return _cache.data;
94
+ }