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/README.md +3 -2
- package/README.zh-CN.md +2 -1
- package/hooks/handmux-codex-usage.cjs +141 -0
- package/hooks/handmux-statusline.cjs +16 -3
- package/hooks/handmux-write.cjs +9 -0
- package/package.json +4 -3
- package/public/assets/index-CIlbBw_U.js +330 -0
- package/public/assets/index-erQcVC-q.css +32 -0
- package/public/index.html +5 -6
- package/src/cli/codexHooks.js +10 -0
- package/src/cli/hookScaffold.js +3 -3
- package/src/routes/system.js +4 -5
- package/src/routes/terminal.js +23 -0
- package/src/server.js +9 -0
- package/src/staticCompression.js +10 -0
- package/src/tmux/commands.js +8 -3
- package/src/usage.js +214 -60
- package/public/assets/index-CvNuQ-bC.js +0 -330
- package/public/assets/index-DjJf1FX9.css +0 -32
package/src/usage.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
// Usage/quota reader for the phone's Usage page
|
|
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 —
|
|
7
|
-
//
|
|
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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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:
|
|
60
|
-
windowMinutes: typeof
|
|
61
|
-
resetsAt: typeof
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
export function
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
106
|
-
|
|
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
|
}
|