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.
@@ -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 { spawn } from 'node:child_process';
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
- * Spawns `agy` via `expect`, opens the `/usage` panel, waits for the
15
- * Model Quota list to render, then exits.
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
- 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();
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 (after ANSI strip):
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 ← or "80% remaining · Refreshes in …"
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
- const clean = stripAnsi(raw);
67
- const lines = clean.split(/\r?\n/);
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' },
@@ -1,7 +1,5 @@
1
1
  import { QuotaAdapter, UsageSnapshot } from './index.js';
2
2
  export declare class ClaudeQuotaAdapter implements QuotaAdapter {
3
- private readonly budgetLimit;
4
- constructor();
5
3
  fetchSnapshots(): Promise<UsageSnapshot[]>;
6
4
  private _fetch;
7
5
  }
@@ -1,15 +1,70 @@
1
- import { exec } from 'node:child_process';
2
- import { promisify } from 'node:util';
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;
7
- export class ClaudeQuotaAdapter {
8
- budgetLimit;
9
- constructor() {
10
- const override = Number(process.env.AGENT_FUEL_CLAUDE_BUDGET);
11
- this.budgetLimit = Number.isFinite(override) && override > 0 ? override : DEFAULT_BUDGET_USD;
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
- let stdout;
26
- try {
27
- ({ stdout } = await execAsync('npx --no-install ccusage blocks --json'));
28
- }
29
- catch {
30
- throw new Error('ccusage not found. Run "npm install -g ccusage" to enable Claude Code tracking.');
31
- }
32
- const data = JSON.parse(stdout);
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.');
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
- return {
56
- tool: 'claude-code',
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 (error) {
65
- return {
66
- ...unknown(),
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
  }
@@ -1,73 +1,125 @@
1
- import { exec, spawn } from 'node:child_process';
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 (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
+ // 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 (expect) ───────────────────────────────────────────────────
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
- * 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.
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
- 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
- });
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.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B[^[]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
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
- 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]);
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
- resetIn = parts.length > 0 ? parts.join(' ') : null;
104
+ debug('codex:parse', 'result', { quotaReached: true, resetIn });
105
+ return { quotaReached: true, resetIn, fiveHourRemainingPct: null, fiveHourResetAt: null };
69
106
  }
70
- return { quotaReached: true, resetIn };
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
- // Primary: scrape the Codex TUI via expect
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
- // TUI loaded cleanly with no quota warning → estimate remaining via ccusage
167
- const estimate = await fetchCcusageEstimate(this.budgetLimit);
168
- return estimate;
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
- // expect not available or codex spawn failed fall back to ccusage estimate
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
  }
@@ -0,0 +1,4 @@
1
+ declare const enabled: boolean;
2
+ declare const logFile: string | null;
3
+ export declare function debug(tag: string, message: string, data?: unknown): void;
4
+ export { enabled as debugEnabled, logFile as debugLogFile };
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, LOADING_LINE } from './render.js';
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 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
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'); // clear line
35
+ process.stdout.write('\x1b[2K\r');
17
36
  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
- }
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
- // Print placeholder rows in fixed order
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(`${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${LOADING_LINE}\n`);
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 (snap.tool === 'codex' && isEstimate(snap)) {
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.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": "^20.11.24",
32
- "typescript": "^5.3.3"
31
+ "@types/node": "^25.9.1",
32
+ "typescript": "^6.0.3"
33
33
  }
34
34
  }