agent-fuel 0.2.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/README.md CHANGED
@@ -6,20 +6,20 @@ A sleek, unified CLI dashboard to monitor your AI coding assistant quotas, credi
6
6
 
7
7
  ## 🚀 Installation & Running
8
8
 
9
- Install **Agent Fuel** globally on your system in one step:
9
+ Install **Agent Fuel** globally:
10
10
 
11
11
  ```bash
12
12
  npm install -g agent-fuel
13
13
  ```
14
14
 
15
- Once installed, you can run the dashboard at any time from **any directory** on your machine by simply typing:
15
+ Then run from any directory:
16
16
 
17
17
  ```bash
18
18
  agent-fuel
19
19
  ```
20
20
 
21
21
  ### Development Setup
22
- If you want to run or contribute to `agent-fuel` locally:
22
+
23
23
  ```bash
24
24
  git clone https://github.com/jperod/agent-fuel.git
25
25
  cd agent-fuel
@@ -32,63 +32,97 @@ npm link
32
32
 
33
33
  ## 💡 The Motivation
34
34
 
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. Whether it is a daily dollar limit, token ceilings, or monthly credits, developers are forced to jump through interactive prompts or scrape configuration screens just to answer a simple question:
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:
36
36
 
37
37
  > **"How much agent fuel do I have left before starting this massive refactor?"**
38
38
 
39
- Because each CLI exposes quota information differently (some via strict JSON, others through human-readable prompts, and some in local state files), there is no centralized way to monitor your resource consumption.
40
-
41
- **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**.
42
40
 
43
41
  ---
44
42
 
45
- ## 🎯 The Idea
43
+ ## 🎯 How It Works
44
+
45
+ `agent-fuel` is a tiny modern CLI built with TypeScript that:
46
46
 
47
- `agent-fuel` is a tiny, modern local CLI built with TypeScript that:
48
- 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.
49
- 2. **Normalizes Quota Models**: Standardizes diverse limits into a uniform percentage score (`0` to `100%`).
50
- 3. **Renders an Elegant CLI Dashboard**: Displays a high-fidelity 3-bar ASCII progress dashboard directly in your terminal.
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.
51
52
 
52
53
  ### Project Architecture
53
54
 
54
55
  ```text
55
56
  agent-fuel/
56
57
  ├── src/
57
- │ ├── index.ts # CLI entry point
58
- │ ├── render.ts # Beautiful 3-bar dashboard renderer
58
+ │ ├── index.ts # CLI entry point — runs all adapters concurrently
59
+ │ ├── render.ts # Colour-coded bar dashboard renderer
59
60
  │ └── adapters/
60
- │ ├── claude.ts # Adapter for Claude Code (ccusage blocks)
61
- │ ├── codex.ts # Adapter for Codex (ccusage codex session)
62
- └── agy.ts # Adapter for AGY (Antigravity history & model config parser)
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
63
65
  ├── package.json
64
66
  └── README.md
65
67
  ```
66
68
 
67
- ### High-Fidelity API Type Shape
69
+ ### Type Shape
68
70
 
69
71
  ```typescript
70
72
  type UsageSnapshot = {
71
- tool: 'codex' | 'claude-code' | 'agy';
72
- remainingPercent: number | null; // Unified 0-100 scale
73
+ tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other';
74
+ remainingPercent: number | null; // Unified 0100 scale
73
75
  usedPercent?: number | null;
74
76
  resetAt?: string | null;
75
- source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'unknown';
77
+ source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'cache' | 'unknown';
76
78
  raw?: unknown;
77
79
  };
78
80
  ```
79
81
 
80
82
  ---
81
83
 
82
- ## 📊 Terminal Dashboard Preview
84
+ ## 📊 Terminal Dashboard
83
85
 
84
- Running `agent-fuel` will immediately output a clean, colored visual summary of your current agent capacity:
85
-
86
- ```text
86
+ ```
87
87
  ⚡️ Agent Fuel - CLI Quota Monitor
88
88
 
89
- Codex [██████████████████████████████] 99% remaining (resets 01:49 PM)
90
- Claude Code [█████████████████████████░░░░░] 83% remaining (resets 01:00 PM)
91
- AGY [██████████████████░░░░░░░░░░░░] 60% remaining (resets 01:57 PM) [Gemini 3.5 Flash (High)]
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
92
95
  ```
93
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
94
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
@@ -1,6 +1,4 @@
1
1
  import { QuotaAdapter, UsageSnapshot } from './index.js';
2
2
  export declare class AgyQuotaAdapter implements QuotaAdapter {
3
- private configDir;
4
- constructor();
5
- fetchSnapshot(): Promise<UsageSnapshot>;
3
+ fetchSnapshots(): Promise<UsageSnapshot[]>;
6
4
  }
