handmux 0.18.0 → 0.19.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 CHANGED
@@ -1,15 +1,21 @@
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:
1
+ // Usage/quota reader for the phone's Usage page:
3
2
  // • Claude — the snapshot the statusLine capturer writes to ~/.handmux/claude-usage.json. Claude Code's
4
3
  // statusLine stdin is the ONLY documented local source of the 5h/weekly rate-limit % (see
5
4
  // 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.
5
+ // • Codex — account limits come from Codex's local app-server (which owns auth); rollout snapshots provide
6
+ // cumulative tokens/context and remain the fallback when that stable local method is unavailable.
8
7
  import fs from 'node:fs';
9
8
  import path from 'node:path';
10
9
  import { homedir } from 'node:os';
10
+ import { createRequire } from 'node:module';
11
+ import { spawn } from 'node:child_process';
11
12
  import { pocketHome } from './cli/state.js';
12
13
 
14
+ const require = createRequire(import.meta.url);
15
+ const {
16
+ readLatestUsage, readSnapshot, writeSnapshot,
17
+ } = require('../hooks/handmux-codex-usage.cjs');
18
+
13
19
  export function claudeUsagePath(home = homedir()) { return path.join(pocketHome(home), 'claude-usage.json'); }
14
20
  export function claudeContextDir(home = homedir()) { return path.join(pocketHome(home), 'context'); }
15
21
 
@@ -25,6 +31,7 @@ export function readClaudeContext(sessionId, home = homedir()) {
25
31
  } catch { return null; }
26
32
  }
27
33
  export function codexSessionsDir(home = homedir()) { return path.join(home, '.codex', 'sessions'); }
34
+ export function codexUsagePath(home = homedir()) { return path.join(pocketHome(home), 'codex-usage.json'); }
28
35
 
29
36
  // Claude: read the statusLine snapshot. null if the capturer isn't wired / never populated it.
30
37
  export function readClaudeUsage(home = homedir()) {
@@ -34,74 +41,221 @@ export function readClaudeUsage(home = homedir()) {
34
41
  } catch { return null; }
35
42
  }
36
43
 
