agent-tank 0.9.8 → 0.9.10
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/package.json +1 -1
- package/src/agents/agy.js +65 -10
- package/src/agents/base.js +83 -29
- package/src/agents/codex.js +2 -2
- package/src/agents/pty-output-parser.js +14 -2
- package/src/index.js +3 -1
- package/src/shutdown-handler.js +15 -4
package/package.json
CHANGED
package/src/agents/agy.js
CHANGED
|
@@ -4,6 +4,10 @@ const { CYCLE_DURATIONS } = require('../usage-formatters.js');
|
|
|
4
4
|
const logger = require('../logger.js');
|
|
5
5
|
|
|
6
6
|
class AgyAgent extends BaseAgent {
|
|
7
|
+
// Matches both the older "Model Quota" screen and the newer grouped
|
|
8
|
+
// "Models & Quota" screen reported by recent Antigravity CLI builds.
|
|
9
|
+
static QUOTA_HEADER = /Models?\s*&?\s*Quota/i;
|
|
10
|
+
|
|
7
11
|
constructor() {
|
|
8
12
|
super('agy', 'agy', ['--dangerously-skip-permissions']);
|
|
9
13
|
this._aboutSent = false;
|
|
@@ -128,8 +132,8 @@ class AgyAgent extends BaseAgent {
|
|
|
128
132
|
hasCompleteOutput(output) {
|
|
129
133
|
const clean = this.stripAnsi(output);
|
|
130
134
|
if (this.detectAuthenticationState(clean)) return true;
|
|
131
|
-
if (
|
|
132
|
-
return /(?:Gemini|Claude|GPT)[\s\S]*\d+(?:\.\d+)
|
|
135
|
+
if (AgyAgent.QUOTA_HEADER.test(clean)) {
|
|
136
|
+
return /(?:Gemini|Claude|GPT|Weekly\s+Limit|Five[\s-]?Hour\s+Limit)[\s\S]*\d+(?:\.\d+)?\s*%/i.test(clean);
|
|
133
137
|
}
|
|
134
138
|
const hasModel = /(?:Gemini|Claude|GPT)[^\n]+/i.test(clean);
|
|
135
139
|
const hasPercent = /\d+(?:\.\d+)?%/i.test(clean);
|
|
@@ -171,8 +175,32 @@ class AgyAgent extends BaseAgent {
|
|
|
171
175
|
return;
|
|
172
176
|
}
|
|
173
177
|
logger.agent(this.name, 'Sending /usage command...');
|
|
174
|
-
|
|
175
|
-
|
|
178
|
+
// A prior /usage leaves Antigravity's usage modal open. On a reused
|
|
179
|
+
// persistent process that modal swallows the next /usage (0 bytes back ->
|
|
180
|
+
// 35s timeout -> kill/respawn, which is why refreshes alternated
|
|
181
|
+
// success/timeout). Press Escape first to return to the prompt; it is a
|
|
182
|
+
// no-op at a clean prompt and safe because auth flows are guarded above.
|
|
183
|
+
setTimeout(() => shell.write('\x1b'), 200);
|
|
184
|
+
setTimeout(() => shell.write('/usage'), 600);
|
|
185
|
+
setTimeout(() => shell.write('\r'), 1000);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Convert an ALL-CAPS Antigravity group header (e.g. "CLAUDE AND GPT") into a
|
|
189
|
+
// readable label ("Claude and GPT"), preserving known acronyms and lowercasing
|
|
190
|
+
// connective words.
|
|
191
|
+
_formatGroupName(raw) {
|
|
192
|
+
const acronyms = new Set(['GPT', 'GPU', 'AI', 'CLI', 'API', 'OSS']);
|
|
193
|
+
const connectives = new Set(['and', 'or', 'of', 'the', 'for', 'with']);
|
|
194
|
+
return raw
|
|
195
|
+
.split(/\s+/)
|
|
196
|
+
.map((word, idx) => {
|
|
197
|
+
const upper = word.toUpperCase();
|
|
198
|
+
if (acronyms.has(upper)) return upper;
|
|
199
|
+
const lower = word.toLowerCase();
|
|
200
|
+
if (idx > 0 && connectives.has(lower)) return lower;
|
|
201
|
+
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
202
|
+
})
|
|
203
|
+
.join(' ');
|
|
176
204
|
}
|
|
177
205
|
|
|
178
206
|
// Calculate pace data for a model entry when reset timing is available.
|
|
@@ -201,21 +229,40 @@ class AgyAgent extends BaseAgent {
|
|
|
201
229
|
}
|
|
202
230
|
|
|
203
231
|
_parseAgyUsage(clean, usage) {
|
|
204
|
-
if (
|
|
232
|
+
if (!AgyAgent.QUOTA_HEADER.test(clean)) return;
|
|
205
233
|
|
|
206
234
|
const lines = clean.split('\n').map(line => line.trim()).filter(Boolean);
|
|
207
235
|
const knownMarkers = new Set([
|
|
208
236
|
'Model Quota',
|
|
237
|
+
'Models & Quota',
|
|
209
238
|
'Quota available',
|
|
210
239
|
'? for shortcuts',
|
|
211
240
|
]);
|
|
212
241
|
|
|
242
|
+
// Newer Antigravity builds group quotas under section headers such as
|
|
243
|
+
// "GEMINI MODELS" / "CLAUDE AND GPT MODELS", with each group reporting its
|
|
244
|
+
// own "Weekly Limit" and "Five Hour Limit". Track the active group so those
|
|
245
|
+
// generic labels can be qualified and not collapsed into a single entry.
|
|
246
|
+
let currentGroup = null;
|
|
247
|
+
|
|
213
248
|
for (let i = 0; i < lines.length; i++) {
|
|
214
|
-
const
|
|
215
|
-
|
|
249
|
+
const line = lines[i];
|
|
250
|
+
|
|
251
|
+
const groupMatch = line.match(/^([A-Z][A-Z0-9 &/-]*?)\s+MODELS$/);
|
|
252
|
+
if (groupMatch) {
|
|
253
|
+
currentGroup = this._formatGroupName(groupMatch[1].trim());
|
|
216
254
|
continue;
|
|
217
255
|
}
|
|
218
|
-
|
|
256
|
+
|
|
257
|
+
if (knownMarkers.has(line) || /^(?:[>└│]|[-─↑/↓]|esc\s+to\s+cancel)/i.test(line)) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
// Descriptive lines that are not models (e.g. "Account: ...",
|
|
261
|
+
// "Models within this group: ...").
|
|
262
|
+
if (/^(?:Account|Models within this group)\s*:/i.test(line)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (!/[A-Za-z]/.test(line) || /\d+\s*%/.test(line)) {
|
|
219
266
|
continue;
|
|
220
267
|
}
|
|
221
268
|
|
|
@@ -232,8 +279,16 @@ class AgyAgent extends BaseAgent {
|
|
|
232
279
|
const resetsIn = resetMatch?.[1]?.trim() || null;
|
|
233
280
|
const resetsInSeconds = resetsIn ? this.parseDurationToSeconds(resetsIn) : null;
|
|
234
281
|
|
|
235
|
-
|
|
236
|
-
|
|
282
|
+
// Qualify shared limit labels with their group so entries from different
|
|
283
|
+
// groups stay distinct; per-model labels are already unique on their own.
|
|
284
|
+
const isSharedLimit = /^(?:weekly|five[\s-]?hour|5[\s-]?hour|daily|hourly)\b/i.test(line);
|
|
285
|
+
const modelName = (currentGroup && isSharedLimit)
|
|
286
|
+
? `${currentGroup} · ${line}`
|
|
287
|
+
: line;
|
|
288
|
+
|
|
289
|
+
if (!usage.models.find(m => m.model === modelName)) {
|
|
290
|
+
const modelEntry = { model: modelName, usageLeft, percentUsed, resetsIn, resetsInSeconds };
|
|
291
|
+
if (currentGroup) modelEntry.group = currentGroup;
|
|
237
292
|
this._addPaceData(modelEntry, resetsInSeconds);
|
|
238
293
|
usage.models.push(modelEntry);
|
|
239
294
|
}
|
package/src/agents/base.js
CHANGED
|
@@ -5,6 +5,10 @@ const process = require('node:process');
|
|
|
5
5
|
const logger = require('../logger.js');
|
|
6
6
|
|
|
7
7
|
class BaseAgent {
|
|
8
|
+
// Grace period before escalating a runtime SIGTERM to SIGKILL for CLIs that
|
|
9
|
+
// ignore graceful termination. Overridable in tests.
|
|
10
|
+
static FORCE_KILL_GRACE_MS = 2000;
|
|
11
|
+
|
|
8
12
|
constructor(name, command, args = []) {
|
|
9
13
|
this.name = name;
|
|
10
14
|
this.command = command;
|
|
@@ -130,14 +134,6 @@ class BaseAgent {
|
|
|
130
134
|
return;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
if (/rate.?limited|rate_limit_error/i.test(cleanOutput)) {
|
|
134
|
-
logger.agent(this.name, 'Rate limited, preserving last known usage data');
|
|
135
|
-
this.error = 'Rate limited — using cached data';
|
|
136
|
-
this.lastUpdated = new Date().toISOString();
|
|
137
|
-
// Don't overwrite this.usage — keep the last known good data
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
137
|
// Check for session/auth errors — preserve cached data if available
|
|
142
138
|
const sessionErrorMatch = cleanOutput.match(/session.?expired|session.?error|invalid.?session|authentication.?error|auth.?failed|Unable to (?:load|fetch)|Error loading|could not (?:load|fetch)|Failed to load usage|not authenticated|login required|sign.?in required/i);
|
|
143
139
|
if (sessionErrorMatch) {
|
|
@@ -148,12 +144,7 @@ class BaseAgent {
|
|
|
148
144
|
return;
|
|
149
145
|
}
|
|
150
146
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// Only update usage if we got meaningful data (not all-null)
|
|
154
|
-
const hasData = parsed && Object.values(parsed).some(v => v !== null && v !== undefined && (typeof v !== 'object' || (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0)));
|
|
155
|
-
if (hasData) { this.usage = parsed; this.lastUpdated = new Date().toISOString(); } else if (this.usage) { logger.agent(this.name, 'Parse returned no data, preserving last known usage'); this.error = 'Failed to parse — using cached data'; } else { this.usage = parsed; this.lastUpdated = new Date().toISOString(); }
|
|
156
|
-
logger.agent(this.name, 'Parsed usage:', logger.json(this.usage));
|
|
147
|
+
this._applyParsedUsage(this.parseOutput(output), cleanOutput);
|
|
157
148
|
} catch (err) {
|
|
158
149
|
if (this.isStopping() && err.message === 'Agent stopping') {
|
|
159
150
|
logger.agent(this.name, 'Refresh cancelled during shutdown');
|
|
@@ -167,6 +158,34 @@ class BaseAgent {
|
|
|
167
158
|
}
|
|
168
159
|
}
|
|
169
160
|
|
|
161
|
+
// Decide what to do with freshly parsed usage, given the raw (cleaned) output.
|
|
162
|
+
// Prefers fresh data; treats rate-limit notices as an error only when no
|
|
163
|
+
// usable data came back (a partial rate limit — e.g. Claude's per-model /
|
|
164
|
+
// Sonnet breakdown — still yields session/weekly data, and the rate-limited
|
|
165
|
+
// section simply parses to null and is omitted by the formatter).
|
|
166
|
+
_applyParsedUsage(parsed, cleanOutput) {
|
|
167
|
+
const hasData = parsed && Object.values(parsed).some(v =>
|
|
168
|
+
v !== null && v !== undefined &&
|
|
169
|
+
(typeof v !== 'object' || (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0)));
|
|
170
|
+
|
|
171
|
+
if (hasData) {
|
|
172
|
+
this.usage = parsed;
|
|
173
|
+
this.lastUpdated = new Date().toISOString();
|
|
174
|
+
} else if (/rate.?limited|rate_limit_error/i.test(cleanOutput)) {
|
|
175
|
+
logger.agent(this.name, 'Rate limited with no usable data, preserving last known usage');
|
|
176
|
+
this.error = this.usage ? 'Rate limited — using cached data' : 'Rate limited';
|
|
177
|
+
this.lastUpdated = new Date().toISOString();
|
|
178
|
+
return;
|
|
179
|
+
} else if (this.usage) {
|
|
180
|
+
logger.agent(this.name, 'Parse returned no data, preserving last known usage');
|
|
181
|
+
this.error = 'Failed to parse — using cached data';
|
|
182
|
+
} else {
|
|
183
|
+
this.usage = parsed;
|
|
184
|
+
this.lastUpdated = new Date().toISOString();
|
|
185
|
+
}
|
|
186
|
+
logger.agent(this.name, 'Parsed usage:', logger.json(this.usage));
|
|
187
|
+
}
|
|
188
|
+
|
|
170
189
|
async runCommand() {
|
|
171
190
|
if (this.isStopping()) {
|
|
172
191
|
throw this._createStopError();
|
|
@@ -409,22 +428,57 @@ class BaseAgent {
|
|
|
409
428
|
}
|
|
410
429
|
|
|
411
430
|
_handleAdditionalPrompts(_s, _d, _o) { } // Hook for subclasses; killProcess: see below
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
431
|
+
// Terminates the persistent PTY process.
|
|
432
|
+
//
|
|
433
|
+
// Some CLIs (notably `agy` and `codex`) ignore SIGTERM/SIGHUP and would
|
|
434
|
+
// otherwise survive as orphaned processes (reparented to init) every time we
|
|
435
|
+
// respawn them or the server exits. We always attempt a graceful SIGTERM
|
|
436
|
+
// first, then escalate to SIGKILL:
|
|
437
|
+
// - runtime (default): asynchronously, after a short grace period, if the
|
|
438
|
+
// process is still alive (the event loop is running, so the timer fires);
|
|
439
|
+
// - immediate (shutdown): synchronously, because the process is about to
|
|
440
|
+
// call process.exit() and pending timers would never run.
|
|
441
|
+
killProcess({ immediate = false } = {}) {
|
|
442
|
+
if (!this.shell) return;
|
|
443
|
+
const shell = this.shell;
|
|
444
|
+
const pid = (typeof shell.pid === 'number' && shell.pid > 0) ? shell.pid : null;
|
|
445
|
+
const pidText = pid != null ? ` (pid ${pid})` : '';
|
|
446
|
+
logger.agent(this.name, `Killing persistent process${pidText}`);
|
|
447
|
+
for (const d of this._disposables) { d.dispose(); }
|
|
448
|
+
this._disposables = []; this._onDataCallback = null; this.processReady = false;
|
|
449
|
+
|
|
450
|
+
this._signalProcess(pid, shell, 'SIGTERM');
|
|
451
|
+
|
|
452
|
+
if (immediate) {
|
|
453
|
+
this._signalProcess(pid, shell, 'SIGKILL');
|
|
454
|
+
} else if (pid != null) {
|
|
455
|
+
const timer = setTimeout(() => {
|
|
456
|
+
if (!this._isProcessAlive(pid)) return;
|
|
457
|
+
logger.agent(this.name, `Process ${pid} ignored SIGTERM; sending SIGKILL`);
|
|
458
|
+
this._signalProcess(pid, shell, 'SIGKILL');
|
|
459
|
+
}, BaseAgent.FORCE_KILL_GRACE_MS);
|
|
460
|
+
// Never let the escalation timer keep the process alive on its own.
|
|
461
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
427
462
|
}
|
|
463
|
+
|
|
464
|
+
this.shell = null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Best-effort signal to a PTY process and its process group. PTY-backed CLIs
|
|
468
|
+
// may spawn descendants, so we signal the whole group when we have a pid.
|
|
469
|
+
// Never throws: the process or group may already be gone.
|
|
470
|
+
_signalProcess(pid, shell, signal) {
|
|
471
|
+
try {
|
|
472
|
+
if (pid != null) {
|
|
473
|
+
try { process.kill(-pid, signal); } catch (_e) { /* Group may not exist */ }
|
|
474
|
+
}
|
|
475
|
+
shell.kill(signal);
|
|
476
|
+
} catch (_e) { /* Process may already be dead */ }
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Returns true if the process is still alive (signal 0 probes without killing).
|
|
480
|
+
_isProcessAlive(pid) {
|
|
481
|
+
try { process.kill(pid, 0); return true; } catch (_e) { return false; }
|
|
428
482
|
}
|
|
429
483
|
|
|
430
484
|
async _runCommandFresh() {
|
package/src/agents/codex.js
CHANGED
|
@@ -347,12 +347,12 @@ class CodexAgent extends BaseAgent {
|
|
|
347
347
|
this._rpcSupported = useRpc;
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
-
killProcess() {
|
|
350
|
+
killProcess(options = {}) {
|
|
351
351
|
if (this._rpcClient) {
|
|
352
352
|
this._rpcClient.stop();
|
|
353
353
|
this._rpcClient = null;
|
|
354
354
|
}
|
|
355
|
-
super.killProcess();
|
|
355
|
+
super.killProcess(options);
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
/** Lightweight keepalive to prevent session expiration. @returns {Promise<boolean>} True if keepalive succeeded */
|
|
@@ -250,8 +250,20 @@ function addPaceData(sectionData, cycleType) {
|
|
|
250
250
|
function parsePtyOutput(clean) {
|
|
251
251
|
const usage = { session: null, weeklyAll: null, weeklySonnet: null };
|
|
252
252
|
|
|
253
|
-
// Parse session
|
|
254
|
-
|
|
253
|
+
// Parse session. The "Current session" label is frequently corrupted while
|
|
254
|
+
// Claude's /usage dialog is still rendering (e.g. "Curretsession"), even
|
|
255
|
+
// though the bar, "N% used", and reset time are intact. When the labeled
|
|
256
|
+
// extraction fails, fall back to the region before the weekly section — the
|
|
257
|
+
// session block is the only "N% used" that appears there.
|
|
258
|
+
let sessionData = parseUsageSection(extractSection(clean, 'Current\\s*session', 'Current\\s*week'));
|
|
259
|
+
if (!sessionData) {
|
|
260
|
+
const beforeWeekly = clean.split(/Current\s*week/i);
|
|
261
|
+
// length > 1 confirms the weekly section exists, i.e. this is real /usage
|
|
262
|
+
// output and beforeWeekly[0] is the session region (not unrelated text).
|
|
263
|
+
if (beforeWeekly.length > 1) {
|
|
264
|
+
sessionData = parseUsageSection(beforeWeekly[0]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
255
267
|
if (sessionData) {
|
|
256
268
|
usage.session = { label: 'Current session', ...sessionData };
|
|
257
269
|
addPaceData(usage.session, 'session');
|
package/src/index.js
CHANGED
|
@@ -571,7 +571,9 @@ class AgentTank {
|
|
|
571
571
|
if (typeof agent.requestStop === 'function') {
|
|
572
572
|
agent.requestStop();
|
|
573
573
|
}
|
|
574
|
-
|
|
574
|
+
// Shutdown is followed by process.exit(), so escalate to SIGKILL
|
|
575
|
+
// synchronously — a deferred timer would never run.
|
|
576
|
+
agent.killProcess({ immediate: true });
|
|
575
577
|
}
|
|
576
578
|
if (this.servers.length > 0) {
|
|
577
579
|
console.log('[Shutdown] Closing HTTP server');
|
package/src/shutdown-handler.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
// Signals that should trigger a graceful shutdown so agent PTY processes are
|
|
2
|
+
// cleaned up instead of leaking as orphans. SIGINT and SIGHUP matter for
|
|
3
|
+
// detached/background runs that have no TTY (where the stdin Ctrl+C watcher
|
|
4
|
+
// below does not apply). SIGKILL is intentionally absent — it cannot be caught.
|
|
5
|
+
const SHUTDOWN_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
|
|
6
|
+
|
|
1
7
|
function installShutdownHandlers({ shutdown, processObj = process, stdin = process.stdin }) {
|
|
2
|
-
const
|
|
8
|
+
const signalHandlers = SHUTDOWN_SIGNALS.map(signal => {
|
|
9
|
+
const handler = () => shutdown(signal);
|
|
10
|
+
processObj.on(signal, handler);
|
|
11
|
+
return [signal, handler];
|
|
12
|
+
});
|
|
13
|
+
|
|
3
14
|
const onStdinData = (chunk) => {
|
|
4
15
|
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ''));
|
|
5
16
|
if (data.includes(0x03)) {
|
|
@@ -7,8 +18,6 @@ function installShutdownHandlers({ shutdown, processObj = process, stdin = proce
|
|
|
7
18
|
}
|
|
8
19
|
};
|
|
9
20
|
|
|
10
|
-
processObj.on('SIGTERM', onSigterm);
|
|
11
|
-
|
|
12
21
|
const shouldWatchStdin = stdin && stdin.isTTY && typeof stdin.on === 'function';
|
|
13
22
|
if (shouldWatchStdin) {
|
|
14
23
|
stdin.on('data', onStdinData);
|
|
@@ -18,7 +27,9 @@ function installShutdownHandlers({ shutdown, processObj = process, stdin = proce
|
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
return () => {
|
|
21
|
-
|
|
30
|
+
for (const [signal, handler] of signalHandlers) {
|
|
31
|
+
processObj.off(signal, handler);
|
|
32
|
+
}
|
|
22
33
|
|
|
23
34
|
if (shouldWatchStdin) {
|
|
24
35
|
stdin.off('data', onStdinData);
|