agent-fuel 0.3.0 → 0.4.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/dist/adapters/agy.js +27 -55
- package/dist/adapters/claude.d.ts +0 -2
- package/dist/adapters/claude.js +69 -53
- package/dist/adapters/codex.js +115 -61
- package/dist/debug.d.ts +4 -0
- package/dist/debug.js +18 -0
- package/dist/index.js +42 -16
- package/dist/render.js +2 -4
- package/dist/tmux.d.ts +13 -0
- package/dist/tmux.js +72 -0
- package/package.json +1 -1
package/dist/adapters/agy.js
CHANGED
|
@@ -1,70 +1,53 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import {
|
|
4
|
+
import { TuiScraper } from '../tmux.js';
|
|
5
5
|
const CACHE_PATH = path.join(os.homedir(), '.gemini/antigravity-cli/.agent-fuel-quota-cache.json');
|
|
6
6
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
7
|
-
// ── ANSI / terminal helpers ────────────────────────────────────────────────
|
|
8
|
-
function stripAnsi(str) {
|
|
9
|
-
// eslint-disable-next-line no-control-regex
|
|
10
|
-
return str.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B[^[]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
11
|
-
}
|
|
12
7
|
// ── Scraping ───────────────────────────────────────────────────────────────
|
|
8
|
+
async function sleep(ms) {
|
|
9
|
+
return new Promise(res => setTimeout(res, ms));
|
|
10
|
+
}
|
|
13
11
|
/**
|
|
14
|
-
*
|
|
15
|
-
* Model Quota list to render, then
|
|
12
|
+
* Launches `agy` in a tmux session, opens the `/usage` panel, waits for
|
|
13
|
+
* the Model Quota list to render, then returns clean rendered screen text.
|
|
16
14
|
*/
|
|
17
|
-
function runAgyUsage() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// keyring daemon can be reached via the existing session environment.
|
|
34
|
-
const child = spawn('expect', ['-c', expectScript], {
|
|
35
|
-
env: { ...process.env },
|
|
36
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
37
|
-
});
|
|
38
|
-
const append = (chunk) => {
|
|
39
|
-
if (output.length < MAX_OUTPUT_BYTES) {
|
|
40
|
-
output += chunk.toString();
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
child.stdout.on('data', append);
|
|
44
|
-
child.stderr.on('data', append);
|
|
45
|
-
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(output); }, 25_000);
|
|
46
|
-
child.on('close', () => { clearTimeout(timer); resolve(output); });
|
|
47
|
-
});
|
|
15
|
+
async function runAgyUsage() {
|
|
16
|
+
const tui = new TuiScraper('agy');
|
|
17
|
+
try {
|
|
18
|
+
tui.start();
|
|
19
|
+
// Wait for AGY main menu ready
|
|
20
|
+
await tui.waitFor(/for shortcuts/, 20_000);
|
|
21
|
+
// Navigate to /usage panel
|
|
22
|
+
tui.send('/usage');
|
|
23
|
+
await tui.waitFor(/Model Quota/, 10_000);
|
|
24
|
+
// Brief pause for all model rows to finish rendering
|
|
25
|
+
await sleep(500);
|
|
26
|
+
return tui.capture();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
tui.kill();
|
|
30
|
+
}
|
|
48
31
|
}
|
|
49
32
|
// ── Parsing ────────────────────────────────────────────────────────────────
|
|
50
33
|
/**
|
|
51
34
|
* Parse the Model Quota panel into an array of entries.
|
|
52
35
|
*
|
|
53
|
-
* Panel format (
|
|
36
|
+
* Panel format (tmux rendered — no ANSI codes):
|
|
54
37
|
*
|
|
55
38
|
* └ Model Quota
|
|
56
39
|
*
|
|
57
40
|
* Gemini 3.5 Flash (High)
|
|
58
41
|
* ░░░░░░░░░░░ ... 20%
|
|
59
|
-
* Refreshes in 3h 28m
|
|
42
|
+
* Refreshes in 3h 28m
|
|
60
43
|
*
|
|
61
44
|
* Claude Sonnet 4.6 (Thinking)
|
|
62
45
|
* ███████████ ... 100%
|
|
63
46
|
* Quota available
|
|
64
47
|
*/
|
|
65
48
|
function parseQuotaPanel(raw) {
|
|
66
|
-
|
|
67
|
-
const lines =
|
|
49
|
+
// tmux capture-pane returns clean rendered text — no ANSI stripping needed
|
|
50
|
+
const lines = raw.split(/\r?\n/);
|
|
68
51
|
const results = [];
|
|
69
52
|
const headerIdx = lines.findIndex(l => l.includes('Model Quota'));
|
|
70
53
|
if (headerIdx === -1)
|
|
@@ -91,15 +74,12 @@ function parseQuotaPanel(raw) {
|
|
|
91
74
|
j++;
|
|
92
75
|
continue;
|
|
93
76
|
}
|
|
94
|
-
// Progress bar line: has block chars OR starts with a digit%
|
|
95
77
|
if (barLine === null && (candidate.includes('░') || candidate.includes('█') || /^\d+%/.test(candidate))) {
|
|
96
78
|
barLine = candidate;
|
|
97
79
|
j++;
|
|
98
80
|
continue;
|
|
99
81
|
}
|
|
100
|
-
// Refresh / availability line
|
|
101
82
|
if (barLine !== null && (candidate.includes('Refreshes') || candidate.includes('Quota available'))) {
|
|
102
|
-
// Strip any leading "NN% remaining · " prefix
|
|
103
83
|
const m = candidate.match(/(Refreshes in [^\r\n]+|Quota available)/);
|
|
104
84
|
refreshLine = m ? m[1] : candidate;
|
|
105
85
|
j++;
|
|
@@ -107,7 +87,6 @@ function parseQuotaPanel(raw) {
|
|
|
107
87
|
break;
|
|
108
88
|
}
|
|
109
89
|
if (barLine !== null) {
|
|
110
|
-
// Percentage is always at the END of the bar line: "░░░ ... 20%" or "20% remaining · …"
|
|
111
90
|
const percentMatch = barLine.match(/(\d+)%/);
|
|
112
91
|
if (percentMatch) {
|
|
113
92
|
results.push({ model: line, percent: parseInt(percentMatch[1], 10), refreshLine });
|
|
@@ -137,11 +116,6 @@ async function writeCache(entries) {
|
|
|
137
116
|
catch { /* non-fatal */ }
|
|
138
117
|
}
|
|
139
118
|
// ── Bucket aggregation ────────────────────────────────────────────────────
|
|
140
|
-
/**
|
|
141
|
-
* Given all quota entries, build the two UsageSnapshot rows:
|
|
142
|
-
* - agy-gemini: worst-case (min remaining) across all Gemini models
|
|
143
|
-
* - agy-other: worst-case across all non-Gemini models (Claude, etc.)
|
|
144
|
-
*/
|
|
145
119
|
function buildSnapshots(entries, fromCache) {
|
|
146
120
|
const source = fromCache ? 'cache' : 'official-cli';
|
|
147
121
|
const geminiEntries = entries.filter(e => /gemini/i.test(e.model));
|
|
@@ -150,7 +124,6 @@ function buildSnapshots(entries, fromCache) {
|
|
|
150
124
|
if (bucket.length === 0) {
|
|
151
125
|
return { tool, remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' };
|
|
152
126
|
}
|
|
153
|
-
// Show the lowest remaining % (most constrained model in the bucket)
|
|
154
127
|
const worst = bucket.reduce((a, b) => a.percent <= b.percent ? a : b);
|
|
155
128
|
return {
|
|
156
129
|
tool,
|
|
@@ -174,7 +147,7 @@ export class AgyQuotaAdapter {
|
|
|
174
147
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
175
148
|
return buildSnapshots(cached.entries, true);
|
|
176
149
|
}
|
|
177
|
-
// Slow path: spawn agy, scrape the quota panel
|
|
150
|
+
// Slow path: spawn agy via tmux, scrape the quota panel
|
|
178
151
|
try {
|
|
179
152
|
const raw = await runAgyUsage();
|
|
180
153
|
const entries = parseQuotaPanel(raw);
|
|
@@ -182,7 +155,6 @@ export class AgyQuotaAdapter {
|
|
|
182
155
|
await writeCache(entries);
|
|
183
156
|
return buildSnapshots(entries, false);
|
|
184
157
|
}
|
|
185
|
-
// Panel not found — return unknown rows (do not cache failures)
|
|
186
158
|
return [
|
|
187
159
|
{ tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
|
|
188
160
|
{ tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
|
package/dist/adapters/claude.js
CHANGED
|
@@ -1,15 +1,55 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import { TuiScraper } from '../tmux.js';
|
|
2
|
+
import { debug } from '../debug.js';
|
|
3
|
+
// ── TUI scraper ────────────────────────────────────────────────────────────
|
|
4
|
+
async function sleep(ms) {
|
|
5
|
+
return new Promise(res => setTimeout(res, ms));
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Launches `claude` in a tmux session, opens /status, navigates to the
|
|
9
|
+
* Status tab (which shows real quota usage bars), and returns the captured
|
|
10
|
+
* screen text.
|
|
11
|
+
*
|
|
12
|
+
* The Status tab renders persistently (not transient), so regular
|
|
13
|
+
* capture-pane is sufficient — no pipe-pane needed.
|
|
14
|
+
*/
|
|
15
|
+
async function runClaudeScrape() {
|
|
16
|
+
const tui = new TuiScraper('claude');
|
|
17
|
+
try {
|
|
18
|
+
tui.start();
|
|
19
|
+
// Wait for TUI ready — welcome banner or prompt hint visible
|
|
20
|
+
await tui.waitFor(/Welcome back|Try "/i, 15_000, 0);
|
|
21
|
+
// Open the /status panel
|
|
22
|
+
tui.send('/status');
|
|
23
|
+
// Wait for the status panel tabs to appear
|
|
24
|
+
await tui.waitFor(/Settings\s+Status/i, 10_000, 0);
|
|
25
|
+
// Navigate to the Status tab (second tab after Settings)
|
|
26
|
+
tui.sendKey('Tab');
|
|
27
|
+
await sleep(300);
|
|
28
|
+
tui.sendKey('Tab');
|
|
29
|
+
// Wait for the usage bars — shows "XX% used"
|
|
30
|
+
return await tui.waitFor(/\d+%\s+used/i, 8_000, 0);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
tui.kill();
|
|
12
34
|
}
|
|
35
|
+
}
|
|
36
|
+
function parseScrapeOutput(screen) {
|
|
37
|
+
debug('claude:parse', `screen length: ${screen.length}`);
|
|
38
|
+
debug('claude:parse', 'screen', screen);
|
|
39
|
+
// Match "XX% used" occurrences in order:
|
|
40
|
+
// First = current session (5h block), second = current week
|
|
41
|
+
const usedMatches = [...screen.matchAll(/(\d+)%\s+used/gi)];
|
|
42
|
+
debug('claude:parse', `found ${usedMatches.length} "% used" matches`);
|
|
43
|
+
const sessionUsedPct = usedMatches[0] ? parseInt(usedMatches[0][1], 10) : null;
|
|
44
|
+
const weeklyUsedPct = usedMatches[1] ? parseInt(usedMatches[1][1], 10) : null;
|
|
45
|
+
// Reset time: "Resets H:MMam" or "Resets May 30 at 6am" — grab the first occurrence
|
|
46
|
+
const resetMatch = screen.match(/Resets\s+([^\n\r]+)/i);
|
|
47
|
+
const sessionResetAt = resetMatch ? resetMatch[1].trim() : null;
|
|
48
|
+
debug('claude:parse', 'result', { sessionUsedPct, sessionResetAt, weeklyUsedPct });
|
|
49
|
+
return { sessionUsedPct, sessionResetAt, weeklyUsedPct };
|
|
50
|
+
}
|
|
51
|
+
// ── Adapter ────────────────────────────────────────────────────────────────
|
|
52
|
+
export class ClaudeQuotaAdapter {
|
|
13
53
|
async fetchSnapshots() {
|
|
14
54
|
return [await this._fetch()];
|
|
15
55
|
}
|
|
@@ -21,51 +61,27 @@ export class ClaudeQuotaAdapter {
|
|
|
21
61
|
resetAt: null,
|
|
22
62
|
source: 'unknown',
|
|
23
63
|
});
|
|
64
|
+
debug('claude:fetch', 'starting TUI scrape via tmux');
|
|
24
65
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const activeBlock = blocks.find((b) => b.isActive === true);
|
|
38
|
-
if (!activeBlock)
|
|
39
|
-
return unknown();
|
|
40
|
-
const cost = typeof activeBlock.costUSD === 'number' ? activeBlock.costUSD : 0;
|
|
41
|
-
const usedPct = (cost / this.budgetLimit) * 100;
|
|
42
|
-
const remainingPercent = Math.max(0, Math.min(100, Math.round(100 - usedPct)));
|
|
43
|
-
let resetAt = null;
|
|
44
|
-
if (typeof activeBlock.endTime === 'string') {
|
|
45
|
-
try {
|
|
46
|
-
resetAt = new Date(activeBlock.endTime).toLocaleTimeString([], {
|
|
47
|
-
hour: '2-digit',
|
|
48
|
-
minute: '2-digit',
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
resetAt = activeBlock.endTime;
|
|
53
|
-
}
|
|
66
|
+
const screen = await runClaudeScrape();
|
|
67
|
+
const result = parseScrapeOutput(screen);
|
|
68
|
+
if (result.sessionUsedPct !== null) {
|
|
69
|
+
const remainingPercent = Math.max(0, 100 - result.sessionUsedPct);
|
|
70
|
+
debug('claude:fetch', `parsed Usage tab → ${result.sessionUsedPct}% used (${remainingPercent}% remaining)`);
|
|
71
|
+
return {
|
|
72
|
+
tool: 'claude-code',
|
|
73
|
+
remainingPercent,
|
|
74
|
+
usedPercent: result.sessionUsedPct,
|
|
75
|
+
resetAt: result.sessionResetAt,
|
|
76
|
+
source: 'official-cli',
|
|
77
|
+
};
|
|
54
78
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
remainingPercent,
|
|
58
|
-
usedPercent: Math.round(usedPct),
|
|
59
|
-
resetAt,
|
|
60
|
-
source: 'ccusage',
|
|
61
|
-
raw: activeBlock,
|
|
62
|
-
};
|
|
79
|
+
debug('claude:fetch', 'parse failed → unknown');
|
|
80
|
+
return unknown();
|
|
63
81
|
}
|
|
64
|
-
catch (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
raw: error instanceof Error ? error.message : String(error),
|
|
68
|
-
};
|
|
82
|
+
catch (err) {
|
|
83
|
+
debug('claude:fetch', 'caught error', String(err));
|
|
84
|
+
return unknown();
|
|
69
85
|
}
|
|
70
86
|
}
|
|
71
87
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,73 +1,109 @@
|
|
|
1
|
-
import { exec,
|
|
1
|
+
import { exec, execFileSync } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import { readFileSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { TuiScraper } from '../tmux.js';
|
|
5
|
+
import { debug } from '../debug.js';
|
|
3
6
|
const execAsync = promisify(exec);
|
|
4
7
|
// Used ONLY as a rough fallback estimate when the TUI scrape cannot determine
|
|
5
|
-
// a percentage
|
|
6
|
-
//
|
|
7
|
-
// Override with AGENT_FUEL_CODEX_BUDGET env var (dollars).
|
|
8
|
+
// a percentage. This is a GUESS based on local session cost data — not an
|
|
9
|
+
// official Codex quota signal. Override with AGENT_FUEL_CODEX_BUDGET env var.
|
|
8
10
|
const DEFAULT_BUDGET_USD = 20.0;
|
|
9
11
|
const ROLLING_WINDOW_MS = 5 * 60 * 60 * 1000;
|
|
10
|
-
// ── TUI scraper (
|
|
12
|
+
// ── TUI scraper (tmux) ─────────────────────────────────────────────────────
|
|
13
|
+
async function sleep(ms) {
|
|
14
|
+
return new Promise(res => setTimeout(res, ms));
|
|
15
|
+
}
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* Launches `codex` in a tmux session, pipes all terminal bytes to a temp
|
|
18
|
+
* file, sends /status twice, then reads the file and returns the raw bytes.
|
|
19
|
+
*
|
|
20
|
+
* Why pipe-pane instead of capture-pane:
|
|
21
|
+
* The /status overlay is full-screen and transient — it renders in-place for
|
|
22
|
+
* one frame and re-renders away without entering the tmux scrollback buffer.
|
|
23
|
+
* capture-pane (even with -S history) can never catch it. pipe-pane streams
|
|
24
|
+
* every raw byte to a file so even a 10ms overlay is permanently recorded.
|
|
14
25
|
*/
|
|
15
|
-
function runCodexScrape() {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
child.on('close', () => { clearTimeout(timer); resolve(output); });
|
|
43
|
-
});
|
|
26
|
+
async function runCodexScrape() {
|
|
27
|
+
const tui = new TuiScraper('codex');
|
|
28
|
+
const pipePath = `/tmp/af-codex-${Date.now()}.log`;
|
|
29
|
+
try {
|
|
30
|
+
tui.start();
|
|
31
|
+
// Stream all pane output to a file from the start
|
|
32
|
+
execFileSync('tmux', ['pipe-pane', '-t', tui.sessionId, `cat >> '${pipePath}'`]);
|
|
33
|
+
debug('codex:scrape', `pipe-pane logging to ${pipePath}`);
|
|
34
|
+
// Wait for TUI ready: current screen (historyLines=0) shows Tip, meaning MCP boot done
|
|
35
|
+
await tui.waitFor(/Tip:/i, 20_000, 0);
|
|
36
|
+
// First /status: panel says "Limits: refresh requested; run /status again shortly"
|
|
37
|
+
tui.send('/status');
|
|
38
|
+
await sleep(2_000);
|
|
39
|
+
// Second /status: has actual 5h/weekly quota data
|
|
40
|
+
tui.send('/status');
|
|
41
|
+
await sleep(4_000);
|
|
42
|
+
const raw = readFileSync(pipePath, 'utf-8');
|
|
43
|
+
debug('codex:scrape', `pipe log size: ${raw.length} bytes`);
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
tui.kill();
|
|
48
|
+
try {
|
|
49
|
+
unlinkSync(pipePath);
|
|
50
|
+
}
|
|
51
|
+
catch { /* ok */ }
|
|
52
|
+
}
|
|
44
53
|
}
|
|
45
|
-
// ── Output parser ──────────────────────────────────────────────────────────
|
|
46
54
|
function stripAnsi(str) {
|
|
47
55
|
// eslint-disable-next-line no-control-regex
|
|
48
|
-
return str
|
|
56
|
+
return str
|
|
57
|
+
.replace(/\x1B\[[\x20-\x3f]*[\x40-\x7e]/g, '')
|
|
58
|
+
.replace(/\x1B[^[]/g, '')
|
|
59
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
49
60
|
}
|
|
50
61
|
function parseScrapeOutput(raw) {
|
|
62
|
+
// pipe-pane output contains raw ANSI bytes — strip before pattern matching
|
|
51
63
|
const clean = stripAnsi(raw);
|
|
64
|
+
debug('codex:parse', `raw length: ${raw.length}, cleaned length: ${clean.length}`);
|
|
65
|
+
debug('codex:parse', 'cleaned output', clean);
|
|
66
|
+
debug('codex:parse', 'checking patterns', {
|
|
67
|
+
hasIndividualQuota: /Individual quota reached/i.test(clean),
|
|
68
|
+
hasHeadsUp: /less than \d+%\s+of your 5h limit left/i.test(clean),
|
|
69
|
+
has5hLimit: /5h limit:/i.test(clean),
|
|
70
|
+
hasWeeklyLimit: /Weekly limit:/i.test(clean),
|
|
71
|
+
hasLimits: /Limits:/i.test(clean),
|
|
72
|
+
});
|
|
52
73
|
// "Individual quota reached. Contact your administrator to enable overages. Resets in 4h33m29s."
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
parts.push(hm[2]);
|
|
74
|
+
if (/Individual quota reached/i.test(clean)) {
|
|
75
|
+
const resetMatch = clean.match(/Resets in\s*((?:\d+h)?(?:\d+m)?(?:\d+s)?)/i);
|
|
76
|
+
let resetIn = null;
|
|
77
|
+
if (resetMatch) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
const hm = resetMatch[1].match(/^(\d+h)?(\d+m)?/);
|
|
80
|
+
if (hm) {
|
|
81
|
+
if (hm[1])
|
|
82
|
+
parts.push(hm[1]);
|
|
83
|
+
if (hm[2])
|
|
84
|
+
parts.push(hm[2]);
|
|
85
|
+
}
|
|
86
|
+
resetIn = parts.length > 0 ? parts.join(' ') : null;
|
|
67
87
|
}
|
|
68
|
-
|
|
88
|
+
debug('codex:parse', 'result', { quotaReached: true, resetIn });
|
|
89
|
+
return { quotaReached: true, resetIn, fiveHourRemainingPct: null, fiveHourResetAt: null };
|
|
69
90
|
}
|
|
70
|
-
|
|
91
|
+
// "⚠ Heads up, you have less than X% of your 5h limit left."
|
|
92
|
+
const headsUpMatch = clean.match(/less than (\d+)%\s+of your 5h limit left/i);
|
|
93
|
+
if (headsUpMatch) {
|
|
94
|
+
const ceiling = parseInt(headsUpMatch[1], 10);
|
|
95
|
+
const fiveHourRemainingPct = Math.max(0, ceiling - 1);
|
|
96
|
+
debug('codex:parse', 'result', { source: 'headsUp', ceiling, fiveHourRemainingPct });
|
|
97
|
+
return { quotaReached: false, resetIn: null, fiveHourRemainingPct, fiveHourResetAt: null };
|
|
98
|
+
}
|
|
99
|
+
// Parse "/status" panel: "5h limit: [...] X% left (resets HH:MM)"
|
|
100
|
+
// Use the LAST match — /status is sent twice and the second response is fresh.
|
|
101
|
+
const allFiveHMatches = [...clean.matchAll(/5h limit:\s*\[.*?\]\s*(\d+)%\s*left\s*\(resets\s+([^)]+)\)/gi)];
|
|
102
|
+
const fiveHMatch = allFiveHMatches.at(-1) ?? null;
|
|
103
|
+
const fiveHourRemainingPct = fiveHMatch ? parseInt(fiveHMatch[1], 10) : null;
|
|
104
|
+
const fiveHourResetAt = fiveHMatch ? fiveHMatch[2].trim() : null;
|
|
105
|
+
debug('codex:parse', 'result', { quotaReached: false, fiveHourRemainingPct, fiveHourResetAt, fiveHMatchRaw: fiveHMatch?.[0] ?? null });
|
|
106
|
+
return { quotaReached: false, resetIn: null, fiveHourRemainingPct, fiveHourResetAt };
|
|
71
107
|
}
|
|
72
108
|
// ── ccusage fallback estimate ──────────────────────────────────────────────
|
|
73
109
|
async function fetchCcusageEstimate(budgetLimit) {
|
|
@@ -86,6 +122,7 @@ async function fetchCcusageEstimate(budgetLimit) {
|
|
|
86
122
|
catch {
|
|
87
123
|
return unknown();
|
|
88
124
|
}
|
|
125
|
+
debug('codex:ccusage', 'raw stdout', stdout);
|
|
89
126
|
const data = JSON.parse(stdout);
|
|
90
127
|
const sessions = Array.isArray(data?.sessions) ? data.sessions :
|
|
91
128
|
Array.isArray(data?.session) ? data.session :
|
|
@@ -124,6 +161,14 @@ async function fetchCcusageEstimate(budgetLimit) {
|
|
|
124
161
|
}
|
|
125
162
|
catch { /* leave null */ }
|
|
126
163
|
}
|
|
164
|
+
debug('codex:ccusage', 'computed', {
|
|
165
|
+
totalCost,
|
|
166
|
+
todaySessionsCount: todaySessions.length,
|
|
167
|
+
budgetLimit,
|
|
168
|
+
usedPct,
|
|
169
|
+
remainingPercent,
|
|
170
|
+
resetAt,
|
|
171
|
+
});
|
|
127
172
|
return {
|
|
128
173
|
tool: 'codex',
|
|
129
174
|
remainingPercent,
|
|
@@ -148,13 +193,13 @@ export class CodexQuotaAdapter {
|
|
|
148
193
|
return [await this._fetch()];
|
|
149
194
|
}
|
|
150
195
|
async _fetch() {
|
|
151
|
-
|
|
196
|
+
debug('codex:fetch', 'starting TUI scrape via tmux');
|
|
152
197
|
try {
|
|
153
198
|
const raw = await runCodexScrape();
|
|
154
199
|
const result = parseScrapeOutput(raw);
|
|
155
200
|
if (result.quotaReached) {
|
|
156
|
-
// Ground truth: quota is exhausted
|
|
157
201
|
const resetAt = result.resetIn ? `Resets in ${result.resetIn}` : null;
|
|
202
|
+
debug('codex:fetch', 'quota reached → returning 0%');
|
|
158
203
|
return {
|
|
159
204
|
tool: 'codex',
|
|
160
205
|
remainingPercent: 0,
|
|
@@ -163,12 +208,21 @@ export class CodexQuotaAdapter {
|
|
|
163
208
|
source: 'official-cli',
|
|
164
209
|
};
|
|
165
210
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
211
|
+
if (result.fiveHourRemainingPct !== null) {
|
|
212
|
+
debug('codex:fetch', `parsed /status → ${result.fiveHourRemainingPct}% remaining`);
|
|
213
|
+
return {
|
|
214
|
+
tool: 'codex',
|
|
215
|
+
remainingPercent: result.fiveHourRemainingPct,
|
|
216
|
+
usedPercent: 100 - result.fiveHourRemainingPct,
|
|
217
|
+
resetAt: result.fiveHourResetAt,
|
|
218
|
+
source: 'official-cli',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
debug('codex:fetch', '/status parse failed → falling back to ccusage estimate');
|
|
222
|
+
return fetchCcusageEstimate(this.budgetLimit);
|
|
169
223
|
}
|
|
170
|
-
catch {
|
|
171
|
-
|
|
224
|
+
catch (err) {
|
|
225
|
+
debug('codex:fetch', 'caught error, falling back to ccusage', String(err));
|
|
172
226
|
return fetchCcusageEstimate(this.budgetLimit);
|
|
173
227
|
}
|
|
174
228
|
}
|
package/dist/debug.d.ts
ADDED
package/dist/debug.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const enabled = process.argv.includes('--debug');
|
|
4
|
+
const logFile = enabled
|
|
5
|
+
? (() => {
|
|
6
|
+
const dir = join(process.cwd(), '.logs');
|
|
7
|
+
mkdirSync(dir, { recursive: true });
|
|
8
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
9
|
+
return join(dir, `debug-${ts}.log`);
|
|
10
|
+
})()
|
|
11
|
+
: null;
|
|
12
|
+
export function debug(tag, message, data) {
|
|
13
|
+
if (!logFile)
|
|
14
|
+
return;
|
|
15
|
+
const line = `[${new Date().toISOString()}] [${tag}] ${message}${data !== undefined ? '\n' + JSON.stringify(data, null, 2) : ''}\n`;
|
|
16
|
+
appendFileSync(logFile, line);
|
|
17
|
+
}
|
|
18
|
+
export { enabled as debugEnabled, logFile as debugLogFile };
|
package/dist/index.js
CHANGED
|
@@ -1,38 +1,61 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { debugEnabled, debugLogFile } from './debug.js';
|
|
2
3
|
import { ClaudeQuotaAdapter } from './adapters/claude.js';
|
|
3
4
|
import { CodexQuotaAdapter } from './adapters/codex.js';
|
|
4
5
|
import { AgyQuotaAdapter } from './adapters/agy.js';
|
|
5
|
-
import { printHeader, printFooter, formatRow, getDisplayName
|
|
6
|
+
import { printHeader, printFooter, formatRow, getDisplayName } from './render.js';
|
|
6
7
|
// Fixed display order — never changes regardless of which adapter resolves first
|
|
7
8
|
const SLOT_ORDER = ['claude-code', 'codex', 'agy-gemini', 'agy-other'];
|
|
8
9
|
const BOLD = '\x1b[1m';
|
|
10
|
+
const DIM = '\x1b[2m';
|
|
9
11
|
const R = '\x1b[0m';
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const GRAY = '\x1b[90m';
|
|
13
|
+
const isTTY = Boolean(process.stdout.isTTY);
|
|
14
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
15
|
+
let spinnerTick = 0;
|
|
16
|
+
function spinnerLine(tool) {
|
|
17
|
+
const frame = SPINNER[spinnerTick % SPINNER.length];
|
|
18
|
+
return `${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${DIM}${GRAY}${frame} loading...${R}\x1b[K`;
|
|
19
|
+
}
|
|
20
|
+
// In TTY mode: restore cursor to saved position and repaint all slots.
|
|
21
|
+
// In pipe mode: emit each newly-resolved line exactly once (tracked via emitted set).
|
|
22
|
+
function redraw(slots, emitted) {
|
|
23
|
+
if (!isTTY) {
|
|
24
|
+
for (const tool of SLOT_ORDER) {
|
|
25
|
+
const line = slots.get(tool);
|
|
26
|
+
if (line != null && !emitted.has(tool)) {
|
|
27
|
+
process.stdout.write(line + '\n');
|
|
28
|
+
emitted.add(tool);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
process.stdout.write('\x1b8'); // DEC restore-cursor — teleports back to saved position
|
|
15
34
|
for (const tool of SLOT_ORDER) {
|
|
16
|
-
process.stdout.write('\x1b[2K\r');
|
|
35
|
+
process.stdout.write('\x1b[2K\r');
|
|
17
36
|
const line = slots.get(tool);
|
|
18
|
-
|
|
19
|
-
process.stdout.write(line + '\n');
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
process.stdout.write(`${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${LOADING_LINE}\n`);
|
|
23
|
-
}
|
|
37
|
+
process.stdout.write((line != null ? line + '\x1b[K' : spinnerLine(tool)) + '\n');
|
|
24
38
|
}
|
|
25
39
|
}
|
|
26
40
|
async function main() {
|
|
27
41
|
const claudeAdapter = new ClaudeQuotaAdapter();
|
|
28
42
|
const codexAdapter = new CodexQuotaAdapter();
|
|
29
43
|
const agyAdapter = new AgyQuotaAdapter();
|
|
44
|
+
if (debugEnabled)
|
|
45
|
+
process.stderr.write(`\x1b[2m[debug] logging to ${debugLogFile}\x1b[0m\n`);
|
|
30
46
|
printHeader();
|
|
31
|
-
//
|
|
47
|
+
// Save cursor before the placeholder rows so redraw() can teleport back and overwrite them
|
|
32
48
|
const slots = new Map(SLOT_ORDER.map(t => [t, null]));
|
|
49
|
+
const emitted = new Set(); // pipe-mode: tracks which lines have been printed
|
|
50
|
+
if (isTTY)
|
|
51
|
+
process.stdout.write('\x1b7'); // DEC save-cursor
|
|
33
52
|
for (const tool of SLOT_ORDER) {
|
|
34
|
-
process.stdout.write(
|
|
53
|
+
process.stdout.write(spinnerLine(tool) + '\n');
|
|
35
54
|
}
|
|
55
|
+
// Animate spinner at 80ms while any slot is still loading
|
|
56
|
+
const spinnerTimer = isTTY
|
|
57
|
+
? setInterval(() => { spinnerTick++; redraw(slots, emitted); }, 80)
|
|
58
|
+
: null;
|
|
36
59
|
// Each adapter fills its slot(s) and triggers a redraw; order is always fixed
|
|
37
60
|
function fill(snaps) {
|
|
38
61
|
for (const snap of snaps) {
|
|
@@ -40,13 +63,16 @@ async function main() {
|
|
|
40
63
|
slots.set(snap.tool, formatRow(snap));
|
|
41
64
|
}
|
|
42
65
|
}
|
|
43
|
-
redraw(slots);
|
|
66
|
+
redraw(slots, emitted);
|
|
44
67
|
}
|
|
45
68
|
await Promise.allSettled([
|
|
46
69
|
claudeAdapter.fetchSnapshots().then(fill),
|
|
47
70
|
codexAdapter.fetchSnapshots().then(fill),
|
|
48
71
|
agyAdapter.fetchSnapshots().then(fill),
|
|
49
72
|
]);
|
|
73
|
+
if (spinnerTimer)
|
|
74
|
+
clearInterval(spinnerTimer);
|
|
75
|
+
redraw(slots, emitted); // final clean repaint with all data
|
|
50
76
|
printFooter();
|
|
51
77
|
}
|
|
52
78
|
main().catch((error) => {
|
package/dist/render.js
CHANGED
|
@@ -56,9 +56,7 @@ function formatResetAt(resetAt) {
|
|
|
56
56
|
return `${DIM}${GRAY}(resets ${resetAt})${R}`;
|
|
57
57
|
}
|
|
58
58
|
function isEstimate(snap) {
|
|
59
|
-
return snap.source === 'ccusage'
|
|
60
|
-
typeof snap.raw === 'object' && snap.raw !== null &&
|
|
61
|
-
snap.raw.isEstimate === true;
|
|
59
|
+
return snap.source === 'ccusage';
|
|
62
60
|
}
|
|
63
61
|
// ── Core format (returns string, no newline) ───────────────────────────────
|
|
64
62
|
export function formatRow(snap) {
|
|
@@ -87,7 +85,7 @@ export function formatRow(snap) {
|
|
|
87
85
|
parts.push(`${DIM}${GRAY}[${label}]${R}`);
|
|
88
86
|
}
|
|
89
87
|
}
|
|
90
|
-
if (
|
|
88
|
+
if (isEstimate(snap)) {
|
|
91
89
|
parts.push(`${DIM}${GRAY}[~est]${R}`);
|
|
92
90
|
}
|
|
93
91
|
const detailStr = parts.length > 0 ? ` ${parts.join(' ')}` : '';
|
package/dist/tmux.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class TuiScraper {
|
|
2
|
+
private readonly command;
|
|
3
|
+
private readonly width;
|
|
4
|
+
private readonly height;
|
|
5
|
+
readonly sessionId: string;
|
|
6
|
+
constructor(command: string, width?: number, height?: number);
|
|
7
|
+
start(): void;
|
|
8
|
+
capture(historyLines?: number): string;
|
|
9
|
+
send(text: string): void;
|
|
10
|
+
sendKey(key: string): void;
|
|
11
|
+
waitFor(pattern: RegExp, timeoutMs: number, historyLines?: number): Promise<string>;
|
|
12
|
+
kill(): void;
|
|
13
|
+
}
|
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { debug } from './debug.js';
|
|
3
|
+
export class TuiScraper {
|
|
4
|
+
command;
|
|
5
|
+
width;
|
|
6
|
+
height;
|
|
7
|
+
sessionId;
|
|
8
|
+
constructor(command, width = 220, height = 50) {
|
|
9
|
+
this.command = command;
|
|
10
|
+
this.width = width;
|
|
11
|
+
this.height = height;
|
|
12
|
+
this.sessionId = `af-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
13
|
+
}
|
|
14
|
+
start() {
|
|
15
|
+
try {
|
|
16
|
+
execFileSync('which', ['tmux'], { stdio: 'ignore' });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error('tmux not found — install with: brew install tmux');
|
|
20
|
+
}
|
|
21
|
+
debug('tmux', `starting session ${this.sessionId} for command: ${this.command}`);
|
|
22
|
+
execFileSync('tmux', [
|
|
23
|
+
'new-session', '-d', '-s', this.sessionId,
|
|
24
|
+
'-x', String(this.width), '-y', String(this.height),
|
|
25
|
+
this.command,
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
// historyLines > 0 → include that many lines of scrollback above the visible screen.
|
|
29
|
+
// This catches transient overlays that rendered and then re-rendered away.
|
|
30
|
+
capture(historyLines = 0) {
|
|
31
|
+
const args = historyLines > 0
|
|
32
|
+
? ['capture-pane', '-t', this.sessionId, '-S', `-${historyLines}`, '-p']
|
|
33
|
+
: ['capture-pane', '-t', this.sessionId, '-p'];
|
|
34
|
+
const text = execFileSync('tmux', args).toString();
|
|
35
|
+
debug('tmux:capture', `[${this.sessionId}] captured ${text.length} chars (history=${historyLines})`);
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
38
|
+
send(text) {
|
|
39
|
+
debug('tmux:send', `[${this.sessionId}] sending: ${JSON.stringify(text)}`);
|
|
40
|
+
execFileSync('tmux', ['send-keys', '-t', this.sessionId, text, 'Enter']);
|
|
41
|
+
}
|
|
42
|
+
// Send a named key (Tab, Escape, Up, Down, etc.) without appending Enter.
|
|
43
|
+
sendKey(key) {
|
|
44
|
+
debug('tmux:send', `[${this.sessionId}] sendKey: ${key}`);
|
|
45
|
+
execFileSync('tmux', ['send-keys', '-t', this.sessionId, key]);
|
|
46
|
+
}
|
|
47
|
+
async waitFor(pattern, timeoutMs, historyLines = 500) {
|
|
48
|
+
const deadline = Date.now() + timeoutMs;
|
|
49
|
+
debug('tmux:waitFor', `[${this.sessionId}] waiting for ${pattern} (timeout ${timeoutMs}ms, history=${historyLines})`);
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
const text = this.capture(historyLines);
|
|
52
|
+
if (pattern.test(text)) {
|
|
53
|
+
debug('tmux:waitFor', `[${this.sessionId}] matched ${pattern}`);
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
await sleep(100);
|
|
57
|
+
}
|
|
58
|
+
const last = this.capture(historyLines);
|
|
59
|
+
debug('tmux:waitFor', `[${this.sessionId}] TIMEOUT for ${pattern}, last screen:\n${last}`);
|
|
60
|
+
throw new Error(`TuiScraper.waitFor timeout after ${timeoutMs}ms: ${pattern}`);
|
|
61
|
+
}
|
|
62
|
+
kill() {
|
|
63
|
+
debug('tmux', `killing session ${this.sessionId}`);
|
|
64
|
+
try {
|
|
65
|
+
execFileSync('tmux', ['kill-session', '-t', this.sessionId], { stdio: 'ignore' });
|
|
66
|
+
}
|
|
67
|
+
catch { /* already dead */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function sleep(ms) {
|
|
71
|
+
return new Promise(res => setTimeout(res, ms));
|
|
72
|
+
}
|