agent-tank 0.9.10 → 0.9.11

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/CHANGELOG.md ADDED
@@ -0,0 +1,157 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.9.11] - 2026-09-29
9
+
10
+ ### Verified
11
+
12
+ Verified with the following agent versions (versions checked on 2026-09-29):
13
+
14
+ - Claude Code `2.1.284`
15
+ - Antigravity CLI (`agy`) `1.2.12`
16
+ - Codex CLI `0.158.0`
17
+
18
+ ### Added
19
+
20
+ - Monitor multiple accounts from the same provider using the repeatable `--agent provider[:alias][=config-path]` flag or the config file's `agents` array, with separate config directories, dashboard labels, history, and refresh endpoints
21
+ - Parse Codex model-specific rate-limit buckets alongside the main five-hour and weekly limits
22
+ - Include absolute `resetsAt` timestamps and limit `cycle` identifiers in Antigravity usage entries
23
+
24
+ ### Changed
25
+
26
+ - Replace Claude's `weeklySonnet` usage field with `weeklyFable` in PTY and OAuth API parsing, dashboard display, history, and pace calculations; API consumers using `weeklySonnet` must update to the new field
27
+
28
+ ### Fixed
29
+
30
+ - Recover swallowed Claude `/usage` commands without interrupting partially rendered usage or an active refresh, and parse metadata correctly from compact `/status` output
31
+ - Submit Codex slash commands through the newer command picker and accept weekly-only usage output without waiting for a missing five-hour limit
32
+ - Initialize the Codex app-server JSON-RPC connection and parse its current rate-limit response, including used percentages, window durations, and Unix reset timestamps
33
+ - Preserve Claude Fable allowances at zero usage and wait for delayed allowance rows before dismissing the usage dialog, while allowing accounts without Fable to complete
34
+ - Parse Antigravity's `Refreshes in` reset countdowns, including lines with leftover terminal box borders, and use the correct weekly or five-hour window for pace calculations
35
+
36
+ ## [0.9.10] - 2026-06-15
37
+
38
+ ### Fixed
39
+
40
+ - Recover Claude session usage from the `/usage` dialog even when its "Current session" row is captured mid-render in a corrupted state, instead of returning only weekly and Sonnet data (and timing out)
41
+ - Stop treating a partial Claude rate limit (e.g. the per-model/Sonnet breakdown) as an error: keep the fresh session and weekly data, omit only the rate-limited section, and surface a rate-limit error only when no usable data is returned
42
+
43
+ ## [0.9.9] - 2026-06-15
44
+
45
+ ### Fixed
46
+
47
+ - Parse the newer Antigravity "Models & Quota" usage screen, keeping each model group's weekly and five-hour limits as distinct, readable entries instead of collapsing them into two generic limits
48
+ - Dismiss the Antigravity usage modal before re-running `/usage`, fixing refreshes that alternated between success and a 35s timeout on the reused session
49
+ - Force-kill agent PTY processes that ignore `SIGTERM`/`SIGHUP` (e.g. `agy`, `codex`) so they no longer survive as orphaned processes after respawns or shutdown
50
+ - Clean up agent processes on `SIGINT` and `SIGHUP` in addition to `SIGTERM`, so detached/background runs shut down cleanly
51
+
52
+ ## [0.9.8] - 2026-06-14
53
+
54
+ ### Added
55
+
56
+ - Add `--background` (and `AGENT_TANK_BACKGROUND`) to start Agent Tank as a detached process that prints its PID and log path, then exits the parent command
57
+ - Add `AGENT_TANK_BACKGROUND_LOG` to set the background child's stdout/stderr log path, and `AGENT_TANK_BACKGROUND_GRACE_MS` to configure the parent's startup grace period before reporting success
58
+ - Warn at startup when other Agent Tank processes are already running, listing each PID with credential flags redacted, and including `kill` / `kill -9` instructions to stop them; the warning header is shown in bold red on an interactive terminal
59
+ - Document running Agent Tank from source in the README (clone, install, run, flags, prerequisites)
60
+
61
+ ### Fixed
62
+
63
+ - Harden the debug output dump on the status page so malformed agent output can't crash the process
64
+
65
+ ## [0.9.7] - 2026-06-05
66
+
67
+ ### Changed
68
+
69
+ - Use chokidar polling for activity monitoring to reduce native watcher pressure on large CLI log trees
70
+ - Replace Gemini CLI support with Antigravity CLI (`agy`) using the interactive `/usage` command
71
+
72
+ ### Fixed
73
+
74
+ - Fix Claude Code PTY one-shot stats checks against Claude Code 2.1 compact usage output
75
+ - Prevent Claude metadata fetch from crashing when the CLI exits before delayed PTY writes run
76
+ - Dismiss Claude `/status` metadata dialogs before sending `/usage` so one-shot usage checks can complete
77
+ - Parse compact Claude reset timestamps such as `Jun10,6pm(Europe/Berlin)`
78
+ - Wait for Claude's separate weekly Sonnet usage section before completing `/usage` parsing
79
+
80
+ ## [0.9.5] - 2026-04-13
81
+
82
+ ### Added
83
+
84
+ - Add a per-agent refresh cooldown with a default of 30 seconds, applied to manual refreshes, startup refreshes, and auto-refresh activity
85
+ - Add `--refresh-cooldown` and `AGENT_TANK_REFRESH_COOLDOWN` to reconfigure or disable the refresh cooldown
86
+
87
+ ### Changed
88
+
89
+ - Prefer Docker bridge gateway discovery via `docker network inspect`, falling back to local interface detection only when Docker is unavailable
90
+
91
+ ### Fixed
92
+
93
+ - Stop interval-mode auto-refresh from bypassing the central refresh guard
94
+ - Tighten Gemini CLI readiness detection so auth and agent-thinking screens are less likely to be misread as ready prompts
95
+
96
+ ## [0.9.4] - 2026-04-12
97
+
98
+ ### Added
99
+
100
+ - Bind to the local Docker bridge by default when `--host` is omitted, so containers on the same host can reach Agent Tank without exposing it publicly
101
+ - Add `--no-docker` and `AGENT_TANK_DOCKER` to disable Docker bridge binding explicitly
102
+
103
+ ### Fixed
104
+
105
+ - Keep zero-usage reset times visible in the UI for Claude, Gemini, and Codex when reset information exists
106
+ - Remove trailing slashes from startup banner status page URLs
107
+
108
+ ## [0.9.3] - 2026-04-11
109
+
110
+ ### Added
111
+
112
+ - Add `--version` to the `agent-tank` CLI for quick version checks
113
+ - Log PTY and JSON-RPC subprocess PIDs when agent processes start
114
+
115
+ ### Fixed
116
+
117
+ - Make `Ctrl+C` exits immediate again by avoiding reliance on a JS `SIGINT` handler
118
+ - Prevent shutdown races from respawning agent processes after teardown begins
119
+ - Guard PTY refresh, metadata fetch, and keepalive paths against restart during shutdown
120
+
121
+ ## [0.9.2] - 2026-04-09
122
+
123
+ ### Fixed
124
+
125
+ - Prevent Gemini agent crashes when the CLI process exits during authentication flows
126
+ - Avoid sending `Escape` while Gemini OAuth authentication is in progress, which could cancel login
127
+ - Improve Gemini trust prompt handling during command execution and slow startup scenarios
128
+ - Detect Gemini CLI unauthenticated/setup screens and report them clearly instead of surfacing a generic timeout
129
+
130
+ ### Added
131
+
132
+ - Regression tests covering Gemini shell null-safety during delayed callbacks
133
+ - Gemini authentication-required status in the API response and a concise login instruction in the UI
134
+
135
+ ## [0.9.0] - 2024-01-20
136
+
137
+ ### Added
138
+
139
+ - Initial release
140
+ - Support for Claude Code CLI (`/usage` command)
141
+ - Session usage percentage
142
+ - Weekly usage (all models)
143
+ - Weekly usage (Sonnet only)
144
+ - Reset times
145
+ - Support for Gemini CLI (`/stats` command)
146
+ - Per-model usage percentages
147
+ - Reset times for each model
148
+ - Support for OpenAI Codex CLI (`/status` command)
149
+ - 5-hour limit percentage
150
+ - Weekly limit percentage
151
+ - Model and account info
152
+ - Auto-discovery of installed LLM CLI tools
153
+ - HTTP API with JSON responses
154
+ - Built-in HTML status page dashboard
155
+ - Refresh endpoints for on-demand updates
156
+ - CORS support for cross-origin requests
157
+ - Cursor position query response for Codex compatibility
package/README.md CHANGED
@@ -170,7 +170,7 @@ agent-tank --refresh-cooldown 60
170
170
 
