agent-tank 0.9.6 → 0.9.8

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.
@@ -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 };
@@ -1,3 +1,5 @@
1
+ /* eslint-disable max-lines -- Tracked in issue #90; keep this PR scoped to background startup behavior. */
2
+
1
3
  const pty = require('node-pty');
2
4
  const process = require('node:process');
3
5
  const logger = require('../logger.js');
@@ -32,6 +34,25 @@ class BaseAgent {
32
34
  return new Error('Agent stopping');
33
35
  }
34
36
 
37
+ /**
38
+ * Best-effort dump of raw output to a temp file for debugging.
39
+ * Never throws: the target may be unwritable (e.g. a stale file in
40
+ * /tmp owned by another user), and these dumps run inside timer
41
+ * callbacks where an exception would crash the whole process.
42
+ * @param {string} output Raw output to dump
43
+ * @returns {string|null} Path written to, or null on failure
44
+ */
45
+ writeDebugOutput(output) {
46
+ const debugPath = require('node:path').join(require('node:os').tmpdir(), `${this.name}-output.txt`);
47
+ try {
48
+ require('fs').writeFileSync(debugPath, output);
49
+ return debugPath;
50
+ } catch (err) {
51
+ logger.agent(this.name, 'Could not write debug output:', logger.dim(err.message));
52
+ return null;
53
+ }
54
+ }
55
+
35
56
  isStopping() {
36
57
  return this._stopRequested;
37
58
  }
@@ -308,7 +329,7 @@ class BaseAgent {
308
329
  logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
309
330
  if (this.output.length > 0) {
310
331
  logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
311
- require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, this.output);
332
+ this.writeDebugOutput(this.output);
312
333
  }
313
334
  // Kill the stuck process so it respawns fresh on the next refresh
314
335
  logger.agent(this.name, 'Killing stuck process to force respawn');
@@ -351,7 +372,7 @@ class BaseAgent {
351
372
  this.output += data;
352
373
 
353
374
  // Always respond to terminal queries — suppressing them causes deadlocks
354
- // when CLIs (e.g. Gemini) wait for cursor position responses before
375
+ // when CLIs wait for cursor position responses before
355
376
  // rendering output. Delay slightly during command-in-flight to avoid
356
377
  // interleaving with command text being sent via setTimeout.
357
378
  if (this._commandInFlight) {
@@ -448,8 +469,10 @@ class BaseAgent {
448
469
  logger.agent(this.name, 'Timeout after', logger.dim(`${timeout}ms`), ', output length:', logger.dim(`${output.length}`));
449
470
  if (output.length > 0) {
450
471
  logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(output).substring(0, 500)));
451
- require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, output);
452
- logger.agent(this.name, 'Full output written to', logger.dim(`/tmp/${this.name}-output.txt`));
472
+ const debugPath = this.writeDebugOutput(output);
473
+ if (debugPath) {
474
+ logger.agent(this.name, 'Full output written to', logger.dim(debugPath));
475
+ }
453
476
  }
454
477
  shell.kill();
455
478
  if (output.length > 100) {
@@ -209,20 +209,28 @@ class ClaudeAgent extends BaseAgent {
209
209
  const hasSessionData = parsed.session && typeof parsed.session.percent === 'number';
210
210
  const hasLegacyWeekly = parsed.weekly && typeof parsed.weekly.percent === 'number';
211
211
  const hasAllModelsWeekly = parsed.weeklyAll && typeof parsed.weeklyAll.percent === 'number';
212
+ const hasSonnetWeekly = parsed.weeklySonnet && typeof parsed.weeklySonnet.percent === 'number';
212
213
 
213
214
  // Newer Claude builds often emit usable session/weekly data before the UI fully settles.
214
- // If we can already parse session data plus either weekly format, treat the response as complete.
215
- return hasSessionData && (hasLegacyWeekly || hasAllModelsWeekly);
215
+ // For the all-model weekly format, wait for the separate Sonnet section too.
216
+ return Boolean(hasSessionData && (hasLegacyWeekly || (hasAllModelsWeekly && hasSonnetWeekly)));
216
217
  }
217
218
 
218
219
  sendCommands(shell, _output) {
219
220
  logger.agent(this.name, 'Sending /usage command...');
220
221
  // Claude keeps slash-command suggestions open for /usage on newer builds.
221
222
  // Confirm the command selection, then submit the actual command execution.
222
- setTimeout(() => shell.write('\x1b'), 50);
223
- setTimeout(() => shell.write('/usage'), 600);
224
- setTimeout(() => shell.write('\r'), 1000);
225
- setTimeout(() => shell.write('\r'), 1400);
223
+ const writeIfActive = (value) => {
224
+ if (!shell) return;
225
+ try {
226
+ shell.write(value);
227
+ } catch (_err) {
228
+ // The persistent exit handler records the underlying process state.
229
+ }
230
+ };
231
+ setTimeout(() => writeIfActive('/usage'), 100);
232
+ setTimeout(() => writeIfActive('\r'), 500);
233
+ setTimeout(() => writeIfActive('\r'), 900);
226
234
  }
227
235
 
228
236
  // After getting /usage output, dismiss dialog so next refresh starts with clean prompt
@@ -286,6 +294,18 @@ class ClaudeAgent extends BaseAgent {
286
294
  this._onDataCallback = null;
287
295
  this._commandInFlight = false;
288
296
  clearTimeout(timer);
297
+ if (this.shell && statusOutput) {
298
+ try {
299
+ this.shell.write('\x1b');
300
+ } catch (_err) {
301
+ // The persistent exit handler records the underlying process state.
302
+ }
303
+ setTimeout(() => {
304
+ this.output = '';
305
+ resolve(result);
306
+ }, 300);
307
+ return;
308
+ }
289
309
  resolve(result);
290
310
  };
291
311
 
@@ -306,10 +326,19 @@ class ClaudeAgent extends BaseAgent {
306
326
 
307
327
  logger.agent(this.name, 'Sending /status command for metadata...');
308
328
  this.output = '';
309
- // Send escape first to dismiss any UI, then /status
310
- setTimeout(() => this.shell.write('\x1b'), 50);
311
- setTimeout(() => this.shell.write('/status'), 600);
312
- setTimeout(() => this.shell.write('\r'), 1000);
329
+ const writeIfActive = (value) => {
330
+ if (!this.shell) {
331
+ finish(this._parseStatusOutput(statusOutput));
332
+ return;
333
+ }
334
+ try {
335
+ this.shell.write(value);
336
+ } catch (_err) {
337
+ finish(this._parseStatusOutput(statusOutput));
338
+ }
339
+ };
340
+ setTimeout(() => writeIfActive('/status'), 100);
341
+ setTimeout(() => writeIfActive('\r'), 500);
313
342
  });
