@guenbankhai/tools 0.0.0-stage → 0.1.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.
Files changed (40) hide show
  1. package/README.md +53 -2
  2. package/README.vi.md +54 -0
  3. package/agentmoni/lib/agents/claude/billing.js +13 -0
  4. package/agentmoni/lib/agents/claude/home.js +4 -0
  5. package/agentmoni/lib/agents/claude/index.js +181 -0
  6. package/agentmoni/lib/agents/claude/sources/oauth-usage.js +74 -0
  7. package/agentmoni/lib/agents/claude/timeline.js +26 -0
  8. package/agentmoni/lib/agents/codex/billing.js +9 -0
  9. package/agentmoni/lib/agents/codex/index.js +280 -0
  10. package/agentmoni/lib/agents/codex/sources/rollout.js +26 -0
  11. package/agentmoni/lib/agents/codex/timeline.js +22 -0
  12. package/agentmoni/lib/agents/index.js +13 -0
  13. package/agentmoni/lib/catalog.js +96 -0
  14. package/agentmoni/lib/catalog.json +493 -0
  15. package/agentmoni/lib/collect.js +11 -0
  16. package/agentmoni/lib/colors.js +35 -0
  17. package/agentmoni/lib/cost.js +87 -0
  18. package/agentmoni/lib/format.js +18 -0
  19. package/agentmoni/lib/graph.js +106 -0
  20. package/agentmoni/lib/history.js +50 -0
  21. package/agentmoni/lib/json-fields.js +58 -0
  22. package/agentmoni/lib/jsonl.js +43 -0
  23. package/agentmoni/lib/launch.js +118 -0
  24. package/agentmoni/lib/limits.js +11 -0
  25. package/agentmoni/lib/links.js +139 -0
  26. package/agentmoni/lib/list.js +71 -0
  27. package/agentmoni/lib/log.js +226 -0
  28. package/agentmoni/lib/monitor.js +58 -0
  29. package/agentmoni/lib/page.html +591 -0
  30. package/agentmoni/lib/present.js +184 -0
  31. package/agentmoni/lib/procs.js +22 -0
  32. package/agentmoni/lib/quota.js +34 -0
  33. package/agentmoni/lib/repokey.js +44 -0
  34. package/agentmoni/lib/share.js +106 -0
  35. package/agentmoni/lib/state.js +62 -0
  36. package/agentmoni/lib/terminal.js +143 -0
  37. package/agentmoni/lib/timeline.js +40 -0
  38. package/agentmoni/lib/web.js +75 -0
  39. package/agentmoni/monitor.js +98 -0
  40. package/package.json +22 -4