171
171
  | Agent | Method | Metrics |
172
172
  |---|---|---|
173
- | Claude | PTY `/usage` or Anthropic OAuth API | Current session, weekly all-models, weekly Sonnet-only |
173
+ | Claude | PTY `/usage` or Anthropic OAuth API | Current session, weekly all-models, weekly Fable allowance (Max accounts only) |
174
174
  | Antigravity | PTY `/usage` | Per-model quota availability and reset windows when reported |
175
175
  | Codex | JSON-RPC preferred, PTY fallback | 5-hour limits, weekly limits, model/account info |
176
176
 
@@ -237,6 +237,7 @@ Options:
237
237
  --claude Enable Claude monitoring
238
238
  --agy Enable Antigravity monitoring
239
239
  --codex Enable Codex monitoring
240
+ --agent <spec> Add an account as provider[:alias][=config-path] (repeatable)
240
241
  --port <port> HTTP server port (default: 3456)
241
242
  --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
242
243
  --docker Enable Docker bridge bind when --host is omitted (default: true)
@@ -293,11 +294,32 @@ three exceptions: `--no-background`, `--once`, and `--json` suppress
293
294
 
294
295
  ### Config File
295
296
 
297
+ Use the `agents` array to monitor multiple accounts, including multiple accounts
298
+ from the same provider. Each entry accepts a `provider`, an optional unique `id`
299
+ and display `alias`, and an optional `configPath` directory:
300
+
296
301
  ```json
297
302
  {
298
- "claude": true,
299
- "agy": true,
300
- "codex": false,
303
+ "agents": [
304
+ {
305
+ "provider": "codex",
306
+ "id": "work",
307
+ "alias": "Work",
308
+ "configPath": "/srv/accounts/codex-work"
309
+ },
310
+ {
311
+ "provider": "codex",
312
+ "id": "personal",
313
+ "alias": "Personal",
314
+ "configPath": "/srv/accounts/codex-personal"
315
+ },
316
+ {
317
+ "provider": "claude",
318
+ "id": "client-a",
319
+ "alias": "Client A",
320
+ "configPath": "./accounts/claude-client-a"
321
+ }
322
+ ],
301
323
  "port": 8080,
302
324
  "dockerAccess": true,
303
325
  "claudeApi": false,
@@ -318,12 +340,32 @@ three exceptions: `--no-background`, `--once`, and `--json` suppress
318
340
  }
319
341
  ```
