agent-fuel 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,70 +1,53 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { spawn } from 'node:child_process';
4
+ import { TuiScraper } from '../tmux.js';
5
5
  const CACHE_PATH = path.join(os.homedir(), '.gemini/antigravity-cli/.agent-fuel-quota-cache.json');
6
6
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
7
- // ── ANSI / terminal helpers ────────────────────────────────────────────────
8
- function stripAnsi(str) {
9
- // eslint-disable-next-line no-control-regex
10
- return str.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B[^[]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
11
- }
12
7
  // ── Scraping ───────────────────────────────────────────────────────────────
8
+ async function sleep(ms) {
9
+ return new Promise(res => setTimeout(res, ms));
10
+ }
13
11
  /**
14
- * Spawns `agy` via `expect`, opens the `/usage` panel, waits for the
15
- * Model Quota list to render, then exits.
12
+ * Launches `agy` in a tmux session, opens the `/usage` panel, waits for
13
+ * the Model Quota list to render, then returns clean rendered screen text.
16
14
  */
17
- function runAgyUsage() {
18
- 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
- });
15
+ async function runAgyUsage() {
16
+ const tui = new TuiScraper('agy');
17
+ try {
18
+ tui.start();
19
+ // Wait for AGY main menu ready
20
+ await tui.waitFor(/for shortcuts/, 20_000);
21
+ // Navigate to /usage panel
22
+ tui.send('/usage');
23
+ await tui.waitFor(/Model Quota/, 10_000);
24
+ // Brief pause for all model rows to finish rendering
25
+ await sleep(500);
26
+ return tui.capture();
27
+ }
28
+ finally {
29
+ tui.kill();
30
+ }
48
31
  }
49
32
  // ── Parsing ────────────────────────────────────────────────────────────────
50
33
  /**
51
34
  * Parse the Model Quota panel into an array of entries.
52
35
  *
53
- * Panel format (after ANSI strip):
36
+ * Panel format (tmux rendered — no ANSI codes):
54
37
  *
55
38
  * └ Model Quota
56
39
  *
57
40
  * Gemini 3.5 Flash (High)
58
41
  * ░░░░░░░░░░░ ... 20%
59
- * Refreshes in 3h 28m ← or "80% remaining · Refreshes in …"
42
+ * Refreshes in 3h 28m
60
43
  *
61
44
  * Claude Sonnet 4.6 (Thinking)
62
45
  * ███████████ ... 100%
63
46
  * Quota available
64
47
  */
