agent-fuel 0.4.0 → 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/agy.js +12 -6
- package/dist/adapters/claude.js +21 -6
- package/dist/adapters/codex.js +56 -17
- package/dist/debug.js +32 -1
- package/dist/index.js +0 -0
- package/dist/tmux.d.ts +3 -0
- package/dist/tmux.js +67 -8
- package/package.json +3 -3
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/agy.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
import { TuiScraper } from '../tmux.js';
|
|
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
8
|
// ── Scraping ───────────────────────────────────────────────────────────────
|
|
8
|
-
async function sleep(ms) {
|
|
9
|
-
return new Promise(res => setTimeout(res, ms));
|
|
10
|
-
}
|
|
11
9
|
/**
|
|
12
10
|
* Launches `agy` in a tmux session, opens the `/usage` panel, waits for
|
|
13
11
|
* the Model Quota list to render, then returns clean rendered screen text.
|
|
@@ -16,8 +14,16 @@ async function runAgyUsage() {
|
|
|
16
14
|
const tui = new TuiScraper('agy');
|
|
17
15
|
try {
|
|
18
16
|
tui.start();
|
|
19
|
-
// Wait for AGY main menu ready
|
|
20
|
-
|
|
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
|
+
}
|
|
21
27
|
// Navigate to /usage panel
|
|
22
28
|
tui.send('/usage');
|
|
23
29
|
await tui.waitFor(/Model Quota/, 10_000);
|
package/dist/adapters/claude.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import { TuiScraper } from '../tmux.js';
|
|
1
|
+
import { TuiScraper, sleep } from '../tmux.js';
|
|
2
2
|
import { debug } from '../debug.js';
|
|
3
3
|
// ── TUI scraper ────────────────────────────────────────────────────────────
|
|
4
|
-
async function sleep(ms) {
|
|
5
|
-
return new Promise(res => setTimeout(res, ms));
|
|
6
|
-
}
|
|
7
4
|
/**
|
|
8
5
|
* Launches `claude` in a tmux session, opens /status, navigates to the
|
|
9
6
|
* Status tab (which shows real quota usage bars), and returns the captured
|
|
@@ -33,6 +30,23 @@ async function runClaudeScrape() {
|
|
|
33
30
|
tui.kill();
|
|
34
31
|
}
|
|
35
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
|
+
}
|
|
36
50
|
function parseScrapeOutput(screen) {
|
|
37
51
|
debug('claude:parse', `screen length: ${screen.length}`);
|
|
38
52
|
debug('claude:parse', 'screen', screen);
|
|
@@ -42,9 +56,10 @@ function parseScrapeOutput(screen) {
|
|
|
42
56
|
debug('claude:parse', `found ${usedMatches.length} "% used" matches`);
|
|
43
57
|
const sessionUsedPct = usedMatches[0] ? parseInt(usedMatches[0][1], 10) : null;
|
|
44
58
|
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
|
|
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").
|
|
46
61
|
const resetMatch = screen.match(/Resets\s+([^\n\r]+)/i);
|
|
47
|
-
const sessionResetAt = resetMatch ? resetMatch[1].trim() : null;
|
|
62
|
+
const sessionResetAt = resetMatch ? to24h(resetMatch[1].trim()) : null;
|
|
48
63
|
debug('claude:parse', 'result', { sessionUsedPct, sessionResetAt, weeklyUsedPct });
|
|
49
64
|
return { sessionUsedPct, sessionResetAt, weeklyUsedPct };
|
|
50
65
|
}
|
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
|
|
@@ -10,9 +13,17 @@ const execAsync = promisify(exec);
|
|
|
10
13
|
const DEFAULT_BUDGET_USD = 20.0;
|
|
11
14
|
const ROLLING_WINDOW_MS = 5 * 60 * 60 * 1000;
|
|
12
15
|
// ── TUI scraper (tmux) ─────────────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
// Codex may show one or more blocking dialogs before its main screen ("Tip:").
|
|
17
|
+
// Known dialogs and their dismissal key ("2" = skip/use existing):
|
|
18
|
+
// • Update nag: "Update available! x.x → y.y"
|
|
19
|
+
// • New-model intro: "Introducing GPT-5.5"
|
|
20
|
+
const CODEX_READY = /Tip:/i;
|
|
21
|
+
const CODEX_DIALOG = /Update available|Introducing GPT|Try new model|Use existing model/i;
|
|
22
|
+
const CODEX_EITHER = new RegExp(`(?:${CODEX_READY.source})|(?:${CODEX_DIALOG.source})`, 'i');
|
|
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
|
|
16
27
|
/**
|
|
17
28
|
* Launches `codex` in a tmux session, pipes all terminal bytes to a temp
|
|
18
29
|
* file, sends /status twice, then reads the file and returns the raw bytes.
|
|
@@ -25,30 +36,56 @@ async function sleep(ms) {
|
|
|
25
36
|
*/
|
|
26
37
|
async function runCodexScrape() {
|
|
27
38
|
const tui = new TuiScraper('codex');
|
|
28
|
-
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 });
|
|
29
45
|
try {
|
|
30
46
|
tui.start();
|
|
31
|
-
// Stream all pane output to a file from the start
|
|
32
|
-
|
|
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}`]);
|
|
33
55
|
debug('codex:scrape', `pipe-pane logging to ${pipePath}`);
|
|
34
|
-
// Wait for TUI ready
|
|
35
|
-
|
|
56
|
+
// Wait for TUI ready, dismissing any blocking dialogs along the way.
|
|
57
|
+
const dialogDeadline = Date.now() + CODEX_STARTUP_MS;
|
|
58
|
+
let screen = await tui.waitFor(CODEX_EITHER, CODEX_STARTUP_MS, 0);
|
|
59
|
+
while (!CODEX_READY.test(screen)) {
|
|
60
|
+
debug('codex:scrape', 'blocking dialog detected — sending "2" to dismiss');
|
|
61
|
+
tui.send('2');
|
|
62
|
+
await sleep(CODEX_DIALOG_SETTLE_MS);
|
|
63
|
+
const remaining = dialogDeadline - Date.now(); // compute AFTER sleep
|
|
64
|
+
if (remaining < 500) {
|
|
65
|
+
throw new Error('Codex TUI never reached ready state after dismissing dialogs');
|
|
66
|
+
}
|
|
67
|
+
screen = await tui.waitFor(CODEX_EITHER, remaining, 0);
|
|
68
|
+
}
|
|
36
69
|
// First /status: panel says "Limits: refresh requested; run /status again shortly"
|
|
37
70
|
tui.send('/status');
|
|
38
|
-
await sleep(
|
|
71
|
+
await sleep(CODEX_STATUS_REFRESH_MS);
|
|
39
72
|
// Second /status: has actual 5h/weekly quota data
|
|
40
73
|
tui.send('/status');
|
|
41
|
-
await sleep(
|
|
74
|
+
await sleep(CODEX_STATUS_READY_MS);
|
|
42
75
|
const raw = readFileSync(pipePath, 'utf-8');
|
|
43
76
|
debug('codex:scrape', `pipe log size: ${raw.length} bytes`);
|
|
44
77
|
return raw;
|
|
45
78
|
}
|
|
46
79
|
finally {
|
|
47
|
-
|
|
80
|
+
try {
|
|
81
|
+
tui.kill();
|
|
82
|
+
}
|
|
83
|
+
catch { /* already dead */ }
|
|
84
|
+
unregisterTempFile(pipePath); // always remove from registry, regardless of unlink success
|
|
48
85
|
try {
|
|
49
86
|
unlinkSync(pipePath);
|
|
50
87
|
}
|
|
51
|
-
catch { /* ok */ }
|
|
88
|
+
catch { /* ok if already gone */ }
|
|
52
89
|
}
|
|
53
90
|
}
|
|
54
91
|
function stripAnsi(str) {
|
|
@@ -119,7 +156,8 @@ async function fetchCcusageEstimate(budgetLimit) {
|
|
|
119
156
|
try {
|
|
120
157
|
({ stdout } = await execAsync('npx --no-install ccusage codex session --json'));
|
|
121
158
|
}
|
|
122
|
-
catch {
|
|
159
|
+
catch (err) {
|
|
160
|
+
debug('codex:ccusage', 'ccusage exec failed', String(err));
|
|
123
161
|
return unknown();
|
|
124
162
|
}
|
|
125
163
|
debug('codex:ccusage', 'raw stdout', stdout);
|
|
@@ -156,7 +194,7 @@ async function fetchCcusageEstimate(budgetLimit) {
|
|
|
156
194
|
if (latestActivity > 0) {
|
|
157
195
|
try {
|
|
158
196
|
resetAt = new Date(latestActivity + ROLLING_WINDOW_MS).toLocaleTimeString([], {
|
|
159
|
-
hour: '2-digit', minute: '2-digit',
|
|
197
|
+
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
160
198
|
});
|
|
161
199
|
}
|
|
162
200
|
catch { /* leave null */ }
|
|
@@ -178,7 +216,8 @@ async function fetchCcusageEstimate(budgetLimit) {
|
|
|
178
216
|
raw: { totalCost, todaySessionsCount: todaySessions.length, isEstimate: true },
|
|
179
217
|
};
|
|
180
218
|
}
|
|
181
|
-
catch {
|
|
219
|
+
catch (err) {
|
|
220
|
+
debug('codex:ccusage', 'unexpected error in ccusage fallback', String(err));
|
|
182
221
|
return unknown();
|
|
183
222
|
}
|
|
184
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/index.js
CHANGED
|
File without changes
|
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;
|
|
@@ -11,3 +13,4 @@ export declare class TuiScraper {
|
|
|
11
13
|
waitFor(pattern: RegExp, timeoutMs: number, historyLines?: number): Promise<string>;
|
|
12
14
|
kill(): void;
|
|
13
15
|
}
|
|
16
|
+
export declare function sleep(ms: number): Promise<void>;
|
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,11 +120,12 @@ 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
|
-
function sleep(ms) {
|
|
129
|
+
export function sleep(ms) {
|
|
71
130
|
return new Promise(res => setTimeout(res, ms));
|
|
72
131
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-fuel",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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": "^
|
|
32
|
-
"typescript": "^
|
|
31
|
+
"@types/node": "^25.9.1",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
33
|
}
|
|
34
34
|
}
|