320
342
 
343
+ Relative account paths are resolved relative to the JSON config file, and `~/`
344
+ paths are expanded to the current user's home directory. The path
345
+ is passed to the provider CLI as its config home: `CLAUDE_CONFIG_DIR` for Claude,
346
+ `CODEX_HOME` for Codex, and `GEMINI_CLI_HOME` for Antigravity. Legacy top-level
347
+ settings such as `"claude": true` remain supported for single default accounts.
348
+
321
349
  Run with:
322
350
 
323
351
  ```bash
324
352
  agent-tank -c config.json
325
353
  ```
326
354
 
355
+ The same setup can be supplied directly on the command line. Repeat `--agent`
356
+ once per account:
357
+
358
+ ```bash
359
+ agent-tank \
360
+ --agent codex:work=/srv/accounts/codex-work \
361
+ --agent codex:personal=/srv/accounts/codex-personal
362
+ ```
363
+
364
+ The `id` (or CLI alias after `:`) is the key returned by `/status` and is used
365
+ by account-specific status, history, and refresh endpoints. IDs may contain
366
+ letters, numbers, dots, underscores, and hyphens. If both `id` and `alias` are
367
+ set in JSON, `id` is the API key and `alias` is the dashboard label.
368
+
327
369
  ## Antigravity
328
370
 