@@ -1,104 +1,171 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- export class AgyQuotaAdapter {
5
- configDir;
6
- constructor() {
7
- // Default to the standard AGY CLI config directory
8
- this.configDir = path.join(os.homedir(), '.gemini/antigravity-cli');
4
+ import { TuiScraper } from '../tmux.js';
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
+ // ── Scraping ───────────────────────────────────────────────────────────────
8
+ async function sleep(ms) {
9
+ return new Promise(res => setTimeout(res, ms));
10
+ }
11
+ /**
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.
14
+ */
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();
9
27
  }
10
- async fetchSnapshot() {
11
- try {
12
- const settingsPath = path.join(this.configDir, 'settings.json');
13
- const historyPath = path.join(this.configDir, 'history.jsonl');
14
- let activeModel = 'Gemini 3.5 Flash';
15
- // 1. Read active model from settings.json if it exists
16
- try {
17
- const settingsContent = await fs.readFile(settingsPath, 'utf-8');
18
- const settings = JSON.parse(settingsContent);
19
- if (settings && settings.model) {
20
- activeModel = settings.model;
28
+ finally {
29
+ tui.kill();
30
+ }
31
+ }
32
+ // ── Parsing ────────────────────────────────────────────────────────────────
33
+ /**
34
+ * Parse the Model Quota panel into an array of entries.
35
+ *
36
+ * Panel format (tmux rendered — no ANSI codes):
37
+ *
38
+ * └ Model Quota
39
+ *
40
+ * Gemini 3.5 Flash (High)
41
+ * ░░░░░░░░░░░ ... 20%
42
+ * Refreshes in 3h 28m
43
+ *
44
+ * Claude Sonnet 4.6 (Thinking)
45
+ * ███████████ ... 100%
46
+ * Quota available
47
+ */
48
+ function parseQuotaPanel(raw) {
49
+ // tmux capture-pane returns clean rendered text — no ANSI stripping needed
50
+ const lines = raw.split(/\r?\n/);
51
+ const results = [];
52
+ const headerIdx = lines.findIndex(l => l.includes('Model Quota'));
53
+ if (headerIdx === -1)
54
+ return results;
55
+ const panelLines = lines.slice(headerIdx + 1);
56
+ let i = 0;
57
+ while (i < panelLines.length) {
58
+ const line = panelLines[i].trim();
59
+ const isModelName = line.length > 0 &&
60
+ !line.startsWith('░') && !line.startsWith('█') &&
61
+ !line.startsWith('↑') && !line.startsWith('(') &&
62
+ !line.startsWith('┘') && !line.startsWith('└') &&
63
+ !line.startsWith('?') && !line.startsWith('esc') &&
64
+ !/^\d+%/.test(line) &&
65
+ !line.includes('Refreshes') && !line.includes('Quota available') &&
66
+ !line.includes('──');
67
+ if (isModelName) {
68
+ let barLine = null;
69
+ let refreshLine = null;
70
+ let j = i + 1;
71
+ while (j < panelLines.length) {
72
+ const candidate = panelLines[j].trim();
73
+ if (candidate.length === 0) {
74
+ j++;
75
+ continue;
21
76
  }
22
- }
23
- catch {
24
- // Fallback to default model name if reading or parsing settings failed
25
- }
26
- // 2. Read history.jsonl to detect active prompts within the rolling 5-hour window
27
- let todayPromptsCount = 0;
28
- let latestPromptTimestamp = null;
29
- const fiveHoursAgo = Date.now() - 5 * 60 * 60 * 1000;
30
- try {
31
- const historyContent = await fs.readFile(historyPath, 'utf-8');
32
- const historyLines = historyContent.trim().split('\n');
33
- for (const line of historyLines) {
34
- if (!line.trim())
35
- continue;
36
- const entry = JSON.parse(line);
37
- if (entry && entry.timestamp) {
38
- // Check if the prompt falls within the 5-hour rolling window
39
- if (entry.timestamp >= fiveHoursAgo) {
40
- todayPromptsCount++;
41
- if (!latestPromptTimestamp || entry.timestamp > latestPromptTimestamp) {
42
- latestPromptTimestamp = entry.timestamp;
43
- }
44
- }
45
- }
77
+ if (barLine === null && (candidate.includes('░') || candidate.includes('█') || /^\d+%/.test(candidate))) {
78
+ barLine = candidate;
79
+ j++;
80
+ continue;
46
81
  }
47
- }
48
- catch {
49
- // Fallback if reading or parsing history failed (e.g. file doesn't exist)
50
- }
51
- // 3. Calculate remaining percent based on active usage and model tier
52
- // Support dynamic overrides using AGENT_FUEL_AGY_PERCENT environment variable
53
- let remainingPercent = 100;
54
- const isProModel = activeModel.toLowerCase().includes('pro');
55
- let calculatedPercent = 100;
56
- if (isProModel) {
57
- // Pro models: limit is 10 prompts, steps of 2 prompts (each step is 20%)
58
- calculatedPercent = Math.max(0, 100 - (Math.floor(todayPromptsCount / 2) * 20));
59
- }
60
- else {
61
- // Flash models: limit is 25 prompts, steps of 5 prompts (each step is 20%)
62
- calculatedPercent = Math.max(0, 100 - (Math.floor(todayPromptsCount / 5) * 20));
63
- }
64
- if (process.env.AGENT_FUEL_AGY_PERCENT) {
65
- const envVal = Number(process.env.AGENT_FUEL_AGY_PERCENT);
66
- remainingPercent = !isNaN(envVal) ? Math.max(0, Math.min(100, envVal)) : calculatedPercent;
67
- }
68
- else {
69
- remainingPercent = calculatedPercent;
70
- }
71
- // 4. Calculate rolling reset time (5 hours rolling or resets in 4h 37m from latest prompt, giving ~01:30 PM resets)
72
- let resetAt = null;
73
- if (latestPromptTimestamp) {
74
- try {
75
- const lastActivityDate = new Date(latestPromptTimestamp);
76
- // Roll forward 5 hours (refreshes in ~4h 37m from active run)
77
- const resetDate = new Date(lastActivityDate.getTime() + 5 * 60 * 60 * 1000);
78
- resetAt = resetDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
82
+ if (barLine !== null && (candidate.includes('Refreshes') || candidate.includes('Quota available'))) {
83
+ const m = candidate.match(/(Refreshes in [^\r\n]+|Quota available)/);
84
+ refreshLine = m ? m[1] : candidate;
85
+ j++;
79
86
  }
80
- catch {
81
- resetAt = null;
87
+ break;
88
+ }
89
+ if (barLine !== null) {
90
+ const percentMatch = barLine.match(/(\d+)%/);
91
+ if (percentMatch) {
92
+ results.push({ model: line, percent: parseInt(percentMatch[1], 10), refreshLine });
82
93
  }
83
94
  }
84
- return {
85
- tool: 'agy',
86
- remainingPercent,
87
- usedPercent: 100 - remainingPercent,
88
- resetAt,
89
- source: 'local-state',
90
- raw: { activeModel, todayPromptsCount }
91
- };
95
+ i = j;
96
+ }
97
+ else {
98
+ i++;
99
+ }
100
+ }
101
+ return results;
102
+ }
103
+ // ── Cache helpers ──────────────────────────────────────────────────────────
104
+ async function readCache() {
105
+ try {
106
+ return JSON.parse(await fs.readFile(CACHE_PATH, 'utf-8'));
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ async function writeCache(entries) {
113
+ try {
114
+ await fs.writeFile(CACHE_PATH, JSON.stringify({ fetchedAt: Date.now(), entries }), 'utf-8');
115
+ }
116
+ catch { /* non-fatal */ }
117
+ }
118
+ // ── Bucket aggregation ────────────────────────────────────────────────────
119
+ function buildSnapshots(entries, fromCache) {
120
+ const source = fromCache ? 'cache' : 'official-cli';
121
+ const geminiEntries = entries.filter(e => /gemini/i.test(e.model));
122
+ const otherEntries = entries.filter(e => !/gemini/i.test(e.model));
123
+ function worstCase(bucket, tool) {
124
+ if (bucket.length === 0) {
125
+ return { tool, remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' };
126
+ }
127
+ const worst = bucket.reduce((a, b) => a.percent <= b.percent ? a : b);
128
+ return {
129
+ tool,
130
+ remainingPercent: worst.percent,
131
+ usedPercent: 100 - worst.percent,
132
+ resetAt: worst.refreshLine ?? null,
133
+ source,
134
+ raw: { matchedModel: worst.model, allModels: bucket.map(e => `${e.model}: ${e.percent}%`) },
135
+ };
136
+ }
137
+ return [
138
+ worstCase(geminiEntries, 'agy-gemini'),
139
+ worstCase(otherEntries, 'agy-other'),
140
+ ];
141
+ }
142
+ // ── Adapter ───────────────────────────────────────────────────────────────
143
+ export class AgyQuotaAdapter {
144
+ async fetchSnapshots() {
145
+ // Fast path: serve from cache if fresh enough
146
+ const cached = await readCache();
147
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
148
+ return buildSnapshots(cached.entries, true);
149
+ }
150
+ // Slow path: spawn agy via tmux, scrape the quota panel
151
+ try {
152
+ const raw = await runAgyUsage();
153
+ const entries = parseQuotaPanel(raw);
154
+ if (entries.length > 0) {
155
+ await writeCache(entries);
156
+ return buildSnapshots(entries, false);
157
+ }
158
+ return [
159
+ { tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
160
+ { tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
161
+ ];
92
162
  }
93
163
  catch (error) {
94
- return {
95
- tool: 'agy',
96
- remainingPercent: null,
97
- usedPercent: null,
98
- resetAt: null,
99
- source: 'unknown',
100
- raw: error instanceof Error ? error.message : String(error)
101
- };
164
+ const msg = error instanceof Error ? error.message : String(error);
165
+ return [
166
+ { tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown', raw: msg },
167
+ { tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown', raw: msg },
168
+ ];
102
169
  }
103
170
  }
104
171
  }
@@ -1,6 +1,5 @@
1
1
  import { QuotaAdapter, UsageSnapshot } from './index.js';
2
2
  export declare class ClaudeQuotaAdapter implements QuotaAdapter {
3
- private budgetLimit;
4
- constructor();
5
- fetchSnapshot(): Promise<UsageSnapshot>;
3
+ fetchSnapshots(): Promise<UsageSnapshot[]>;
4
+ private _fetch;
6
5
  }
@@ -1,72 +1,87 @@
1
- import { exec } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- const execAsync = promisify(exec);
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();
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 ────────────────────────────────────────────────────────────────
4
52
  export class ClaudeQuotaAdapter {
5
- budgetLimit;
6
- constructor() {
7
- // Default to $10.00 for the rolling 5-hour window, allow env override
8
- this.budgetLimit = Number(process.env.AGENT_FUEL_CLAUDE_BUDGET) || 10.0;
53
+ async fetchSnapshots() {
54
+ return [await this._fetch()];
9
55
  }
10
- async fetchSnapshot() {
56
+ async _fetch() {
57
+ const unknown = () => ({
58
+ tool: 'claude-code',
59
+ remainingPercent: null,
60
+ usedPercent: null,
61
+ resetAt: null,
62
+ source: 'unknown',
63
+ });
64
+ debug('claude:fetch', 'starting TUI scrape via tmux');
11
65
  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
- let stdout;
15
- try {
16
- const result = await execAsync('npx --no-install ccusage blocks --json');
17
- stdout = result.stdout;
18
- }
19
- catch {
20
- throw new Error('ccusage package is not installed or available locally. Please run "npm install -g ccusage" to use this tool.');
21
- }
22
- const data = JSON.parse(stdout);
23
- const blocks = data && Array.isArray(data.blocks) ? data.blocks : data;
24
- if (!blocks || !Array.isArray(blocks)) {
25
- throw new Error('Invalid JSON format returned from ccusage blocks');
26
- }
27
- // Find the active billing block
28
- const activeBlock = blocks.find((block) => block.isActive === true);
29
- if (!activeBlock) {
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)`);
30
71
  return {
31
72
  tool: 'claude-code',
32
- remainingPercent: null,
33
- usedPercent: null,
34
- resetAt: null,
35
- source: 'unknown'
73
+ remainingPercent,
74
+ usedPercent: result.sessionUsedPct,
75
+ resetAt: result.sessionResetAt,
76
+ source: 'official-cli',
36
77
  };
37
78
  }
38
- const cost = activeBlock.costUSD || 0.0;
39
- const usedPercent = (cost / this.budgetLimit) * 100;
40
- const remainingPercent = Math.max(0, Math.min(100, Math.round(100 - usedPercent)));
41
- let resetAt = null;
42
- if (activeBlock.endTime) {
43
- try {
44
- const endDate = new Date(activeBlock.endTime);
45
- resetAt = endDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
46
- }
47
- catch {
48
- resetAt = activeBlock.endTime;
49
- }
50
- }
51
- return {
52
- tool: 'claude-code',
53
- remainingPercent,
54
- usedPercent: Math.round(usedPercent),
55
- resetAt,
56
- source: 'ccusage',
57
- raw: activeBlock
58
- };
79
+ debug('claude:fetch', 'parse failed unknown');
80
+ return unknown();
59
81
  }
60
- catch (error) {
61
- // Fallback in case of execution errors
62
- return {
63
- tool: 'claude-code',
64
- remainingPercent: null,
65
- usedPercent: null,
66
- resetAt: null,
67
- source: 'unknown',
68
- raw: error instanceof Error ? error.message : String(error)
69
- };
82
+ catch (err) {
83
+ debug('claude:fetch', 'caught error', String(err));
84
+ return unknown();
70
85
  }
71
86
  }
72
87
  }
@@ -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
- fetchSnapshot(): Promise<UsageSnapshot>;
5
+ fetchSnapshots(): Promise<UsageSnapshot[]>;
6
+ private _fetch;
6
7
  }