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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
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;
@@ -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
- const parsed = this.parseOutput(output);
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
- 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;
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() {
@@ -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
- const sessionData = parseUsageSection(extractSection(clean, 'Current\\s*session', 'Current\\s*week'));
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
- 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);