agent-fuel 0.3.0 → 0.4.1
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 +33 -55
- package/dist/adapters/claude.d.ts +0 -2
- package/dist/adapters/claude.js +84 -53
- package/dist/adapters/codex.js +132 -62
- 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 +14 -0
- package/dist/tmux.js +72 -0
- package/package.json +3 -3
package/dist/adapters/agy.js
CHANGED
|
@@ -1,70 +1,59 @@
|
|
|
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, sleep } from '../tmux.js';
|
|
5
|
+
import { debug } from '../debug.js';
|
|
5
6
|
const CACHE_PATH = path.join(os.homedir(), '.gemini/antigravity-cli/.agent-fuel-quota-cache.json');
|
|
6
7
|
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
8
|
// ── Scraping ───────────────────────────────────────────────────────────────
|
|
13
9
|
/**
|
|
14
|
-
*
|
|
15
|
-
* Model Quota list to render, then
|
|
10
|
+
* Launches `agy` in a tmux session, opens the `/usage` panel, waits for
|
|
11
|
+
* the Model Quota list to render, then returns clean rendered screen text.
|
|
16
12
|
*/
|
|
17
|
-
function runAgyUsage() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
});
|
|
13
|
+
async function runAgyUsage() {
|
|
14
|
+
const tui = new TuiScraper('agy');
|
|
15
|
+
try {
|
|
16
|
+
tui.start();
|
|
17
|
+
// Wait for AGY main menu ready.
|
|
18
|
+
// On first run in a new directory, AGY shows a "Do you trust this project?"
|
|
19
|
+
// prompt. The "Yes, I trust this folder" option is pre-selected; press Enter.
|
|
20
|
+
const firstScreen = await tui.waitFor(/for shortcuts|Do you trust/i, 20_000);
|
|
21
|
+
if (!/for shortcuts/i.test(firstScreen)) {
|
|
22
|
+
debug('agy:scrape', 'trust prompt detected — confirming with Enter');
|
|
23
|
+
await sleep(300); // ensure app is fully interactive before sending input
|
|
24
|
+
tui.sendKey('Enter');
|
|
25
|
+
await tui.waitFor(/for shortcuts/, 15_000);
|
|
26
|
+
}
|
|
27
|
+
// Navigate to /usage panel
|
|
28
|
+
tui.send('/usage');
|
|
29
|
+
await tui.waitFor(/Model Quota/, 10_000);
|
|
30
|
+
// Brief pause for all model rows to finish rendering
|
|
31
|
+
await sleep(500);
|
|
32
|
+
return tui.capture();
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
tui.kill();
|
|
36
|
+
}
|
|
48
37
|
}
|
|
49
38
|
// ── Parsing ────────────────────────────────────────────────────────────────
|
|
50
39
|
/**
|
|
51
40
|
* Parse the Model Quota panel into an array of entries.
|
|
52
41
|
*
|
|
53
|
-
* Panel format (
|
|
42
|
+
* Panel format (tmux rendered — no ANSI codes):
|
|
54
43
|
*
|
|
55
44
|
* └ Model Quota
|
|
56
45
|
*
|
|
57
46
|
* Gemini 3.5 Flash (High)
|
|
58
47
|
* ░░░░░░░░░░░ ... 20%
|
|
59
|
-
* Refreshes in 3h 28m
|
|
48
|
+
* Refreshes in 3h 28m
|
|
60
49
|
*
|
|
61
50
|
* Claude Sonnet 4.6 (Thinking)
|
|
62
51
|
* ███████████ ... 100%
|
|
63
52
|
* Quota available
|
|
64
53
|
*/
|
|
65
54
|
function parseQuotaPanel(raw) {
|
|
66
|
-
|
|
67
|
-
const lines =
|
|
55
|
+
// tmux capture-pane returns clean rendered text — no ANSI stripping needed
|
|
56
|
+
const lines = raw.split(/\r?\n/);
|
|
68
57
|
const results = [];
|
|
69
58
|
const headerIdx = lines.findIndex(l => l.includes('Model Quota'));
|
|
70
59
|
if (headerIdx === -1)
|
|
@@ -91,15 +80,12 @@ function parseQuotaPanel(raw) {
|
|
|
91
80
|
j++;
|
|
92
81
|
continue;
|
|
93
82
|
}
|
|
94
|
-
// Progress bar line: has block chars OR starts with a digit%
|
|
95
83
|
if (barLine === null && (candidate.includes('░') || candidate.includes('█') || /^\d+%/.test(candidate))) {
|
|
96
84
|
barLine = candidate;
|
|
97
85
|
j++;
|
|
98
86
|
continue;
|
|
99
87
|
}
|
|
100
|
-
// Refresh / availability line
|
|
101
88
|
if (barLine !== null && (candidate.includes('Refreshes') || candidate.includes('Quota available'))) {
|
|
102
|
-
// Strip any leading "NN% remaining · " prefix
|
|
103
89
|
const m = candidate.match(/(Refreshes in [^\r\n]+|Quota available)/);
|
|
104
90
|
refreshLine = m ? m[1] : candidate;
|
|
105
91
|
j++;
|
|
@@ -107,7 +93,6 @@ function parseQuotaPanel(raw) {
|
|
|
107
93
|
break;
|
|
108
94
|
}
|
|
109
95
|
if (barLine !== null) {
|
|
110
|
-
// Percentage is always at the END of the bar line: "░░░ ... 20%" or "20% remaining · …"
|
|
111
96
|
const percentMatch = barLine.match(/(\d+)%/);
|
|
112
97
|
if (percentMatch) {
|
|
113
98
|
results.push({ model: line, percent: parseInt(percentMatch[1], 10), refreshLine });
|
|
@@ -137,11 +122,6 @@ async function writeCache(entries) {
|
|
|
137
122
|
catch { /* non-fatal */ }
|
|
138
123
|
}
|
|
139
124
|
// ── 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
125
|
function buildSnapshots(entries, fromCache) {
|
|
146
126
|
const source = fromCache ? 'cache' : 'official-cli';
|
|
147
127
|
const geminiEntries = entries.filter(e => /gemini/i.test(e.model));
|
|
@@ -150,7 +130,6 @@ function buildSnapshots(entries, fromCache) {
|
|
|
150
130
|
if (bucket.length === 0) {
|
|
151
131
|
return { tool, remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' };
|
|
152
132
|
}
|
|
153
|
-
// Show the lowest remaining % (most constrained model in the bucket)
|
|
154
133
|
const worst = bucket.reduce((a, b) => a.percent <= b.percent ? a : b);
|
|
155
134
|
return {
|
|
156
135
|
tool,
|
|
@@ -174,7 +153,7 @@ export class AgyQuotaAdapter {
|
|
|
174
153
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
175
154
|
return buildSnapshots(cached.entries, true);
|
|
176
155
|
}
|
|
177
|
-
// Slow path: spawn agy, scrape the quota panel
|
|
156
|
+
// Slow path: spawn agy via tmux, scrape the quota panel
|
|
178
157
|
try {
|
|
179
158
|
const raw = await runAgyUsage();
|
|
180
159
|
const entries = parseQuotaPanel(raw);
|
|
@@ -182,7 +161,6 @@ export class AgyQuotaAdapter {
|
|
|
182
161
|
await writeCache(entries);
|
|
183
162
|
return buildSnapshots(entries, false);
|
|
184
163
|
}
|
|
185
|
-
// Panel not found — return unknown rows (do not cache failures)
|
|
186
164
|
return [
|
|
187
165
|
{ tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
|
|
188
166
|
{ tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
|
package/dist/adapters/claude.js
CHANGED
|
@@ -1,15 +1,70 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import { TuiScraper, sleep } from '../tmux.js';
|
|
2
|
+
import { debug } from '../debug.js';
|
|
3
|
+
// ── TUI scraper ────────────────────────────────────────────────────────────
|
|
4
|
+
/**
|
|
5
|
+
* Launches `claude` in a tmux session, opens /status, navigates to the
|
|
6
|
+
* Status tab (which shows real quota usage bars), and returns the captured
|
|
7
|
+
* screen text.
|
|
8
|
+
*
|
|
9
|
+
* The Status tab renders persistently (not transient), so regular
|
|
10
|
+
* capture-pane is sufficient — no pipe-pane needed.
|
|
11
|
+
*/
|
|
12
|
+
async function runClaudeScrape() {
|
|
13
|
+
const tui = new TuiScraper('claude');
|
|
14
|
+
try {
|
|
15
|
+
tui.start();
|
|
16
|
+
// Wait for TUI ready — welcome banner or prompt hint visible
|
|
17
|
+
await tui.waitFor(/Welcome back|Try "/i, 15_000, 0);
|
|
18
|
+
// Open the /status panel
|
|
19
|
+
tui.send('/status');
|
|
20
|
+
// Wait for the status panel tabs to appear
|
|
21
|
+
await tui.waitFor(/Settings\s+Status/i, 10_000, 0);
|
|
22
|
+
// Navigate to the Status tab (second tab after Settings)
|
|
23
|
+
tui.sendKey('Tab');
|
|
24
|
+
await sleep(300);
|
|
25
|
+
tui.sendKey('Tab');
|
|
26
|
+
// Wait for the usage bars — shows "XX% used"
|
|
27
|
+
return await tui.waitFor(/\d+%\s+used/i, 8_000, 0);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
tui.kill();
|
|
12
31
|
}
|
|
32
|
+
}
|
|
33
|
+
// ── Parser ─────────────────────────────────────────────────────────────────
|
|
34
|
+
/** Convert any 12h am/pm time within a string to 24h (HH:MM). */
|
|
35
|
+
function to24h(s) {
|
|
36
|
+
return s.replace(/\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/gi, (_, h, m, meridiem) => {
|
|
37
|
+
let hour = parseInt(h, 10);
|
|
38
|
+
const min = m ?? '00';
|
|
39
|
+
if (meridiem.toLowerCase() === 'am') {
|
|
40
|
+
if (hour === 12)
|
|
41
|
+
hour = 0; // 12am → 00:xx
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
if (hour !== 12)
|
|
45
|
+
hour += 12; // 1–11pm → 13–23
|
|
46
|
+
}
|
|
47
|
+
return `${String(hour).padStart(2, '0')}:${min}`;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function parseScrapeOutput(screen) {
|
|
51
|
+
debug('claude:parse', `screen length: ${screen.length}`);
|
|
52
|
+
debug('claude:parse', 'screen', screen);
|
|
53
|
+
// Match "XX% used" occurrences in order:
|
|
54
|
+
// First = current session (5h block), second = current week
|
|
55
|
+
const usedMatches = [...screen.matchAll(/(\d+)%\s+used/gi)];
|
|
56
|
+
debug('claude:parse', `found ${usedMatches.length} "% used" matches`);
|
|
57
|
+
const sessionUsedPct = usedMatches[0] ? parseInt(usedMatches[0][1], 10) : null;
|
|
58
|
+
const weeklyUsedPct = usedMatches[1] ? parseInt(usedMatches[1][1], 10) : null;
|
|
59
|
+
// Reset time: "Resets H:MMam" or "Resets May 30 at 6am" — grab the first occurrence,
|
|
60
|
+
// then normalise any 12h am/pm component to 24h (e.g. "11:10pm" → "23:10").
|
|
61
|
+
const resetMatch = screen.match(/Resets\s+([^\n\r]+)/i);
|
|
62
|
+
const sessionResetAt = resetMatch ? to24h(resetMatch[1].trim()) : null;
|
|
63
|
+
debug('claude:parse', 'result', { sessionUsedPct, sessionResetAt, weeklyUsedPct });
|
|
64
|
+
return { sessionUsedPct, sessionResetAt, weeklyUsedPct };
|
|
65
|
+
}
|
|
66
|
+
// ── Adapter ────────────────────────────────────────────────────────────────
|
|
67
|
+
export class ClaudeQuotaAdapter {
|
|
13
68
|
async fetchSnapshots() {
|
|
14
69
|
return [await this._fetch()];
|
|
15
70
|
}
|
|
@@ -21,51 +76,27 @@ export class ClaudeQuotaAdapter {
|
|
|
21
76
|
resetAt: null,
|
|
22
77
|
source: 'unknown',
|
|
23
78
|
});
|
|
79
|
+
debug('claude:fetch', 'starting TUI scrape via tmux');
|
|
24
80
|
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
|
-
}
|
|
81
|
+
const screen = await runClaudeScrape();
|
|
82
|
+
const result = parseScrapeOutput(screen);
|
|
83
|
+
if (result.sessionUsedPct !== null) {
|
|
84
|
+
const remainingPercent = Math.max(0, 100 - result.sessionUsedPct);
|
|
85
|
+
debug('claude:fetch', `parsed Usage tab → ${result.sessionUsedPct}% used (${remainingPercent}% remaining)`);
|
|
86
|
+
return {
|
|
87
|
+
tool: 'claude-code',
|
|
88
|
+
remainingPercent,
|
|
89
|
+
usedPercent: result.sessionUsedPct,
|
|
90
|
+
resetAt: result.sessionResetAt,
|
|
91
|
+
source: 'official-cli',
|
|
92
|
+
};
|
|
54
93
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
remainingPercent,
|
|
58
|
-
usedPercent: Math.round(usedPct),
|
|
59
|
-
resetAt,
|
|
60
|
-
source: 'ccusage',
|
|
61
|
-
raw: activeBlock,
|
|
62
|
-
};
|
|
94
|
+
debug('claude:fetch', 'parse failed → unknown');
|
|
95
|
+
return unknown();
|
|
63
96
|
}
|
|
64
|
-
catch (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
raw: error instanceof Error ? error.message : String(error),
|
|
68
|
-
};
|
|
97
|
+
catch (err) {
|
|
98
|
+
debug('claude:fetch', 'caught error', String(err));
|
|
99
|
+
return unknown();
|
|
69
100
|
}
|
|
70
101
|
}
|
|
71
102
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,73 +1,125 @@
|
|
|
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, sleep } 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
|
+
// Codex may show one or more blocking dialogs before its main screen ("Tip:").
|
|
14
|
+
// Known dialogs and their dismissal key ("2" = skip/use existing):
|
|
15
|
+
// • Update nag: "Update available! x.x → y.y"
|
|
16
|
+
// • New-model intro: "Introducing GPT-5.5"
|
|
17
|
+
const CODEX_READY = /Tip:/i;
|
|
18
|
+
const CODEX_DIALOG = /Update available|Introducing GPT|Try new model|Use existing model/i;
|
|
19
|
+
const CODEX_EITHER = new RegExp(`(?:${CODEX_READY.source})|(?:${CODEX_DIALOG.source})`, 'i');
|
|
20
|
+
const CODEX_STARTUP_MS = 25_000;
|
|
11
21
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
22
|
+
* Launches `codex` in a tmux session, pipes all terminal bytes to a temp
|
|
23
|
+
* file, sends /status twice, then reads the file and returns the raw bytes.
|
|
24
|
+
*
|
|
25
|
+
* Why pipe-pane instead of capture-pane:
|
|
26
|
+
* The /status overlay is full-screen and transient — it renders in-place for
|
|
27
|
+
* one frame and re-renders away without entering the tmux scrollback buffer.
|
|
28
|
+
* capture-pane (even with -S history) can never catch it. pipe-pane streams
|
|
29
|
+
* every raw byte to a file so even a 10ms overlay is permanently recorded.
|
|
14
30
|
*/
|
|
15
|
-
function runCodexScrape() {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
31
|
+
async function runCodexScrape() {
|
|
32
|
+
const tui = new TuiScraper('codex');
|
|
33
|
+
const pipePath = `/tmp/af-codex-${Date.now()}.log`;
|
|
34
|
+
try {
|
|
35
|
+
tui.start();
|
|
36
|
+
// Stream all pane output to a file from the start
|
|
37
|
+
execFileSync('tmux', ['pipe-pane', '-t', tui.sessionId, `cat >> '${pipePath}'`]);
|
|
38
|
+
debug('codex:scrape', `pipe-pane logging to ${pipePath}`);
|
|
39
|
+
// Wait for TUI ready, dismissing any blocking dialogs along the way.
|
|
40
|
+
const dialogDeadline = Date.now() + CODEX_STARTUP_MS;
|
|
41
|
+
let screen = await tui.waitFor(CODEX_EITHER, CODEX_STARTUP_MS, 0);
|
|
42
|
+
while (!CODEX_READY.test(screen)) {
|
|
43
|
+
debug('codex:scrape', 'blocking dialog detected — sending "2" to dismiss');
|
|
44
|
+
tui.send('2');
|
|
45
|
+
await sleep(1_000); // wait for dialog to actually clear before re-checking
|
|
46
|
+
const remaining = dialogDeadline - Date.now(); // compute AFTER sleep
|
|
47
|
+
if (remaining < 500) {
|
|
48
|
+
throw new Error('Codex TUI never reached ready state after dismissing dialogs');
|
|
49
|
+
}
|
|
50
|
+
screen = await tui.waitFor(CODEX_EITHER, remaining, 0);
|
|
51
|
+
}
|
|
52
|
+
// First /status: panel says "Limits: refresh requested; run /status again shortly"
|
|
53
|
+
tui.send('/status');
|
|
54
|
+
await sleep(2_000);
|
|
55
|
+
// Second /status: has actual 5h/weekly quota data
|
|
56
|
+
tui.send('/status');
|
|
57
|
+
await sleep(4_000);
|
|
58
|
+
const raw = readFileSync(pipePath, 'utf-8');
|
|
59
|
+
debug('codex:scrape', `pipe log size: ${raw.length} bytes`);
|
|
60
|
+
return raw;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
tui.kill();
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(pipePath);
|
|
66
|
+
}
|
|
67
|
+
catch { /* ok */ }
|
|
68
|
+
}
|
|
44
69
|
}
|
|
45
|
-
// ── Output parser ──────────────────────────────────────────────────────────
|
|
46
70
|
function stripAnsi(str) {
|
|
47
71
|
// eslint-disable-next-line no-control-regex
|
|
48
|
-
return str
|
|
72
|
+
return str
|
|
73
|
+
.replace(/\x1B\[[\x20-\x3f]*[\x40-\x7e]/g, '')
|
|
74
|
+
.replace(/\x1B[^[]/g, '')
|
|
75
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
49
76
|
}
|
|
50
77
|
function parseScrapeOutput(raw) {
|
|
78
|
+
// pipe-pane output contains raw ANSI bytes — strip before pattern matching
|
|
51
79
|
const clean = stripAnsi(raw);
|
|
80
|
+
debug('codex:parse', `raw length: ${raw.length}, cleaned length: ${clean.length}`);
|
|
81
|
+
debug('codex:parse', 'cleaned output', clean);
|
|
82
|
+
debug('codex:parse', 'checking patterns', {
|
|
83
|
+
hasIndividualQuota: /Individual quota reached/i.test(clean),
|
|
84
|
+
hasHeadsUp: /less than \d+%\s+of your 5h limit left/i.test(clean),
|
|
85
|
+
has5hLimit: /5h limit:/i.test(clean),
|
|
86
|
+
hasWeeklyLimit: /Weekly limit:/i.test(clean),
|
|
87
|
+
hasLimits: /Limits:/i.test(clean),
|
|
88
|
+
});
|
|
52
89
|
// "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]);
|
|
90
|
+
if (/Individual quota reached/i.test(clean)) {
|
|
91
|
+
const resetMatch = clean.match(/Resets in\s*((?:\d+h)?(?:\d+m)?(?:\d+s)?)/i);
|
|
92
|
+
let resetIn = null;
|
|
93
|
+
if (resetMatch) {
|
|
94
|
+
const parts = [];
|
|
95
|
+
const hm = resetMatch[1].match(/^(\d+h)?(\d+m)?/);
|
|
96
|
+
if (hm) {
|
|
97
|
+
if (hm[1])
|
|
98
|
+
parts.push(hm[1]);
|
|
99
|
+
if (hm[2])
|
|
100
|
+
parts.push(hm[2]);
|
|
101
|
+
}
|
|
102
|
+
resetIn = parts.length > 0 ? parts.join(' ') : null;
|
|
67
103
|
}
|
|
68
|
-
|
|
104
|
+
debug('codex:parse', 'result', { quotaReached: true, resetIn });
|
|
105
|
+
return { quotaReached: true, resetIn, fiveHourRemainingPct: null, fiveHourResetAt: null };
|
|
69
106
|
}
|
|
70
|
-
|
|
107
|
+
// "⚠ Heads up, you have less than X% of your 5h limit left."
|
|
108
|
+
const headsUpMatch = clean.match(/less than (\d+)%\s+of your 5h limit left/i);
|
|
109
|
+
if (headsUpMatch) {
|
|
110
|
+
const ceiling = parseInt(headsUpMatch[1], 10);
|
|
111
|
+
const fiveHourRemainingPct = Math.max(0, ceiling - 1);
|
|
112
|
+
debug('codex:parse', 'result', { source: 'headsUp', ceiling, fiveHourRemainingPct });
|
|
113
|
+
return { quotaReached: false, resetIn: null, fiveHourRemainingPct, fiveHourResetAt: null };
|
|
114
|
+
}
|
|
115
|
+
// Parse "/status" panel: "5h limit: [...] X% left (resets HH:MM)"
|
|
116
|
+
// Use the LAST match — /status is sent twice and the second response is fresh.
|
|
117
|
+
const allFiveHMatches = [...clean.matchAll(/5h limit:\s*\[.*?\]\s*(\d+)%\s*left\s*\(resets\s+([^)]+)\)/gi)];
|
|
118
|
+
const fiveHMatch = allFiveHMatches.at(-1) ?? null;
|
|
119
|
+
const fiveHourRemainingPct = fiveHMatch ? parseInt(fiveHMatch[1], 10) : null;
|
|
120
|
+
const fiveHourResetAt = fiveHMatch ? fiveHMatch[2].trim() : null;
|
|
121
|
+
debug('codex:parse', 'result', { quotaReached: false, fiveHourRemainingPct, fiveHourResetAt, fiveHMatchRaw: fiveHMatch?.[0] ?? null });
|
|
122
|
+
return { quotaReached: false, resetIn: null, fiveHourRemainingPct, fiveHourResetAt };
|
|
71
123
|
}
|
|
72
124
|
// ── ccusage fallback estimate ──────────────────────────────────────────────
|
|
73
125
|
async function fetchCcusageEstimate(budgetLimit) {
|
|
@@ -86,6 +138,7 @@ async function fetchCcusageEstimate(budgetLimit) {
|
|
|
86
138
|
catch {
|
|
87
139
|
return unknown();
|
|
88
140
|
}
|
|
141
|
+
debug('codex:ccusage', 'raw stdout', stdout);
|
|
89
142
|
const data = JSON.parse(stdout);
|
|
90
143
|
const sessions = Array.isArray(data?.sessions) ? data.sessions :
|
|
91
144
|
Array.isArray(data?.session) ? data.session :
|
|
@@ -119,11 +172,19 @@ async function fetchCcusageEstimate(budgetLimit) {
|
|
|
119
172
|
if (latestActivity > 0) {
|
|
120
173
|
try {
|
|
121
174
|
resetAt = new Date(latestActivity + ROLLING_WINDOW_MS).toLocaleTimeString([], {
|
|
122
|
-
hour: '2-digit', minute: '2-digit',
|
|
175
|
+
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
123
176
|
});
|
|
124
177
|
}
|
|
125
178
|
catch { /* leave null */ }
|
|
126
179
|
}
|
|
180
|
+
debug('codex:ccusage', 'computed', {
|
|
181
|
+
totalCost,
|
|
182
|
+
todaySessionsCount: todaySessions.length,
|
|
183
|
+
budgetLimit,
|
|
184
|
+
usedPct,
|
|
185
|
+
remainingPercent,
|
|
186
|
+
resetAt,
|
|
187
|
+
});
|
|
127
188
|
return {
|
|
128
189
|
tool: 'codex',
|
|
129
190
|
remainingPercent,
|
|
@@ -148,13 +209,13 @@ export class CodexQuotaAdapter {
|
|
|
148
209
|
return [await this._fetch()];
|
|
149
210
|
}
|
|
150
211
|
async _fetch() {
|
|
151
|
-
|
|
212
|
+
debug('codex:fetch', 'starting TUI scrape via tmux');
|
|
152
213
|
try {
|
|
153
214
|
const raw = await runCodexScrape();
|
|
154
215
|
const result = parseScrapeOutput(raw);
|
|
155
216
|
if (result.quotaReached) {
|
|
156
|
-
// Ground truth: quota is exhausted
|
|
157
217
|
const resetAt = result.resetIn ? `Resets in ${result.resetIn}` : null;
|
|
218
|
+
debug('codex:fetch', 'quota reached → returning 0%');
|
|
158
219
|
return {
|
|
159
220
|
tool: 'codex',
|
|
160
221
|
remainingPercent: 0,
|
|
@@ -163,12 +224,21 @@ export class CodexQuotaAdapter {
|
|
|
163
224
|
source: 'official-cli',
|
|
164
225
|
};
|
|
165
226
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
227
|
+
if (result.fiveHourRemainingPct !== null) {
|
|
228
|
+
debug('codex:fetch', `parsed /status → ${result.fiveHourRemainingPct}% remaining`);
|
|
229
|
+
return {
|
|
230
|
+
tool: 'codex',
|
|
231
|
+
remainingPercent: result.fiveHourRemainingPct,
|
|
232
|
+
usedPercent: 100 - result.fiveHourRemainingPct,
|
|
233
|
+
resetAt: result.fiveHourResetAt,
|
|
234
|
+
source: 'official-cli',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
debug('codex:fetch', '/status parse failed → falling back to ccusage estimate');
|
|
238
|
+
return fetchCcusageEstimate(this.budgetLimit);
|
|
169
239
|
}
|
|
170
|
-
catch {
|
|
171
|
-
|
|
240
|
+
catch (err) {
|
|
241
|
+
debug('codex:fetch', 'caught error, falling back to ccusage', String(err));
|
|
172
242
|
return fetchCcusageEstimate(this.budgetLimit);
|
|
173
243
|
}
|
|
174
244
|
}
|
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,14 @@
|
|
|
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
|
+
}
|
|
14
|
+
export declare function sleep(ms: number): Promise<void>;
|
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
|
+
export function sleep(ms) {
|
|
71
|
+
return new Promise(res => setTimeout(res, ms));
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-fuel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Sleek term-based dashboard for AI coding CLI quotas",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"author": "Pedro Rodrigues",
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@types/node": "^
|
|
32
|
-
"typescript": "^
|
|
31
|
+
"@types/node": "^25.9.1",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
33
|
}
|
|
34
34
|
}
|