37
- // The rollout tree is date-nested (sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl) and every path segment
38
- // sorts lexically = chronologically, so the newest rollout is the lexically-largest entry at each level —
39
- // found without walking the whole tree.
40
- function newestRollout(dir) {
41
- const maxEntry = (d, pred) => {
42
- let names;
43
- try { names = fs.readdirSync(d); } catch { return null; }
44
- names = names.filter((n) => !n.startsWith('.') && (!pred || pred(n))).sort();
45
- return names.length ? names[names.length - 1] : null;
44
+ // Usage is machine-wide, not owned by whichever session happened to be created last. Enumerate rollout
45
+ // files by modification time: an active older session can be newer than a freshly-created rollout, while
46
+ // a new rollout may have no token_count until its first response. Once a file's mtime is older than the
47
+ // newest event already found, no remaining file can contain a newer event, so the scan stays bounded.
48
+ function rolloutFilesByMtime(dir) {
49
+ const files = [];
50
+ const visit = (current) => {
51
+ let entries;
52
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
53
+ for (const entry of entries) {
54
+ if (entry.name.startsWith('.')) continue;
55
+ const file = path.join(current, entry.name);
56
+ if (entry.isDirectory()) visit(file);
57
+ else if (entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) {
58
+ try { files.push({ file, mtimeMs: fs.statSync(file).mtimeMs }); } catch { /* file raced away */ }
59
+ }
60
+ }
46
61
  };
47
- const y = maxEntry(dir); if (!y) return null;
48
- const m = maxEntry(path.join(dir, y)); if (!m) return null;
49
- const d = maxEntry(path.join(dir, y, m)); if (!d) return null;
50
- const dayDir = path.join(dir, y, m, d);
51
- const f = maxEntry(dayDir, (n) => n.startsWith('rollout-') && n.endsWith('.jsonl'));
52
- return f ? path.join(dayDir, f) : null;
62
+ visit(dir);
63
+ return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
64
+ }
65
+
66
+ // Full reconciliation fallback. It remains zero-config, but now runs at most once per calibration interval;
67
+ // the normal path reads the persistent snapshot written by Codex hooks.
68
+ function scanCodexUsage(home) {
69
+ let latest = null;
70
+ for (const { file, mtimeMs } of rolloutFilesByMtime(codexSessionsDir(home))) {
71
+ if (latest?.updatedAt && mtimeMs < latest.updatedAt) break;
72
+ const usage = readLatestUsage(file);
73
+ if (usage && (!latest || usage.updatedAt > latest.updatedAt)) latest = usage;
74
+ }
75
+ return latest;
76
+ }
77
+
78
+ // Prefer the machine-wide snapshot. A full rollout scan seeds it and calibrates it once per minute for
79
+ // users without hooks; hook updates between calibrations are never rolled back by an older scan result.
80
+ export function readCodexUsage(
81
+ home = homedir(),
82
+ { now = Date.now(), calibrationMs = 60_000 } = {},
83
+ ) {
84
+ const file = codexUsagePath(home);
85
+ const snapshot = readSnapshot(file);
86
+ if (snapshot && (now - snapshot.checkedAt) < calibrationMs) return snapshot.usage;
87
+
88
+ const scanned = scanCodexUsage(home);
89
+ writeSnapshot(file, scanned, { checkedAt: now });
90
+ return readSnapshot(file)?.usage || null;
53
91
  }
54
92
 
55
- // One Codex rate-limit window → our shape, or null if absent (secondary is often null on plans without it).
56
- function codexWindow(w) {
57
- if (!w || typeof w.used_percent !== 'number') return null;
93
+ function normalizeRateLimitWindow(value) {
94
+ if (!value || typeof value.usedPercent !== 'number') return null;
58
95
  return {
59
- usedPercent: w.used_percent,
60
- windowMinutes: typeof w.window_minutes === 'number' ? w.window_minutes : null,
61
- resetsAt: typeof w.resets_at === 'number' ? w.resets_at : null,
96
+ usedPercent: value.usedPercent,
97
+ windowMinutes: typeof value.windowDurationMins === 'number' ? value.windowDurationMins : null,
98
+ resetsAt: typeof value.resetsAt === 'number' ? value.resetsAt : null,
62
99
  };
63
100
  }
64
101
 
65
- // Codex: scan the newest rollout from the end for the last `token_count` event (carries the account-wide
66
- // rate_limits + the session's cumulative tokens). null if Codex hasn't run or the rollout has none yet.
67
- export function readCodexUsage(home = homedir()) {
68
- const f = newestRollout(codexSessionsDir(home));
69
- if (!f) return null;
70
- let lines;
71
- try { lines = fs.readFileSync(f, 'utf8').split('\n'); } catch { return null; }
72
- for (let i = lines.length - 1; i >= 0; i--) {
73
- const ln = lines[i];
74
- if (!ln || ln.indexOf('token_count') === -1) continue;
75
- let rec; try { rec = JSON.parse(ln); } catch { continue; }
76
- const p = rec.payload;
77
- if (!p || p.type !== 'token_count') continue;
78
- const info = p.info || {};
79
- const tu = info.total_token_usage || {};
80
- const rl = p.rate_limits || {};
81
- return {
82
- updatedAt: Date.parse(rec.timestamp) || null,
83
- rateLimits: { primary: codexWindow(rl.primary), secondary: codexWindow(rl.secondary) },
84
- tokens: {
85
- total: tu.total_tokens ?? null,
86
- input: tu.input_tokens ?? null,
87
- cachedInput: tu.cached_input_tokens ?? null,
88
- output: tu.output_tokens ?? null,
89
- reasoning: tu.reasoning_output_tokens ?? null,
90
- },
91
- contextWindow: typeof info.model_context_window === 'number' ? info.model_context_window : null,
102
+ // Query the same stable local account method used by Codex's own rich clients. The child process reuses
103
+ // Codex's auth internally; handmux neither reads nor receives credentials. Every failure is a null fallback.
104
+ export function fetchCodexRateLimits(
105
+ home = homedir(),
106
+ {
107
+ codexCommand = 'codex',
108
+ codexArgs = ['app-server', '--stdio'],
109
+ codexTimeoutMs = 5000,
110
+ } = {},
111
+ ) {
112
+ return new Promise((resolve) => {
113
+ let child;
114
+ let settled = false;
115
+ let stdout = '';
116
+
117
+ const finish = (value) => {
118
+ if (settled) return;
119
+ settled = true;
120
+ clearTimeout(timer);
121
+ try { child?.kill(); } catch { /* best effort */ }
122
+ resolve(value);
92
123
  };
124
+
125
+ const timer = setTimeout(() => finish(null), codexTimeoutMs);
126
+ try {
127
+ child = spawn(codexCommand, codexArgs, {
128
+ env: { ...process.env, CODEX_HOME: path.join(home, '.codex') },
129
+ stdio: ['pipe', 'pipe', 'ignore'],
130
+ });
131
+ } catch {
132
+ finish(null);
133
+ return;
134
+ }
135
+
136
+ child.on('error', () => finish(null));
137
+ child.on('exit', () => finish(null));
138
+ child.stdout.on('data', (chunk) => {
139
+ stdout += chunk;
140
+ if (stdout.length > 1024 * 1024) {
141
+ finish(null);
142
+ return;
143
+ }
144
+ let newline;
145
+ while ((newline = stdout.indexOf('\n')) >= 0) {
146
+ const line = stdout.slice(0, newline);
147
+ stdout = stdout.slice(newline + 1);
148
+ if (!line) continue;
149
+ let message;
150
+ try { message = JSON.parse(line); } catch { continue; }
151
+
152
+ if (message.id === 1 && message.result) {
153
+ child.stdin.write(`${JSON.stringify({ method: 'initialized', params: {} })}\n`);
154
+ child.stdin.write(`${JSON.stringify({ method: 'account/rateLimits/read', id: 2 })}\n`);
155
+ } else if (message.id === 2) {
156
+ const source = message.result?.rateLimits;
157
+ if (!source || typeof source !== 'object') {
158
+ finish(null);
159
+ return;
160
+ }
161
+ finish({
162
+ primary: normalizeRateLimitWindow(source.primary),
163
+ secondary: normalizeRateLimitWindow(source.secondary),
164
+ });
165
+ return;
166
+ }
167
+ }
168
+ });
169
+
170
+ child.stdin.on('error', () => finish(null));
171
+ child.stdin.write(`${JSON.stringify({
172
+ method: 'initialize',
173
+ id: 1,
174
+ params: { clientInfo: { name: 'handmux', title: 'handmux', version: '0.0.0' } },
175
+ })}\n`);
176
+ });
177
+ }
178
+
179
+ let _codexLimitsCache = {
180
+ at: 0, home: null, data: null, ready: false, promise: null,
181
+ };
182
+
183
+ async function getCodexRateLimitsCached(
184
+ home,
185
+ {
186
+ now = Date.now(),
187
+ codexRateLimitsTtlMs = 60_000,
188
+ ...fetchOptions
189
+ } = {},
190
+ ) {
191
+ if (_codexLimitsCache.home === home
192
+ && _codexLimitsCache.ready
193
+ && (now - _codexLimitsCache.at) < codexRateLimitsTtlMs) {
194
+ return _codexLimitsCache.data;
195
+ }
196
+ if (_codexLimitsCache.home === home && _codexLimitsCache.promise) {
197
+ return _codexLimitsCache.promise;
93
198
  }
94
- return null;
199
+
200
+ const promise = fetchCodexRateLimits(home, fetchOptions).then((data) => {
201
+ _codexLimitsCache = {
202
+ at: now, home, data, ready: true, promise: null,
203
+ };
204
+ return data;
205
+ });
206
+ _codexLimitsCache = {
207
+ at: _codexLimitsCache.at,
208
+ home,
209
+ data: _codexLimitsCache.home === home ? _codexLimitsCache.data : null,
210
+ ready: false,
211
+ promise,
212
+ };
213
+ return promise;
95
214
  }
96
215
 
97
- export function getUsage(home = homedir()) {
98
- return { claude: readClaudeUsage(home), codex: readCodexUsage(home) };
216
+ function mergeCodexRateLimits(usage, rateLimits, now) {
217
+ if (!rateLimits) return usage;
218
+ return {
219
+ updatedAt: now,
220
+ rateLimits,
221
+ tokens: usage?.tokens || {
222
+ total: null, input: null, cachedInput: null, output: null, reasoning: null,
223
+ },
224
+ contextWindow: usage?.contextWindow ?? null,
225
+ };
99
226
  }
100
227
 
101
- // Small TTL cache so a phone that re-polls doesn't rescan the rollout every few seconds.
102
- let _cache = { at: 0, home: null, data: null };
103
- export function getUsageCached(home = homedir(), { ttlMs = 15000, now = Date.now() } = {}) {
228
+ export async function getUsage(home = homedir(), options = {}) {
229
+ const codex = readCodexUsage(home, options);
230
+ const rateLimits = await getCodexRateLimitsCached(home, options);
231
+ return {
232
+ claude: readClaudeUsage(home),
233
+ codex: mergeCodexRateLimits(codex, rateLimits, options.now ?? Date.now()),
234
+ };
235
+ }
236
+
237
+ // Small TTL cache so a phone that re-polls doesn't rescan the rollout every few seconds. In-flight requests
238
+ // share the same promise, while the heavier Codex account query has its own one-minute cache above.
239
+ let _cache = {
240
+ at: 0, home: null, data: null, promise: null,
241
+ };
242
+ export async function getUsageCached(
243
+ home = homedir(),
244
+ options = {},
245
+ ) {
246
+ const { ttlMs = 15000, now = Date.now(), calibrationMs = 60_000 } = options;
104
247
  if (_cache.data && _cache.home === home && (now - _cache.at) < ttlMs) return _cache.data;
105
- _cache = { at: now, home, data: getUsage(home) };
106
- return _cache.data;
248
+ if (_cache.promise && _cache.home === home) return _cache.promise;
249
+ const promise = getUsage(home, {
250
+ ...options, now, calibrationMs,
251
+ }).then((data) => {
252
+ _cache = {
253
+ at: now, home, data, promise: null,
254
+ };
255
+ return data;
256
+ });
257
+ _cache = {
258
+ at: _cache.at, home, data: _cache.home === home ? _cache.data : null, promise,
259
+ };
260
+ return promise;
107
261
  }