agent-fuel 0.4.1 → 0.5.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
@@ -45,10 +45,11 @@ AI coding assistants are now integral to developer workflows. Tools like **Claud
45
45
  `agent-fuel` is a tiny modern CLI built with TypeScript that:
46
46
 
47
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.
48
+ 2. **Streams Consolidated Quota Live** — renders a weighted **Total** bar on top which calculates and updates in real-time as each provider finishes loading, showing the live calculated portion rather than waiting for all adapters to finish.
49
+ 3. **Normalises Quota Models** — standardises diverse limits into a uniform `0–100%` score.
50
+ 4. **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.
51
+ 5. **Caches AGY results** — AGY quota is cached for 5 minutes so repeated runs are instant (~1s).
52
+ 6. **Renders a clean dashboard** — colour-coded bars with reset times directly in your terminal.
52
53
 
53
54
  ### Project Architecture
54
55
 
@@ -57,6 +58,7 @@ agent-fuel/
57
58
  ├── src/
58
59
  │ ├── index.ts # CLI entry point — runs all adapters concurrently
59
60
  │ ├── render.ts # Colour-coded bar dashboard renderer
61
+ │ ├── config.ts # Config file manager & config command handler
60
62
  │ └── adapters/
61
63
  │ ├── index.ts # Shared UsageSnapshot type & QuotaAdapter interface
62
64
  │ ├── claude.ts # Claude Code (via ccusage blocks)
@@ -86,43 +88,67 @@ type UsageSnapshot = {
86
88
  ```
87
89
  ⚡️ Agent Fuel - CLI Quota Monitor
88
90
 
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)]
91
+ Total [████████████████████████░░░░░░] 81% remaining (tune weights: agent-fuel config)
93
92
 
94
- agent-fuel v0.3.0
93
+ Claude Code [██████████████████████░░░░░░░░] 74% remaining (resets 13:19 (Europe/Copenhagen))
94
+ Codex [██████████████████████████████] 99% remaining (resets 13:56)
95
+ AGY Gemini [██████████████████████████████] 100% remaining ✓ quota available [Gemini 3.5 Flash (Medium)]
96
+ AGY Other [████████████░░░░░░░░░░░░░░░░░░] 40% remaining (resets in 109h 12m) [Claude Sonnet 4.6 (Thinking)]
97
+
98
+ agent-fuel v0.5.0
95
99
  ```
96
100
 
97
- Rows appear as each adapter resolves Claude Code (instant) prints first, Codex and AGY follow as their TUI scrapes complete.
101
+ - **Total** bar prints on top (in TTY interactive mode) showing the weighted consolidated remaining quota. As adapters load, the bar fills up in real-time. When fully loaded, a helpful CLI reminder is displayed alongside the Total percentage.
102
+ - Rows appear as each adapter resolves — Claude Code (instant) prints first, Codex and AGY follow as their TUI scrapes complete.
103
+ - **AGY Gemini** shows the worst-case remaining across all `Gemini *` model tiers.
104
+ - **AGY Other** shows the worst-case across Claude and other non-Gemini models.
105
+ - **Codex** row tagged `[~est]` when quota has not been reached and the percentage is estimated from local session cost data (see fallback note below).
106
+
107
+ ---
108
+
109
+ ## ⚙️ Configuration & Custom Weights
110
+
111
+ Different developers operate under different quota sizes. By default, `agent-fuel` weights each provider bucket as standard proxies for monthly dollar subscription amounts:
112
+ - Claude Code (`claude-code`): `20`
113
+ - Codex CLI (`codex`): `20`
114
+ - AGY Gemini (`agy-gemini`): `10`
115
+ - AGY Other (`agy-other`): `10`
116
+
117
+ If a provider is completely unused or fails to return a quota percentage, its weight is **dynamically excluded** from the calculation, ensuring that missing/unused services don't break the consolidated bar.
118
+
119
+ ### Managing Settings via the CLI
120
+
121
+ You can view or update your weights and settings directly using the CLI:
98
122
 
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).
123
+ * **View Active Configuration**:
124
+ ```bash
125
+ agent-fuel config
126
+ ```
127
+ * **Change Provider Weight**:
128
+ ```bash
129
+ agent-fuel config set claude-code 50
130
+ ```
131
+ * **Disable/Enable Total Bar**:
132
+ ```bash
133
+ agent-fuel config set show-total false
134
+ ```
135
+
136
+ Settings are persistently saved to `~/.config/agent-fuel/config.json`.
102
137
 
103
138
  ---
104
139
 
105
140
  ## ⚙️ Environment Overrides
106
141
 
142
+ Environment variables take highest precedence and override any values saved in the config JSON file:
143
+
107
144
  | Variable | Default | Description |
108
145
  |---|---|---|
109
146
  | `AGENT_FUEL_CLAUDE_BUDGET` | `20.0` | Claude Code rolling budget in USD |
110
147
  | `AGENT_FUEL_CODEX_BUDGET` | `20.0` | **Fallback estimate only** — Codex rolling budget in USD |
