agent-tank 0.9.5 → 0.9.7

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/README.md CHANGED
@@ -7,7 +7,7 @@ Agent Tank is a local dashboard and HTTP API for monitoring usage limits in AI c
7
7
  It supports:
8
8
 
9
9
  - Claude Code
10
- - Gemini CLI
10
+ - Antigravity CLI
11
11
  - OpenAI Codex
12
12
 
13
13
  ## How It Gets the Data
@@ -17,7 +17,7 @@ This is the part that matters.
17
17
  Agent Tank reads usage directly from the local CLI tools you already use. It launches the installed CLIs locally, runs their built-in usage commands, and parses the output into a unified web UI and JSON API.
18
18
 
19
19
  - Claude: runs `/usage`
20
- - Gemini: runs `/stats`
20
+ - Antigravity: runs `/usage`
21
21
  - Codex: prefers JSON-RPC `account/rateLimits/read`, falls back to `/status`
22
22
 
23
23
  What it does not do:
@@ -37,7 +37,7 @@ Agent Tank is meant for subscription-based coding agent products where the CLI i
37
37
  Examples:
38
38
 
39
39
  - Claude Code Pro / Max
40
- - Gemini CLI with supported subscription plans
40
+ - Antigravity CLI with supported subscription plans
41
41
  - ChatGPT Codex with supported plans
42
42
 
43
43
  It is not for:
@@ -87,7 +87,7 @@ agent-tank --no-docker
87
87
 
88
88
  ```bash
89
89
  # Monitor only specific agents
90
- agent-tank --claude --gemini
90
+ agent-tank --claude --agy
91
91
 
92
92
  # Use a custom port
93
93
  agent-tank --port 8080
@@ -127,7 +127,7 @@ agent-tank --refresh-cooldown 60
127
127
  | Agent | Method | Metrics |
128
128
  |---|---|---|
129
129
  | Claude | PTY `/usage` or Anthropic OAuth API | Current session, weekly all-models, weekly Sonnet-only |
130
- | Gemini | PTY `/stats` | Per-model usage and reset windows |
130
+ | Antigravity | PTY `/usage` | Per-model quota availability and reset windows when reported |
131
131
  | Codex | JSON-RPC preferred, PTY fallback | 5-hour limits, weekly limits, model/account info |
132
132
 
133
133
  ## Basic Usage
@@ -183,7 +183,7 @@ Set it to `0` to disable the cooldown entirely.
183
183
  ```text
184
184
  Options:
185
185
  --claude Enable Claude monitoring
186
- --gemini Enable Gemini monitoring
186
+ --agy Enable Antigravity monitoring
187
187
  --codex Enable Codex monitoring
188
188
  --port <port> HTTP server port (default: 3456)
189
189
  --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
@@ -238,7 +238,7 @@ Environment variables override CLI flags and config file values.
238
238
  ```json
239
239
  {
240
240
  "claude": true,
241
- "gemini": true,
241
+ "agy": true,
242
242
  "codex": false,
243
243
  "port": 8080,
244
244
  "dockerAccess": true,
@@ -266,6 +266,19 @@ Run with:
266
266
  agent-tank -c config.json
267
267
  ```
268
268
 
269
+ ## Antigravity
270
+
271
+ Antigravity support uses the `agy` CLI and reads the interactive `/usage` output.
272
+
273
+ In `--once` mode, Agent Tank prints the result and exits immediately after the output is flushed.
274
+
275
+ Examples:
276
+
277
+ ```bash
278
+ agent-tank --agy
279
+ agent-tank --agy --once --json
280
+ ```
281
+
269
282
  ## HTTP API
270
283
 
271
284
  ### Endpoints
@@ -393,7 +406,7 @@ In activity mode, Agent Tank watches common local directories:
393
406
 
394
407
  - Claude: `~/.config/claude/projects/`, `~/.claude/`
395
408
  - Codex: `~/.codex/sessions/`, `~/.codex/`