329
371
  Antigravity support uses the `agy` CLI and reads the interactive `/usage` output.
@@ -337,6 +379,25 @@ agent-tank --agy
337
379
  agent-tank --agy --once --json
338
380
  ```
339
381
 
382
+ Each entry in `usage.models` reports the quota left, the countdown Antigravity
383
+ displays (`Resets in` on older builds, `Refreshes in` on the grouped
384
+ `Models & Quota` screen), the absolute instant that countdown points at, and the
385
+ cycle the limit belongs to. A limit showing `Quota available` has no pending
386
+ reset, so its reset fields are `null`.
387
+
388
+ ```json
389
+ {
390
+ "model": "Gemini · Five Hour Limit Remaining",
391
+ "usageLeft": 90.31,
392
+ "percentUsed": 9.7,
393
+ "resetsIn": "58m",
394
+ "resetsInSeconds": 3480,
395
+ "resetsAt": "2026-09-23T11:52:00.000Z",
396
+ "cycle": "fiveHour",
397
+ "group": "Gemini"
398
+ }
399
+ ```
400
+
340
401
  ## HTTP API
341
402
 
342
403
  ### Endpoints
@@ -345,12 +406,12 @@ agent-tank --agy --once --json
345
406
  |---|---|---|
346
407
  | GET | `/` | HTML status page |
347
408
  | GET | `/status` | JSON status for all agents |
348
- | GET | `/status/:agent` | JSON status for one agent |
409
+ | GET | `/status/:id` | JSON status for one configured account |
349
410
  | GET | `/config` | Auto-refresh and history config |
350
411
  | GET | `/history` | History summary |
351
- | GET | `/history/:agent` | History for one agent |
412
+ | GET | `/history/:id` | History for one configured account |
352
413
  | POST | `/refresh` | Refresh all agents |
353
- | POST | `/refresh/:agent` | Refresh one agent |
414
+ | POST | `/refresh/:id` | Refresh one configured account |
354
415
 
355
416
  ### Example `GET /status`
356
417
 
@@ -358,6 +419,9 @@ agent-tank --agy --once --json
358
419
  {
359
420
  "claude": {
360
421
  "name": "claude",
422
+ "id": "claude",
423
+ "alias": null,
424
+ "provider": "claude",
361
425
  "usage": {
362
426
  "session": {
363
427
  "label": "Current session",
@@ -372,6 +436,13 @@ agent-tank --agy --once --json
372
436
  "resetsAt": "Mar 13, 3am (Europe/London)",
373
437
  "resetsIn": "4d 5h",
374
438
  "resetsInSeconds": 364364
439
+ },
440
+ "weeklyFable": {
441
+ "label": "Current week (Fable)",
442
+ "percent": 82,
443
+ "resetsAt": "Mar 13, 3am (Europe/London)",
444
+ "resetsIn": "4d 5h",
445
+ "resetsInSeconds": 364364
375
446
  }
376
447
  },
377
448
  "metadata": {
@@ -570,7 +641,11 @@ codex --version
570
641
  const { AgentTank } = require('agent-tank');
571
642
 
572
643
  const watcher = new AgentTank({
573
- agents: ['claude', 'agy'],
644
+ agents: [
645
+ { provider: 'codex', id: 'work', configPath: '/srv/accounts/codex-work' },
646
+ { provider: 'codex', id: 'personal', configPath: '/srv/accounts/codex-personal' },
647
+ 'agy'
648
+ ],
574
649
  port: 3456,
575
650
  autoDiscover: true
576
651
  });
@@ -580,7 +655,7 @@ await watcher.start();
580
655
  const status = watcher.getStatus();
581
656
  console.log(status);
582
657
 
583
- await watcher.refreshAgent('claude');
658
+ await watcher.refreshAgent('work');
584
659
 
585
660
  watcher.stop();
586
661
  ```
package/bin/agent-tank.js CHANGED
@@ -10,12 +10,14 @@ const {
10
10
  spawnBackgroundProcess,
11
11
  warnAboutRunningProcesses,
12
12
  } = require('../src/cli-background.js');
13
+ const { normalizeAgentSpecs, parseAgentOption } = require('../src/agent-config.js');
13
14
  const pkg = require('../package.json');