package/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # @guenbankhai/tools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Tiếng Việt](README.vi.md)
4
+
5
+ Small local command-line tools for Windows, one folder per tool. Requires Node.js 20+. The package is public on npmjs.com as `@guenbankhai/tools`.
6
+
7
+ ## Tools
8
+
9
+ | Command | Purpose | Docs |
10
+ | --- | --- | --- |
11
+ | `agentmoni` | Watch live Claude Code and Codex sessions, their parent-child links and quota use | [agentmoni README](agentmoni/README.md) |
12
+
13
+ One package, one version: installing `@guenbankhai/tools` installs every tool.
14
+
15
+ ## Install
16
+
17
+ Installing needs no account or token.
18
+
19
+ ### Install
20
+
21
+ ```
22
+ npm i -g @guenbankhai/tools
23
+ npm i -g @guenbankhai/tools@0.1.0 # pin a version
24
+ ```
25
+
26
+ ### Update
27
+
28
+ ```
29
+ npm i -g @guenbankhai/tools@latest
30
+ npm update -g
31
+ ```
32
+
33
+ ### Uninstall
34
+
35
+ ```
36
+ npm rm -g @guenbankhai/tools
37
+ ```
38
+
39
+ ### Check
40
+
41
+ ```
42
+ npm ls -g --depth=0
43
+ Get-Command agentmoni -All
44
+ ```
45
+
46
+ ## Development
47
+
48
+ Clone the repo, run `npm test` at the root to run the tests of every tool.
49
+
50
+ ## Releases
51
+
52
+ See [CHANGELOG](CHANGELOG.md).
53
+
54
+ For the repo owner: tag `vX.Y.Z` on `master` and create a GitHub Release, then log in with `npm login` (npmjs.com) and run `npm publish` at the root.
package/README.vi.md ADDED
@@ -0,0 +1,54 @@
1
+ # @guenbankhai/tools
2
+
3
+ [English](README.md)
4
+
5
+ Các công cụ dòng lệnh cục bộ nhỏ cho Windows, mỗi công cụ một thư mục. Cần Node.js 20+. Gói công khai trên npmjs.com với tên `@guenbankhai/tools`.
6
+
7
+ ## Công cụ
8
+
9
+ | Lệnh | Mục đích | Tài liệu |
10
+ | --- | --- | --- |
11
+ | `agentmoni` | Theo dõi các session Claude Code và Codex đang chạy, quan hệ cha-con giữa chúng và mức dùng quota | [agentmoni README](agentmoni/README.vi.md) |
12
+
13
+ Một gói, một version: cài `@guenbankhai/tools` là cài mọi công cụ.
14
+
15
+ ## Cài đặt
16
+
17
+ Cài đặt không cần tài khoản hay token.
18
+
19
+ ### Cài
20
+
21
+ ```
22
+ npm i -g @guenbankhai/tools
23
+ npm i -g @guenbankhai/tools@0.1.0 # ghim version
24
+ ```
25
+
26
+ ### Cập nhật
27
+
28
+ ```
29
+ npm i -g @guenbankhai/tools@latest
30
+ npm update -g
31
+ ```
32
+
33
+ ### Gỡ cài đặt
34
+
35
+ ```
36
+ npm rm -g @guenbankhai/tools
37
+ ```
38
+
39
+ ### Kiểm tra
40
+
41
+ ```
42
+ npm ls -g --depth=0
43
+ Get-Command agentmoni -All
44
+ ```
45
+
46
+ ## Phát triển
47
+
48
+ Clone repo, chạy `npm test` ở thư mục gốc để chạy test của mọi công cụ.
49
+
50
+ ## Phát hành
51
+
52
+ Xem [CHANGELOG](CHANGELOG.vi.md).
53
+
54
+ Dành cho người giữ repo: tạo tag `vX.Y.Z` trên `master` và một GitHub Release, rồi đăng nhập bằng `npm login` (npmjs.com) và chạy `npm publish` ở thư mục gốc.
@@ -0,0 +1,13 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+ const { readFields } = require('../../json-fields.js');
4
+ function readBilling({ home = os.homedir(), env = process.env, claudeHome = env.CLAUDE_CONFIG_DIR ?? path.join(home, '.claude') } = {}) {
5
+ if (Object.hasOwn(env, 'ANTHROPIC_API_KEY') || env.CLAUDE_CODE_USE_BEDROCK === '1' || env.CLAUDE_CODE_USE_VERTEX === '1') return 'api';
6
+ const settings = readFields(path.join(claudeHome, 'settings.json'), ['apiKeyHelper'], ['apiKeyHelper']);
7
+ if (settings.apiKeyHelper) return 'api';
8
+ const credentials = readFields(path.join(claudeHome, '.credentials.json'), ['claudeAiOauth.subscriptionType']);
9
+ if (['pro', 'max', 'team', 'enterprise'].includes(credentials['claudeAiOauth.subscriptionType'])) return 'subscription';
10
+ const config = readFields(path.join(home, '.claude.json'), ['oauthAccount.billingType']);
11
+ return ['stripe_subscription', 'subscription'].includes(config['oauthAccount.billingType']) ? 'subscription' : 'unknown';
12
+ }
13
+ module.exports = { readBilling };
@@ -0,0 +1,4 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+ const claudeDir = () => process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude');
4
+ module.exports = { claudeDir };
@@ -0,0 +1,181 @@
1
+ // Claude adapter: transcripts under ~/.claude/projects/<dir>/<session>.jsonl and <session>/subagents/agent-*.jsonl.
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { files, scan, cached } = require('../../jsonl.js');
5
+ const { launchKind, launchOf, launchCwd, resultJob, resultSessions } = require('../../launch.js');
6
+
7
+ const { readBilling } = require('./billing.js');
8
+ const declaration = require('../../catalog.json').agents.claude;
9
+ const { claudeDir } = require('./home.js');
10
+ const WANT = ['"type":"user"', '"type":"assistant"', '"subtype":"turn_duration"', '"type":"api-request"', '"type":"ai-title"', '"type":"custom-title"'];
11
+ const titleText = v => typeof v === 'string' && v.trim() ? v.replace(/\s+/g, ' ').trim().slice(0, 120) : null;
12
+ const num = v => v ?? 0;
13
+ const validUsage = u => u && typeof u === 'object' &&
14
+ ['input_tokens', 'output_tokens'].every(k => Number.isSafeInteger(u[k]) && u[k] >= 0) &&
15
+ ['cache_read_input_tokens', 'cache_creation_input_tokens'].every(k => u[k] === undefined || (Number.isSafeInteger(u[k]) && u[k] >= 0)) &&
16
+ (u.cache_creation === undefined || (u.cache_creation &&
17
+ ['ephemeral_5m_input_tokens', 'ephemeral_1h_input_tokens'].every(k => Number.isSafeInteger(u.cache_creation[k]) && u.cache_creation[k] >= 0)));
18
+
19
+ function promptText(message) {
20
+ const c = message?.content;
21
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find(i => i?.type === 'text')?.text : null;
22
+ // A leading "<" marks injected system text (command caveats, reminders), not what the user typed.
23
+ if (typeof text !== 'string' || text.trimStart().startsWith('<')) return null;
24
+ return text.replace(/\s+/g, ' ').trim().slice(0, 80) || null;
25
+ }
26
+
27
+ // Parses one transcript. Returns null when the file holds no usable record.
28
+ function parseTranscript(file) {
29
+ const sub = path.basename(path.dirname(file)) === 'subagents';
30
+ const s = { provider: 'claude', id: path.basename(file, '.jsonl'), parentId: null, model: null, effort: null, cwd: null, name: null,
31
+ startedAt: null, lastActivityAt: null, kind: sub ? 'sub' : 'main', tokens: null, context: null, turn: null, launches: [], usage: [], source: { file } };
32
+ if (sub) s.parentId = path.basename(path.dirname(path.dirname(file)));
33
+ const calls = new Map();
34
+ const messages = new Map(); // message.id -> { u, at, model }; streaming repeats a message, the last copy is complete
35
+ let lastUsage = null, entrypoint = null, aiTitle = null, customTitle = null; // the title lines repeat; the last one is current
36
+ s.bad = scan(file, line => WANT.some(w => line.includes(w)), r => {
37
+ if (r.type === 'ai-title') { aiTitle = titleText(r.aiTitle) ?? aiTitle; return; }
38
+ if (r.type === 'custom-title') { customTitle = titleText(r.customTitle) ?? customTitle; return; }
39
+ const at = Date.parse(r.timestamp);
40
+ if (Number.isFinite(at)) { s.startedAt ??= at; s.lastActivityAt = at; }
41
+ s.cwd ??= typeof r.cwd === 'string' ? r.cwd : null;
42
+ if (r.type === 'api-request') {
43
+ const effort = /"effort":"(\w+)"/.exec(JSON.stringify(r.params ?? {}))?.[1];
44
+ if (effort) s.effort = effort;
45
+ } else if (r.type === 'user' && !r.isMeta) {
46
+ for (const c of Array.isArray(r.message?.content) ? r.message.content : []) {
47
+ const l = c?.type === 'tool_result' && calls.get(c.tool_use_id);
48
+ if (l) { l.job = resultJob(c.content); if (l.kind === 'claude -p') l.sessionIds = resultSessions(c.content); }
49
+ }
50
+ entrypoint ??= r.entrypoint ?? null;
51
+ s.prompt ??= promptText(r.message);
52
+ s.turn = r.toolEndsTurn ? 'done' : 'open'; // a prompt or a tool result; toolEndsTurn is the final tool of a sub-agent (SubagentHandback)
53
+ } else if (r.type === 'assistant' && r.message) {
54
+ const m = r.message;
55
+ if (m.model && m.model !== '<synthetic>') s.model = m.model;
56
+ if (m.model !== '<synthetic>') { messages.set(m.id ?? r.uuid ?? messages.size, { u: m.usage, at, model: m.model ?? null, costUSD: m.costUSD ?? r.costUSD, speed: m.speed ?? m.usage?.speed ?? r.speed, inference_geo: m.inference_geo ?? m.usage?.inference_geo ?? r.inference_geo }); lastUsage = m.usage; }
57
+ for (const c of Array.isArray(m.content) ? m.content : []) {
58
+ const kind = c?.type === 'tool_use' ? launchKind(c.input?.command) : null;
59
+ if (kind) {
60
+ const l = launchOf(at, kind, c.input.command);
61
+ l.cwd = launchCwd(c.input.command, c.input.cwd ?? r.cwd ?? s.cwd, kind === 'codex plugin' ? ['--cwd'] : ['-C', '--cd']);
62
+ l.toolUseId = c.id;
63
+ s.launches.push(l);
64
+ if (typeof c.id === 'string') calls.set(c.id, l);
65
+ }
66
+ }
67
+ s.turn = m.stop_reason === 'end_turn' ? 'done' : 'open';
68
+ } else if (r.type === 'system') s.turn = 'done';
69
+ });
70
+ if (s.startedAt == null) return null;
71
+ s.name = customTitle ?? aiTitle ?? s.prompt; // a sub-agent's description (below) replaces it
72
+ const t = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, cacheWrite1h: 0 };
73
+ for (const { u, at, model, costUSD, speed, inference_geo } of messages.values()) {
74
+ if (!validUsage(u)) continue;
75
+ const e = { at, model, cacheWrite1h: u.cache_creation?.ephemeral_1h_input_tokens ?? 0, input: num(u.input_tokens), cacheRead: num(u.cache_read_input_tokens), cacheWrite: num(u.cache_creation_input_tokens), output: num(u.output_tokens) };
76
+ for (const k of ['input', 'cacheRead', 'cacheWrite', 'output', 'cacheWrite1h']) t[k] += e[k];
77
+ if (Number.isFinite(at)) s.usage.push({ at, model, speed, inference_geo, tokens: Object.fromEntries(Object.entries(e).filter(([k]) => k !== 'at' && k !== 'model')), ...(Number.isFinite(costUSD) && costUSD >= 0 ? { costUSD } : {}) });
78
+ }
79
+ s.tokens = t;
80
+ if (validUsage(lastUsage)) s.context = num(lastUsage.input_tokens) + num(lastUsage.cache_read_input_tokens) + num(lastUsage.cache_creation_input_tokens);
81
+ if (sub) {
82
+ try {
83
+ const meta = JSON.parse(fs.readFileSync(file.replace(/\.jsonl$/, '.meta.json'), 'utf8'));
84
+ s.agentType = typeof meta.agentType === 'string' ? meta.agentType : null;
85
+ if (typeof meta.description === 'string' && meta.description.trim()) s.name = meta.description.replace(/\s+/g, ' ').trim().slice(0, 80);
86
+ } catch { s.agentType = null; }
87
+ } else if (entrypoint === 'sdk-cli') s.kind = 'standalone'; // claude -p: a parent, if any, is resolved by the link step
88
+ s.entrypoint = entrypoint;
89
+ return s;
90
+ }
91
+
92
+ // shortcut: a changed transcript is re-read whole (unchanged ones come from the cache); upgrade to offset reads if one gets huge.
93
+ function readSessions({ claudeHome = claudeDir(), dir = path.join(claudeHome, 'projects'), since = 0, ...options } = {}) {
94
+ const billing = readBilling({ ...options, claudeHome });
95
+ return files(dir, since).map(f => cached(f, parseTranscript)).filter(Boolean).map(s => ({ ...s, billing }));
96
+ }
97
+
98
+ const STALE_MS = 15 * 60 * 1000; // only for a Claude sub-agent whose parent session is not on disk to ask
99
+ const PID_START_SLACK_MS = 5000; // pid file procStart vs. the process table (guards against pid reuse)
100
+
101
+ const isClaude = p => p.name === 'claude.exe' || p.name === 'claude';
102
+ const isHeadlessClaude = p => isClaude(p) && /\s(-p|--print)(\s|$)/.test(p.cmd);
103
+
104
+ function claudeAlive(entry, procs) {
105
+ const p = procs.find(x => x.pid === entry.pid && isClaude(x));
106
+ if (!p) return null;
107
+ return entry.procStartMs == null || Math.abs(p.startMs - entry.procStartMs) <= PID_START_SLACK_MS ? p : null;
108
+ }
109
+
110
+ function liveness({ sessions, procs, pidFiles, now, owners = new Map(), claim }) {
111
+ const out = new Map(), claimed = new Set(), key = s => `${s.provider}:${s.id}`;
112
+ const set = (s, state, pid = null) => out.set(key(s), { state, pid });
113
+ const order = [...sessions].sort((a, b) => b.startedAt - a.startedAt);
114
+ for (const s of order.filter(x => x.provider === 'claude' && x.kind !== 'sub')) {
115
+ const entry = pidFiles.find(e => e.sessionId === s.id && claudeAlive(e, procs));
116
+ if (entry) { set(s, entry.status === 'busy' ? 'running' : 'waiting', entry.pid); claimed.add(entry.pid); continue; }
117
+ // claude -p writes no pid file here (seen on this machine only for interactive runs): match by start time.
118
+ const p = s.kind === 'standalone' ? claim(procs.filter(isHeadlessClaude), s, claimed, owners) : null;
119
+ set(s, p ? (s.turn === 'open' ? 'running' : 'waiting') : 'ended', p?.pid ?? null);
120
+ }
121
+
122
+ for (const s of sessions.filter(x => x.provider === 'claude' && x.kind === 'sub')) {
123
+ const parent = out.get(`claude:${s.parentId}`);
124
+ // Known parent: alive while the parent is and the result has not come back. Unknown parent: recency is all there is.
125
+ const alive = s.turn !== 'done' && (parent ? parent.state !== 'ended' : now - s.lastActivityAt < STALE_MS);
126
+ set(s, alive ? 'running' : 'ended');
127
+ }
128
+ return out;
129
+ }
130
+
131
+ const FILETIME_EPOCH_MS = 11644473600000; // 1601-01-01 to 1970-01-01
132
+ const filetimeToMs = ft => Number(BigInt(ft) / 10000n) - FILETIME_EPOCH_MS;
133
+
134
+ // Live Claude sessions register ~/.claude/sessions/<pid>.json; other files in the folder (.key etc.) are ignored.
135
+ function readClaudePidFiles(dir = path.join(claudeDir(), 'sessions')) {
136
+ let names;
137
+ try { names = fs.readdirSync(dir); } catch { return []; }
138
+ const out = [];
139
+ for (const name of names) {
140
+ if (!/^\d+\.json$/.test(name)) continue;
141
+ try {
142
+ const j = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
143
+ if (j?.pid !== Number.parseInt(name, 10) || typeof j.sessionId !== 'string') continue;
144
+ let procStartMs = null;
145
+ try { procStartMs = filetimeToMs(j.procStart); } catch { /* no start time: pid alone decides */ }
146
+ out.push({ pid: j.pid, sessionId: j.sessionId, cwd: j.cwd ?? null, status: j.status ?? null, kind: j.kind ?? null, procStartMs });
147
+ } catch { /* partial or unreadable file */ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ const { createClaudeQuota } = require('./sources/oauth-usage.js');
153
+ const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
154
+
155
+ function loadLimits({ claudeHome = claudeDir() } = {}) {
156
+ const table = new Map();
157
+ const dir = path.join(claudeHome, 'cache', 'model-catalog');
158
+ let files = []; try { files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); } catch { /* no cache yet */ }
159
+ for (const f of files) {
160
+ const models = readJson(path.join(dir, f))?.catalog?.config?.models;
161
+ for (const m of Array.isArray(models) ? models : [])
162
+ if (typeof m?.id === 'string' && Number.isSafeInteger(m?.runtime?.max_input_tokens) && m.runtime.max_input_tokens > 0) table.set(m.id, m.runtime.max_input_tokens);
163
+ }
164
+ return table;
165
+ }
166
+
167
+ const launchPatterns = [
168
+ ['claude -p', /^["']?\S*\bclaude(?:\.exe|\.cmd)?["']?\s+(?:\S+\s+)*?(?:-p|--print)(?:\s|$)/]
169
+ ];
170
+
171
+ module.exports = { readTimeline: require('./timeline.js').readTimeline,
172
+ readSessions, claudeDir, readClaudePidFiles, filetimeToMs, createClaudeQuota,
173
+ readBilling,
174
+ processNames: ['claude.exe', 'claude'], liveness, loadLimits, launchPatterns,
175
+ launchLine: line => line.includes('"type":"tool_use"'),
176
+ launchCalls: r => Array.isArray(r.message?.content) ? r.message.content.filter(c => c?.type === 'tool_use').map(c => [c.id, [c.input?.command]]) : [],
177
+ launchClaims: l => l.kind === 'claude -p' ? (l.sessionIds ?? []).map(id => 'claude:' + id) : [],
178
+ readPidFiles: readClaudePidFiles,
179
+ parentLabel: 'Agent tool', launchKindOf: s => s.parentId ? null : s.entrypoint === 'sdk-cli' ? 'claude -p' : null,
180
+ launchPolicy: () => ({ process: true })
181
+ };
@@ -0,0 +1,74 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { claudeDir } = require('../home.js');
4
+ const declaration = require('../../../catalog.json').agents.claude;
5
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
6
+ const REFRESH_MS = 3 * 60 * 1000;
7
+ const RETRY_LIMITED_MS = 10 * 60 * 1000;
8
+ const CLAUDE_KEYS = Object.fromEntries(declaration.windows.map(w => [w.id, w.field]));
9
+ const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
10
+ function writeJson(file, data) {
11
+ fs.mkdirSync(path.dirname(file), { recursive: true });
12
+ const temp = `${file}.${process.pid}.tmp`;
13
+ fs.writeFileSync(temp, JSON.stringify(data)); fs.renameSync(temp, file);
14
+ }
15
+ const validPercent = v => typeof v === 'number' && Number.isFinite(v) && v >= 0;
16
+ const afterReset = (resetsAt, now) => resetsAt != null && resetsAt <= now;
17
+
18
+ // A window missing from a newer reply (seen when a limit is hit) must not erase a reading that has not reset yet.
19
+ function mergeWindows(previous, body, at) {
20
+ const result = {};
21
+ for (const w of Object.keys(CLAUDE_KEYS)) {
22
+ const next = body?.[CLAUDE_KEYS[w]], old = previous?.[w];
23
+ if (validPercent(next?.utilization)) result[w] = { percent: next.utilization, resetsAt: Date.parse(next.resets_at) || null, at };
24
+ else if (old && old.resetsAt > at) result[w] = old;
25
+ }
26
+ return result;
27
+ }
28
+
29
+ // The cache file is shared, so several monitors make at most one request per interval.
30
+ function createClaudeQuota({ claudeDir, cacheFile, fetch = globalThis.fetch }) {
31
+ let pending = null;
32
+ function token(at) {
33
+ const oauth = readJson(path.join(claudeDir, '.credentials.json'))?.claudeAiOauth;
34
+ if (typeof oauth?.accessToken !== 'string') throw new Error('Claude login not found (OAuth credentials are required)');
35
+ // shortcut: an expired token is not refreshed here; Claude Code refreshes it on its next request.
36
+ if (Number.isFinite(oauth.expiresAt) && oauth.expiresAt <= at) throw new Error('Claude token expired; it refreshes when Claude Code next runs');
37
+ return oauth.accessToken;
38
+ }
39
+ async function fetchUsage(at, cache) {
40
+ try {
41
+ const response = await fetch(USAGE_URL, { headers: { Authorization: `Bearer ${token(at)}`, 'anthropic-beta': 'oauth-2025-04-20' }, signal: AbortSignal.timeout(10000) });
42
+ if (!response.ok) {
43
+ const wait = response.status === 429 ? RETRY_LIMITED_MS : REFRESH_MS;
44
+ throw Object.assign(new Error(response.status === 429 ? 'usage endpoint rate-limited' : `usage endpoint returned HTTP ${response.status}`), { wait });
45
+ }
46
+ writeJson(cacheFile, { windows: mergeWindows(cache?.windows, await response.json(), at), checkedAt: at, nextAt: at + REFRESH_MS, error: null });
47
+ } catch (error) {
48
+ writeJson(cacheFile, { windows: mergeWindows(cache?.windows, null, at), checkedAt: at, nextAt: at + (error.wait ?? REFRESH_MS), error: error.message });
49
+ }
50
+ }
51
+ return {
52
+ // Asks the endpoint unless the cache is younger than 3 minutes.
53
+ refresh(at = Date.now()) {
54
+ const cache = readJson(cacheFile);
55
+ if (pending || (Number.isFinite(cache?.nextAt) && at < cache.nextAt && at >= cache.checkedAt)) return pending ?? Promise.resolve();
56
+ pending = fetchUsage(at, cache).catch(() => {}).finally(() => { pending = null; });
57
+ return pending;
58
+ },
59
+ read(now = Date.now()) {
60
+ const cache = readJson(cacheFile);
61
+ const windows = [];
62
+ for (const w of Object.keys(CLAUDE_KEYS)) {
63
+ const r = cache?.windows?.[w];
64
+ windows.push({ id: w, label: declaration.windows.find(x => x.id === w).label, usedPercent: r && !afterReset(r.resetsAt, now) ? r.percent : null, reset: !!r && afterReset(r.resetsAt, now), resetsAt: r?.resetsAt ?? null, readAt: r?.at ?? cache?.checkedAt ?? null });
65
+ }
66
+ return { windows, readAt: cache?.checkedAt ?? null, source: 'live', error: cache?.error ?? (cache ? null : 'no reading yet') };
67
+ }
68
+ };
69
+ }
70
+
71
+ module.exports = { createClaudeQuota, createQuota: ({ claudeHome = claudeDir(), logDir, fetch }) => {
72
+ const q = createClaudeQuota({ claudeDir: claudeHome, cacheFile: path.join(logDir, '_machine', 'claude-usage.json'), fetch });
73
+ return { refresh: q.refresh, read: (sessions, now) => q.read(now) };
74
+ } };
@@ -0,0 +1,26 @@
1
+ const { read, clip, typed, toolText, n } = require('../../timeline.js');
2
+ function readTimeline(file) {
3
+ return read(file, (r, { out, add, lastOf, seen }) => {
4
+ const m = r.message;
5
+ if (r.type === 'user' && m) {
6
+ const c = m.content, t = typeof c === 'string' ? c : Array.isArray(c) ? c.find(i => i?.type === 'text')?.text : null;
7
+ if (!r.isMeta && typed(t)) add(r, 'user', clip(t));
8
+ } else if (r.type === 'assistant' && m) {
9
+ const from = out.length;
10
+ for (const c of Array.isArray(m.content) ? m.content : []) {
11
+ if (c?.type === 'text') add(r, 'assistant', clip(c.text));
12
+ else if (c?.type === 'tool_use') add(r, 'tool', toolText(c.name, c.input));
13
+ }
14
+ const u = m.usage;
15
+ if (u && m.id && out.length > from) {
16
+ const prev = lastOf.get(m.id);
17
+ if (prev) delete prev.tokens;
18
+ const e = out[out.length - 1];
19
+ e.tokens = { input: n(u.input_tokens), cacheRead: n(u.cache_read_input_tokens), cacheWrite: n(u.cache_creation_input_tokens), output: n(u.output_tokens) };
20
+ lastOf.set(m.id, e);
21
+ }
22
+ } else if (r.type === 'system' && r.subtype === 'turn_duration') add(r, 'turn', `turn done${Number.isFinite(r.durationMs) ? ` (${Math.round(r.durationMs / 1000)}s)` : ''}`);
23
+
24
+ });
25
+ }
26
+ module.exports = { readTimeline };
@@ -0,0 +1,9 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+ const { readFields } = require('../../json-fields.js');
4
+ function readBilling({ home = os.homedir(), env = process.env, codexHome = env.CODEX_HOME ?? path.join(home, '.codex') } = {}) {
5
+ if (Object.hasOwn(env, 'OPENAI_API_KEY')) return 'api';
6
+ const mode = readFields(path.join(codexHome, 'auth.json'), ['auth_mode']).auth_mode;
7
+ return mode === 'chatgpt' ? 'subscription' : ['apikey', 'api_key', 'api-key'].includes(mode) ? 'api' : 'unknown';
8
+ }
9
+ module.exports = { readBilling };