396
- - Gemini: `~/.config/gemini/`, `~/.gemini/`
409
+ - Antigravity: `~/.gemini/antigravity-cli/`
397
410
 
398
411
  If no suitable directories are found, it falls back to interval mode.
399
412
 
@@ -481,15 +494,15 @@ You need at least one supported CLI installed and authenticated.
481
494
 
482
495
  - [Claude Code](https://claude.ai/download) as `claude`
483
496
  - Version 2.0+ required for `/usage`
484
- - Gemini CLI as `gemini`
485
- - Version 0.24.5+ required for `/stats`
497
+ - Antigravity CLI as `agy`
498
+ - `/usage` command support required
486
499
  - OpenAI Codex as `codex`
487
500
 
488
501
  Useful checks:
489
502
 
490
503
  ```bash
491
504
  claude --version
492
- gemini --version
505
+ agy --version
493
506
  codex --version
494
507
  ```
495
508
 
@@ -499,7 +512,7 @@ codex --version
499
512
  const { AgentTank } = require('agent-tank');
500
513
 
501
514
  const watcher = new AgentTank({
502
- agents: ['claude', 'gemini'],
515
+ agents: ['claude', 'agy'],
503
516
  port: 3456,
504
517
  autoDiscover: true
505
518
  });
package/bin/agent-tank.js CHANGED
@@ -8,7 +8,7 @@ const pkg = require('../package.json');
8
8
 
9
9
  const options = {
10
10
  claude: { type: 'boolean', default: false },
11
- gemini: { type: 'boolean', default: false },
11
+ agy: { type: 'boolean', default: false },
12
12
  codex: { type: 'boolean', default: false },
13
13
  'claude-api': { type: 'boolean', default: false },
14
14
  port: { type: 'string', default: '3456' },
@@ -43,6 +43,17 @@ function exitWithCode(code, message, stream = process.stderr) {
43
43
  process.exitCode = code;
44
44
  }
45
45
 
46
+ function writeAndExit(stream, text, exitCode, { cleanup, watcher, jsonMode, originalLog }) {
47
+ stream.write(text, () => {
48
+ cleanup();
49
+ watcher.stop();
50
+ if (jsonMode) {
51
+ console.log = originalLog;
52
+ }
53
+ process.exit(exitCode);
54
+ });
55
+ }
56
+
46
57
  function printHelp() {
47
58
  process.stdout.write(`
48
59
  agent-tank - Monitor LLM CLI usage limits
@@ -51,7 +62,7 @@ Usage: agent-tank [options]
51
62
 
52
63
  Options:
53
64
  --claude Enable Claude monitoring
54
- --gemini Enable Gemini monitoring
65
+ --agy Enable Antigravity monitoring
55
66
  --codex Enable Codex monitoring
56
67
  --claude-api Use direct Anthropic API for Claude usage (faster, 60s refresh)
57
68
  --port <port> HTTP server port (default: 3456)
@@ -101,7 +112,7 @@ Environment variables:
101
112
 
102
113
  Examples:
103
114
  agent-tank # Auto-discover and monitor all available
104
- agent-tank --claude --gemini # Monitor specific agents
115
+ agent-tank --claude --agy # Monitor specific agents
105
116
  agent-tank --port 8080 # Use custom port
106
117
  agent-tank --host 0.0.0.0 # Expose on all interfaces
107
118
  agent-tank --no-docker # Bind localhost only when host is omitted
@@ -159,7 +170,7 @@ async function main() {
159
170
  // Merge CLI options with config (env vars > CLI flags > config file)
160
171
  const agents = [];
161
172
  if (values.claude || config.claude) agents.push('claude');
162
- if (values.gemini || config.gemini) agents.push('gemini');
173
+ if (values.agy || config.agy) agents.push('agy');
163
174
  if (values.codex || config.codex) agents.push('codex');
164
175
 
165
176
  const auth = {
@@ -311,21 +322,28 @@ async function main() {
311
322
  await watcher.start();
312
323
  const status = watcher.getStatus();
313
324
  if (jsonMode) {
314
- // Restore console.log for JSON output
315
- console.log = originalLog;
316
- console.log(JSON.stringify(status, null, 2));
317
- // Suppress console.log again before stopping to avoid extra shutdown output
318
- console.log = () => {};
325
+ const output = `${JSON.stringify(status, null, 2)}\n`;
326
+ writeAndExit(process.stdout, output, 0, {
327
+ cleanup: cleanupShutdownHandlers,
328
+ watcher,
329
+ jsonMode,
330
+ originalLog,
331
+ });
332
+ return;
319
333
  } else {
334
+ let output = '';
320
335
  for (const [name, agentStatus] of Object.entries(status)) {
321
- originalLog(`\n=== ${name.toUpperCase()} ===`);
322
- originalLog(JSON.stringify(agentStatus, null, 2));
336
+ output += `\n=== ${name.toUpperCase()} ===\n`;
337
+ output += `${JSON.stringify(agentStatus, null, 2)}\n`;
323
338
  }
339
+ writeAndExit(process.stdout, output, 0, {
340
+ cleanup: cleanupShutdownHandlers,
341
+ watcher,
342
+ jsonMode,
343
+ originalLog,
344
+ });
345
+ return;
324
346
  }
325
- cleanupShutdownHandlers();
326
- watcher.stop();
327
- process.exitCode = 0;
328
- return;
329
347
  }
330
348
 
331
349
  await watcher.start();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.5",
4
- "description": "Monitor and query usage limits for LLM CLI tools (Claude, Gemini, Codex) via HTTP API",
3
+ "version": "0.9.7",
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": {
7
7
  "agent-tank": "bin/agent-tank.js"
@@ -19,7 +19,8 @@
19
19
  "keywords": [
20
20
  "llm",
21
21
  "claude",
22
- "gemini",
22
+ "antigravity",
23
+ "agy",
23
24
  "codex",
24
25
  "openai",
25
26
  "anthropic",
@@ -42,6 +43,7 @@
42
43
  },
43
44
  "homepage": "https://agenttank.io",
44
45
  "dependencies": {
46
+ "chokidar": "^3.6.0",
45
47
  "node-pty": "^1.0.0",
46
48
  "source-map-support": "^0.5.21"
47
49
  },
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ActivityMonitor - Monitors local log directories for LLM CLI activity
3
3
  *
4
- * Watches known log directories for Claude, Codex, and Gemini using fs.watch.
4
+ * Watches known log directories for Claude, Codex, and Antigravity using chokidar with polling.
5
5
  * When activity is detected (new log entries), it triggers usage refresh callbacks
6
6
  * with configurable debouncing to prevent excessive polling.
7
7
  */
@@ -9,6 +9,7 @@
9
9
  const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const os = require('node:os');
12
+ const chokidar = require('chokidar');
12
13
 
13
14
  /**
14
15
  * Default log directories for each agent.
@@ -23,9 +24,8 @@ const DEFAULT_LOG_DIRECTORIES = {
23
24
  path.join(os.homedir(), '.codex', 'sessions'),
24
25
  path.join(os.homedir(), '.codex'),
25
26
  ],
26
- gemini: [
27
- path.join(os.homedir(), '.config', 'gemini'),
28
- path.join(os.homedir(), '.gemini'),
27
+ agy: [
28
+ path.join(os.homedir(), '.gemini', 'antigravity-cli'),
29
29
  ],
30
30
  };
31
31
 
@@ -72,7 +72,7 @@ class ActivityMonitor {
72
72
 
73
73
  /**
74
74
  * Start monitoring log directories for activity.
75
- * Sets up fs.watch on each agent's log directories.
75
+ * Sets up chokidar polling watchers on each agent's log directories.
76
76
  *
77
77
  * @returns {Object} Status object with monitoring details
78
78
  */
@@ -92,7 +92,18 @@ class ActivityMonitor {
92
92
  for (const dir of dirs) {
93
93
  if (fs.existsSync(dir)) {
94
94
  try {
95
- const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
95
+ // Use chokidar with polling to avoid EMFILE errors from too many native watchers
96
+ const watcher = chokidar.watch(dir, {
97
+ persistent: true,
98
+ ignoreInitial: true,
99
+ usePolling: true,
100
+ interval: 1000,
101
+ depth: 99,
102
+ ignored: ['**/.git/**', '**/*.lock'],
103
+ });
104
+
105
+ watcher.on('all', (eventType, filepath) => {
106
+ const filename = path.relative(dir, filepath);
96
107
  this._handleFileChange(agent, eventType, filename);
97
108
  });
98
109
 
@@ -158,7 +169,7 @@ class ActivityMonitor {
158
169
  }
159
170
 
160
171
  /**
161
- * Handle a file change event from fs.watch.
172
+ * Handle a file change event from chokidar.
162
173
  * Debounces activity detection per agent.
163
174
  *
164
175
  * @param {string} agent - Agent name
@@ -0,0 +1,301 @@
1
+ const { BaseAgent } = require('./base.js');
2
+ const { calculatePace } = require('../pace-evaluator.js');
3
+ const { CYCLE_DURATIONS } = require('../usage-formatters.js');
4
+ const logger = require('../logger.js');
5
+
6
+ class AgyAgent extends BaseAgent {
7
+ constructor() {
8
+ super('agy', 'agy', ['--dangerously-skip-permissions']);
9
+ this._aboutSent = false;
10
+ }
11
+
12
+ getTimeout() {
13
+ return 35000; // Antigravity can take 10+ seconds to start and sign in
14
+ }
15
+
16
+ getEnv() {
17
+ // Use xterm-256color for proper terminal support
18
+ // Antigravity CLI needs good terminal emulation for its TUI
19
+ return {
20
+ ...process.env,
21
+ TERM: 'xterm-256color',
22
+ COLORTERM: 'truecolor',
23
+ };
24
+ }
25
+
26
+ _hasReadyPrompt(output) {
27
+ const clean = this.stripAnsi(output);
28
+ return /Antigravity CLI[\s\S]*\n>\s*(?:\n|$)/i.test(clean)
29
+ || /Google AI Pro[\s\S]*\n>\s*(?:\n|$)/i.test(clean)
30
+ || /Ready\s*\(/.test(clean);
31
+ }
32
+
33
+ isReadyForCommands(output) {
34
+ // Don't consider ready if authentication is in progress
35
+ if (this._isAuthenticating(output)) {
36
+ return false;
37
+ }
38
+ return this._hasReadyPrompt(output);
39
+ }
40
+
41
+ _isAuthenticating(output) {
42
+ // Detect OAuth/authentication screens where Escape would cancel the flow
43
+ const clean = this.stripAnsi(output);
44
+ const hasAuthFlow = /waiting for authentication/i.test(clean)
45
+ || /signing in/i.test(clean)
46
+ || /press esc.*to cancel/i.test(clean)
47
+ || /authenticating\.\.\./i.test(clean);
48
+
49
+ if (!hasAuthFlow) {
50
+ return false;
51
+ }
52
+
53
+ // Footer hints can render before the actual input prompt is available.
54
+ // Treat auth as complete only when the real prompt has returned.
55
+ return !this._hasReadyPrompt(clean);
56
+ }
57
+
58
+ detectAuthenticationState(output) {
59
+ const clean = this.stripAnsi(output);
60
+ if (!clean) return null;
61
+
62
+ const hasAuthFailure = /failed to sign in\.\s*message:\s*([^\n]+)/i.test(clean)
63
+ || /authentication consent could not be obtained/i.test(clean);
64
+ if (hasAuthFailure) {
65
+ const failureMatch = clean.match(/failed to sign in\.\s*message:\s*([^\n]+)/i);
66
+ const detail = failureMatch
67
+ ? `Failed to sign in: ${failureMatch[1].trim()}`
68
+ : 'Antigravity CLI authentication failed.';
69
+
70
+ return {
71
+ authenticated: false,
72
+ status: 'unauthenticated',
73
+ message: 'Authentication required',
74
+ detail,
75
+ action: 'Run agy in an interactive terminal to authenticate.',
76
+ };
77
+ }
78
+
79
+ if (this._hasReadyPrompt(clean) || /signing in/i.test(clean)) {
80
+ return null;
81
+ }
82
+
83
+ const hasAuthMenu = /how would you like to authenticate for this project\?/i.test(clean)
84
+ || /currently not signed in/i.test(clean);
85
+
86
+ if (!hasAuthMenu) {
87
+ return null;
88
+ }
89
+
90
+ return {
91
+ authenticated: false,
92
+ status: 'unauthenticated',
93
+ message: 'Authentication required',
94
+ detail: 'Antigravity CLI is waiting for authentication.',
95
+ action: 'Run agy in an interactive terminal to authenticate.',
96
+ };
97
+ }
98
+
99
+ handleTrustPrompt(shell, output) {
100
+ // Check for trust prompt with various wordings
101
+ const trustPatterns = [
102
+ 'Do you trust',
103
+ 'trust the files',
104
+ 'trust this folder',
105
+ 'Trust this workspace',
106
+ 'allow access',
107
+ 'grant access',
108
+ 'grant full access',
109
+ ];
110
+
111
+ if (trustPatterns.some(pattern => output.toLowerCase().includes(pattern.toLowerCase()))) {
112
+ logger.agent(this.name, 'Detected trust prompt, auto-accepting...');
113
+ // Numbered menu with "1. Trust folder" pre-selected; Enter accepts it.
114
+ shell.write('1\r');
115
+
116
+ // Antigravity can redraw after trust; clear transitional UI before readiness.
117
+ setTimeout(() => {
118
+ if (!shell) return;
119
+ logger.agent(this.name, 'Post-trust: sending Escape to clear restart UI...');
120
+ shell.write('\x1b');
121
+ }, 3000);
122
+
123
+ return true;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ hasCompleteOutput(output) {
129
+ const clean = this.stripAnsi(output);
130
+ 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);
133
+ }
134
+ const hasModel = /(?:Gemini|Claude|GPT)[^\n]+/i.test(clean);
135
+ const hasPercent = /\d+(?:\.\d+)?%/i.test(clean);
136
+ return hasModel && hasPercent;
137
+ }
138
+
139
+ // Convert duration string like "3h 26m" or "5d 2h" to seconds
140
+ parseDurationToSeconds(durationStr) {
141
+ if (!durationStr) return null;
142
+
143
+ let totalSeconds = 0;
144
+
145
+ // Match days
146
+ const daysMatch = durationStr.match(/(\d+)\s*d/i);
147
+ if (daysMatch) {
148
+ totalSeconds += parseInt(daysMatch[1]) * 24 * 60 * 60;
149
+ }
150
+
151
+ // Match hours
152
+ const hoursMatch = durationStr.match(/(\d+)\s*h/i);
153
+ if (hoursMatch) {
154
+ totalSeconds += parseInt(hoursMatch[1]) * 60 * 60;
155
+ }
156
+
157
+ // Match minutes
158
+ const minsMatch = durationStr.match(/(\d+)\s*m/i);
159
+ if (minsMatch) {
160
+ totalSeconds += parseInt(minsMatch[1]) * 60;
161
+ }
162
+
163
+ return totalSeconds > 0 ? totalSeconds : null;
164
+ }
165
+
166
+ sendCommands(shell, output) {
167
+ // Check if authentication is still in progress - don't send commands that could interfere
168
+ if (this._isAuthenticating(output) || this.detectAuthenticationState(output)) {
169
+ logger.agent(this.name, 'Authentication required or in progress, cannot send /usage command');
170
+ // Don't retry - let the timeout handle it. Sending Escape would cancel auth.
171
+ return;
172
+ }
173
+ logger.agent(this.name, 'Sending /usage command...');
174
+ setTimeout(() => shell.write('/usage'), 200);
175
+ setTimeout(() => shell.write('\r'), 600);
176
+ }
177
+
178
+ // Calculate pace data for a model entry when reset timing is available.
179
+ _addPaceData(entry, resetsInSeconds) {
180
+ const cycleDuration = CYCLE_DURATIONS.sessionAgy;
181
+ if (!cycleDuration || resetsInSeconds == null) return;
182
+ const paceData = calculatePace({ usagePercent: entry.percentUsed, resetsInSeconds, cycleDurationSeconds: cycleDuration });
183
+ if (paceData) entry.pace = paceData;
184
+ }
185
+
186
+ parseOutput(output) {
187
+ const clean = this.stripAnsi(output);
188
+ const usage = {
189
+ models: [],
190
+ };
191
+
192
+ this._parseAgyUsage(clean, usage);
193
+
194
+ // Detect version update notification in output
195
+ const updateMatch = clean.match(/[Uu]pdate\s+available[^\d]*([\d.]+)\s*(?:→|->|to)\s*([\d.]+)/);
196
+ if (updateMatch) {
197
+ usage.version = { current: updateMatch[1], latest: updateMatch[2] };
198
+ }
199
+
200
+ return usage;
201
+ }
202
+
203
+ _parseAgyUsage(clean, usage) {
204
+ if (!/Model\s+Quota/i.test(clean)) return;
205
+
206
+ const lines = clean.split('\n').map(line => line.trim()).filter(Boolean);
207
+ const knownMarkers = new Set([
208
+ 'Model Quota',
209
+ 'Quota available',
210
+ '? for shortcuts',
211
+ ]);
212
+
213
+ 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)) {
216
+ continue;
217
+ }
218
+ if (!/[A-Za-z]/.test(modelLine) || /\d+\s*%/.test(modelLine)) {
219
+ continue;
220
+ }
221
+
222
+ const percentLine = lines[i + 1] || '';
223
+ const percentMatch = percentLine.match(/(\d+(?:\.\d+)?)\s*%/);
224
+ if (!percentMatch) {
225
+ continue;
226
+ }
227
+
228
+ const usageLeft = parseFloat(percentMatch[1]);
229
+ const percentUsed = Number((100 - usageLeft).toFixed(1));
230
+ const resetLine = lines[i + 2] || '';
231
+ const resetMatch = resetLine.match(/(?:resets?|available)\s*(?:in|at)?\s*([0-9dhm\s]+)?/i);
232
+ const resetsIn = resetMatch?.[1]?.trim() || null;
233
+ const resetsInSeconds = resetsIn ? this.parseDurationToSeconds(resetsIn) : null;
234
+
235
+ if (!usage.models.find(m => m.model === modelLine)) {
236
+ const modelEntry = { model: modelLine, usageLeft, percentUsed, resetsIn, resetsInSeconds };
237
+ this._addPaceData(modelEntry, resetsInSeconds);
238
+ usage.models.push(modelEntry);
239
+ }
240
+ }
241
+ }
242
+
243
+ async fetchMetadata() {
244
+ if (this._aboutSent) {
245
+ return this.metadata;
246
+ }
247
+
248
+ this._aboutSent = true;
249
+ return this._parseAgyMetadata(this.output);
250
+ }
251
+
252
+ _parseAgyMetadata(output) {
253
+ const clean = this.stripAnsi(output);
254
+ const metadata = {};
255
+
256
+ const versionMatch = clean.match(/Antigravity\s+CLI\s+v?([\d.]+)/i);
257
+ if (versionMatch) metadata.version = versionMatch[1];
258
+
259
+ const emailMatch = clean.match(/(\S+@\S+)/);
260
+ if (emailMatch) metadata.email = this.stripBoxChars(emailMatch[1]);
261
+
262
+ const cwdMatch = clean.match(/\n\s*(\/[^\n]+)\n/);
263
+ if (cwdMatch) metadata.cwd = this.stripBoxChars(cwdMatch[1]);
264
+
265
+ const modelMatch = clean.match(/((?:Gemini|Claude|GPT)[^\n]+?)\s+\((?:Google AI|[^)]*Pro|[^)]*Free|[^)]*)\)/i)
266
+ || clean.match(/\?\s+for\s+shortcuts\s+(.+?)(?:\n|$)/i);
267
+ if (modelMatch) metadata.model = this.stripBoxChars(modelMatch[1]);
268
+
269
+ metadata.cli = 'agy';
270
+ return Object.keys(metadata).length > 1 ? metadata : { cli: 'agy' };
271
+ }
272
+
273
+ /**
274
+ * Lightweight keepalive to prevent session expiration.
275
+ * Sends escape key to maintain the PTY session without triggering API calls.
276
+ *
277
+ * @returns {Promise<boolean>} True if keepalive succeeded
278
+ */
279
+ async keepalive() {
280
+ if (this.freshProcess) {
281
+ console.log(`[${this.name}] Keepalive skipped (fresh process mode)`);
282
+ return true;
283
+ }
284
+
285
+ if (!this.shell || !this.processReady) {
286
+ console.log(`[${this.name}] Keepalive: spawning process...`);
287
+ await this.spawnProcess();
288
+ }
289
+
290
+ if (this.shell) {
291
+ console.log(`[${this.name}] Keepalive: sending ping...`);
292
+ // Send escape key to dismiss any UI and trigger activity
293
+ this.shell.write('\x1b');
294
+ return true;
295
+ }
296
+
297
+ return false;
298
+ }
299
+ }
300
+
301
+ module.exports = { AgyAgent };
@@ -231,6 +231,10 @@ class BaseAgent {
231
231
  clearTimeout(timer);
232
232
  spawnDataHandler.dispose();
233
233
  spawnExitHandler.dispose();
234
+ if (!this.shell) {
235
+ reject(new Error('Process exited during spawn before handlers were attached'));
236
+ return;
237
+ }
234
238
  this.processReady = true;
235
239
  this.output = spawnOutput;
236
240
  this._setupPersistentDataHandler();
@@ -243,6 +247,11 @@ class BaseAgent {
243
247
  logger.agent(this.name, 'Process ready for commands');
244
248
  clearTimeout(timer);
245
249
  spawnDataHandler.dispose();
250
+ spawnExitHandler.dispose();
251
+ if (!this.shell) {
252
+ reject(new Error('Process exited during spawn before handlers were attached'));
253
+ return;
254
+ }
246
255
  this.processReady = true;
247
256
  this._setupPersistentDataHandler();
248
257
  this._setupPersistentExitHandler();
@@ -334,11 +343,15 @@ class BaseAgent {
334
343
  }
335
344
 
336
345
  _setupPersistentDataHandler() {
346
+ if (!this.shell) {
347
+ logger.agent(this.name, 'Skipping persistent data handler setup: shell already exited');
348
+ return;
349
+ }
337
350
  const handler = this.shell.onData((data) => {
338
351
  this.output += data;
339
352
 
340
353
  // Always respond to terminal queries — suppressing them causes deadlocks
341
- // when CLIs (e.g. Gemini) wait for cursor position responses before
354
+ // when CLIs wait for cursor position responses before
342
355
  // rendering output. Delay slightly during command-in-flight to avoid
343
356
  // interleaving with command text being sent via setTimeout.
344
357
  if (this._commandInFlight) {
@@ -356,6 +369,10 @@ class BaseAgent {
356
369
  }
357
370
 
358
371
  _setupPersistentExitHandler() {
372
+ if (!this.shell) {
373
+ logger.agent(this.name, 'Skipping persistent exit handler setup: shell already exited');
374
+ return;
375
+ }
359
376
  const handler = this.shell.onExit(({ exitCode }) => {
360
377
  logger.agent(this.name, 'Persistent process exited with code', logger.dim(exitCode));
361
378
  this.shell = null; this.processReady = false; this._onDataCallback = null; this._disposables = [];