148
+ | `AGENT_FUEL_WEIGHT_CLAUDE` | `20` | Weight size ratio of the Claude Code quota pool |
149
+ | `AGENT_FUEL_WEIGHT_CODEX` | `20` | Weight size ratio of the Codex quota pool |
150
+ | `AGENT_FUEL_WEIGHT_AGY_GEMINI` | `10` | Weight size ratio of the AGY Gemini quota pool |
151
+ | `AGENT_FUEL_WEIGHT_AGY_OTHER` | `10` | Weight size ratio of the AGY Other quota pool |
152
+ | `AGENT_FUEL_SHOW_TOTAL` | `true` | Show or hide the consolidated Total quota bar (`true`/`false`) |
111
153
 
112
154
  > **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
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,7 +1,10 @@
1
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';
3
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import crypto from 'node:crypto';
6
+ import path from 'node:path';
7
+ import { TuiScraper, sleep, registerTempFile, unregisterTempFile } from '../tmux.js';
5
8
  import { debug } from '../debug.js';
6
9
  const execAsync = promisify(exec);
7
10
  // Used ONLY as a rough fallback estimate when the TUI scrape cannot determine
@@ -18,6 +21,9 @@ const CODEX_READY = /Tip:/i;
18
21
  const CODEX_DIALOG = /Update available|Introducing GPT|Try new model|Use existing model/i;
19
22
  const CODEX_EITHER = new RegExp(`(?:${CODEX_READY.source})|(?:${CODEX_DIALOG.source})`, 'i');
20
23
  const CODEX_STARTUP_MS = 25_000;
24
+ const CODEX_DIALOG_SETTLE_MS = 1_000; // wait for UI to re-render after dismissing a dialog
25
+ const CODEX_STATUS_REFRESH_MS = 2_000; // first /status just triggers a quota refresh
26
+ const CODEX_STATUS_READY_MS = 4_000; // second /status carries the live quota data
21
27
  /**
22
28
  * Launches `codex` in a tmux session, pipes all terminal bytes to a temp
23
29
  * file, sends /status twice, then reads the file and returns the raw bytes.
@@ -30,11 +36,22 @@ const CODEX_STARTUP_MS = 25_000;
30
36
  */
