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.
- package/LICENSE +21 -0
- package/README.md +675 -0
- package/bin/agent-tank.js +301 -0
- package/media/www-preview.png +0 -0
- package/package.json +65 -0
- package/src/activity-monitor.js +312 -0
- package/src/agents/api-response-parser.js +255 -0
- package/src/agents/base.js +396 -0
- package/src/agents/claude.js +388 -0
- package/src/agents/codex-pty-helpers.js +226 -0
- package/src/agents/codex-rpc-helpers.js +167 -0
- package/src/agents/codex.js +383 -0
- package/src/agents/gemini.js +302 -0
- package/src/agents/keepalive-helper.js +115 -0
- package/src/agents/oauth-helper.js +210 -0
- package/src/agents/pty-output-parser.js +301 -0
- package/src/auto-refresh-manager.js +246 -0
- package/src/client-auto-refresh-helpers.js +143 -0
- package/src/client-auto-refresh.js +321 -0
- package/src/client-favicon.js +198 -0
- package/src/client-monitor.js +242 -0
- package/src/client-script.js +147 -0
- package/src/discovery.js +128 -0
- package/src/history-store.js +273 -0
- package/src/http-handler.js +182 -0
- package/src/icons.js +67 -0
- package/src/index.js +376 -0
- package/src/json-rpc-client.js +285 -0
- package/src/keepalive-manager.js +163 -0
- package/src/logger.js +213 -0
- package/src/pace-attachment.js +104 -0
- package/src/pace-evaluator.js +269 -0
- package/src/public-status.js +276 -0
- package/src/server.js +144 -0
- package/src/snapshot-metrics.js +79 -0
- package/src/status-page.css +1149 -0
- package/src/status-page.js +128 -0
- package/src/usage-formatters.js +259 -0
|
@@ -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 };
|