agent-tank 0.9.0

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,255 @@
1
+ /**
2
+ * API Response Parser for Claude Agent
3
+ * Handles parsing of direct Anthropic API responses into the unified usage schema
4
+ */
5
+
6
+ const { calculatePace } = require('../pace-evaluator.js');
7
+ const { CYCLE_DURATIONS } = require('../usage-formatters.js');
8
+
9
+ /**
10
+ * Format duration from seconds to readable text
11
+ * @param {number} diffSeconds - Duration in seconds
12
+ * @returns {string} Formatted duration string
13
+ */
14
+ function formatDuration(diffSeconds) {
15
+ const mins = Math.floor(diffSeconds / 60);
16
+ const hours = Math.floor(mins / 60);
17
+ const m = mins % 60;
18
+ if (hours > 24) {
19
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
20
+ }
21
+ return hours > 0 ? `${hours}h ${m}m` : `${m}m`;
22
+ }
23
+
24
+ /**
25
+ * Calculate reset time info from an ISO timestamp
26
+ * @param {string|null} isoTimestamp - ISO format timestamp
27
+ * @param {Date} now - Current date/time
28
+ * @returns {Object} Reset time information
29
+ */
30
+ function parseResetTimestamp(isoTimestamp, now) {
31
+ if (!isoTimestamp) {
32
+ return { resetsAt: null, resetsIn: null, resetsInSeconds: null };
33
+ }
34
+ const resetDate = new Date(isoTimestamp);
35
+ const diffMs = resetDate - now;
36
+ if (diffMs <= 0) {
37
+ return { resetsAt: isoTimestamp, resetsIn: 'soon', resetsInSeconds: 0 };
38
+ }
39
+ const diffSeconds = Math.floor(diffMs / 1000);
40
+ return {
41
+ resetsAt: isoTimestamp,
42
+ resetsIn: formatDuration(diffSeconds),
43
+ resetsInSeconds: diffSeconds,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Add pace data to a usage section
49
+ * @param {Object|null} sectionData - Usage section data
50
+ * @param {string} cycleType - Type of cycle ('session' or 'weekly')
51
+ * @returns {Object|null} Section data with pace info added
52
+ */
53
+ function addPaceData(sectionData, cycleType) {
54
+ if (!sectionData || sectionData.resetsInSeconds == null) return sectionData;
55
+ const cycleDuration = CYCLE_DURATIONS[cycleType];
56
+ if (!cycleDuration) return sectionData;
57
+ const paceData = calculatePace({
58
+ usagePercent: sectionData.percent ?? 0,
59
+ resetsInSeconds: sectionData.resetsInSeconds,
60
+ cycleDurationSeconds: cycleDuration,
61
+ });
62
+ if (paceData) sectionData.pace = paceData;
63
+ return sectionData;
64
+ }
65
+
66
+ /**
67
+ * Parse session limit from API response
68
+ * @param {Object} apiResponse - Raw API response
69
+ * @param {Date} now - Current date/time
70
+ * @returns {Object|null} Parsed session data
71
+ */
72
+ function parseSessionLimit(apiResponse, now) {
73
+ if (!apiResponse.sessionLimit) return null;
74
+
75
+ const sl = apiResponse.sessionLimit;
76
+ const percent = sl.percentUsed ?? (sl.used && sl.limit ? Math.round((sl.used / sl.limit) * 100) : null);
77
+
78
+ if (percent === null) return null;
79
+
80
+ const resetInfo = parseResetTimestamp(sl.resetsAt, now);
81
+ const session = { label: 'Current session', percent, ...resetInfo };
82
+ return addPaceData(session, 'session');
83
+ }
84
+
85
+ /**
86
+ * Parse weekly all models limit from API response
87
+ * @param {Object} apiResponse - Raw API response
88
+ * @param {Date} now - Current date/time
89
+ * @returns {Object|null} Parsed weekly all models data
90
+ */
91
+ function parseWeeklyAllModels(apiResponse, now) {
92
+ const wl = apiResponse.weeklyAllModels || apiResponse.weeklyLimit;
93
+ if (!wl) return null;
94
+
95
+ const percent = wl.percentUsed ?? (wl.used && wl.limit ? Math.round((wl.used / wl.limit) * 100) : null);
96
+
97
+ if (percent === null) return null;
98
+
99
+ const resetInfo = parseResetTimestamp(wl.resetsAt, now);
100
+ const weeklyAll = { label: 'Current week (all models)', percent, ...resetInfo };
101
+ return addPaceData(weeklyAll, 'weekly');
102
+ }
103
+
104
+ /**
105
+ * Parse weekly Sonnet only limit from API response
106
+ * @param {Object} apiResponse - Raw API response
107
+ * @param {Date} now - Current date/time
108
+ * @returns {Object|null} Parsed weekly Sonnet data
109
+ */
110
+ function parseWeeklySonnet(apiResponse, now) {
111
+ const ws = apiResponse.weeklySonnet || apiResponse.weeklySonnetOnly;
112
+ if (!ws) return null;
113
+
114
+ const percent = ws.percentUsed ?? (ws.used && ws.limit ? Math.round((ws.used / ws.limit) * 100) : null);
115
+
116
+ if (percent === null) return null;
117
+
118
+ const resetInfo = parseResetTimestamp(ws.resetsAt, now);
119
+ const weeklySonnet = { label: 'Current week (Sonnet only)', percent, ...resetInfo };
120
+ return addPaceData(weeklySonnet, 'weekly');
121
+ }
122
+
123
+ /**
124
+ * Parse extra usage from API response
125
+ * @param {Object} apiResponse - Raw API response
126
+ * @param {Date} now - Current date/time
127
+ * @returns {Object|null} Parsed extra usage data
128
+ */
129
+ function parseExtraUsage(apiResponse, now) {
130
+ if (!apiResponse.extraUsage) return null;
131
+
132
+ const eu = apiResponse.extraUsage;
133
+ const percent = eu.percentUsed ?? (eu.spent && eu.budget ? Math.round((eu.spent / eu.budget) * 100) : null);
134
+ const resetInfo = parseResetTimestamp(eu.resetsAt, now);
135
+
136
+ const extraUsage = {
137
+ label: 'Extra usage',
138
+ percent,
139
+ spent: eu.spent ?? null,
140
+ budget: eu.budget ?? null,
141
+ ...resetInfo,
142
+ };
143
+ return addPaceData(extraUsage, 'weekly');
144
+ }
145
+
146
+ /**
147
+ * Parse flat percentage fields from API response
148
+ * @param {Object} apiResponse - Raw API response
149
+ * @param {Object} usage - Current usage object to augment
150
+ * @param {Date} now - Current date/time
151
+ * @returns {Object} Updated usage object
152
+ */
153
+ function parseFlatPercentageFields(apiResponse, usage, now) {
154
+ if (!usage.session && apiResponse.sessionPercent !== undefined) {
155
+ const session = {
156
+ label: 'Current session',
157
+ percent: apiResponse.sessionPercent,
158
+ ...parseResetTimestamp(apiResponse.sessionResetsAt, now),
159
+ };
160
+ usage.session = addPaceData(session, 'session');
161
+ }
162
+
163
+ if (!usage.weeklyAll && apiResponse.weeklyPercent !== undefined) {
164
+ const weeklyAll = {
165
+ label: 'Current week (all models)',
166
+ percent: apiResponse.weeklyPercent,
167
+ ...parseResetTimestamp(apiResponse.weeklyResetsAt, now),
168
+ };
169
+ usage.weeklyAll = addPaceData(weeklyAll, 'weekly');
170
+ }
171
+
172
+ return usage;
173
+ }
174
+
175
+ /**
176
+ * Normalize the OAuth usage API response to the parser's expected schema.
177
+ * The API returns: five_hour, seven_day, seven_day_sonnet, extra_usage
178
+ * The parser expects: sessionLimit, weeklyAllModels, weeklySonnet, extraUsage
179
+ * @param {Object} raw - Raw API response
180
+ * @returns {Object} Normalized response
181
+ */
182
+ function normalizeOAuthResponse(raw) {
183
+ if (!raw.five_hour && !raw.seven_day) return raw; // already normalized or unknown
184
+ const norm = {};
185
+ if (raw.five_hour) {
186
+ norm.sessionLimit = {
187
+ percentUsed: Math.round(raw.five_hour.utilization),
188
+ resetsAt: raw.five_hour.resets_at,
189
+ };
190
+ }
191
+ if (raw.seven_day) {
192
+ norm.weeklyAllModels = {
193
+ percentUsed: Math.round(raw.seven_day.utilization),
194
+ resetsAt: raw.seven_day.resets_at,
195
+ };
196
+ }
197
+ if (raw.seven_day_sonnet) {
198
+ norm.weeklySonnet = {
199
+ percentUsed: Math.round(raw.seven_day_sonnet.utilization),
200
+ resetsAt: raw.seven_day_sonnet.resets_at,
201
+ };
202
+ }
203
+ if (raw.extra_usage?.is_enabled) {
204
+ norm.extraUsage = {
205
+ percentUsed: Math.round(raw.extra_usage.utilization),
206
+ spent: raw.extra_usage.used_credits != null
207
+ ? Math.round(raw.extra_usage.used_credits) / 100 : null,
208
+ budget: raw.extra_usage.monthly_limit != null
209
+ ? Math.round(raw.extra_usage.monthly_limit) / 100 : null,
210
+ resetsAt: raw.seven_day?.resets_at || null,
211
+ };
212
+ }
213
+ return norm;
214
+ }
215
+
216
+ /**
217
+ * Parse the direct API response into the unified usage schema
218
+ * @param {Object} apiResponse - The raw API response from Anthropic
219
+ * @returns {Object} The parsed usage object matching the PTY format
220
+ */
221
+ function parseApiResponse(apiResponse) {
222
+ const usage = { session: null, weeklyAll: null, weeklySonnet: null };
223
+
224
+ if (!apiResponse) return usage;
225
+
226
+ // Handle nested usage object format
227
+ if (apiResponse.usage) {
228
+ return parseApiResponse(apiResponse.usage);
229
+ }
230
+
231
+ // Normalize OAuth API response format to parser schema
232
+ const normalized = normalizeOAuthResponse(apiResponse);
233
+
234
+ const now = new Date();
235
+
236
+ // Parse each section
237
+ usage.session = parseSessionLimit(normalized, now);
238
+ usage.weeklyAll = parseWeeklyAllModels(normalized, now);
239
+ usage.weeklySonnet = parseWeeklySonnet(normalized, now);
240
+
241
+ const extraUsage = parseExtraUsage(normalized, now);
242
+ if (extraUsage) {
243
+ usage.extraUsage = extraUsage;
244
+ }
245
+
246
+ // Handle flat percentage fields as fallback
247
+ return parseFlatPercentageFields(normalized, usage, now);
248
+ }
249
+
250
+ module.exports = {
251
+ parseApiResponse,
252
+ formatDuration,
253
+ parseResetTimestamp,
254
+ addPaceData,
255
+ };
@@ -0,0 +1,396 @@
1
+ const pty = require('node-pty');
2
+ const logger = require('../logger.js');
3
+
4
+ class BaseAgent {
5
+ constructor(name, command, args = []) {
6
+ this.name = name;
7
+ this.command = command;
8
+ this.args = args;
9
+ this.usage = null;
10
+ this.metadata = null;
11
+ this._metadataFetched = false;
12
+ this.lastUpdated = null;
13
+ this.error = null;
14
+ this.isRefreshing = false;
15
+ this.refreshInterval = null; // Per-agent override (seconds), null = use global
16
+ this.minRefreshInterval = null; // Per-agent minimum (seconds), null = no minimum
17
+
18
+ // Persistent process state
19
+ this.shell = null;
20
+ this.processReady = false;
21
+ this.output = '';
22
+ this.freshProcess = false;
23
+ this._onDataCallback = null;
24
+ this._commandInFlight = false;
25
+ this._disposables = [];
26
+ }
27
+
28
+ getStatus() {
29
+ return {
30
+ name: this.name,
31
+ usage: this.usage,
32
+ metadata: this.metadata,
33
+ lastUpdated: this.lastUpdated,
34
+ error: this.error,
35
+ isRefreshing: this.isRefreshing,
36
+ };
37
+ }
38
+
39
+ async refresh() {
40
+ if (this.isRefreshing) {
41
+ logger.agent(this.name, 'Already refreshing, skipping...');
42
+ return;
43
+ }
44
+
45
+ logger.agent(this.name, 'Starting refresh...');
46
+ this.isRefreshing = true;
47
+ this.error = null;
48
+
49
+ try {
50
+ // Fetch metadata once on first refresh (if agent supports it)
51
+ if (!this._metadataFetched && this.fetchMetadata) {
52
+ try {
53
+ logger.agent(this.name, 'Fetching metadata (first refresh)...');
54
+ this.metadata = await this.fetchMetadata();
55
+ logger.agent(this.name, 'Parsed metadata:', logger.json(this.metadata));
56
+ } catch (metaErr) {
57
+ logger.error(`[${this.name}] Error fetching metadata:`, metaErr.message);
58
+ // Continue with usage fetch even if metadata fails
59
+ }
60
+ this._metadataFetched = true;
61
+ // In fresh process mode, kill the persistent process spawned for metadata
62
+ // so that runCommand() starts cleanly with _runCommandFresh()
63
+ if (this.freshProcess) {
64
+ this.killProcess();
65
+ }
66
+ }
67
+
68
+ const output = await this.runCommand();
69
+ logger.agent(this.name, 'Got output, length:', logger.dim(`${output.length} chars`));
70
+
71
+ // Check for rate limit and session errors before parsing
72
+ // Match actual error messages, not incidental mentions like "rate limits and credits"
73
+ const cleanOutput = this.stripAnsi(output);
74
+ if (/rate.?limited|rate_limit_error/i.test(cleanOutput)) {
75
+ logger.agent(this.name, 'Rate limited, preserving last known usage data');
76
+ this.error = 'Rate limited — using cached data';
77
+ this.lastUpdated = new Date().toISOString();
78
+ // Don't overwrite this.usage — keep the last known good data
79
+ return;
80
+ }
81
+
82
+ // Check for session/auth errors — preserve cached data if available
83
+ 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);
84
+ if (sessionErrorMatch) {
85
+ const errorMsg = sessionErrorMatch[0];
86
+ logger.agent(this.name, 'Session error detected:', logger.dim(errorMsg));
87
+ this.error = this.usage ? `Session error — using cached data` : `Session error: ${errorMsg}`;
88
+ this.lastUpdated = new Date().toISOString();
89
+ return;
90
+ }
91
+
92
+ const parsed = this.parseOutput(output);
93
+
94
+ // Only update usage if we got meaningful data (not all-null)
95
+ 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)));
96
+ 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(); }
97
+ logger.agent(this.name, 'Parsed usage:', logger.json(this.usage));
98
+ } catch (err) {
99
+ logger.error(`[${this.name}] Error during refresh:`, err.message);
100
+ this.error = err.message;
101
+ } finally {
102
+ this.isRefreshing = false;
103
+ logger.agent(this.name, 'Refresh complete');
104
+ }
105
+ }
106
+
107
+ async runCommand() {
108
+ if (this.freshProcess) {
109
+ return this._runCommandFresh();
110
+ }
111
+
112
+ // Persistent mode: spawn if needed, then send command
113
+ if (!this.shell || !this.processReady) {
114
+ await this.spawnProcess();
115
+ }
116
+
117
+ return this.sendCommandAndWait();
118
+ }
119
+
120
+ async spawnProcess() {
121
+ return new Promise((resolve, reject) => {
122
+ let trustHandled = false;
123
+ let spawnOutput = '';
124
+
125
+ logger.agent(this.name, 'Spawning persistent process:', logger.dim(`${this.command} ${this.args.join(' ')}`));
126
+
127
+ try {
128
+ const env = this.getEnv ? this.getEnv() : { ...process.env, TERM: 'xterm-256color' };
129
+ this.shell = pty.spawn(this.command, this.args, {
130
+ name: 'xterm-color',
131
+ cols: 120,
132
+ rows: 40,
133
+ cwd: '/tmp',
134
+ env,
135
+ });
136
+ } catch (spawnErr) {
137
+ logger.error(`[${this.name}] Failed to spawn:`, spawnErr.message);
138
+ reject(new Error(`Failed to spawn ${this.command}: ${spawnErr.message}`));
139
+ return;
140
+ }
141
+
142
+ const timer = setTimeout(() => {
143
+ logger.error(`[${this.name}] Spawn timeout after ${this.getTimeout()}ms`);
144
+ this.killProcess();
145
+ reject(new Error('Timeout waiting for process to become ready'));
146
+ }, this.getTimeout());
147
+
148
+ let trustCooldownUntil = 0;
149
+ const spawnDataHandler = this.shell.onData((data) => {
150
+ spawnOutput += data;
151
+
152
+ // Handle trust/update prompts during spawn (only once each)
153
+ if (!trustHandled && this.handleTrustPrompt && this.handleTrustPrompt(this.shell, spawnOutput)) {
154
+ trustHandled = true;
155
+ spawnOutput = '';
156
+ trustCooldownUntil = Date.now() + 4000;
157
+ return;
158
+ }
159
+ this._handleAdditionalPrompts(this.shell, data, spawnOutput);
160
+ this._respondToTerminalQueries(data);
161
+
162
+ if (Date.now() >= trustCooldownUntil && this.isReadyForCommands(spawnOutput)) {
163
+ logger.agent(this.name, 'Process ready for commands');
164
+ clearTimeout(timer);
165
+ spawnDataHandler.dispose();
166
+ this.processReady = true;
167
+ this._setupPersistentDataHandler();
168
+ this._setupPersistentExitHandler();
169
+ resolve();
170
+ }
171
+ });
172
+
173
+ const spawnExitHandler = this.shell.onExit(({ exitCode }) => {
174
+ clearTimeout(timer);
175
+ spawnDataHandler.dispose();
176
+ spawnExitHandler.dispose();
177
+ this.shell = null;
178
+ this.processReady = false;
179
+ reject(new Error(`Process exited during spawn with code ${exitCode}`));
180
+ });
181
+ });
182
+ }
183
+
184
+ async sendCommandAndWait() {
185
+ return new Promise((resolve, reject) => {
186
+ this.output = '';
187
+ this._commandInFlight = true;
188
+
189
+ const finish = (result) => {
190
+ this._commandInFlight = false;
191
+ this._onDataCallback = null;
192
+ clearTimeout(timer);
193
+ resolve(result);
194
+ };
195
+
196
+ const timer = setTimeout(() => {
197
+ logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
198
+ if (this.output.length > 0) {
199
+ logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
200
+ require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, this.output);
201
+ }
202
+ // Kill the stuck process so it respawns fresh on the next refresh
203
+ logger.agent(this.name, 'Killing stuck process to force respawn');
204
+ const partialOutput = this.output;
205
+ this.killProcess();
206
+ if (partialOutput.length > 100) {
207
+ resolve(partialOutput);
208
+ } else {
209
+ reject(new Error('Timeout waiting for usage data'));
210
+ }
211
+ }, this.getTimeout());
212
+
213
+ this._onDataCallback = () => {
214
+ if (this.hasCompleteOutput(this.output)) {
215
+ logger.agent(this.name, 'Complete output detected');
216
+ // Small delay to capture any remaining output
217
+ setTimeout(() => finish(this.output), 100);
218
+ }
219
+ };
220
+
221
+ // Process is already at prompt, send commands immediately
222
+ logger.agent(this.name, 'Sending commands to persistent process...');
223
+ this.sendCommands(this.shell, this.output);
224
+ });
225
+ }
226
+
227
+ _setupPersistentDataHandler() {
228
+ const handler = this.shell.onData((data) => {
229
+ this.output += data;
230
+
231
+ // Always respond to terminal queries — suppressing them causes deadlocks
232
+ // when CLIs (e.g. Gemini) wait for cursor position responses before
233
+ // rendering output. Delay slightly during command-in-flight to avoid
234
+ // interleaving with command text being sent via setTimeout.
235
+ if (this._commandInFlight) {
236
+ setTimeout(() => this._respondToTerminalQueries(data), 50);
237
+ } else {
238
+ this._respondToTerminalQueries(data);
239
+ }
240
+
241
+ // Invoke command waiter callback if set
242
+ if (this._onDataCallback) {
243
+ this._onDataCallback(data);
244
+ }
245
+ });
246
+ this._disposables.push(handler);
247
+ }
248
+
249
+ _setupPersistentExitHandler() {
250
+ const handler = this.shell.onExit(({ exitCode }) => {
251
+ logger.agent(this.name, 'Persistent process exited with code', logger.dim(exitCode));
252
+ this.shell = null; this.processReady = false; this._onDataCallback = null; this._disposables = [];
253
+ });
254
+ this._disposables.push(handler);
255
+ }
256
+
257
+ _respondToTerminalQueries(data, target) {
258
+ const sh = target || this.shell; if (!sh) return;
259
+ if (data.includes('\x1b[6n')) sh.write('\x1b[1;1R'); if (data.includes('\x1b[c')) sh.write('\x1b[?62;22c'); if (data.includes('\x1b[?u')) sh.write('\x1b[?0u');
260
+ if (data.includes('\x1b]10;?')) sh.write('\x1b]10;rgb:ffff/ffff/ffff\x1b\\'); if (data.includes('\x1b]11;?')) sh.write('\x1b]11;rgb:0000/0000/0000\x1b\\');
261
+ if (data.includes('\x1b[>q')) sh.write('\x1bP>|xterm(1)\x1b\\'); if (data.includes('\x1b[>4;?m')) sh.write('\x1b[>4m');
262
+ }
263
+
264
+ _handleAdditionalPrompts(_s, _d, _o) { } // Hook for subclasses; killProcess: see below
265
+ killProcess() { // Terminates the persistent PTY process
266
+ if (this.shell) {
267
+ logger.agent(this.name, 'Killing persistent process');
268
+ for (const d of this._disposables) { d.dispose(); }
269
+ this._disposables = []; this._onDataCallback = null; this.processReady = false;
270
+ try { this.shell.kill(); } catch (_e) { /* Process may already be dead */ }
271
+ this.shell = null;
272
+ }
273
+ }
274
+
275
+ async _runCommandFresh() {
276
+ return new Promise((resolve, reject) => {
277
+ const timeout = this.getTimeout();
278
+ let output = '';
279
+ let completed = false;
280
+ let commandsSent = false;
281
+ let trustHandled = false;
282
+
283
+ logger.agent(this.name, 'Spawning:', logger.dim(`${this.command} ${this.args.join(' ')}`));
284
+
285
+ let shell;
286
+ try {
287
+ const env = this.getEnv ? this.getEnv() : { ...process.env, TERM: 'xterm-256color' };
288
+ shell = pty.spawn(this.command, this.args, {
289
+ name: 'xterm-color',
290
+ cols: 120,
291
+ rows: 40,
292
+ cwd: '/tmp',
293
+ env,
294
+ });
295
+ } catch (spawnErr) {
296
+ logger.error(`[${this.name}] Failed to spawn:`, spawnErr.message);
297
+ reject(new Error(`Failed to spawn ${this.command}: ${spawnErr.message}`));
298
+ return;
299
+ }
300
+
301
+ const timer = setTimeout(() => {
302
+ if (!completed) {
303
+ completed = true;
304
+ logger.agent(this.name, 'Timeout after', logger.dim(`${timeout}ms`), ', output length:', logger.dim(`${output.length}`));
305
+ if (output.length > 0) {
306
+ logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(output).substring(0, 500)));
307
+ require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, output);
308
+ logger.agent(this.name, 'Full output written to', logger.dim(`/tmp/${this.name}-output.txt`));
309
+ }
310
+ shell.kill();
311
+ if (output.length > 100) {
312
+ resolve(output);
313
+ } else {
314
+ reject(new Error('Timeout waiting for usage data'));
315
+ }
316
+ }
317
+ }, timeout);
318
+
319
+ let trustCooldownUntil = 0;
320
+ shell.onData((data) => {
321
+ output += data;
322
+
323
+ if (output.length <= data.length) {
324
+ logger.agent(this.name, 'First data received', logger.dim(`(${data.length} chars)`));
325
+ if (data.length < 200) {
326
+ logger.agent(this.name, 'Initial output:', logger.dim(this.stripAnsi(data).substring(0, 100)));
327
+ }
328
+ }
329
+
330
+ if (!trustHandled && this.handleTrustPrompt && this.handleTrustPrompt(shell, output)) {
331
+ trustHandled = true;
332
+ output = '';
333
+ trustCooldownUntil = Date.now() + 4000;
334
+ return;
335
+ }
336
+
337
+ this._respondToTerminalQueries(data, shell);
338
+ this._handleAdditionalPrompts(shell, data, output);
339
+
340
+ if (Date.now() >= trustCooldownUntil && !commandsSent && this.isReadyForCommands(output)) {
341
+ logger.agent(this.name, 'Ready for commands, sending...');
342
+ commandsSent = true;
343
+ this.sendCommands(shell, output);
344
+ }
345
+
346
+ // Check if we have enough data to extract usage
347
+ if (commandsSent && this.hasCompleteOutput(output)) {
348
+ logger.agent(this.name, 'Complete output detected, finishing...');
349
+ setTimeout(() => {
350
+ if (!completed) {
351
+ completed = true;
352
+ clearTimeout(timer);
353
+ shell.kill();
354
+ resolve(output);
355
+ }
356
+ }, 300);
357
+ }
358
+ });
359
+
360
+ shell.onExit(({ exitCode }) => {
361
+ if (!completed) {
362
+ completed = true;
363
+ clearTimeout(timer);
364
+ logger.agent(this.name, 'Process exited with code', logger.dim(exitCode), ', output length:', logger.dim(`${output.length}`));
365
+ if (output) {
366
+ resolve(output);
367
+ } else {
368
+ reject(new Error(`Process exited with code ${exitCode}`));
369
+ }
370
+ }
371
+ });
372
+ });
373
+ }
374
+
375
+ getTimeout() { return 30000; }
376
+ isReadyForCommands(_o) { return false; }
377
+ hasCompleteOutput(_o) { return false; }
378
+ sendCommands(_s, _o) { }
379
+ parseOutput(_o) { return null; }
380
+
381
+ /** Lightweight keepalive method to prevent session expiration. Subclasses can override. @returns {Promise<boolean>} True if keepalive succeeded */
382
+ async keepalive() {
383
+ if (this.freshProcess) { console.log(`[${this.name}] Keepalive skipped (fresh process mode)`); return true; }
384
+ if (!this.shell || !this.processReady) { console.log(`[${this.name}] Keepalive: spawning process...`); await this.spawnProcess(); }
385
+ if (this.shell) { console.log(`[${this.name}] Keepalive: sending ping...`); this.shell.write('\x1b'); return true; }
386
+ return false;
387
+ }
388
+ /* eslint-disable no-control-regex */
389
+ static ANSI_CURSOR_RIGHT = /\x1B\[(\d+)C/g; static ANSI_ESCAPE_SEQ = /\x1B\[[0-9;?]*[a-zA-Z]/g;
390
+ static ANSI_OSC_SEQ = /\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g; static ANSI_DCS_SEQ = /\x1BP[^\x1B]*\x1B\\/g;
391
+ static ANSI_CHARSET = /\x1B[()#][A-Za-z0-9]/g; static ANSI_TWOCHAR = /\x1B[=>DMEH78cNOZn\\|}{~]/g;
392
+ static ANSI_LEFTOVER = /\x1B/g; static MULTI_SPACE = / +/g; /* eslint-enable no-control-regex */
393
+ stripAnsi(str) { return str.replace(BaseAgent.ANSI_CURSOR_RIGHT, (_, n) => ' '.repeat(parseInt(n))).replace(BaseAgent.ANSI_ESCAPE_SEQ, '').replace(BaseAgent.ANSI_OSC_SEQ, '').replace(BaseAgent.ANSI_DCS_SEQ, '').replace(BaseAgent.ANSI_CHARSET, '').replace(BaseAgent.ANSI_TWOCHAR, '').replace(BaseAgent.ANSI_LEFTOVER, '').replace(/\r/g, '').replace(BaseAgent.MULTI_SPACE, ' '); }
394
+ stripBoxChars(str) { return str ? str.replace(/[│╭╮╯╰─┌┐└┘├┤┬┴┼║═╔╗╚╝╠╣╦╩╬]/g, '').trim() : str; }
395
+ }
396
+ module.exports = { BaseAgent };