agent-fuel 0.4.1 → 0.4.2
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 +5 -20
- package/dist/adapters/codex.js +35 -12
- package/dist/debug.js +32 -1
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.js +66 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,12 +86,12 @@ type UsageSnapshot = {
|
|
|
86
86
|
```
|
|
87
87
|
⚡️ Agent Fuel - CLI Quota Monitor
|
|
88
88
|
|
|
89
|
-
Claude Code [
|
|
90
|
-
Codex [
|
|
91
|
-
AGY Gemini [
|
|
92
|
-
AGY Other [
|
|
89
|
+
Claude Code [██████████████████████░░░░░░░░] 72% remaining (resets 23:10 (Europe/Copenhagen))
|
|
90
|
+
Codex [█████████████████████░░░░░░░░░] 69% remaining (resets 23:37)
|
|
91
|
+
AGY Gemini [██████████████████████████████] 100% remaining ✓ quota available [Gemini 3.5 Flash (Medium)]
|
|
92
|
+
AGY Other [████████████░░░░░░░░░░░░░░░░░░] 40% remaining (resets in 122h 53m) [Claude Sonnet 4.6 (Thinking)]
|
|
93
93
|
|
|
94
|
-
agent-fuel v0.
|
|
94
|
+
agent-fuel v0.x.y
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
Rows appear as each adapter resolves — Claude Code (instant) prints first, Codex and AGY follow as their TUI scrapes complete.
|
|
@@ -111,18 +111,3 @@ Rows appear as each adapter resolves — Claude Code (instant) prints first, Cod
|
|
|
111
111
|
|
|
112
112
|
> **Note on `AGENT_FUEL_CODEX_BUDGET`:** Codex quota is read directly from the Codex TUI via `expect` scraping. This variable is only used as a rough fallback estimate (shown as `[~est]`) when the TUI reports no quota warning and a percentage cannot be determined. It is a guess based on local session cost data — not an official Codex quota signal. The TUI scrape is always preferred.
|
|
113
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
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
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/tmux.d.ts
CHANGED
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()}-${
|
|
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) {
|