agent-fuel 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -31
- package/dist/adapters/agy.d.ts +1 -3
- package/dist/adapters/agy.js +186 -95
- package/dist/adapters/claude.d.ts +3 -2
- package/dist/adapters/claude.js +36 -37
- package/dist/adapters/codex.d.ts +3 -2
- package/dist/adapters/codex.js +164 -88
- package/dist/adapters/index.d.ts +4 -3
- package/dist/index.js +44 -18
- package/dist/render.d.ts +7 -0
- package/dist/render.js +109 -57
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,82 +4,125 @@ A sleek, unified CLI dashboard to monitor your AI coding assistant quotas, credi
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## 🚀
|
|
7
|
+
## 🚀 Installation & Running
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Install **Agent Fuel** globally:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npm install
|
|
12
|
+
npm install -g agent-fuel
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Then run from any directory:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
agent-fuel
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
### Development Setup
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/jperod/agent-fuel.git
|
|
25
|
+
cd agent-fuel
|
|
26
|
+
npm install
|
|
27
|
+
npm run build
|
|
28
|
+
npm link
|
|
29
|
+
```
|
|
30
|
+
|
|
21
31
|
---
|
|
22
32
|
|
|
23
33
|
## 💡 The Motivation
|
|
24
34
|
|
|
25
|
-
AI coding assistants are now integral to developer workflows. Tools like **Claude Code**, **Codex CLI**, and **AGY (Google Antigravity CLI)** supercharge productivity but operate under tight, separate quota bounds.
|
|
35
|
+
AI coding assistants are now integral to developer workflows. Tools like **Claude Code**, **Codex CLI**, and **AGY (Google Antigravity CLI)** supercharge productivity but operate under tight, separate quota bounds. Developers are forced to jump through interactive prompts or scrape configuration screens just to answer:
|
|
26
36
|
|
|
27
37
|
> **"How much agent fuel do I have left before starting this massive refactor?"**
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
**Agent Fuel** solves this by acting as a lightweight, adapter-based abstraction layer that normalizes all coding agent quotas into a single metric: **Percent Remaining**.
|
|
39
|
+
**Agent Fuel** solves this by acting as a lightweight, adapter-based abstraction layer that normalises all coding agent quotas into a single metric: **Percent Remaining**.
|
|
32
40
|
|
|
33
41
|
---
|
|
34
42
|
|
|
35
|
-
## 🎯
|
|
43
|
+
## 🎯 How It Works
|
|
36
44
|
|
|
37
|
-
`agent-fuel` is a tiny
|
|
38
|
-
1. **Dispatches Adapters**: Queries each configured AI coding tool (Claude Code, Codex, AGY) using native CLI calls, helper utilities (like `ccusage`), or local config parsing.
|
|
39
|
-
2. **Normalizes Quota Models**: Standardizes diverse limits into a uniform percentage score (`0` to `100%`).
|
|
40
|
-
3. **Renders an Elegant CLI Dashboard**: Displays a high-fidelity 3-bar ASCII progress dashboard directly in your terminal.
|
|
45
|
+
`agent-fuel` is a tiny modern CLI built with TypeScript that:
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
1. **Dispatches Adapters concurrently** — all adapters run in parallel and each row is printed the moment its adapter resolves; you never wait for the slowest tool.
|
|
48
|
+
2. **Normalises Quota Models** — standardises diverse limits into a uniform `0–100%` score.
|
|
49
|
+
3. **Scrapes TUI output directly** — Codex and AGY quotas are read by spawning the real CLIs via `expect` and parsing terminal output, so the numbers match what the tools themselves show.
|
|
50
|
+
4. **Caches AGY results** — AGY quota is cached for 5 minutes so repeated runs are instant (~1s).
|
|
51
|
+
5. **Renders a clean dashboard** — colour-coded bars with reset times directly in your terminal.
|
|
52
|
+
|
|
53
|
+
### Project Architecture
|
|
43
54
|
|
|
44
55
|
```text
|
|
45
56
|
agent-fuel/
|
|
46
57
|
├── src/
|
|
47
|
-
│ ├── index.ts
|
|
48
|
-
│ ├── render.ts
|
|
58
|
+
│ ├── index.ts # CLI entry point — runs all adapters concurrently
|
|
59
|
+
│ ├── render.ts # Colour-coded bar dashboard renderer
|
|
49
60
|
│ └── adapters/
|
|
50
|
-
│ ├──
|
|
51
|
-
│ ├──
|
|
52
|
-
│ ├──
|
|
53
|
-
│ └──
|
|
61
|
+
│ ├── index.ts # Shared UsageSnapshot type & QuotaAdapter interface
|
|
62
|
+
│ ├── claude.ts # Claude Code (via ccusage blocks)
|
|
63
|
+
│ ├── codex.ts # Codex CLI (expect TUI scrape; ccusage as fallback estimate)
|
|
64
|
+
│ └── agy.ts # AGY — split into Gemini + Other buckets
|
|
54
65
|
├── package.json
|
|
55
66
|
└── README.md
|
|
56
67
|
```
|
|
57
68
|
|
|
58
|
-
###
|
|
69
|
+
### Type Shape
|
|
59
70
|
|
|
60
71
|
```typescript
|
|
61
72
|
type UsageSnapshot = {
|
|
62
|
-
tool: 'codex' | 'claude-code' | 'agy';
|
|
63
|
-
remainingPercent: number | null;
|
|
73
|
+
tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other';
|
|
74
|
+
remainingPercent: number | null; // Unified 0–100 scale
|
|
64
75
|
usedPercent?: number | null;
|
|
65
76
|
resetAt?: string | null;
|
|
66
|
-
source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'unknown';
|
|
77
|
+
source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'cache' | 'unknown';
|
|
67
78
|
raw?: unknown;
|
|
68
79
|
};
|
|
69
80
|
```
|
|
70
81
|
|
|
71
82
|
---
|
|
72
83
|
|
|
73
|
-
## 📊 Terminal Dashboard
|
|
74
|
-
|
|
75
|
-
Running `agent-fuel` will immediately output a clean, colored visual summary of your current agent capacity:
|
|
84
|
+
## 📊 Terminal Dashboard
|
|
76
85
|
|
|
77
|
-
```
|
|
86
|
+
```
|
|
78
87
|
⚡️ Agent Fuel - CLI Quota Monitor
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
AGY
|
|
89
|
+
Claude Code [███████████████████░░░░░░░░░░░] 64% remaining (resets 01:00 PM)
|
|
90
|
+
Codex [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0% remaining (resets in 4h 33m)
|
|
91
|
+
AGY Gemini [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0% remaining (resets in 3h 16m) [Gemini 3.5 Flash (Medium)]
|
|
92
|
+
AGY Other [██████████████████░░░░░░░░░░░░] 60% remaining (resets in 4h 47m) [Claude Sonnet 4.6 (Thinking)]
|
|
93
|
+
|
|
94
|
+
agent-fuel v0.3.0
|
|
83
95
|
```
|
|
84
96
|
|
|
97
|
+
Rows appear as each adapter resolves — Claude Code (instant) prints first, Codex and AGY follow as their TUI scrapes complete.
|
|
98
|
+
|
|
99
|
+
**AGY Gemini** shows the worst-case remaining across all `Gemini *` model tiers.
|
|
100
|
+
**AGY Other** shows the worst-case across Claude and other non-Gemini models.
|
|
101
|
+
**Codex** row tagged `[~est]` when quota has not been reached and the percentage is estimated from local session cost data (see fallback note below).
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## ⚙️ Environment Overrides
|
|
106
|
+
|
|
107
|
+
| Variable | Default | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `AGENT_FUEL_CLAUDE_BUDGET` | `20.0` | Claude Code rolling budget in USD |
|
|
110
|
+
| `AGENT_FUEL_CODEX_BUDGET` | `20.0` | **Fallback estimate only** — Codex rolling budget in USD |
|
|
111
|
+
|
|
112
|
+
> **Note on `AGENT_FUEL_CODEX_BUDGET`:** Codex quota is read directly from the Codex TUI via `expect` scraping. This variable is only used as a rough fallback estimate (shown as `[~est]`) when the TUI reports no quota warning and a percentage cannot be determined. It is a guess based on local session cost data — not an official Codex quota signal. The TUI scrape is always preferred.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 📦 Changelog
|
|
117
|
+
|
|
118
|
+
### v0.3.0
|
|
119
|
+
- **Codex TUI scrape**: replaced inaccurate `ccusage` cost estimate with an `expect` wrapper that reads the real Codex quota warning (`"Individual quota reached. Resets in Xh Ym"`) — same pattern as AGY. `ccusage` kept as a labelled `[~est]` fallback when quota has not yet been exhausted.
|
|
120
|
+
- **Streaming render with fixed order**: placeholder rows print immediately; each bar overwrites in-place as its adapter resolves. Row order is always `Claude Code → Codex → AGY Gemini → AGY Other`.
|
|
121
|
+
- **AGY split view**: Gemini and non-Gemini (Claude, etc.) quota buckets shown as separate rows
|
|
122
|
+
- **5-minute disk cache** for AGY quota — repeated runs complete in ~1s instead of ~20s
|
|
123
|
+
- Output size cap, typed `any` removal, env validation hardening
|
|
85
124
|
|
|
125
|
+
### v0.2.x
|
|
126
|
+
- AGY quota now scraped live from `agy /usage` panel via `expect` wrapper (zero token cost)
|
|
127
|
+
- Claude Code budget corrected to $20 rolling limit
|
|
128
|
+
- Replaced token-consuming `claude -p` calls with offline `ccusage` scraping
|
package/dist/adapters/agy.d.ts
CHANGED
package/dist/adapters/agy.js
CHANGED
|
@@ -1,108 +1,199 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
const CACHE_PATH = path.join(os.homedir(), '.gemini/antigravity-cli/.agent-fuel-quota-cache.json');
|
|
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
|
+
// ── Scraping ───────────────────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Spawns `agy` via `expect`, opens the `/usage` panel, waits for the
|
|
15
|
+
* Model Quota list to render, then exits.
|
|
16
|
+
*/
|
|
17
|
+
function runAgyUsage() {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const expectScript = [
|
|
20
|
+
'set timeout 20',
|
|
21
|
+
'spawn agy',
|
|
22
|
+
'expect -re "for shortcuts"',
|
|
23
|
+
'send "/usage\\r"',
|
|
24
|
+
'expect -re "Model Quota"',
|
|
25
|
+
'after 800',
|
|
26
|
+
'send "\\x03"',
|
|
27
|
+
'expect eof',
|
|
28
|
+
].join('\n');
|
|
29
|
+
// Cap output to avoid unbounded memory growth if agy misbehaves
|
|
30
|
+
const MAX_OUTPUT_BYTES = 64 * 1024; // 64 KB — far more than the quota panel needs
|
|
31
|
+
let output = '';
|
|
32
|
+
// Spread process.env so `expect` can locate `agy` via PATH and the
|
|
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();
|
|
25
41
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// ── Parsing ────────────────────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Parse the Model Quota panel into an array of entries.
|
|
52
|
+
*
|
|
53
|
+
* Panel format (after ANSI strip):
|
|
54
|
+
*
|
|
55
|
+
* └ Model Quota
|
|
56
|
+
*
|
|
57
|
+
* Gemini 3.5 Flash (High)
|
|
58
|
+
* ░░░░░░░░░░░ ... 20%
|
|
59
|
+
* Refreshes in 3h 28m ← or "80% remaining · Refreshes in …"
|
|
60
|
+
*
|
|
61
|
+
* Claude Sonnet 4.6 (Thinking)
|
|
62
|
+
* ███████████ ... 100%
|
|
63
|
+
* Quota available
|
|
64
|
+
*/
|
|
65
|
+
function parseQuotaPanel(raw) {
|
|
66
|
+
const clean = stripAnsi(raw);
|
|
67
|
+
const lines = clean.split(/\r?\n/);
|
|
68
|
+
const results = [];
|
|
69
|
+
const headerIdx = lines.findIndex(l => l.includes('Model Quota'));
|
|
70
|
+
if (headerIdx === -1)
|
|
71
|
+
return results;
|
|
72
|
+
const panelLines = lines.slice(headerIdx + 1);
|
|
73
|
+
let i = 0;
|
|
74
|
+
while (i < panelLines.length) {
|
|
75
|
+
const line = panelLines[i].trim();
|
|
76
|
+
const isModelName = line.length > 0 &&
|
|
77
|
+
!line.startsWith('░') && !line.startsWith('█') &&
|
|
78
|
+
!line.startsWith('↑') && !line.startsWith('(') &&
|
|
79
|
+
!line.startsWith('┘') && !line.startsWith('└') &&
|
|
80
|
+
!line.startsWith('?') && !line.startsWith('esc') &&
|
|
81
|
+
!/^\d+%/.test(line) &&
|
|
82
|
+
!line.includes('Refreshes') && !line.includes('Quota available') &&
|
|
83
|
+
!line.includes('──');
|
|
84
|
+
if (isModelName) {
|
|
85
|
+
let barLine = null;
|
|
86
|
+
let refreshLine = null;
|
|
87
|
+
let j = i + 1;
|
|
88
|
+
while (j < panelLines.length) {
|
|
89
|
+
const candidate = panelLines[j].trim();
|
|
90
|
+
if (candidate.length === 0) {
|
|
91
|
+
j++;
|
|
92
|
+
continue;
|
|
56
93
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// Support dynamic overrides using AGENT_FUEL_AGY_PERCENT environment variable
|
|
63
|
-
let remainingPercent = 100;
|
|
64
|
-
const isProModel = activeModel.toLowerCase().includes('pro');
|
|
65
|
-
const limit = isProModel ? 3 : 5; // Pro models have a tighter limit of 3, Flash has 5
|
|
66
|
-
const costPerPrompt = 100 / limit;
|
|
67
|
-
const calculatedPercent = Math.max(0, Math.round(100 - (todayPromptsCount * costPerPrompt)));
|
|
68
|
-
if (process.env.AGENT_FUEL_AGY_PERCENT) {
|
|
69
|
-
const envVal = Number(process.env.AGENT_FUEL_AGY_PERCENT);
|
|
70
|
-
remainingPercent = !isNaN(envVal) ? Math.max(0, Math.min(100, envVal)) : calculatedPercent;
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
remainingPercent = calculatedPercent;
|
|
74
|
-
}
|
|
75
|
-
// 4. Calculate rolling reset time (5 hours rolling or resets in 4h 37m from latest prompt, giving ~01:30 PM resets)
|
|
76
|
-
let resetAt = null;
|
|
77
|
-
if (latestPromptTimestamp) {
|
|
78
|
-
try {
|
|
79
|
-
const lastActivityDate = new Date(latestPromptTimestamp);
|
|
80
|
-
// Roll forward 5 hours (refreshes in ~4h 37m from active run)
|
|
81
|
-
const resetDate = new Date(lastActivityDate.getTime() + 5 * 60 * 60 * 1000);
|
|
82
|
-
resetAt = resetDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
94
|
+
// Progress bar line: has block chars OR starts with a digit%
|
|
95
|
+
if (barLine === null && (candidate.includes('░') || candidate.includes('█') || /^\d+%/.test(candidate))) {
|
|
96
|
+
barLine = candidate;
|
|
97
|
+
j++;
|
|
98
|
+
continue;
|
|
83
99
|
}
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
// Refresh / availability line
|
|
101
|
+
if (barLine !== null && (candidate.includes('Refreshes') || candidate.includes('Quota available'))) {
|
|
102
|
+
// Strip any leading "NN% remaining · " prefix
|
|
103
|
+
const m = candidate.match(/(Refreshes in [^\r\n]+|Quota available)/);
|
|
104
|
+
refreshLine = m ? m[1] : candidate;
|
|
105
|
+
j++;
|
|
86
106
|
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
if (barLine !== null) {
|
|
110
|
+
// Percentage is always at the END of the bar line: "░░░ ... 20%" or "20% remaining · …"
|
|
111
|
+
const percentMatch = barLine.match(/(\d+)%/);
|
|
112
|
+
if (percentMatch) {
|
|
113
|
+
results.push({ model: line, percent: parseInt(percentMatch[1], 10), refreshLine });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
i = j;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return results;
|
|
123
|
+
}
|
|
124
|
+
// ── Cache helpers ──────────────────────────────────────────────────────────
|
|
125
|
+
async function readCache() {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(await fs.readFile(CACHE_PATH, 'utf-8'));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function writeCache(entries) {
|
|
134
|
+
try {
|
|
135
|
+
await fs.writeFile(CACHE_PATH, JSON.stringify({ fetchedAt: Date.now(), entries }), 'utf-8');
|
|
136
|
+
}
|
|
137
|
+
catch { /* non-fatal */ }
|
|
138
|
+
}
|
|
139
|
+
// ── 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
|
+
function buildSnapshots(entries, fromCache) {
|
|
146
|
+
const source = fromCache ? 'cache' : 'official-cli';
|
|
147
|
+
const geminiEntries = entries.filter(e => /gemini/i.test(e.model));
|
|
148
|
+
const otherEntries = entries.filter(e => !/gemini/i.test(e.model));
|
|
149
|
+
function worstCase(bucket, tool) {
|
|
150
|
+
if (bucket.length === 0) {
|
|
151
|
+
return { tool, remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' };
|
|
152
|
+
}
|
|
153
|
+
// Show the lowest remaining % (most constrained model in the bucket)
|
|
154
|
+
const worst = bucket.reduce((a, b) => a.percent <= b.percent ? a : b);
|
|
155
|
+
return {
|
|
156
|
+
tool,
|
|
157
|
+
remainingPercent: worst.percent,
|
|
158
|
+
usedPercent: 100 - worst.percent,
|
|
159
|
+
resetAt: worst.refreshLine ?? null,
|
|
160
|
+
source,
|
|
161
|
+
raw: { matchedModel: worst.model, allModels: bucket.map(e => `${e.model}: ${e.percent}%`) },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return [
|
|
165
|
+
worstCase(geminiEntries, 'agy-gemini'),
|
|
166
|
+
worstCase(otherEntries, 'agy-other'),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
// ── Adapter ───────────────────────────────────────────────────────────────
|
|
170
|
+
export class AgyQuotaAdapter {
|
|
171
|
+
async fetchSnapshots() {
|
|
172
|
+
// Fast path: serve from cache if fresh enough
|
|
173
|
+
const cached = await readCache();
|
|
174
|
+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
175
|
+
return buildSnapshots(cached.entries, true);
|
|
176
|
+
}
|
|
177
|
+
// Slow path: spawn agy, scrape the quota panel
|
|
178
|
+
try {
|
|
179
|
+
const raw = await runAgyUsage();
|
|
180
|
+
const entries = parseQuotaPanel(raw);
|
|
181
|
+
if (entries.length > 0) {
|
|
182
|
+
await writeCache(entries);
|
|
183
|
+
return buildSnapshots(entries, false);
|
|
87
184
|
}
|
|
88
|
-
return
|
|
89
|
-
|
|
90
|
-
remainingPercent,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
source: 'local-state',
|
|
94
|
-
raw: { activeModel, todayPromptsCount }
|
|
95
|
-
};
|
|
185
|
+
// Panel not found — return unknown rows (do not cache failures)
|
|
186
|
+
return [
|
|
187
|
+
{ tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
|
|
188
|
+
{ tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
|
|
189
|
+
];
|
|
96
190
|
}
|
|
97
191
|
catch (error) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
remainingPercent: null,
|
|
101
|
-
usedPercent: null,
|
|
102
|
-
|
|
103
|
-
source: 'unknown',
|
|
104
|
-
raw: error instanceof Error ? error.message : String(error)
|
|
105
|
-
};
|
|
192
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
193
|
+
return [
|
|
194
|
+
{ tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown', raw: msg },
|
|
195
|
+
{ tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown', raw: msg },
|
|
196
|
+
];
|
|
106
197
|
}
|
|
107
198
|
}
|
|
108
199
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { QuotaAdapter, UsageSnapshot } from './index.js';
|
|
2
2
|
export declare class ClaudeQuotaAdapter implements QuotaAdapter {
|
|
3
|
-
private budgetLimit;
|
|
3
|
+
private readonly budgetLimit;
|
|
4
4
|
constructor();
|
|
5
|
-
|
|
5
|
+
fetchSnapshots(): Promise<UsageSnapshot[]>;
|
|
6
|
+
private _fetch;
|
|
6
7
|
}
|
package/dist/adapters/claude.js
CHANGED
|
@@ -1,48 +1,52 @@
|
|
|
1
1
|
import { exec } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const execAsync = promisify(exec);
|
|
4
|
+
// Budget limit for the rolling 5-hour billing window.
|
|
5
|
+
// Override with AGENT_FUEL_CLAUDE_BUDGET env var (dollars).
|
|
6
|
+
const DEFAULT_BUDGET_USD = 20.0;
|
|
4
7
|
export class ClaudeQuotaAdapter {
|
|
5
8
|
budgetLimit;
|
|
6
9
|
constructor() {
|
|
7
|
-
|
|
8
|
-
this.budgetLimit = Number(
|
|
10
|
+
const override = Number(process.env.AGENT_FUEL_CLAUDE_BUDGET);
|
|
11
|
+
this.budgetLimit = Number.isFinite(override) && override > 0 ? override : DEFAULT_BUDGET_USD;
|
|
9
12
|
}
|
|
10
|
-
async
|
|
13
|
+
async fetchSnapshots() {
|
|
14
|
+
return [await this._fetch()];
|
|
15
|
+
}
|
|
16
|
+
async _fetch() {
|
|
17
|
+
const unknown = () => ({
|
|
18
|
+
tool: 'claude-code',
|
|
19
|
+
remainingPercent: null,
|
|
20
|
+
usedPercent: null,
|
|
21
|
+
resetAt: null,
|
|
22
|
+
source: 'unknown',
|
|
23
|
+
});
|
|
11
24
|
try {
|
|
12
|
-
// Execute ccusage to get billing block information in JSON format
|
|
13
|
-
// We run npx --no-install first to see if it's already cached/available, otherwise fall back to regular npx
|
|
14
25
|
let stdout;
|
|
15
26
|
try {
|
|
16
|
-
|
|
17
|
-
stdout = result.stdout;
|
|
27
|
+
({ stdout } = await execAsync('npx --no-install ccusage blocks --json'));
|
|
18
28
|
}
|
|
19
29
|
catch {
|
|
20
|
-
throw new Error('ccusage
|
|
30
|
+
throw new Error('ccusage not found. Run "npm install -g ccusage" to enable Claude Code tracking.');
|
|
21
31
|
}
|
|
22
32
|
const data = JSON.parse(stdout);
|
|
23
|
-
const blocks =
|
|
24
|
-
if (!
|
|
25
|
-
throw new Error('
|
|
26
|
-
}
|
|
27
|
-
// Find the active billing block
|
|
28
|
-
const activeBlock = blocks.find((block) => block.isActive === true);
|
|
29
|
-
if (!activeBlock) {
|
|
30
|
-
return {
|
|
31
|
-
tool: 'claude-code',
|
|
32
|
-
remainingPercent: null,
|
|
33
|
-
usedPercent: null,
|
|
34
|
-
resetAt: null,
|
|
35
|
-
source: 'unknown'
|
|
36
|
-
};
|
|
33
|
+
const blocks = Array.isArray(data?.blocks) ? data.blocks : data;
|
|
34
|
+
if (!Array.isArray(blocks)) {
|
|
35
|
+
throw new Error('Unexpected JSON shape from ccusage blocks.');
|
|
37
36
|
}
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
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)));
|
|
41
43
|
let resetAt = null;
|
|
42
|
-
if (activeBlock.endTime) {
|
|
44
|
+
if (typeof activeBlock.endTime === 'string') {
|
|
43
45
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
resetAt = new Date(activeBlock.endTime).toLocaleTimeString([], {
|
|
47
|
+
hour: '2-digit',
|
|
48
|
+
minute: '2-digit',
|
|
49
|
+
});
|
|
46
50
|
}
|
|
47
51
|
catch {
|
|
48
52
|
resetAt = activeBlock.endTime;
|
|
@@ -51,21 +55,16 @@ export class ClaudeQuotaAdapter {
|
|
|
51
55
|
return {
|
|
52
56
|
tool: 'claude-code',
|
|
53
57
|
remainingPercent,
|
|
54
|
-
usedPercent: Math.round(
|
|
58
|
+
usedPercent: Math.round(usedPct),
|
|
55
59
|
resetAt,
|
|
56
60
|
source: 'ccusage',
|
|
57
|
-
raw: activeBlock
|
|
61
|
+
raw: activeBlock,
|
|
58
62
|
};
|
|
59
63
|
}
|
|
60
64
|
catch (error) {
|
|
61
|
-
// Fallback in case of execution errors
|
|
62
65
|
return {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
usedPercent: null,
|
|
66
|
-
resetAt: null,
|
|
67
|
-
source: 'unknown',
|
|
68
|
-
raw: error instanceof Error ? error.message : String(error)
|
|
66
|
+
...unknown(),
|
|
67
|
+
raw: error instanceof Error ? error.message : String(error),
|
|
69
68
|
};
|
|
70
69
|
}
|
|
71
70
|
}
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { QuotaAdapter, UsageSnapshot } from './index.js';
|
|
2
2
|
export declare class CodexQuotaAdapter implements QuotaAdapter {
|
|
3
|
-
private budgetLimit;
|
|
3
|
+
private readonly budgetLimit;
|
|
4
4
|
constructor();
|
|
5
|
-
|
|
5
|
+
fetchSnapshots(): Promise<UsageSnapshot[]>;
|
|
6
|
+
private _fetch;
|
|
6
7
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,105 +1,181 @@
|
|
|
1
|
-
import { exec } from 'node:child_process';
|
|
1
|
+
import { exec, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
const execAsync = promisify(exec);
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
// Used ONLY as a rough fallback estimate when the TUI scrape cannot determine
|
|
5
|
+
// a percentage (i.e. quota has not yet been reached). This is a GUESS based on
|
|
6
|
+
// local session cost data — not an official Codex quota signal.
|
|
7
|
+
// Override with AGENT_FUEL_CODEX_BUDGET env var (dollars).
|
|
8
|
+
const DEFAULT_BUDGET_USD = 20.0;
|
|
9
|
+
const ROLLING_WINDOW_MS = 5 * 60 * 60 * 1000;
|
|
10
|
+
// ── TUI scraper (expect) ───────────────────────────────────────────────────
|
|
11
|
+
/**
|
|
12
|
+
* Spawns `codex` via `expect`, handles the trust prompt, waits for the TUI to
|
|
13
|
+
* settle, then captures stdout/stderr to check for the quota-reached warning.
|
|
14
|
+
*/
|
|
15
|
+
function runCodexScrape() {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const expectScript = [
|
|
18
|
+
'set timeout 15',
|
|
19
|
+
'spawn codex',
|
|
20
|
+
'expect {',
|
|
21
|
+
' -re "Press enter to continue" { send "\\r"; exp_continue }',
|
|
22
|
+
' -re "Individual quota reached" { after 300; send "\\x03" }',
|
|
23
|
+
' -re "for shortcuts" { after 300; send "\\x03" }',
|
|
24
|
+
' timeout { }',
|
|
25
|
+
' eof { }',
|
|
26
|
+
'}',
|
|
27
|
+
'expect eof',
|
|
28
|
+
].join('\n');
|
|
29
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
30
|
+
let output = '';
|
|
31
|
+
const child = spawn('expect', ['-c', expectScript], {
|
|
32
|
+
env: { ...process.env },
|
|
33
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
34
|
+
});
|
|
35
|
+
const append = (chunk) => {
|
|
36
|
+
if (output.length < MAX_OUTPUT_BYTES)
|
|
37
|
+
output += chunk.toString();
|
|
38
|
+
};
|
|
39
|
+
child.stdout.on('data', append);
|
|
40
|
+
child.stderr.on('data', append);
|
|
41
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(output); }, 20_000);
|
|
42
|
+
child.on('close', () => { clearTimeout(timer); resolve(output); });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// ── Output parser ──────────────────────────────────────────────────────────
|
|
46
|
+
function stripAnsi(str) {
|
|
47
|
+
// eslint-disable-next-line no-control-regex
|
|
48
|
+
return str.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B[^[]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
49
|
+
}
|
|
50
|
+
function parseScrapeOutput(raw) {
|
|
51
|
+
const clean = stripAnsi(raw);
|
|
52
|
+
// "Individual quota reached. Contact your administrator to enable overages. Resets in 4h33m29s."
|
|
53
|
+
const quotaMatch = clean.match(/Individual quota reached/i);
|
|
54
|
+
if (!quotaMatch)
|
|
55
|
+
return { quotaReached: false, resetIn: null };
|
|
56
|
+
// Parse "Resets in 4h33m29s" → "4h 33m"
|
|
57
|
+
const resetMatch = clean.match(/Resets in\s*((?:\d+h)?(?:\d+m)?(?:\d+s)?)/i);
|
|
58
|
+
let resetIn = null;
|
|
59
|
+
if (resetMatch) {
|
|
60
|
+
const parts = [];
|
|
61
|
+
const hm = resetMatch[1].match(/^(\d+h)?(\d+m)?/);
|
|
62
|
+
if (hm) {
|
|
63
|
+
if (hm[1])
|
|
64
|
+
parts.push(hm[1]);
|
|
65
|
+
if (hm[2])
|
|
66
|
+
parts.push(hm[2]);
|
|
67
|
+
}
|
|
68
|
+
resetIn = parts.length > 0 ? parts.join(' ') : null;
|
|
10
69
|
}
|
|
11
|
-
|
|
70
|
+
return { quotaReached: true, resetIn };
|
|
71
|
+
}
|
|
72
|
+
// ── ccusage fallback estimate ──────────────────────────────────────────────
|
|
73
|
+
async function fetchCcusageEstimate(budgetLimit) {
|
|
74
|
+
const unknown = () => ({
|
|
75
|
+
tool: 'codex',
|
|
76
|
+
remainingPercent: null,
|
|
77
|
+
usedPercent: null,
|
|
78
|
+
resetAt: null,
|
|
79
|
+
source: 'unknown',
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
let stdout;
|
|
12
83
|
try {
|
|
13
|
-
|
|
14
|
-
|
|
84
|
+
({ stdout } = await execAsync('npx --no-install ccusage codex session --json'));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return unknown();
|
|
88
|
+
}
|
|
89
|
+
const data = JSON.parse(stdout);
|
|
90
|
+
const sessions = Array.isArray(data?.sessions) ? data.sessions :
|
|
91
|
+
Array.isArray(data?.session) ? data.session :
|
|
92
|
+
Array.isArray(data) ? data : [];
|
|
93
|
+
if (sessions.length === 0) {
|
|
94
|
+
return { tool: 'codex', remainingPercent: 100, usedPercent: 0, resetAt: null, source: 'ccusage' };
|
|
95
|
+
}
|
|
96
|
+
const todayStr = localDateString(new Date());
|
|
97
|
+
const todaySessions = sessions.filter((s) => {
|
|
98
|
+
if (typeof s.lastActivity !== 'string')
|
|
99
|
+
return false;
|
|
15
100
|
try {
|
|
16
|
-
|
|
17
|
-
stdout = result.stdout;
|
|
101
|
+
return localDateString(new Date(s.lastActivity)) === todayStr;
|
|
18
102
|
}
|
|
19
103
|
catch {
|
|
20
|
-
|
|
104
|
+
return false;
|
|
21
105
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
106
|
+
});
|
|
107
|
+
if (todaySessions.length === 0) {
|
|
108
|
+
return { tool: 'codex', remainingPercent: 100, usedPercent: 0, resetAt: null, source: 'ccusage' };
|
|
109
|
+
}
|
|
110
|
+
const totalCost = todaySessions.reduce((acc, s) => acc + (typeof s.costUSD === 'number' ? s.costUSD : 0), 0);
|
|
111
|
+
const usedPct = (totalCost / budgetLimit) * 100;
|
|
112
|
+
const rawRemaining = 100 - usedPct;
|
|
113
|
+
const remainingPercent = usedPct > 0 && rawRemaining > 99 ? 99
|
|
114
|
+
: Math.max(0, Math.min(100, Math.round(rawRemaining)));
|
|
115
|
+
const latestActivity = todaySessions
|
|
116
|
+
.map((s) => new Date(s.lastActivity).getTime())
|
|
117
|
+
.reduce((a, b) => (b > a ? b : a), 0);
|
|
118
|
+
let resetAt = null;
|
|
119
|
+
if (latestActivity > 0) {
|
|
120
|
+
try {
|
|
121
|
+
resetAt = new Date(latestActivity + ROLLING_WINDOW_MS).toLocaleTimeString([], {
|
|
122
|
+
hour: '2-digit', minute: '2-digit',
|
|
123
|
+
});
|
|
26
124
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
125
|
+
catch { /* leave null */ }
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
tool: 'codex',
|
|
129
|
+
remainingPercent,
|
|
130
|
+
usedPercent: Math.round(usedPct),
|
|
131
|
+
resetAt,
|
|
132
|
+
source: 'ccusage',
|
|
133
|
+
raw: { totalCost, todaySessionsCount: todaySessions.length, isEstimate: true },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return unknown();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ── Adapter ────────────────────────────────────────────────────────────────
|
|
141
|
+
export class CodexQuotaAdapter {
|
|
142
|
+
budgetLimit;
|
|
143
|
+
constructor() {
|
|
144
|
+
const override = Number(process.env.AGENT_FUEL_CODEX_BUDGET);
|
|
145
|
+
this.budgetLimit = Number.isFinite(override) && override > 0 ? override : DEFAULT_BUDGET_USD;
|
|
146
|
+
}
|
|
147
|
+
async fetchSnapshots() {
|
|
148
|
+
return [await this._fetch()];
|
|
149
|
+
}
|
|
150
|
+
async _fetch() {
|
|
151
|
+
// Primary: scrape the Codex TUI via expect
|
|
152
|
+
try {
|
|
153
|
+
const raw = await runCodexScrape();
|
|
154
|
+
const result = parseScrapeOutput(raw);
|
|
155
|
+
if (result.quotaReached) {
|
|
156
|
+
// Ground truth: quota is exhausted
|
|
157
|
+
const resetAt = result.resetIn ? `Resets in ${result.resetIn}` : null;
|
|
50
158
|
return {
|
|
51
159
|
tool: 'codex',
|
|
52
|
-
remainingPercent:
|
|
53
|
-
usedPercent:
|
|
54
|
-
resetAt
|
|
55
|
-
source: '
|
|
160
|
+
remainingPercent: 0,
|
|
161
|
+
usedPercent: 100,
|
|
162
|
+
resetAt,
|
|
163
|
+
source: 'official-cli',
|
|
56
164
|
};
|
|
57
165
|
}
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
// Calculate remaining percentage
|
|
62
|
-
let remainingPercent = 100 - usedPercent;
|
|
63
|
-
if (usedPercent > 0 && remainingPercent > 99) {
|
|
64
|
-
// Micro-interaction: if they burned any credits, show 99% instead of rounding to 100%
|
|
65
|
-
remainingPercent = 99;
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
68
|
-
remainingPercent = Math.max(0, Math.min(100, Math.round(remainingPercent)));
|
|
69
|
-
}
|
|
70
|
-
// Calculate rolling 5-hour reset time based on the most recent session's activity
|
|
71
|
-
let resetAt = null;
|
|
72
|
-
const sortedSessions = [...todaySessions].sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
|
|
73
|
-
const latestSession = sortedSessions[0];
|
|
74
|
-
if (latestSession && latestSession.lastActivity) {
|
|
75
|
-
try {
|
|
76
|
-
const lastActivityDate = new Date(latestSession.lastActivity);
|
|
77
|
-
// Roll forward 5 hours for the rolling limit window
|
|
78
|
-
const resetDate = new Date(lastActivityDate.getTime() + 5 * 60 * 60 * 1000);
|
|
79
|
-
resetAt = resetDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
resetAt = null;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return {
|
|
86
|
-
tool: 'codex',
|
|
87
|
-
remainingPercent,
|
|
88
|
-
usedPercent: Math.round(usedPercent),
|
|
89
|
-
resetAt,
|
|
90
|
-
source: 'ccusage',
|
|
91
|
-
raw: { totalCost, todaySessionsCount: todaySessions.length }
|
|
92
|
-
};
|
|
166
|
+
// TUI loaded cleanly with no quota warning → estimate remaining via ccusage
|
|
167
|
+
const estimate = await fetchCcusageEstimate(this.budgetLimit);
|
|
168
|
+
return estimate;
|
|
93
169
|
}
|
|
94
|
-
catch
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
remainingPercent: null,
|
|
98
|
-
usedPercent: null,
|
|
99
|
-
resetAt: null,
|
|
100
|
-
source: 'unknown',
|
|
101
|
-
raw: error instanceof Error ? error.message : String(error)
|
|
102
|
-
};
|
|
170
|
+
catch {
|
|
171
|
+
// expect not available or codex spawn failed → fall back to ccusage estimate
|
|
172
|
+
return fetchCcusageEstimate(this.budgetLimit);
|
|
103
173
|
}
|
|
104
174
|
}
|
|
105
175
|
}
|
|
176
|
+
function localDateString(date) {
|
|
177
|
+
const y = date.getFullYear();
|
|
178
|
+
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
179
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
180
|
+
return `${y}-${m}-${d}`;
|
|
181
|
+
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
export interface UsageSnapshot {
|
|
2
|
-
tool: 'codex' | 'claude-code' | 'agy';
|
|
2
|
+
tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other';
|
|
3
3
|
remainingPercent: number | null;
|
|
4
4
|
usedPercent?: number | null;
|
|
5
5
|
resetAt?: string | null;
|
|
6
|
-
source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'unknown';
|
|
6
|
+
source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'cache' | 'unknown';
|
|
7
7
|
raw?: unknown;
|
|
8
8
|
}
|
|
9
9
|
export interface QuotaAdapter {
|
|
10
|
-
|
|
10
|
+
/** Returns one or more snapshots (adapters that produce multiple rows return an array). */
|
|
11
|
+
fetchSnapshots(): Promise<UsageSnapshot[]>;
|
|
11
12
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,28 +2,54 @@
|
|
|
2
2
|
import { ClaudeQuotaAdapter } from './adapters/claude.js';
|
|
3
3
|
import { CodexQuotaAdapter } from './adapters/codex.js';
|
|
4
4
|
import { AgyQuotaAdapter } from './adapters/agy.js';
|
|
5
|
-
import {
|
|
5
|
+
import { printHeader, printFooter, formatRow, getDisplayName, LOADING_LINE } from './render.js';
|
|
6
|
+
// Fixed display order — never changes regardless of which adapter resolves first
|
|
7
|
+
const SLOT_ORDER = ['claude-code', 'codex', 'agy-gemini', 'agy-other'];
|
|
8
|
+
const BOLD = '\x1b[1m';
|
|
9
|
+
const R = '\x1b[0m';
|
|
10
|
+
const N = SLOT_ORDER.length;
|
|
11
|
+
// Redraws all N slot lines from the current cursor position (cursor must be
|
|
12
|
+
// just below the last slot line when called).
|
|
13
|
+
function redraw(slots) {
|
|
14
|
+
process.stdout.write(`\x1b[${N}A`); // cursor up N lines
|
|
15
|
+
for (const tool of SLOT_ORDER) {
|
|
16
|
+
process.stdout.write('\x1b[2K\r'); // clear line
|
|
17
|
+
const line = slots.get(tool);
|
|
18
|
+
if (line != null) {
|
|
19
|
+
process.stdout.write(line + '\n');
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
process.stdout.write(`${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${LOADING_LINE}\n`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
6
26
|
async function main() {
|
|
7
27
|
const claudeAdapter = new ClaudeQuotaAdapter();
|
|
8
28
|
const codexAdapter = new CodexQuotaAdapter();
|
|
9
29
|
const agyAdapter = new AgyQuotaAdapter();
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
agyAdapter.fetchSnapshot()
|
|
16
|
-
]);
|
|
17
|
-
// Render the beautiful 3-bar ASCII progress dashboard
|
|
18
|
-
renderDashboard([
|
|
19
|
-
codexSnap,
|
|
20
|
-
claudeSnap,
|
|
21
|
-
agySnap
|
|
22
|
-
]);
|
|
30
|
+
printHeader();
|
|
31
|
+
// Print placeholder rows in fixed order
|
|
32
|
+
const slots = new Map(SLOT_ORDER.map(t => [t, null]));
|
|
33
|
+
for (const tool of SLOT_ORDER) {
|
|
34
|
+
process.stdout.write(`${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${LOADING_LINE}\n`);
|
|
23
35
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
// Each adapter fills its slot(s) and triggers a redraw; order is always fixed
|
|
37
|
+
function fill(snaps) {
|
|
38
|
+
for (const snap of snaps) {
|
|
39
|
+
if (SLOT_ORDER.includes(snap.tool)) {
|
|
40
|
+
slots.set(snap.tool, formatRow(snap));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
redraw(slots);
|
|
27
44
|
}
|
|
45
|
+
await Promise.allSettled([
|
|
46
|
+
claudeAdapter.fetchSnapshots().then(fill),
|
|
47
|
+
codexAdapter.fetchSnapshots().then(fill),
|
|
48
|
+
agyAdapter.fetchSnapshots().then(fill),
|
|
49
|
+
]);
|
|
50
|
+
printFooter();
|
|
28
51
|
}
|
|
29
|
-
main()
|
|
52
|
+
main().catch((error) => {
|
|
53
|
+
console.error('\x1b[31mFatal error orchestrating Agent Fuel CLI:\x1b[0m', error);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
package/dist/render.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
import { UsageSnapshot } from './adapters/index.js';
|
|
2
|
+
export declare function getDisplayName(tool: string): string;
|
|
3
|
+
export declare function formatRow(snap: UsageSnapshot): string;
|
|
4
|
+
export declare function printHeader(): void;
|
|
5
|
+
export declare function printRow(snap: UsageSnapshot): void;
|
|
6
|
+
export declare function printFooter(): void;
|
|
7
|
+
export declare const LOADING_LINE = "\u001B[2m\u001B[90mloading...\u001B[0m";
|
|
8
|
+
/** Convenience wrapper — renders a full static dashboard in one call. */
|
|
2
9
|
export declare function renderDashboard(snapshots: UsageSnapshot[]): void;
|
package/dist/render.js
CHANGED
|
@@ -1,61 +1,113 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// Color scheme based on remaining percentage
|
|
26
|
-
let color = green;
|
|
27
|
-
if (remaining < 20) {
|
|
28
|
-
color = red;
|
|
29
|
-
}
|
|
30
|
-
else if (remaining < 50) {
|
|
31
|
-
color = yellow;
|
|
32
|
-
}
|
|
33
|
-
const blockChar = '█';
|
|
34
|
-
const shadeChar = '░';
|
|
35
|
-
barStr = `${color}${blockChar.repeat(filled)}${reset}${gray}${shadeChar.repeat(empty)}${reset}`;
|
|
36
|
-
percentStr = `${bold}${color}${remaining.toString().padStart(3)}% remaining${reset}`;
|
|
37
|
-
}
|
|
38
|
-
// Add metadata/reset times if available
|
|
39
|
-
let detailStr = '';
|
|
40
|
-
if (snap.resetAt) {
|
|
41
|
-
detailStr = ` ${dim}${gray}(resets ${snap.resetAt})${reset}`;
|
|
42
|
-
}
|
|
43
|
-
if (snap.tool === 'agy' && snap.raw && typeof snap.raw === 'object' && 'activeModel' in snap.raw) {
|
|
44
|
-
detailStr += ` ${dim}${gray}[${snap.raw.activeModel}]${reset}`;
|
|
45
|
-
}
|
|
46
|
-
console.log(`${bold}${displayName.padEnd(12)}${reset} [${barStr}] ${percentStr}${detailStr}`);
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
5
|
+
const BLOCK_CHAR = '█';
|
|
6
|
+
const SHADE_CHAR = '░';
|
|
7
|
+
const BAR_WIDTH = 30;
|
|
8
|
+
// ── ANSI colour helpers ────────────────────────────────────────────────────
|
|
9
|
+
const R = '\x1b[0m';
|
|
10
|
+
const BOLD = '\x1b[1m';
|
|
11
|
+
const DIM = '\x1b[2m';
|
|
12
|
+
const CYAN = '\x1b[36m';
|
|
13
|
+
const GREEN = '\x1b[32m';
|
|
14
|
+
const YELLOW = '\x1b[33m';
|
|
15
|
+
const RED = '\x1b[31m';
|
|
16
|
+
const GRAY = '\x1b[90m';
|
|
17
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
18
|
+
export function getDisplayName(tool) {
|
|
19
|
+
switch (tool) {
|
|
20
|
+
case 'codex': return 'Codex';
|
|
21
|
+
case 'claude-code': return 'Claude Code';
|
|
22
|
+
case 'agy-gemini': return 'AGY Gemini';
|
|
23
|
+
case 'agy-other': return 'AGY Other';
|
|
24
|
+
default: return tool;
|
|
47
25
|
}
|
|
48
|
-
console.log('');
|
|
49
26
|
}
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
27
|
+
function pickColour(remaining) {
|
|
28
|
+
if (remaining < 20)
|
|
29
|
+
return RED;
|
|
30
|
+
if (remaining < 50)
|
|
31
|
+
return YELLOW;
|
|
32
|
+
return GREEN;
|
|
33
|
+
}
|
|
34
|
+
function loadVersion() {
|
|
35
|
+
try {
|
|
36
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, '../package.json'), 'utf8'));
|
|
38
|
+
return ` v${pkg.version}`;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function formatResetAt(resetAt) {
|
|
45
|
+
if (resetAt.toLowerCase().includes('available')) {
|
|
46
|
+
return `${DIM}${GREEN}✓ quota available${R}`;
|
|
47
|
+
}
|
|
48
|
+
const codexMatch = resetAt.match(/^Resets in\s*(.+)/i);
|
|
49
|
+
if (codexMatch) {
|
|
50
|
+
return `${DIM}${GRAY}(resets in ${codexMatch[1]})${R}`;
|
|
51
|
+
}
|
|
52
|
+
const agyMatch = resetAt.match(/^Refreshes in\s*(.+)/i);
|
|
53
|
+
if (agyMatch) {
|
|
54
|
+
return `${DIM}${GRAY}(resets in ${agyMatch[1]})${R}`;
|
|
60
55
|
}
|
|
56
|
+
return `${DIM}${GRAY}(resets ${resetAt})${R}`;
|
|
57
|
+
}
|
|
58
|
+
function isEstimate(snap) {
|
|
59
|
+
return snap.source === 'ccusage' &&
|
|
60
|
+
typeof snap.raw === 'object' && snap.raw !== null &&
|
|
61
|
+
snap.raw.isEstimate === true;
|
|
62
|
+
}
|
|
63
|
+
// ── Core format (returns string, no newline) ───────────────────────────────
|
|
64
|
+
export function formatRow(snap) {
|
|
65
|
+
const displayName = getDisplayName(snap.tool);
|
|
66
|
+
const remaining = snap.remainingPercent;
|
|
67
|
+
let barStr;
|
|
68
|
+
let percentStr;
|
|
69
|
+
if (remaining === null) {
|
|
70
|
+
barStr = `${GRAY}${SHADE_CHAR.repeat(BAR_WIDTH)}${R}`;
|
|
71
|
+
percentStr = `${GRAY}unknown${R}`;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const colour = pickColour(remaining);
|
|
75
|
+
const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((remaining * BAR_WIDTH) / 100)));
|
|
76
|
+
const empty = BAR_WIDTH - filled;
|
|
77
|
+
barStr = `${colour}${BLOCK_CHAR.repeat(filled)}${R}${GRAY}${SHADE_CHAR.repeat(empty)}${R}`;
|
|
78
|
+
percentStr = `${BOLD}${colour}${remaining.toString().padStart(3)}% remaining${R}`;
|
|
79
|
+
}
|
|
80
|
+
const parts = [];
|
|
81
|
+
if (snap.resetAt)
|
|
82
|
+
parts.push(formatResetAt(snap.resetAt));
|
|
83
|
+
if ((snap.tool === 'agy-gemini' || snap.tool === 'agy-other') &&
|
|
84
|
+
snap.raw && typeof snap.raw === 'object') {
|
|
85
|
+
const label = snap.raw.matchedModel;
|
|
86
|
+
if (typeof label === 'string' && label) {
|
|
87
|
+
parts.push(`${DIM}${GRAY}[${label}]${R}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (snap.tool === 'codex' && isEstimate(snap)) {
|
|
91
|
+
parts.push(`${DIM}${GRAY}[~est]${R}`);
|
|
92
|
+
}
|
|
93
|
+
const detailStr = parts.length > 0 ? ` ${parts.join(' ')}` : '';
|
|
94
|
+
return `${BOLD}${displayName.padEnd(13)}${R} [${barStr}] ${percentStr}${detailStr}`;
|
|
95
|
+
}
|
|
96
|
+
// ── Public render functions ────────────────────────────────────────────────
|
|
97
|
+
export function printHeader() {
|
|
98
|
+
console.log(`\n${BOLD}${CYAN}⚡️ Agent Fuel - CLI Quota Monitor${R}\n`);
|
|
99
|
+
}
|
|
100
|
+
export function printRow(snap) {
|
|
101
|
+
process.stdout.write(formatRow(snap) + '\n');
|
|
102
|
+
}
|
|
103
|
+
export function printFooter() {
|
|
104
|
+
console.log(`\n${DIM}${GRAY}agent-fuel${loadVersion()}${R}\n`);
|
|
105
|
+
}
|
|
106
|
+
export const LOADING_LINE = `${DIM}${GRAY}loading...${R}`;
|
|
107
|
+
/** Convenience wrapper — renders a full static dashboard in one call. */
|
|
108
|
+
export function renderDashboard(snapshots) {
|
|
109
|
+
printHeader();
|
|
110
|
+
for (const snap of snapshots)
|
|
111
|
+
printRow(snap);
|
|
112
|
+
printFooter();
|
|
61
113
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-fuel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Sleek term-based dashboard for AI coding CLI quotas",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,4 +31,4 @@
|
|
|
31
31
|
"@types/node": "^20.11.24",
|
|
32
32
|
"typescript": "^5.3.3"
|
|
33
33
|
}
|
|
34
|
-
}
|
|
34
|
+
}
|