65
48
  function parseQuotaPanel(raw) {
66
- const clean = stripAnsi(raw);
67
- const lines = clean.split(/\r?\n/);
49
+ // tmux capture-pane returns clean rendered text — no ANSI stripping needed
50
+ const lines = raw.split(/\r?\n/);
68
51
  const results = [];
69
52
  const headerIdx = lines.findIndex(l => l.includes('Model Quota'));
70
53
  if (headerIdx === -1)
@@ -91,15 +74,12 @@ function parseQuotaPanel(raw) {
91
74
  j++;
92
75
  continue;
93
76
  }
94
- // Progress bar line: has block chars OR starts with a digit%
95
77
  if (barLine === null && (candidate.includes('░') || candidate.includes('█') || /^\d+%/.test(candidate))) {
96
78
  barLine = candidate;
97
79
  j++;
98
80
  continue;
99
81
  }
100
- // Refresh / availability line
101
82
  if (barLine !== null && (candidate.includes('Refreshes') || candidate.includes('Quota available'))) {
102
- // Strip any leading "NN% remaining · " prefix
103
83
  const m = candidate.match(/(Refreshes in [^\r\n]+|Quota available)/);
104
84
  refreshLine = m ? m[1] : candidate;
105
85
  j++;
@@ -107,7 +87,6 @@ function parseQuotaPanel(raw) {
107
87
  break;
108
88
  }
109
89
  if (barLine !== null) {
110
- // Percentage is always at the END of the bar line: "░░░ ... 20%" or "20% remaining · …"
111
90
  const percentMatch = barLine.match(/(\d+)%/);
112
91
  if (percentMatch) {
113
92
  results.push({ model: line, percent: parseInt(percentMatch[1], 10), refreshLine });
@@ -137,11 +116,6 @@ async function writeCache(entries) {
137
116
  catch { /* non-fatal */ }
138
117
  }
139
118
  // ── Bucket aggregation ────────────────────────────────────────────────────
140
- /**
141
- * Given all quota entries, build the two UsageSnapshot rows:
142
- * - agy-gemini: worst-case (min remaining) across all Gemini models
143
- * - agy-other: worst-case across all non-Gemini models (Claude, etc.)
144
- */
145
119
  function buildSnapshots(entries, fromCache) {
146
120
  const source = fromCache ? 'cache' : 'official-cli';
147
121
  const geminiEntries = entries.filter(e => /gemini/i.test(e.model));
@@ -150,7 +124,6 @@ function buildSnapshots(entries, fromCache) {
150
124
  if (bucket.length === 0) {
151
125
  return { tool, remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' };
152
126
  }
153
- // Show the lowest remaining % (most constrained model in the bucket)
154
127
  const worst = bucket.reduce((a, b) => a.percent <= b.percent ? a : b);
155
128
  return {
156
129
  tool,
@@ -174,7 +147,7 @@ export class AgyQuotaAdapter {
174
147
  if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
175
148
  return buildSnapshots(cached.entries, true);
176
149
  }
177
- // Slow path: spawn agy, scrape the quota panel
150
+ // Slow path: spawn agy via tmux, scrape the quota panel
178
151
  try {
179
152
  const raw = await runAgyUsage();
180
153
  const entries = parseQuotaPanel(raw);
@@ -182,7 +155,6 @@ export class AgyQuotaAdapter {
182
155
  await writeCache(entries);
183
156
  return buildSnapshots(entries, false);
184
157
  }
185
- // Panel not found — return unknown rows (do not cache failures)
186
158
  return [
187
159
  { tool: 'agy-gemini', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
188
160
  { tool: 'agy-other', remainingPercent: null, usedPercent: null, resetAt: null, source: 'unknown' },
@@ -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,55 @@
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 } from '../tmux.js';
2
+ import { debug } from '../debug.js';
3
+ // ── TUI scraper ────────────────────────────────────────────────────────────
4
+ async function sleep(ms) {
5
+ return new Promise(res => setTimeout(res, ms));
6
+ }
7
+ /**
8
+ * Launches `claude` in a tmux session, opens /status, navigates to the
9
+ * Status tab (which shows real quota usage bars), and returns the captured
10
+ * screen text.
11
+ *
12
+ * The Status tab renders persistently (not transient), so regular
13
+ * capture-pane is sufficient — no pipe-pane needed.
14
+ */
15
+ async function runClaudeScrape() {
16
+ const tui = new TuiScraper('claude');
17
+ try {
18
+ tui.start();
19
+ // Wait for TUI ready — welcome banner or prompt hint visible
20
+ await tui.waitFor(/Welcome back|Try "/i, 15_000, 0);
21
+ // Open the /status panel
22
+ tui.send('/status');
23
+ // Wait for the status panel tabs to appear
24
+ await tui.waitFor(/Settings\s+Status/i, 10_000, 0);
25
+ // Navigate to the Status tab (second tab after Settings)
26
+ tui.sendKey('Tab');
27
+ await sleep(300);
28
+ tui.sendKey('Tab');
29
+ // Wait for the usage bars — shows "XX% used"
30
+ return await tui.waitFor(/\d+%\s+used/i, 8_000, 0);
31
+ }
32
+ finally {
33
+ tui.kill();
12
34
  }
35
+ }
36
+ function parseScrapeOutput(screen) {
37
+ debug('claude:parse', `screen length: ${screen.length}`);
38
+ debug('claude:parse', 'screen', screen);
39
+ // Match "XX% used" occurrences in order:
40
+ // First = current session (5h block), second = current week
41
+ const usedMatches = [...screen.matchAll(/(\d+)%\s+used/gi)];
42
+ debug('claude:parse', `found ${usedMatches.length} "% used" matches`);
43
+ const sessionUsedPct = usedMatches[0] ? parseInt(usedMatches[0][1], 10) : null;
44
+ const weeklyUsedPct = usedMatches[1] ? parseInt(usedMatches[1][1], 10) : null;
45
+ // Reset time: "Resets H:MMam" or "Resets May 30 at 6am" — grab the first occurrence
46
+ const resetMatch = screen.match(/Resets\s+([^\n\r]+)/i);
47
+ const sessionResetAt = resetMatch ? resetMatch[1].trim() : null;
48
+ debug('claude:parse', 'result', { sessionUsedPct, sessionResetAt, weeklyUsedPct });
49
+ return { sessionUsedPct, sessionResetAt, weeklyUsedPct };
50
+ }
51
+ // ── Adapter ────────────────────────────────────────────────────────────────
52
+ export class ClaudeQuotaAdapter {
13
53
  async fetchSnapshots() {
14
54
  return [await this._fetch()];
15
55
  }
@@ -21,51 +61,27 @@ export class ClaudeQuotaAdapter {
21
61
  resetAt: null,
22
62
  source: 'unknown',
23
63
  });
64
+ debug('claude:fetch', 'starting TUI scrape via tmux');
24
65
  try {
25
- 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
- }
66
+ const screen = await runClaudeScrape();
67
+ const result = parseScrapeOutput(screen);
68
+ if (result.sessionUsedPct !== null) {
69
+ const remainingPercent = Math.max(0, 100 - result.sessionUsedPct);
70
+ debug('claude:fetch', `parsed Usage tab → ${result.sessionUsedPct}% used (${remainingPercent}% remaining)`);
71
+ return {
72
+ tool: 'claude-code',
73
+ remainingPercent,
74
+ usedPercent: result.sessionUsedPct,
75
+ resetAt: result.sessionResetAt,
76
+ source: 'official-cli',
77
+ };
54
78
  }
55
- return {
56
- tool: 'claude-code',
57
- remainingPercent,
58
- usedPercent: Math.round(usedPct),
59
- resetAt,
60
- source: 'ccusage',
61
- raw: activeBlock,
62
- };
79
+ debug('claude:fetch', 'parse failed → unknown');
80
+ return unknown();
63
81
  }
64
- catch (error) {
65
- return {
66
- ...unknown(),
67
- raw: error instanceof Error ? error.message : String(error),
68
- };
82
+ catch (err) {
83
+ debug('claude:fetch', 'caught error', String(err));
84
+ return unknown();
69
85
  }
70
86
  }
71
87
  }
@@ -1,73 +1,109 @@
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 } 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
+ async function sleep(ms) {
14
+ return new Promise(res => setTimeout(res, ms));
15
+ }
11
16
  /**
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.
17
+ * Launches `codex` in a tmux session, pipes all terminal bytes to a temp
18
+ * file, sends /status twice, then reads the file and returns the raw bytes.
19
+ *
20
+ * Why pipe-pane instead of capture-pane:
21
+ * The /status overlay is full-screen and transient — it renders in-place for
22
+ * one frame and re-renders away without entering the tmux scrollback buffer.
23
+ * capture-pane (even with -S history) can never catch it. pipe-pane streams
24
+ * every raw byte to a file so even a 10ms overlay is permanently recorded.
14
25
  */
15
- function runCodexScrape() {
16
- 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
- });
26
+ async function runCodexScrape() {
27
+ const tui = new TuiScraper('codex');
28
+ const pipePath = `/tmp/af-codex-${Date.now()}.log`;
29
+ try {
30
+ tui.start();
31
+ // Stream all pane output to a file from the start
32
+ execFileSync('tmux', ['pipe-pane', '-t', tui.sessionId, `cat >> '${pipePath}'`]);
33
+ debug('codex:scrape', `pipe-pane logging to ${pipePath}`);
34
+ // Wait for TUI ready: current screen (historyLines=0) shows Tip, meaning MCP boot done
35
+ await tui.waitFor(/Tip:/i, 20_000, 0);
36
+ // First /status: panel says "Limits: refresh requested; run /status again shortly"
37
+ tui.send('/status');
38
+ await sleep(2_000);
39
+ // Second /status: has actual 5h/weekly quota data
40
+ tui.send('/status');
41
+ await sleep(4_000);
42
+ const raw = readFileSync(pipePath, 'utf-8');
43
+ debug('codex:scrape', `pipe log size: ${raw.length} bytes`);
44
+ return raw;
45
+ }
46
+ finally {
47
+ tui.kill();
48
+ try {
49
+ unlinkSync(pipePath);
50
+ }
51
+ catch { /* ok */ }
52
+ }
44
53
  }
45
- // ── Output parser ──────────────────────────────────────────────────────────
46
54
  function stripAnsi(str) {
47
55
  // eslint-disable-next-line no-control-regex
48
- return str.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B[^[]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
56
+ return str
57
+ .replace(/\x1B\[[\x20-\x3f]*[\x40-\x7e]/g, '')
58
+ .replace(/\x1B[^[]/g, '')
59
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
49
60
  }
50
61
  function parseScrapeOutput(raw) {
62
+ // pipe-pane output contains raw ANSI bytes — strip before pattern matching
51
63
  const clean = stripAnsi(raw);
64
+ debug('codex:parse', `raw length: ${raw.length}, cleaned length: ${clean.length}`);
65
+ debug('codex:parse', 'cleaned output', clean);
66
+ debug('codex:parse', 'checking patterns', {
67
+ hasIndividualQuota: /Individual quota reached/i.test(clean),
68
+ hasHeadsUp: /less than \d+%\s+of your 5h limit left/i.test(clean),
69
+ has5hLimit: /5h limit:/i.test(clean),
70
+ hasWeeklyLimit: /Weekly limit:/i.test(clean),
71
+ hasLimits: /Limits:/i.test(clean),
72
+ });
52
73
  // "Individual quota reached. Contact your administrator to enable overages. Resets in 4h33m29s."
53
- 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]);
74
+ if (/Individual quota reached/i.test(clean)) {
75
+ const resetMatch = clean.match(/Resets in\s*((?:\d+h)?(?:\d+m)?(?:\d+s)?)/i);
76
+ let resetIn = null;
77
+ if (resetMatch) {
78
+ const parts = [];
79
+ const hm = resetMatch[1].match(/^(\d+h)?(\d+m)?/);
80
+ if (hm) {
81
+ if (hm[1])
82
+ parts.push(hm[1]);
83
+ if (hm[2])
84
+ parts.push(hm[2]);
85
+ }
86
+ resetIn = parts.length > 0 ? parts.join(' ') : null;
67
87
  }
68
- resetIn = parts.length > 0 ? parts.join(' ') : null;
88
+ debug('codex:parse', 'result', { quotaReached: true, resetIn });
89
+ return { quotaReached: true, resetIn, fiveHourRemainingPct: null, fiveHourResetAt: null };
69
90
  }
70
- return { quotaReached: true, resetIn };
91
+ // "⚠ Heads up, you have less than X% of your 5h limit left."
92
+ const headsUpMatch = clean.match(/less than (\d+)%\s+of your 5h limit left/i);
93
+ if (headsUpMatch) {
94
+ const ceiling = parseInt(headsUpMatch[1], 10);
95
+ const fiveHourRemainingPct = Math.max(0, ceiling - 1);
96
+ debug('codex:parse', 'result', { source: 'headsUp', ceiling, fiveHourRemainingPct });
97
+ return { quotaReached: false, resetIn: null, fiveHourRemainingPct, fiveHourResetAt: null };
98
+ }
99
+ // Parse "/status" panel: "5h limit: [...] X% left (resets HH:MM)"
100
+ // Use the LAST match — /status is sent twice and the second response is fresh.
101
+ const allFiveHMatches = [...clean.matchAll(/5h limit:\s*\[.*?\]\s*(\d+)%\s*left\s*\(resets\s+([^)]+)\)/gi)];
102
+ const fiveHMatch = allFiveHMatches.at(-1) ?? null;
103
+ const fiveHourRemainingPct = fiveHMatch ? parseInt(fiveHMatch[1], 10) : null;
104
+ const fiveHourResetAt = fiveHMatch ? fiveHMatch[2].trim() : null;
105
+ debug('codex:parse', 'result', { quotaReached: false, fiveHourRemainingPct, fiveHourResetAt, fiveHMatchRaw: fiveHMatch?.[0] ?? null });
106
+ return { quotaReached: false, resetIn: null, fiveHourRemainingPct, fiveHourResetAt };
71
107
  }
72
108
  // ── ccusage fallback estimate ──────────────────────────────────────────────
73
109
  async function fetchCcusageEstimate(budgetLimit) {
@@ -86,6 +122,7 @@ async function fetchCcusageEstimate(budgetLimit) {
86
122
  catch {
87
123
  return unknown();
88
124
  }
125
+ debug('codex:ccusage', 'raw stdout', stdout);
89
126
  const data = JSON.parse(stdout);
90
127
  const sessions = Array.isArray(data?.sessions) ? data.sessions :
91
128
  Array.isArray(data?.session) ? data.session :
@@ -124,6 +161,14 @@ async function fetchCcusageEstimate(budgetLimit) {
124
161
  }
125
162
  catch { /* leave null */ }
126
163
  }
164
+ debug('codex:ccusage', 'computed', {
165
+ totalCost,
166
+ todaySessionsCount: todaySessions.length,
167
+ budgetLimit,
168
+ usedPct,
169
+ remainingPercent,
170
+ resetAt,
171
+ });
127
172
  return {
128
173
  tool: 'codex',
129
174
  remainingPercent,
@@ -148,13 +193,13 @@ export class CodexQuotaAdapter {
148
193
  return [await this._fetch()];
149
194
  }
150
195
  async _fetch() {
151
- // Primary: scrape the Codex TUI via expect
196
+ debug('codex:fetch', 'starting TUI scrape via tmux');
152
197
  try {
153
198
  const raw = await runCodexScrape();
154
199
  const result = parseScrapeOutput(raw);
155
200
  if (result.quotaReached) {
156
- // Ground truth: quota is exhausted
157
201
  const resetAt = result.resetIn ? `Resets in ${result.resetIn}` : null;
202
+ debug('codex:fetch', 'quota reached → returning 0%');
158
203
  return {
159
204
  tool: 'codex',
160
205
  remainingPercent: 0,
@@ -163,12 +208,21 @@ export class CodexQuotaAdapter {
163
208
  source: 'official-cli',
164
209
  };
165
210
  }
166
- // TUI loaded cleanly with no quota warning → estimate remaining via ccusage
167
- const estimate = await fetchCcusageEstimate(this.budgetLimit);
168
- return estimate;
211
+ if (result.fiveHourRemainingPct !== null) {
212
+ debug('codex:fetch', `parsed /status ${result.fiveHourRemainingPct}% remaining`);
213
+ return {
214
+ tool: 'codex',
215
+ remainingPercent: result.fiveHourRemainingPct,
216
+ usedPercent: 100 - result.fiveHourRemainingPct,
217
+ resetAt: result.fiveHourResetAt,
218
+ source: 'official-cli',
219
+ };
220
+ }
221
+ debug('codex:fetch', '/status parse failed → falling back to ccusage estimate');
222
+ return fetchCcusageEstimate(this.budgetLimit);
169
223
  }
170
- catch {
171
- // expect not available or codex spawn failed fall back to ccusage estimate
224
+ catch (err) {
225
+ debug('codex:fetch', 'caught error, falling back to ccusage', String(err));
172
226
  return fetchCcusageEstimate(this.budgetLimit);
173
227
  }
174
228
  }
@@ -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,13 @@
1
+ export declare class TuiScraper {
2
+ private readonly command;
3
+ private readonly width;
4
+ private readonly height;
5
+ readonly sessionId: string;
6
+ constructor(command: string, width?: number, height?: number);
7
+ start(): void;
8
+ capture(historyLines?: number): string;
9
+ send(text: string): void;
10
+ sendKey(key: string): void;
11
+ waitFor(pattern: RegExp, timeoutMs: number, historyLines?: number): Promise<string>;
12
+ kill(): void;
13
+ }
package/dist/tmux.js ADDED
@@ -0,0 +1,72 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { debug } from './debug.js';
3
+ export class TuiScraper {
4
+ command;
5
+ width;
6
+ height;
7
+ sessionId;
8
+ constructor(command, width = 220, height = 50) {
9
+ this.command = command;
10
+ this.width = width;
11
+ this.height = height;
12
+ this.sessionId = `af-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
13
+ }
14
+ start() {
15
+ try {
16
+ execFileSync('which', ['tmux'], { stdio: 'ignore' });
17
+ }
18
+ catch {
19
+ throw new Error('tmux not found — install with: brew install tmux');
20
+ }
21
+ debug('tmux', `starting session ${this.sessionId} for command: ${this.command}`);
22
+ execFileSync('tmux', [
23
+ 'new-session', '-d', '-s', this.sessionId,
24
+ '-x', String(this.width), '-y', String(this.height),
25
+ this.command,
26
+ ]);
27
+ }
28
+ // historyLines > 0 → include that many lines of scrollback above the visible screen.
29
+ // This catches transient overlays that rendered and then re-rendered away.
30
+ capture(historyLines = 0) {
31
+ const args = historyLines > 0
32
+ ? ['capture-pane', '-t', this.sessionId, '-S', `-${historyLines}`, '-p']
33
+ : ['capture-pane', '-t', this.sessionId, '-p'];
34
+ const text = execFileSync('tmux', args).toString();
35
+ debug('tmux:capture', `[${this.sessionId}] captured ${text.length} chars (history=${historyLines})`);
36
+ return text;
37
+ }
38
+ send(text) {
39
+ debug('tmux:send', `[${this.sessionId}] sending: ${JSON.stringify(text)}`);
40
+ execFileSync('tmux', ['send-keys', '-t', this.sessionId, text, 'Enter']);
41
+ }
42
+ // Send a named key (Tab, Escape, Up, Down, etc.) without appending Enter.
43
+ sendKey(key) {
44
+ debug('tmux:send', `[${this.sessionId}] sendKey: ${key}`);
45
+ execFileSync('tmux', ['send-keys', '-t', this.sessionId, key]);
46
+ }
47
+ async waitFor(pattern, timeoutMs, historyLines = 500) {
48
+ const deadline = Date.now() + timeoutMs;
49
+ debug('tmux:waitFor', `[${this.sessionId}] waiting for ${pattern} (timeout ${timeoutMs}ms, history=${historyLines})`);
50
+ while (Date.now() < deadline) {
51
+ const text = this.capture(historyLines);
52
+ if (pattern.test(text)) {
53
+ debug('tmux:waitFor', `[${this.sessionId}] matched ${pattern}`);
54
+ return text;
55
+ }
56
+ await sleep(100);
57
+ }
58
+ const last = this.capture(historyLines);
59
+ debug('tmux:waitFor', `[${this.sessionId}] TIMEOUT for ${pattern}, last screen:\n${last}`);
60
+ throw new Error(`TuiScraper.waitFor timeout after ${timeoutMs}ms: ${pattern}`);
61
+ }
62
+ kill() {
63
+ debug('tmux', `killing session ${this.sessionId}`);
64
+ try {
65
+ execFileSync('tmux', ['kill-session', '-t', this.sessionId], { stdio: 'ignore' });
66
+ }
67
+ catch { /* already dead */ }
68
+ }
69
+ }
70
+ function sleep(ms) {
71
+ return new Promise(res => setTimeout(res, ms));
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-fuel",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Sleek term-based dashboard for AI coding CLI quotas",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",