314
343
  }
315
344
 
@@ -229,7 +229,7 @@ class CodexAgent extends BaseAgent {
229
229
  logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
230
230
  if (this.output.length > 0) {
231
231
  logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
232
- require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, this.output);
232
+ this.writeDebugOutput(this.output);
233
233
  }
234
234
  if (this.output.length > 100) {
235
235
  finish(this.output);
@@ -125,6 +125,12 @@ function extractResetTime(section) {
125
125
  // Look for date+time pattern FIRST (more specific)
126
126
  const dateTimeMatch = section.match(/((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},?\s*\d{1,2}(?::\d{2})?\s*(am|pm))\s*\(([^)]+)\)/i);
127
127
  if (dateTimeMatch) return `${dateTimeMatch[1]} (${dateTimeMatch[3]})`;
128
+ // Compact PTY output can lose the column spaces: "Jun10,6pm(Europe/Berlin)".
129
+ const compactDateTimeMatch = section.match(/((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec))(\d{1,2}),?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i);
130
+ if (compactDateTimeMatch) {
131
+ const [, month, day, hour, minutes, ampm, tz] = compactDateTimeMatch;
132
+ return `${month} ${day}, ${hour}${minutes ? `:${minutes}` : ''}${ampm} (${tz})`;
133
+ }
128
134
  // Try clean time-only pattern
129
135
  const timeOnlyMatch = section.match(/(\d{1,2}(?::\d{2})?\s*(?:am|pm))\s*\(([^)]+)\)/i);
130
136
  if (timeOnlyMatch) return `${timeOnlyMatch[1]} (${timeOnlyMatch[2]})`;
@@ -149,7 +155,7 @@ function extractResetTime(section) {
149
155
  */
150
156
  function parseUsageSection(section) {
151
157
  if (!section) return null;
152
- const percentMatch = section.match(/(\d+)\s*%\s*used/i);
158
+ const percentMatch = section.match(/(\d+)\s*%\s*use?d/i);
153
159
  if (!percentMatch) return null;
154
160
  const resetsAt = extractResetTime(section);
155
161
  const resetData = resetsAt ? parseResetTime(resetsAt) : null;
@@ -245,21 +251,21 @@ function parsePtyOutput(clean) {
245
251
  const usage = { session: null, weeklyAll: null, weeklySonnet: null };
246
252
 
247
253
  // Parse session
248
- const sessionData = parseUsageSection(extractSection(clean, 'Current\\s+session', 'Current\\s+week'));
254
+ const sessionData = parseUsageSection(extractSection(clean, 'Current\\s*session', 'Current\\s*week'));
249
255
  if (sessionData) {
250
256
  usage.session = { label: 'Current session', ...sessionData };
251
257
  addPaceData(usage.session, 'session');
252
258
  }
253
259
 
254
260
  // Parse weekly (all models)
255
- const weeklyAllData = parseUsageSection(extractSection(clean, 'Current\\s+week\\s*\\(?\\s*all\\s+models\\s*\\)?', 'Current\\s+week\\s*\\(?\\s*Sonnet'));
261
+ const weeklyAllData = parseUsageSection(extractSection(clean, 'Current\\s*week\\s*\\(?\\s*all\\s*models\\s*\\)?', 'Current\\s*week\\s*\\(?\\s*Son'));
256
262
  if (weeklyAllData) {
257
263
  usage.weeklyAll = { label: 'Current week (all models)', ...weeklyAllData };
258
264
  addPaceData(usage.weeklyAll, 'weekly');
259
265
  }
260
266
 
261
267
  // Parse weekly (Sonnet only)
262
- const weeklySonnetData = parseUsageSection(extractSection(clean, 'Current\\s+week\\s*\\(?\\s*Sonnet\\s+only\\s*\\)?', 'Extra\\s+usage|esc\\s+to\\s+cancel|Current\\s+week\\s*\\('));
268
+ const weeklySonnetData = parseUsageSection(extractSection(clean, 'Current\\s*week\\s*\\(?\\s*Son\\w*\\s*\\w*ly\\s*\\)?', 'Extra\\s*usage|esc\\s*to\\s*cancel|Current\\s*week\\s*\\('));
263
269
  if (weeklySonnetData) {
264
270
  usage.weeklySonnet = { label: 'Current week (Sonnet only)', ...weeklySonnetData };
265
271
  addPaceData(usage.weeklySonnet, 'weekly');