31
37
  async function runCodexScrape() {
32
38
  const tui = new TuiScraper('codex');
33
- const pipePath = `/tmp/af-codex-${Date.now()}.log`;
39
+ const tmpDir = os.tmpdir();
40
+ const randomSuffix = crypto.randomBytes(6).toString('hex');
41
+ const pipePath = path.join(tmpDir, `af-codex-${Date.now()}-${randomSuffix}.log`);
42
+ registerTempFile(pipePath);
43
+ // Create the file with restricted permissions before tmux starts writing to it
44
+ writeFileSync(pipePath, '', { mode: 0o600 });
34
45
  try {
35
46
  tui.start();
36
- // Stream all pane output to a file from the start
37
- execFileSync('tmux', ['pipe-pane', '-t', tui.sessionId, `cat >> '${pipePath}'`]);
47
+ // Stream all pane output to a file from the start.
48
+ // Single-quote escaping: safe against all shell metacharacters ($, `, \, space, etc.)
49
+ // Note: pipe-pane executes this command via tmux's `default-shell` (defaults to /bin/sh).
50
+ // If the user has set default-shell to a non-POSIX shell (e.g. fish), the `'\\''` idiom
51
+ // will fail — but pipePath is constructed from os.tmpdir() + hex, so single quotes
52
+ // cannot appear in practice, making the replace a no-op and the quoting sh-compatible.
53
+ const shellSafePath = "'" + pipePath.replace(/'/g, "'\\''") + "'";
54
+ execFileSync('tmux', ['pipe-pane', '-t', tui.sessionId, `cat >> ${shellSafePath}`]);
38
55
  debug('codex:scrape', `pipe-pane logging to ${pipePath}`);
39
56
  // Wait for TUI ready, dismissing any blocking dialogs along the way.
40
57
  const dialogDeadline = Date.now() + CODEX_STARTUP_MS;
@@ -42,7 +59,7 @@ async function runCodexScrape() {
42
59
  while (!CODEX_READY.test(screen)) {
43
60
  debug('codex:scrape', 'blocking dialog detected — sending "2" to dismiss');
44
61
  tui.send('2');
45
- await sleep(1_000); // wait for dialog to actually clear before re-checking
62
+ await sleep(CODEX_DIALOG_SETTLE_MS);
46
63
  const remaining = dialogDeadline - Date.now(); // compute AFTER sleep
47
64
  if (remaining < 500) {
48
65
  throw new Error('Codex TUI never reached ready state after dismissing dialogs');
@@ -51,20 +68,24 @@ async function runCodexScrape() {
51
68
  }
52
69
  // First /status: panel says "Limits: refresh requested; run /status again shortly"
53
70
  tui.send('/status');
54
- await sleep(2_000);
71
+ await sleep(CODEX_STATUS_REFRESH_MS);
55
72
  // Second /status: has actual 5h/weekly quota data
56
73
  tui.send('/status');
57
- await sleep(4_000);
74
+ await sleep(CODEX_STATUS_READY_MS);
58
75
  const raw = readFileSync(pipePath, 'utf-8');
59
76
  debug('codex:scrape', `pipe log size: ${raw.length} bytes`);
60
77
  return raw;
61
78
  }
62
79
  finally {
63
- tui.kill();
80
+ try {
81
+ tui.kill();
82
+ }
83
+ catch { /* already dead */ }
84
+ unregisterTempFile(pipePath); // always remove from registry, regardless of unlink success
64
85
  try {
65
86
  unlinkSync(pipePath);
66
87
  }
67
- catch { /* ok */ }
88
+ catch { /* ok if already gone */ }
68
89
  }
69
90
  }
70
91
  function stripAnsi(str) {
@@ -135,7 +156,8 @@ async function fetchCcusageEstimate(budgetLimit) {
135
156
  try {
136
157
  ({ stdout } = await execAsync('npx --no-install ccusage codex session --json'));
137
158
  }
138
- catch {
159
+ catch (err) {
160
+ debug('codex:ccusage', 'ccusage exec failed', String(err));
139
161
  return unknown();
140
162
  }
141
163
  debug('codex:ccusage', 'raw stdout', stdout);
@@ -194,7 +216,8 @@ async function fetchCcusageEstimate(budgetLimit) {
194
216
  raw: { totalCost, todaySessionsCount: todaySessions.length, isEstimate: true },
195
217
  };
196
218
  }
197
- catch {
219
+ catch (err) {
220
+ debug('codex:ccusage', 'unexpected error in ccusage fallback', String(err));
198
221
  return unknown();
199
222
  }
200
223
  }
@@ -1,9 +1,10 @@
1
1
  export interface UsageSnapshot {
2
- tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other';
2
+ tool: 'codex' | 'claude-code' | 'agy-gemini' | 'agy-other' | 'total';
3
3
  remainingPercent: number | null;
4
4
  usedPercent?: number | null;
5
5
  resetAt?: string | null;
6
6
  source: 'official-cli' | 'ccusage' | 'local-state' | 'provider-api' | 'cache' | 'unknown';
7
+ isLoading?: boolean;
7
8
  raw?: unknown;
8
9
  }
9
10
  export interface QuotaAdapter {
@@ -0,0 +1,15 @@
1
+ export interface Config {
2
+ weights: {
3
+ 'claude-code': number;
4
+ 'codex': number;
5
+ 'agy-gemini': number;
6
+ 'agy-other': number;
7
+ };
8
+ showTotal: boolean;
9
+ }
10
+ export declare const CONFIG_DIR: string;
11
+ export declare const CONFIG_FILE: string;
12
+ export declare const DEFAULT_CONFIG: Config;
13
+ export declare function loadConfig(): Config;
14
+ export declare function saveConfig(config: Config): void;
15
+ export declare function handleConfigCommand(args: string[]): boolean;
package/dist/config.js ADDED
@@ -0,0 +1,177 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ export const CONFIG_DIR = path.join(os.homedir(), '.config', 'agent-fuel');
5
+ export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
6
+ export const DEFAULT_CONFIG = {
7
+ weights: {
8
+ 'claude-code': 20,
9
+ 'codex': 20,
10
+ 'agy-gemini': 10,
11
+ 'agy-other': 10,
12
+ },
13
+ showTotal: true,
14
+ };
15
+ function ensureDir(dir) {
16
+ try {
17
+ if (!fs.existsSync(dir)) {
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ }
20
+ }
21
+ catch {
22
+ // Ignore, let write fail if it must
23
+ }
24
+ }
25
+ export function loadConfig() {
26
+ const config = { ...DEFAULT_CONFIG, weights: { ...DEFAULT_CONFIG.weights } };
27
+ // 1. Read from config file
28
+ try {
29
+ if (fs.existsSync(CONFIG_FILE)) {
30
+ const content = fs.readFileSync(CONFIG_FILE, 'utf8');
31
+ const parsed = JSON.parse(content);
32
+ if (parsed && typeof parsed === 'object') {
33
+ if (parsed.weights && typeof parsed.weights === 'object') {
34
+ for (const key of ['claude-code', 'codex', 'agy-gemini', 'agy-other']) {
35
+ const w = parsed.weights[key];
36
+ if (typeof w === 'number' && Number.isFinite(w) && w >= 0) {
37
+ config.weights[key] = w;
38
+ }
39
+ }
40
+ }
41
+ if (typeof parsed.showTotal === 'boolean') {
42
+ config.showTotal = parsed.showTotal;
43
+ }
44
+ }
45
+ }
46
+ }
47
+ catch {
48
+ // Fail silently, use defaults
49
+ }
50
+ // 2. Read from Environment Variables
51
+ const envClaude = process.env.AGENT_FUEL_WEIGHT_CLAUDE_CODE ?? process.env.AGENT_FUEL_WEIGHT_CLAUDE;
52
+ if (envClaude) {
53
+ const val = Number(envClaude);
54
+ if (Number.isFinite(val) && val >= 0)
55
+ config.weights['claude-code'] = val;
56
+ }
57
+ const envCodex = process.env.AGENT_FUEL_WEIGHT_CODEX;
58
+ if (envCodex) {
59
+ const val = Number(envCodex);
60
+ if (Number.isFinite(val) && val >= 0)
61
+ config.weights['codex'] = val;
62
+ }
63
+ const envGemini = process.env.AGENT_FUEL_WEIGHT_AGY_GEMINI ?? process.env.AGENT_FUEL_WEIGHT_GEMINI;
64
+ if (envGemini) {
65
+ const val = Number(envGemini);
66
+ if (Number.isFinite(val) && val >= 0)
67
+ config.weights['agy-gemini'] = val;
68
+ }
69
+ const envOther = process.env.AGENT_FUEL_WEIGHT_AGY_OTHER ?? process.env.AGENT_FUEL_WEIGHT_OTHER;
70
+ if (envOther) {
71
+ const val = Number(envOther);
72
+ if (Number.isFinite(val) && val >= 0)
73
+ config.weights['agy-other'] = val;
74
+ }
75
+ const envShowTotal = process.env.AGENT_FUEL_SHOW_TOTAL;
76
+ if (envShowTotal) {
77
+ if (envShowTotal.toLowerCase() === 'true')
78
+ config.showTotal = true;
79
+ if (envShowTotal.toLowerCase() === 'false')
80
+ config.showTotal = false;
81
+ }
82
+ return config;
83
+ }
84
+ export function saveConfig(config) {
85
+ ensureDir(CONFIG_DIR);
86
+ try {
87
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
88
+ }
89
+ catch (err) {
90
+ const msg = err instanceof Error ? err.message : String(err);
91
+ throw new Error(`Could not write config file ${CONFIG_FILE}: ${msg}`);
92
+ }
93
+ }
94
+ export function handleConfigCommand(args) {
95
+ if (args.length === 0)
96
+ return false;
97
+ const firstArg = args[0].toLowerCase();
98
+ if (firstArg !== 'config')
99
+ return false;
100
+ const BOLD = '\x1b[1m';
101
+ const CYAN = '\x1b[36m';
102
+ const RED = '\x1b[31m';
103
+ const GREEN = '\x1b[32m';
104
+ const R = '\x1b[0m';
105
+ const GRAY = '\x1b[90m';
106
+ const config = loadConfig();
107
+ const subCommand = args[1]?.toLowerCase();
108
+ if (!subCommand || subCommand === 'list') {
109
+ console.log(`\n${BOLD}${CYAN}⚡️ Agent Fuel Configuration${R}`);
110
+ console.log(`${GRAY}Config file: ${CONFIG_FILE}${R}\n`);
111
+ console.log(`${BOLD}Weights:${R}`);
112
+ console.log(` claude-code : ${config.weights['claude-code']}`);
113
+ console.log(` codex : ${config.weights['codex']}`);
114
+ console.log(` agy-gemini : ${config.weights['agy-gemini']}`);
115
+ console.log(` agy-other : ${config.weights['agy-other']}`);
116
+ console.log();
117
+ console.log(`${BOLD}Settings:${R}`);
118
+ console.log(` show-total : ${config.showTotal}`);
119
+ console.log();
120
+ console.log(`${BOLD}Examples:${R}`);
121
+ console.log(` agent-fuel config set claude-code 50`);
122
+ console.log(` agent-fuel config set show-total false`);
123
+ console.log();
124
+ return true;
125
+ }
126
+ if (subCommand === 'set') {
127
+ const key = args[2]?.toLowerCase();
128
+ const rawVal = args[3];
129
+ if (!key || !rawVal) {
130
+ console.error(`\n${BOLD}${RED}Error:${R} Usage: agent-fuel config set <key> <value>`);
131
+ console.error(`Keys: claude, claude-code, codex, gemini, agy-gemini, other, agy-other, show-total\n`);
132
+ process.exit(1);
133
+ }
134
+ if (key === 'show-total') {
135
+ const lowerVal = rawVal.toLowerCase();
136
+ if (lowerVal !== 'true' && lowerVal !== 'false') {
137
+ console.error(`\n${BOLD}${RED}Error:${R} show-total must be true or false\n`);
138
+ process.exit(1);
139
+ }
140
+ config.showTotal = lowerVal === 'true';
141
+ saveConfig(config);
142
+ console.log(`\n${BOLD}${GREEN}✓${R} Set show-total to ${config.showTotal}\n`);
143
+ return true;
144
+ }
145
+ // Handle weights keys
146
+ let targetKey = null;
147
+ if (key === 'claude' || key === 'claude-code') {
148
+ targetKey = 'claude-code';
149
+ }
150
+ else if (key === 'codex') {
151
+ targetKey = 'codex';
152
+ }
153
+ else if (key === 'gemini' || key === 'agy-gemini') {
154
+ targetKey = 'agy-gemini';
155
+ }
156
+ else if (key === 'other' || key === 'agy-other') {
157
+ targetKey = 'agy-other';
158
+ }
159
+ if (!targetKey) {
160
+ console.error(`\n${BOLD}${RED}Error:${R} Unknown key "${key}".`);
161
+ console.error(`Valid keys: claude, claude-code, codex, gemini, agy-gemini, other, agy-other, show-total\n`);
162
+ process.exit(1);
163
+ }
164
+ const val = Number(rawVal);
165
+ if (!Number.isFinite(val) || val < 0) {
166
+ console.error(`\n${BOLD}${RED}Error:${R} Weight must be a positive number or 0.\n`);
167
+ process.exit(1);
168
+ }
169
+ config.weights[targetKey] = val;
170
+ saveConfig(config);
171
+ console.log(`\n${BOLD}${GREEN}✓${R} Set weight.${targetKey} to ${val}\n`);
172
+ return true;
173
+ }
174
+ console.error(`\n${BOLD}${RED}Error:${R} Unknown config sub-command "${subCommand}".`);
175
+ console.error(`Usage: agent-fuel config [list|set]\n`);
176
+ process.exit(1);
177
+ }
package/dist/debug.js CHANGED
@@ -1,10 +1,41 @@
1
- import { appendFileSync, mkdirSync } from 'node:fs';
1
+ import { appendFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  const enabled = process.argv.includes('--debug');
4
+ const MAX_DEBUG_LOGS = 10;
5
+ function pruneOldLogs(dir) {
6
+ try {
7
+ // Count first — stat only if pruning is actually needed.
8
+ // Use MAX_DEBUG_LOGS - 1 to reserve a slot for the new log file that is
9
+ // created after this call, so the total on disk never exceeds MAX_DEBUG_LOGS.
10
+ const names = readdirSync(dir)
11
+ .filter(f => f.startsWith('debug-') && f.endsWith('.log'));
12
+ if (names.length < MAX_DEBUG_LOGS)
13
+ return;
14
+ const files = names.map(f => {
15
+ const p = join(dir, f);
16
+ return { path: p, time: statSync(p).mtimeMs };
17
+ });
18
+ // Sort by modification time, oldest first
19
+ files.sort((a, b) => a.time - b.time);
20
+ const toDeleteCount = files.length - (MAX_DEBUG_LOGS - 1);
21
+ for (let i = 0; i < toDeleteCount; i++) {
22
+ try {
23
+ unlinkSync(files[i].path);
24
+ }
25
+ catch {
26
+ // ignore individual deletion failures
27
+ }
28
+ }
29
+ }
30
+ catch {
31
+ // ignore directory read/stat errors
32
+ }
33
+ }
4
34
  const logFile = enabled
5
35
  ? (() => {
6
36
  const dir = join(process.cwd(), '.logs');
7
37
  mkdirSync(dir, { recursive: true });
38
+ pruneOldLogs(dir);
8
39
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
9
40
  return join(dir, `debug-${ts}.log`);
10
41
  })()
package/dist/index.js CHANGED
@@ -3,23 +3,78 @@ import { debugEnabled, debugLogFile } from './debug.js';
3
3
  import { ClaudeQuotaAdapter } from './adapters/claude.js';
4
4
  import { CodexQuotaAdapter } from './adapters/codex.js';
5
5
  import { AgyQuotaAdapter } from './adapters/agy.js';
6
- import { printHeader, printFooter, formatRow, getDisplayName } from './render.js';
6
+ import { printHeader, printFooter, formatRow, getDisplayName, SHADE_CHAR } from './render.js';
7
+ import { loadConfig, handleConfigCommand } from './config.js';
7
8
  // Fixed display order — never changes regardless of which adapter resolves first
8
9
  const SLOT_ORDER = ['claude-code', 'codex', 'agy-gemini', 'agy-other'];
9
10
  const BOLD = '\x1b[1m';
10
11
  const DIM = '\x1b[2m';
11
12
  const R = '\x1b[0m';
12
13
  const GRAY = '\x1b[90m';
14
+ const CYAN = '\x1b[36m';
13
15
  const isTTY = Boolean(process.stdout.isTTY);
14
16
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
15
17
  let spinnerTick = 0;
18
+ const config = loadConfig();
16
19
  function spinnerLine(tool) {
17
20
  const frame = SPINNER[spinnerTick % SPINNER.length];
18
21
  return `${BOLD}${getDisplayName(tool).padEnd(13)}${R} ${DIM}${GRAY}${frame} loading...${R}\x1b[K`;
19
22
  }
23
+ function calculateTotalLine(snapshots) {
24
+ let totalWeight = 0;
25
+ let totalRemainingWeight = 0;
26
+ let hasActive = false;
27
+ let isAnyLoading = false;
28
+ for (const tool of SLOT_ORDER) {
29
+ const snap = snapshots.get(tool);
30
+ if (snap === undefined || snap === null) {
31
+ isAnyLoading = true;
32
+ continue;
33
+ }
34
+ if (snap.remainingPercent !== null) {
35
+ const w = config.weights[tool] ?? 0;
36
+ totalWeight += w;
37
+ totalRemainingWeight += (snap.remainingPercent / 100) * w;
38
+ hasActive = true;
39
+ }
40
+ }
41
+ if (!hasActive) {
42
+ if (isAnyLoading) {
43
+ const frame = SPINNER[spinnerTick % SPINNER.length];
44
+ return `${BOLD}${CYAN}Total${R} [${GRAY}${SHADE_CHAR.repeat(30)}${R}] ${DIM}${GRAY}${frame} loading...${R}\x1b[K`;
45
+ }
46
+ const totalSnap = {
47
+ tool: 'total',
48
+ remainingPercent: null,
49
+ usedPercent: null,
50
+ source: 'unknown',
51
+ isLoading: true
52
+ };
53
+ return formatRow(totalSnap);
54
+ }
55
+ const pct = totalWeight > 0
56
+ ? Math.max(0, Math.min(100, Math.round((totalRemainingWeight / totalWeight) * 100)))
57
+ : null;
58
+ const totalSnap = {
59
+ tool: 'total',
60
+ remainingPercent: pct,
61
+ usedPercent: pct !== null ? 100 - pct : null,
62
+ source: 'local-state',
63
+ isLoading: isAnyLoading
64
+ };
65
+ let formatted = formatRow(totalSnap);
66
+ if (isAnyLoading) {
67
+ const frame = SPINNER[spinnerTick % SPINNER.length];
68
+ formatted += ` ${DIM}${GRAY}${frame} loading...${R}`;
69
+ }
70
+ else {
71
+ formatted += ` ${DIM}${GRAY}(tune weights: agent-fuel config)${R}`;
72
+ }
73
+ return formatted;
74
+ }
20
75
  // In TTY mode: restore cursor to saved position and repaint all slots.
21
76
  // In pipe mode: emit each newly-resolved line exactly once (tracked via emitted set).
22
- function redraw(slots, emitted) {
77
+ function redraw(slots, emitted, snapshots) {
23
78
  if (!isTTY) {
24
79
  for (const tool of SLOT_ORDER) {
25
80
  const line = slots.get(tool);
@@ -28,9 +83,20 @@ function redraw(slots, emitted) {
28
83
  emitted.add(tool);
29
84
  }
30
85
  }
86
+ if (config.showTotal && emitted.size === SLOT_ORDER.length && !emitted.has('total')) {
87
+ const totalLine = calculateTotalLine(snapshots);
88
+ process.stdout.write('\n' + totalLine + '\n');
89
+ emitted.add('total');
90
+ }
31
91
  return;
32
92
  }
33
93
  process.stdout.write('\x1b8'); // DEC restore-cursor — teleports back to saved position
94
+ if (config.showTotal) {
95
+ process.stdout.write('\x1b[2K\r');
96
+ const totalLine = calculateTotalLine(snapshots);
97
+ process.stdout.write(totalLine + '\n');
98
+ process.stdout.write('\x1b[2K\r\n'); // spacer line
99
+ }
34
100
  for (const tool of SLOT_ORDER) {
35
101
  process.stdout.write('\x1b[2K\r');
36
102
  const line = slots.get(tool);
@@ -38,6 +104,10 @@ function redraw(slots, emitted) {
38
104
  }
39
105
  }
40
106
  async function main() {
107
+ const args = process.argv.slice(2);
108
+ if (handleConfigCommand(args)) {
109
+ return;
110
+ }
41
111
  const claudeAdapter = new ClaudeQuotaAdapter();
42
112
  const codexAdapter = new CodexQuotaAdapter();
43
113
  const agyAdapter = new AgyQuotaAdapter();
@@ -46,24 +116,30 @@ async function main() {
46
116
  printHeader();
47
117
  // Save cursor before the placeholder rows so redraw() can teleport back and overwrite them
48
118
  const slots = new Map(SLOT_ORDER.map(t => [t, null]));
119
+ const snapshots = new Map(SLOT_ORDER.map(t => [t, null]));
49
120
  const emitted = new Set(); // pipe-mode: tracks which lines have been printed
50
- if (isTTY)
121
+ if (isTTY) {
51
122
  process.stdout.write('\x1b7'); // DEC save-cursor
123
+ if (config.showTotal) {
124
+ process.stdout.write(calculateTotalLine(snapshots) + '\n\n');
125
+ }
126
+ }
52
127
  for (const tool of SLOT_ORDER) {
53
128
  process.stdout.write(spinnerLine(tool) + '\n');
54
129
  }
55
130
  // Animate spinner at 80ms while any slot is still loading
56
131
  const spinnerTimer = isTTY
57
- ? setInterval(() => { spinnerTick++; redraw(slots, emitted); }, 80)
132
+ ? setInterval(() => { spinnerTick++; redraw(slots, emitted, snapshots); }, 80)
58
133
  : null;
59
134
  // Each adapter fills its slot(s) and triggers a redraw; order is always fixed
60
135
  function fill(snaps) {
61
136
  for (const snap of snaps) {
62
137
  if (SLOT_ORDER.includes(snap.tool)) {
138
+ snapshots.set(snap.tool, snap);
63
139
  slots.set(snap.tool, formatRow(snap));
64
140
  }
65
141
  }
66
- redraw(slots, emitted);
142
+ redraw(slots, emitted, snapshots);
67
143
  }
68
144
  await Promise.allSettled([
69
145
  claudeAdapter.fetchSnapshots().then(fill),
@@ -72,7 +148,7 @@ async function main() {
72
148
  ]);
73
149
  if (spinnerTimer)
74
150
  clearInterval(spinnerTimer);
75
- redraw(slots, emitted); // final clean repaint with all data
151
+ redraw(slots, emitted, snapshots); // final clean repaint with all data
76
152
  printFooter();
77
153
  }
78
154
  main().catch((error) => {
package/dist/render.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { UsageSnapshot } from './adapters/index.js';
2
+ export declare const SHADE_CHAR = "\u2591";
2
3
  export declare function getDisplayName(tool: string): string;
3
4
  export declare function formatRow(snap: UsageSnapshot): string;
4
5
  export declare function printHeader(): void;
package/dist/render.js CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  // ── Constants ──────────────────────────────────────────────────────────────
5
5
  const BLOCK_CHAR = '█';
6
- const SHADE_CHAR = '░';
6
+ export const SHADE_CHAR = '░';
7
7
  const BAR_WIDTH = 30;
8
8
  // ── ANSI colour helpers ────────────────────────────────────────────────────
9
9
  const R = '\x1b[0m';
@@ -21,10 +21,13 @@ export function getDisplayName(tool) {
21
21
  case 'claude-code': return 'Claude Code';
22
22
  case 'agy-gemini': return 'AGY Gemini';
23
23
  case 'agy-other': return 'AGY Other';
24
+ case 'total': return 'Total';
24
25
  default: return tool;
25
26
  }
26
27
  }
27
- function pickColour(remaining) {
28
+ function pickColour(remaining, isLoading) {
29
+ if (isLoading)
30
+ return CYAN;
28
31
  if (remaining < 20)
29
32
  return RED;
30
33
  if (remaining < 50)
@@ -69,7 +72,7 @@ export function formatRow(snap) {
69
72
  percentStr = `${GRAY}unknown${R}`;
70
73
  }
71
74
  else {
72
- const colour = pickColour(remaining);
75
+ const colour = pickColour(remaining, snap.isLoading);
73
76
  const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((remaining * BAR_WIDTH) / 100)));
74
77
  const empty = BAR_WIDTH - filled;
75
78
  barStr = `${colour}${BLOCK_CHAR.repeat(filled)}${R}${GRAY}${SHADE_CHAR.repeat(empty)}${R}`;
@@ -89,7 +92,9 @@ export function formatRow(snap) {
89
92
  parts.push(`${DIM}${GRAY}[~est]${R}`);
90
93
  }
91
94
  const detailStr = parts.length > 0 ? ` ${parts.join(' ')}` : '';
92
- return `${BOLD}${displayName.padEnd(13)}${R} [${barStr}] ${percentStr}${detailStr}`;
95
+ const isTotal = snap.tool === 'total';
96
+ const labelPrefix = isTotal ? `${BOLD}${CYAN}` : BOLD;
97
+ return `${labelPrefix}${displayName.padEnd(13)}${R} [${barStr}] ${percentStr}${detailStr}`;
93
98
  }
94
99
  // ── Public render functions ────────────────────────────────────────────────
95
100
  export function printHeader() {
package/dist/tmux.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export declare function registerTempFile(filePath: string): void;
2
+ export declare function unregisterTempFile(filePath: string): void;
1
3
  export declare class TuiScraper {
2
4
  private readonly command;
3
5
  private readonly width;
package/dist/tmux.js CHANGED
@@ -1,5 +1,62 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { unlinkSync } from 'node:fs';
3
+ import crypto from 'node:crypto';
2
4
  import { debug } from './debug.js';
5
+ // Global registries for active resources
6
+ const activeScrapers = new Set();
7
+ const activeTempFiles = new Set();
8
+ export function registerTempFile(filePath) {
9
+ activeTempFiles.add(filePath);
10
+ }
11
+ export function unregisterTempFile(filePath) {
12
+ activeTempFiles.delete(filePath);
13
+ }
14
+ function cleanupAll() {
15
+ if (activeScrapers.size === 0 && activeTempFiles.size === 0)
16
+ return;
17
+ process.stderr.write(`\n\x1b[33m[agent-fuel] Clean up triggered. Cleaning up resources...\x1b[0m\n`);
18
+ for (const scraper of activeScrapers) {
19
+ try {
20
+ scraper.kill();
21
+ }
22
+ catch {
23
+ // ignore
24
+ }
25
+ }
26
+ activeScrapers.clear();
27
+ for (const file of activeTempFiles) {
28
+ try {
29
+ unlinkSync(file);
30
+ }
31
+ catch {
32
+ // ignore
33
+ }
34
+ }
35
+ activeTempFiles.clear();
36
+ }
37
+ // Register signal handlers eagerly at module load time so that any temp files
38
+ // registered before TuiScraper.start() (e.g. between registerTempFile and start())
39
+ // are still cleaned up on SIGINT/SIGTERM/SIGHUP.
40
+ let signalsRegistered = false;
41
+ function registerSignalHandlers() {
42
+ if (signalsRegistered)
43
+ return;
44
+ signalsRegistered = true;
45
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
46
+ for (const sig of signals) {
47
+ process.on(sig, () => {
48
+ cleanupAll();
49
+ const code = sig === 'SIGINT' ? 130 : sig === 'SIGTERM' ? 143 : 129;
50
+ process.exit(code);
51
+ });
52
+ }
53
+ process.on('uncaughtException', (err) => {
54
+ process.stderr.write(`\x1b[31mUncaught Exception:\x1b[0m ${err.stack || err}\n`);
55
+ cleanupAll();
56
+ process.exit(1);
57
+ });
58
+ }
59
+ registerSignalHandlers(); // called at import time — guarded by signalsRegistered flag
3
60
  export class TuiScraper {
4
61
  command;
5
62
  width;
@@ -9,11 +66,11 @@ export class TuiScraper {
9
66
  this.command = command;
10
67
  this.width = width;
11
68
  this.height = height;
12
- this.sessionId = `af-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
69
+ this.sessionId = `af-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
13
70
  }
14
71
  start() {
15
72
  try {
16
- execFileSync('which', ['tmux'], { stdio: 'ignore' });
73
+ execFileSync('which', ['tmux'], { stdio: 'ignore', timeout: 5000 });
17
74
  }
18
75
  catch {
19
76
  throw new Error('tmux not found — install with: brew install tmux');
@@ -23,7 +80,8 @@ export class TuiScraper {
23
80
  'new-session', '-d', '-s', this.sessionId,
24
81
  '-x', String(this.width), '-y', String(this.height),
25
82
  this.command,
26
- ]);
83
+ ], { stdio: 'ignore', timeout: 5000 });
84
+ activeScrapers.add(this);
27
85
  }
28
86
  // historyLines > 0 → include that many lines of scrollback above the visible screen.
29
87
  // This catches transient overlays that rendered and then re-rendered away.
@@ -31,18 +89,18 @@ export class TuiScraper {
31
89
  const args = historyLines > 0
32
90
  ? ['capture-pane', '-t', this.sessionId, '-S', `-${historyLines}`, '-p']
33
91
  : ['capture-pane', '-t', this.sessionId, '-p'];
34
- const text = execFileSync('tmux', args).toString();
92
+ const text = execFileSync('tmux', args, { timeout: 5000 }).toString();
35
93
  debug('tmux:capture', `[${this.sessionId}] captured ${text.length} chars (history=${historyLines})`);
36
94
  return text;
37
95
  }
38
96
  send(text) {
39
97
  debug('tmux:send', `[${this.sessionId}] sending: ${JSON.stringify(text)}`);
40
- execFileSync('tmux', ['send-keys', '-t', this.sessionId, text, 'Enter']);
98
+ execFileSync('tmux', ['send-keys', '-t', this.sessionId, text, 'Enter'], { stdio: 'ignore', timeout: 5000 });
41
99
  }
42
100
  // Send a named key (Tab, Escape, Up, Down, etc.) without appending Enter.
43
101
  sendKey(key) {
44
102
  debug('tmux:send', `[${this.sessionId}] sendKey: ${key}`);
45
- execFileSync('tmux', ['send-keys', '-t', this.sessionId, key]);
103
+ execFileSync('tmux', ['send-keys', '-t', this.sessionId, key], { stdio: 'ignore', timeout: 5000 });
46
104
  }
47
105
  async waitFor(pattern, timeoutMs, historyLines = 500) {
48
106
  const deadline = Date.now() + timeoutMs;
@@ -62,9 +120,10 @@ export class TuiScraper {
62
120
  kill() {
63
121
  debug('tmux', `killing session ${this.sessionId}`);
64
122
  try {
65
- execFileSync('tmux', ['kill-session', '-t', this.sessionId], { stdio: 'ignore' });
123
+ execFileSync('tmux', ['kill-session', '-t', this.sessionId], { stdio: 'ignore', timeout: 3000 });
66
124
  }
67
125
  catch { /* already dead */ }
126
+ activeScrapers.delete(this); // delete after kill attempt so a concurrent signal doesn't see a half-killed scraper
68
127
  }
69
128
  }
70
129
  export function sleep(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-fuel",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Sleek term-based dashboard for AI coding CLI quotas",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",