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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "Monitor and query usage limits for LLM CLI tools (Claude, Antigravity, Codex) via HTTP API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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 (/Model\s+Quota/i.test(clean)) {
132
- return /(?:Gemini|Claude|GPT)[\s\S]*\d+(?:\.\d+)?%/i.test(clean);
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
- setTimeout(() => shell.write('/usage'), 200);
175
- setTimeout(() => shell.write('\r'), 600);
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 (!/Model\s+Quota/i.test(clean)) return;
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 modelLine = lines[i];
215
- if (knownMarkers.has(modelLine) || /^(?:[>└]|[-─↑/↓]|esc\s+to\s+cancel)/i.test(modelLine)) {
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
- if (!/[A-Za-z]/.test(modelLine) || /\d+\s*%/.test(modelLine)) {
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
- if (!usage.models.find(m => m.model === modelLine)) {
236
- const modelEntry = { model: modelLine, usageLeft, percentUsed, resetsIn, resetsInSeconds };
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
  }
@@ -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
- killProcess() { // Terminates the persistent PTY process
413
- if (this.shell) {
414
- const pidText = typeof this.shell.pid === 'number' ? ` (pid ${this.shell.pid})` : '';
415
- logger.agent(this.name, `Killing persistent process${pidText}`);
416
- const shell = this.shell;
417
- for (const d of this._disposables) { d.dispose(); }
418
- this._disposables = []; this._onDataCallback = null; this.processReady = false;
419
- try {
420
- // PTY-backed CLIs may spawn descendants; kill the whole process group when available.
421
- if (typeof shell.pid === 'number' && shell.pid > 0) {
422
- try { process.kill(-shell.pid, 'SIGTERM'); } catch (_e) { /* Group may not exist */ }
423
- }
424
- shell.kill();
425
- } catch (_e) { /* Process may already be dead */ }
426
- this.shell = null;
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() {
@@ -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
- agent.killProcess();
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');
@@ -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 onSigterm = () => shutdown('SIGTERM');
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
- processObj.off('SIGTERM', onSigterm);
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);