agent-tank 0.9.8 → 0.9.9
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 +54 -15
- package/src/agents/codex.js +2 -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;
|
|
@@ -409,22 +413,57 @@ class BaseAgent {
|
|
|
409
413
|
}
|
|
410
414
|
|
|
411
415
|
_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
|
-
|
|
416
|
+
// Terminates the persistent PTY process.
|
|
417
|
+
//
|
|
418
|
+
// Some CLIs (notably `agy` and `codex`) ignore SIGTERM/SIGHUP and would
|
|
419
|
+
// otherwise survive as orphaned processes (reparented to init) every time we
|
|
420
|
+
// respawn them or the server exits. We always attempt a graceful SIGTERM
|
|
421
|
+
// first, then escalate to SIGKILL:
|
|
422
|
+
// - runtime (default): asynchronously, after a short grace period, if the
|
|
423
|
+
// process is still alive (the event loop is running, so the timer fires);
|
|
424
|
+
// - immediate (shutdown): synchronously, because the process is about to
|
|
425
|
+
// call process.exit() and pending timers would never run.
|
|
426
|
+
killProcess({ immediate = false } = {}) {
|
|
427
|
+
if (!this.shell) return;
|
|
428
|
+
const shell = this.shell;
|
|
429
|
+
const pid = (typeof shell.pid === 'number' && shell.pid > 0) ? shell.pid : null;
|
|
430
|
+
const pidText = pid != null ? ` (pid ${pid})` : '';
|
|
431
|
+
logger.agent(this.name, `Killing persistent process${pidText}`);
|
|
432
|
+
for (const d of this._disposables) { d.dispose(); }
|
|
433
|
+
this._disposables = []; this._onDataCallback = null; this.processReady = false;
|
|
434
|
+
|
|
435
|
+
this._signalProcess(pid, shell, 'SIGTERM');
|
|
436
|
+
|
|
437
|
+
if (immediate) {
|
|
438
|
+
this._signalProcess(pid, shell, 'SIGKILL');
|
|
439
|
+
} else if (pid != null) {
|
|
440
|
+
const timer = setTimeout(() => {
|
|
441
|
+
if (!this._isProcessAlive(pid)) return;
|
|
442
|
+
logger.agent(this.name, `Process ${pid} ignored SIGTERM; sending SIGKILL`);
|
|
443
|
+
this._signalProcess(pid, shell, 'SIGKILL');
|
|
444
|
+
}, BaseAgent.FORCE_KILL_GRACE_MS);
|
|
445
|
+
// Never let the escalation timer keep the process alive on its own.
|
|
446
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
427
447
|
}
|
|
448
|
+
|
|
449
|
+
this.shell = null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Best-effort signal to a PTY process and its process group. PTY-backed CLIs
|
|
453
|
+
// may spawn descendants, so we signal the whole group when we have a pid.
|
|
454
|
+
// Never throws: the process or group may already be gone.
|
|
455
|
+
_signalProcess(pid, shell, signal) {
|
|
456
|
+
try {
|
|
457
|
+
if (pid != null) {
|
|
458
|
+
try { process.kill(-pid, signal); } catch (_e) { /* Group may not exist */ }
|
|
459
|
+
}
|
|
460
|
+
shell.kill(signal);
|
|
461
|
+
} catch (_e) { /* Process may already be dead */ }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Returns true if the process is still alive (signal 0 probes without killing).
|
|
465
|
+
_isProcessAlive(pid) {
|
|
466
|
+
try { process.kill(pid, 0); return true; } catch (_e) { return false; }
|
|
428
467
|
}
|
|
429
468
|
|
|
430
469
|
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 */
|
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);
|