14
15
 
15
16
  const options = {
16
17
  claude: { type: 'boolean', default: false },
17
18
  agy: { type: 'boolean', default: false },
18
19
  codex: { type: 'boolean', default: false },
20
+ agent: { type: 'string', multiple: true, default: [] },
19
21
  'claude-api': { type: 'boolean', default: false },
20
22
  port: { type: 'string', default: '3456' },
21
23
  host: { type: 'string' },
@@ -76,6 +78,7 @@ Options:
76
78
  --claude Enable Claude monitoring
77
79
  --agy Enable Antigravity monitoring
78
80
  --codex Enable Codex monitoring
81
+ --agent <spec> Add an account as provider[:alias][=config-path] (repeatable)
79
82
  --claude-api Use direct Anthropic API for Claude usage (faster, 60s refresh)
80
83
  --port <port> HTTP server port (default: 3456)
81
84
  --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
@@ -131,6 +134,7 @@ Examples:
131
134
  agent-tank # Auto-discover and monitor all available
132
135
  agent-tank --background # Start in the background and print the PID
133
136
  agent-tank --claude --agy # Monitor specific agents
137
+ agent-tank --agent codex:work=/srv/codex-work --agent codex:personal=/srv/codex-personal
134
138
  agent-tank --port 8080 # Use custom port
135
139
  agent-tank --host 0.0.0.0 # Expose on all interfaces
136
140
  agent-tank --no-docker # Bind localhost only when host is omitted
@@ -152,12 +156,12 @@ Examples:
152
156
  HTTP Endpoints:
153
157
  GET / Status page (HTML)
154
158
  GET /status All agent statuses (JSON)
155
- GET /status/:agent Status for specific agent (JSON)
159
+ GET /status/:id Status for a specific agent account (JSON)
156
160
  GET /config Auto-refresh and history configuration (JSON)
157
161
  GET /history Usage history statistics (JSON)
158
- GET /history/:agent Usage history for specific agent (JSON)
162
+ GET /history/:id Usage history for a specific agent account (JSON)
159
163
  POST /refresh Trigger refresh for all agents
160
- POST /refresh/:agent Trigger refresh for specific agent
164
+ POST /refresh/:id Trigger refresh for a specific agent account
161
165
  `);
162
166
  }
163
167
 
@@ -207,9 +211,12 @@ async function main() {
207
211
 
208
212
  // Load config file if specified
209
213
  let config = {};
214
+ let configBaseDir = process.cwd();
210
215
  if (values.config) {
211
216
  try {
212
- config = require(require('path').resolve(values.config));
217
+ const configPath = require('path').resolve(values.config);
218
+ config = require(configPath);
219
+ configBaseDir = require('path').dirname(configPath);
213
220
  } catch (err) {
214
221
  exitWithCode(1, `Failed to load config file: ${err.message}`);
215
222
  return;
@@ -217,10 +224,21 @@ async function main() {
217
224
  }
218
225
 
219
226
  // Merge CLI options with config (env vars > CLI flags > config file)
220
- const agents = [];
221
- if (values.claude || config.claude) agents.push('claude');
222
- if (values.agy || config.agy) agents.push('agy');
223
- if (values.codex || config.codex) agents.push('codex');
227
+ let agents;
228
+ try {
229
+ const configAgents = config.agents == null
230
+ ? []
231
+ : normalizeAgentSpecs(config.agents, { baseDir: configBaseDir });
232
+ const cliAgents = normalizeAgentSpecs(values.agent.map(parseAgentOption));
233
+ const legacyAgents = [];
234
+ if (values.claude || config.claude) legacyAgents.push('claude');
235
+ if (values.agy || config.agy) legacyAgents.push('agy');
236
+ if (values.codex || config.codex) legacyAgents.push('codex');
237
+ agents = normalizeAgentSpecs([...configAgents, ...cliAgents, ...legacyAgents]);
238
+ } catch (err) {
239
+ exitWithCode(1, `Invalid agent configuration: ${err.message}`);
240
+ return;
241
+ }
224
242
 
225
243
  const auth = {
226
244
  user: process.env.AGENT_TANK_USER || values['auth-user'] || config.auth?.user,
package/favicon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <svg viewBox="0 0 24 24" fill="none" stroke="#48bb78" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
2
+ <rect width="24" height="24" rx="5" fill="#0d1117" stroke="none"/>
3
+ <path d="M6 4v12a4 4 0 0 0 4 4h4a4 4 0 0 0 4-4V4"/>
4
+ <line x1="6" y1="4" x2="18" y2="4"/>
5
+ <line x1="8" y1="8" x2="16" y2="8" opacity="0.3"/>
6
+ <line x1="8" y1="12" x2="16" y2="12" opacity="0.3"/>
7
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
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": {
@@ -61,6 +61,8 @@
61
61
  "bin/",
62
62
  "src/",
63
63
  "media/",
64
+ "favicon.svg",
65
+ "CHANGELOG.md",
64
66
  "LICENSE",
65
67
  "README.md"
66
68
  ]
@@ -29,6 +29,11 @@ const DEFAULT_LOG_DIRECTORIES = {
29
29
  ],
30
30
  };
31
31
 
32
+ function getAgentLogDirectories(provider, configPath = null) {
33
+ if (configPath) return [configPath];
34
+ return DEFAULT_LOG_DIRECTORIES[provider] || [];
35
+ }
36
+
32
37
  function normalizeFilename(filename) {
33
38
  return String(filename || '').replace(/\\/g, '/');
34
39
  }
@@ -320,4 +325,4 @@ class ActivityMonitor {
320
325
  }
321
326
  }
322
327
 
323
- module.exports = { ActivityMonitor, DEFAULT_LOG_DIRECTORIES };
328
+ module.exports = { ActivityMonitor, DEFAULT_LOG_DIRECTORIES, getAgentLogDirectories };
@@ -0,0 +1,105 @@
1
+ /* eslint-disable complexity -- Validation keeps all supported agent config forms in one place. */
2
+
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+
6
+ const SUPPORTED_PROVIDERS = new Set(['claude', 'agy', 'codex']);
7
+ const AGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
8
+
9
+ function resolveConfigPath(configPath, baseDir) {
10
+ if (configPath === '~') return os.homedir();
11
+ if (configPath.startsWith('~/') || configPath.startsWith('~\\')) {
12
+ return path.resolve(os.homedir(), configPath.slice(2));
13
+ }
14
+ return path.resolve(baseDir, configPath);
15
+ }
16
+
17
+ function parseAgentOption(value) {
18
+ if (typeof value !== 'string' || !value.trim()) {
19
+ throw new Error('Agent option must be a non-empty string');
20
+ }
21
+
22
+ const equalsIndex = value.indexOf('=');
23
+ const identity = (equalsIndex === -1 ? value : value.slice(0, equalsIndex)).trim();
24
+ const configPath = equalsIndex === -1 ? null : value.slice(equalsIndex + 1).trim();
25
+ const colonIndex = identity.indexOf(':');
26
+ const provider = (colonIndex === -1 ? identity : identity.slice(0, colonIndex)).trim();
27
+ const alias = colonIndex === -1 ? null : identity.slice(colonIndex + 1).trim();
28
+
29
+ if (equalsIndex !== -1 && !configPath) {
30
+ throw new Error(`Agent option "${value}" has an empty config path`);
31
+ }
32
+ if (colonIndex !== -1 && !alias) {
33
+ throw new Error(`Agent option "${value}" has an empty alias`);
34
+ }
35
+
36
+ return {
37
+ provider,
38
+ ...(alias && { id: alias, alias }),
39
+ ...(configPath && { configPath }),
40
+ };
41
+ }
42
+
43
+ function normalizeAgentSpec(entry, baseDir) {
44
+ const raw = typeof entry === 'string' ? { provider: entry } : entry;
45
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
46
+ throw new Error('Each agent must be a provider name or an agent configuration object');
47
+ }
48
+
49
+ const provider = String(raw.provider || raw.name || '').trim().toLowerCase();
50
+ if (!SUPPORTED_PROVIDERS.has(provider)) {
51
+ throw new Error(`Unsupported agent provider: ${provider || '(missing)'}`);
52
+ }
53
+
54
+ const explicitId = raw.id == null ? null : String(raw.id).trim();
55
+ const alias = raw.alias == null ? null : String(raw.alias).trim();
56
+ const id = explicitId || alias || provider;
57
+ if (!AGENT_ID_PATTERN.test(id)) {
58
+ throw new Error(`Invalid agent id "${id}"; use letters, numbers, dots, underscores, or hyphens`);
59
+ }
60
+ if (raw.alias != null && !alias) {
61
+ throw new Error(`Alias for agent "${id}" must not be empty`);
62
+ }
63
+
64
+ const rawConfigPath = raw.configPath ?? raw.path ?? null;
65
+ let configPath = null;
66
+ if (rawConfigPath != null) {
67
+ if (typeof rawConfigPath !== 'string' || !rawConfigPath.trim()) {
68
+ throw new Error(`Config path for agent "${id}" must be a non-empty string`);
69
+ }
70
+ configPath = resolveConfigPath(rawConfigPath.trim(), baseDir);
71
+ }
72
+
73
+ return {
74
+ provider,
75
+ id,
76
+ alias: alias || (id !== provider ? id : null),
77
+ configPath,
78
+ };
79
+ }
80
+
81
+ function normalizeAgentSpecs(entries, options = {}) {
82
+ if (!Array.isArray(entries)) {
83
+ throw new Error('agents must be an array');
84
+ }
85
+
86
+ const baseDir = options.baseDir || process.cwd();
87
+ const normalized = entries.map(entry => normalizeAgentSpec(entry, baseDir));
88
+ const ids = new Set();
89
+
90
+ for (const spec of normalized) {
91
+ if (ids.has(spec.id)) {
92
+ throw new Error(`Duplicate agent id "${spec.id}"; give each account a unique id or alias`);
93
+ }
94
+ ids.add(spec.id);
95
+ }
96
+
97
+ return normalized;
98
+ }
99
+
100
+ module.exports = {
101
+ AGENT_ID_PATTERN,
102
+ SUPPORTED_PROVIDERS,
103
+ normalizeAgentSpecs,
104
+ parseAgentOption,
105
+ };
package/src/agents/agy.js CHANGED
@@ -8,8 +8,15 @@ class AgyAgent extends BaseAgent {
8
8
  // "Models & Quota" screen reported by recent Antigravity CLI builds.
9
9
  static QUOTA_HEADER = /Models?\s*&?\s*Quota/i;
10
10
 
11
- constructor() {
11
+ // Antigravity words its countdown line differently across builds: older
12
+ // screens say "Resets in 3h 15m", the grouped "Models & Quota" screen says
13
+ // "Refreshes in 160h 12m". A fully replenished limit renders "Quota
14
+ // available" instead, which carries no countdown and yields a null reset.
15
+ static RESET_LINE = /^(?:resets?|refresh(?:es)?|renew(?:s|als)?)\s*(?:in|at)?\s*([0-9dhm\s]+)?/i;
16
+
17
+ constructor(options = {}) {
12
18
  super('agy', 'agy', ['--dangerously-skip-permissions']);
19
+ this.configPath = options.configPath || null;
13
20
  this._aboutSent = false;
14
21
  }
15
22
 
@@ -20,11 +27,13 @@ class AgyAgent extends BaseAgent {
20
27
  getEnv() {
21
28
  // Use xterm-256color for proper terminal support
22
29
  // Antigravity CLI needs good terminal emulation for its TUI
23
- return {
30
+ const env = {
24
31
  ...process.env,
25
32
  TERM: 'xterm-256color',
26
33
  COLORTERM: 'truecolor',
27
34
  };
35
+ if (this.configPath) env.GEMINI_CLI_HOME = this.configPath;
36
+ return env;
28
37
  }
29
38
 
30
39
  _hasReadyPrompt(output) {
@@ -203,9 +212,35 @@ class AgyAgent extends BaseAgent {
203
212
  .join(' ');
204
213
  }
205
214
 
215
+ // Read the countdown line that follows a limit's percentage bar. Returns
216
+ // nulls when the limit is fully replenished ("Quota available") or the line
217
+ // is something else entirely.
218
+ _parseResetLine(line) {
219
+ // A redrawn frame can leave box borders on the line, so normalise before
220
+ // matching the countdown's leading keyword.
221
+ const resetMatch = this.stripBoxChars(line || '').match(AgyAgent.RESET_LINE);
222
+ const resetsIn = resetMatch?.[1]?.trim() || null;
223
+ const resetsInSeconds = resetsIn ? this.parseDurationToSeconds(resetsIn) : null;
224
+ // The countdown goes stale as soon as it is stored, so also record the
225
+ // absolute instant it refers to.
226
+ const resetsAt = resetsInSeconds == null
227
+ ? null
228
+ : new Date(Date.now() + resetsInSeconds * 1000).toISOString();
229
+ return { resetsIn, resetsInSeconds, resetsAt };
230
+ }
231
+
232
+ // Map an Antigravity limit label to the cycle its countdown belongs to, so
233
+ // pace math compares usage against the right window. A weekly limit reports
234
+ // "Refreshes in 160h 12m", which is nonsense against a 24h cycle.
235
+ _cycleTypeFor(label) {
236
+ if (/\bweek(?:ly)?\b/i.test(label)) return 'weekly';
237
+ if (/\b(?:five|5)[\s-]?hour\b/i.test(label)) return 'fiveHour';
238
+ return 'sessionAgy';
239
+ }
240
+
206
241
  // Calculate pace data for a model entry when reset timing is available.
207
- _addPaceData(entry, resetsInSeconds) {
208
- const cycleDuration = CYCLE_DURATIONS.sessionAgy;
242
+ _addPaceData(entry, resetsInSeconds, cycleType = 'sessionAgy') {
243
+ const cycleDuration = CYCLE_DURATIONS[cycleType];
209
244
  if (!cycleDuration || resetsInSeconds == null) return;
210
245
  const paceData = calculatePace({ usagePercent: entry.percentUsed, resetsInSeconds, cycleDurationSeconds: cycleDuration });
211
246
  if (paceData) entry.pace = paceData;
@@ -265,6 +300,11 @@ class AgyAgent extends BaseAgent {
265
300
  if (!/[A-Za-z]/.test(line) || /\d+\s*%/.test(line)) {
266
301
  continue;
267
302
  }
303
+ // A countdown line ("Refreshes in 58m") belongs to the entry above it and
304
+ // is never a model label of its own.
305
+ if (AgyAgent.RESET_LINE.test(line)) {
306
+ continue;
307
+ }
268
308
 
269
309
  const percentLine = lines[i + 1] || '';
270
310
  const percentMatch = percentLine.match(/(\d+(?:\.\d+)?)\s*%/);
@@ -274,10 +314,7 @@ class AgyAgent extends BaseAgent {
274
314
 
275
315
  const usageLeft = parseFloat(percentMatch[1]);
276
316
  const percentUsed = Number((100 - usageLeft).toFixed(1));
277
- const resetLine = lines[i + 2] || '';
278
- const resetMatch = resetLine.match(/(?:resets?|available)\s*(?:in|at)?\s*([0-9dhm\s]+)?/i);
279
- const resetsIn = resetMatch?.[1]?.trim() || null;
280
- const resetsInSeconds = resetsIn ? this.parseDurationToSeconds(resetsIn) : null;
317
+ const reset = this._parseResetLine(lines[i + 2]);
281
318
 
282
319
  // Qualify shared limit labels with their group so entries from different
283
320
  // groups stay distinct; per-model labels are already unique on their own.
@@ -287,9 +324,10 @@ class AgyAgent extends BaseAgent {
287
324
  : line;
288
325
 
289
326
  if (!usage.models.find(m => m.model === modelName)) {
290
- const modelEntry = { model: modelName, usageLeft, percentUsed, resetsIn, resetsInSeconds };
327
+ const cycle = this._cycleTypeFor(line);
328
+ const modelEntry = { model: modelName, usageLeft, percentUsed, ...reset, cycle };
291
329
  if (currentGroup) modelEntry.group = currentGroup;
292
- this._addPaceData(modelEntry, resetsInSeconds);
330
+ this._addPaceData(modelEntry, reset.resetsInSeconds, cycle);
293
331
  usage.models.push(modelEntry);
294
332
  }
295
333
  }