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,167 @@
1
+ const { calculatePace } = require('../pace-evaluator.js');
2
+ const { CYCLE_DURATIONS } = require('../usage-formatters.js');
3
+
4
+ /**
5
+ * Format RPC response as if it were PTY output for parseOutput compatibility
6
+ * Or directly parse and store the usage data
7
+ * @param {Object} rateLimits - The rate limits from RPC
8
+ * @returns {string} - Formatted string for parseOutput
9
+ * @throws {Error} - If the response structure is unexpected
10
+ */
11
+ function formatRpcResponseAsOutput(rateLimits) {
12
+ if (!rateLimits) {
13
+ throw new Error('Empty rate limits response');
14
+ }
15
+
16
+ // Check if we got a structured response we can use directly
17
+ if (rateLimits.fiveHour || rateLimits.weekly || rateLimits.limits) {
18
+ return { marker: '__RPC_RESPONSE__', data: rateLimits };
19
+ }
20
+
21
+ // Handle different response formats
22
+ if (rateLimits.rateLimits) {
23
+ return { marker: '__RPC_RESPONSE__', data: rateLimits.rateLimits };
24
+ }
25
+
26
+ // If structure is unknown, throw to fall back to PTY
27
+ throw new Error('Unexpected rate limits response structure');
28
+ }
29
+
30
+ /**
31
+ * Parse usage data from JSON-RPC response
32
+ * @param {Object} rateLimits - Rate limits from RPC
33
+ * @param {Object} context - Context object with metadata and version info
34
+ * @returns {Object} - Parsed usage object
35
+ */
36
+ function parseRpcRateLimits(rateLimits, context = {}) {
37
+ const usage = {
38
+ fiveHour: null,
39
+ weekly: null,
40
+ version: context.versionInfo || null,
41
+ };
42
+
43
+ // Parse five hour limit
44
+ if (rateLimits.fiveHour) {
45
+ usage.fiveHour = parseRpcLimitEntry(rateLimits.fiveHour, '5h limit', 'fiveHour', context);
46
+ }
47
+
48
+ // Parse weekly limit
49
+ if (rateLimits.weekly) {
50
+ usage.weekly = parseRpcLimitEntry(rateLimits.weekly, 'Weekly limit', 'weekly', context);
51
+ }
52
+
53
+ // Parse model-specific limits if present
54
+ if (rateLimits.modelLimits && Array.isArray(rateLimits.modelLimits)) {
55
+ usage.modelLimits = rateLimits.modelLimits.map(ml => ({
56
+ name: ml.name || ml.model,
57
+ fiveHour: ml.fiveHour ? parseRpcLimitEntry(ml.fiveHour, '5h limit', 'fiveHour', context) : null,
58
+ weekly: ml.weekly ? parseRpcLimitEntry(ml.weekly, 'Weekly limit', 'weekly', context) : null,
59
+ })).filter(ml => ml.fiveHour || ml.weekly);
60
+
61
+ if (usage.modelLimits.length === 0) {
62
+ delete usage.modelLimits;
63
+ }
64
+ }
65
+
66
+ // Extract model and account
67
+ if (rateLimits.model) {
68
+ usage.model = rateLimits.model;
69
+ }
70
+ if (rateLimits.account || rateLimits.email) {
71
+ usage.account = rateLimits.account || rateLimits.email;
72
+ }
73
+
74
+ // Build metadata updates
75
+ const metadataUpdates = {};
76
+ if (rateLimits.model) {
77
+ metadataUpdates.model = rateLimits.model;
78
+ }
79
+ if (rateLimits.account || rateLimits.email) {
80
+ metadataUpdates.email = rateLimits.account || rateLimits.email;
81
+ }
82
+ if (rateLimits.sessionId) {
83
+ metadataUpdates.sessionId = rateLimits.sessionId;
84
+ }
85
+
86
+ return { usage, metadataUpdates };
87
+ }
88
+
89
+ /**
90
+ * Parse a single limit entry from RPC response
91
+ * @param {Object} limit - Limit data from RPC
92
+ * @param {string} label - Label for the limit
93
+ * @param {string} cycleType - Cycle type for pace calculation
94
+ * @param {Object} context - Context with parseResetTime function
95
+ * @returns {Object} - Parsed limit entry
96
+ */
97
+ function parseRpcLimitEntry(limit, label, cycleType, context = {}) {
98
+ // Handle different possible structures
99
+ const percentLeft = limit.percentLeft ?? limit.remaining ?? limit.percent ?? 100;
100
+ const percentUsed = 100 - percentLeft;
101
+
102
+ // Handle reset time
103
+ let resetsAt = limit.resetsAt || limit.resetAt || limit.reset || null;
104
+ let resetsInSeconds = limit.resetsInSeconds || limit.secondsUntilReset || null;
105
+ let resetsIn = null;
106
+
107
+ // If we have seconds, calculate text
108
+ if (resetsInSeconds !== null) {
109
+ resetsIn = formatDuration(resetsInSeconds);
110
+ } else if (resetsAt && context.parseResetTime) {
111
+ // Try to parse the reset time
112
+ const resetData = context.parseResetTime(resetsAt);
113
+ if (resetData) {
114
+ resetsIn = resetData.text;
115
+ resetsInSeconds = resetData.seconds;
116
+ }
117
+ }
118
+
119
+ const entry = {
120
+ percentLeft,
121
+ resetsAt,
122
+ label,
123
+ percentUsed,
124
+ resetsIn,
125
+ resetsInSeconds,
126
+ };
127
+
128
+ // Calculate pace if we have the necessary data
129
+ const cycleDuration = CYCLE_DURATIONS[cycleType];
130
+ if (cycleDuration && resetsInSeconds != null) {
131
+ const paceData = calculatePace({
132
+ usagePercent: percentUsed,
133
+ resetsInSeconds,
134
+ cycleDurationSeconds: cycleDuration,
135
+ });
136
+ if (paceData) entry.pace = paceData;
137
+ }
138
+
139
+ return entry;
140
+ }
141
+
142
+ /**
143
+ * Format seconds as duration string
144
+ * @param {number} seconds - Seconds to format
145
+ * @returns {string} - Formatted duration
146
+ */
147
+ function formatDuration(seconds) {
148
+ const hours = Math.floor(seconds / 3600);
149
+ const mins = Math.floor((seconds % 3600) / 60);
150
+
151
+ if (hours > 24) {
152
+ const days = Math.floor(hours / 24);
153
+ const remainingHours = hours % 24;
154
+ return `${days}d ${remainingHours}h`;
155
+ } else if (hours > 0) {
156
+ return `${hours}h ${mins}m`;
157
+ } else {
158
+ return `${mins}m`;
159
+ }
160
+ }
161
+
162
+ module.exports = {
163
+ formatRpcResponseAsOutput,
164
+ parseRpcRateLimits,
165
+ parseRpcLimitEntry,
166
+ formatDuration,
167
+ };
@@ -0,0 +1,383 @@
1
+ const { BaseAgent } = require('./base.js');
2
+ const logger = require('../logger.js');
3
+ const { JsonRpcClient } = require('../json-rpc-client.js');
4
+ const {
5
+ formatRpcResponseAsOutput,
6
+ parseRpcRateLimits,
7
+ } = require('./codex-rpc-helpers.js');
8
+ const {
9
+ parseResetTime,
10
+ parseLimitEntry,
11
+ parseVersionInfo,
12
+ parseModelLimits,
13
+ extractMetadataFromOutput,
14
+ } = require('./codex-pty-helpers.js');
15
+ const { pingKeepalive } = require('./keepalive-helper.js');
16
+
17
+ class CodexAgent extends BaseAgent {
18
+ constructor() {
19
+ super('codex', 'codex');
20
+ this._rpcClient = null;
21
+ this._rpcSupported = null; // null = unknown, true/false = tested
22
+ }
23
+
24
+ getTimeout() { return 25000; }
25
+
26
+ /**
27
+ * Override runCommand to attempt JSON-RPC first, then fall back to PTY
28
+ */
29
+ async runCommand() {
30
+ if (this._rpcSupported === false) {
31
+ return this._runWithPty();
32
+ }
33
+
34
+ try {
35
+ const result = await this._runWithJsonRpc();
36
+ this._rpcSupported = true;
37
+ return result;
38
+ } catch (err) {
39
+ if (this._rpcSupported === null) {
40
+ console.log(`[${this.name}] JSON-RPC not available, falling back to PTY: ${err.message}`);
41
+ this._rpcSupported = false;
42
+ }
43
+ return this._runWithPty();
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Run using JSON-RPC app-server mode
49
+ */
50
+ async _runWithJsonRpc() {
51
+ console.log(`[${this.name}] Attempting JSON-RPC mode...`);
52
+
53
+ if (this._rpcClient) {
54
+ this._rpcClient.stop();
55
+ this._rpcClient = null;
56
+ }
57
+
58
+ this._rpcClient = new JsonRpcClient('codex', ['-s', 'read-only', '-a', 'untrusted', 'app-server'], {
59
+ cwd: '/tmp',
60
+ timeout: this.getTimeout(),
61
+ });
62
+
63
+ try {
64
+ await this._rpcClient.start();
65
+ console.log(`[${this.name}] JSON-RPC server started`);
66
+
67
+ const rateLimits = await this._rpcClient.call('account/rateLimits/read', {});
68
+ console.log(`[${this.name}] Received rate limits via JSON-RPC:`, JSON.stringify(rateLimits));
69
+
70
+ const { marker, data } = formatRpcResponseAsOutput(rateLimits);
71
+ this._rpcRateLimits = data;
72
+ return marker;
73
+ } finally {
74
+ if (this._rpcClient) {
75
+ this._rpcClient.stop();
76
+ this._rpcClient = null;
77
+ }
78
+ }
79
+ }
80
+
81
+ async _runWithPty() {
82
+ if (this.freshProcess) return this._runCommandFresh();
83
+ if (!this.shell || !this.processReady) await this.spawnProcess();
84
+ return this.sendCommandAndWait();
85
+ }
86
+
87
+ handleTrustPrompt(shell, output) {
88
+ if (!shell) return false;
89
+ const patterns = ['Do you trust', 'trust the files', 'trust this folder', 'Trust this workspace', 'allow access'];
90
+ if (!patterns.some(p => output.toLowerCase().includes(p.toLowerCase()))) return false;
91
+ logger.agent(this.name, 'Detected trust prompt, auto-accepting...');
92
+ shell.write('y\r');
93
+ setTimeout(() => { if (shell) { logger.agent(this.name, 'Sending Enter to proceed...'); shell.write('\r'); } }, 500);
94
+ return true;
95
+ }
96
+ handleUpdateScreen(shell, output) {
97
+ if (!shell) return false;
98
+ const clean = this.stripAnsi(output);
99
+ if (!(/u?pdate available/i.test(clean) && /[\d.]+\s*->\s*[\d.]+/.test(clean))) return false;
100
+ logger.agent(this.name, 'Detected update screen, skipping...');
101
+ // v0.115+: may show numbered menu (1. Update, 2. Skip) or just info box
102
+ // Send "2" to select Skip, Enter to confirm, then Escape + Enter to clear any leftover
103
+ shell.write('2');
104
+ setTimeout(() => { if (shell) shell.write('\r'); }, 300);
105
+ setTimeout(() => { if (shell) shell.write('\x1b'); }, 1000);
106
+ setTimeout(() => { if (shell) shell.write('\r'); }, 1500);
107
+ return true;
108
+ }
109
+ parseVersionInfo(output) { return parseVersionInfo(output, this.stripAnsi.bind(this)); }
110
+ isReadyForCommands(output) { return this.isReadyForStatus(output); }
111
+ isReadyForStatus(output) {
112
+ return output.includes('? for shortcuts') || output.includes('To get started') ||
113
+ output.includes('% left') || /›\s*\S/.test(this.stripAnsi(output));
114
+ }
115
+ sendCommands(shell, _output) { logger.agent(this.name, 'Sending /status command...'); setTimeout(() => { if (shell) shell.write('/status\r'); }, 100); }
116
+
117
+ _handleAdditionalPrompts(shell, _data, output) {
118
+ if (!shell) return;
119
+ if (!this._updateHandled && this.handleUpdateScreen(shell, output)) { this._updateHandled = true; return; }
120
+ const cleanOutput = this.stripAnsi(output);
121
+ const isUpdateScreen = /u?pdate available/i.test(cleanOutput) && /[\d.]+\s*->\s*[\d.]+/.test(cleanOutput);
122
+ if (!this._continuationHandled && !isUpdateScreen && output.includes('Press enter to continue')) {
123
+ logger.agent(this.name, 'Detected continuation prompt');
124
+ this._continuationHandled = true;
125
+ shell.write('\r');
126
+ }
127
+ }
128
+ handleInteractivePrompts(shell, data, output, state) {
129
+ if (!shell) return false;
130
+ if (!state.trustHandled && this.handleTrustPrompt(shell, output)) { state.trustHandled = true; return true; }
131
+ if (!state.updateHandled && this.handleUpdateScreen(shell, output)) { state.updateHandled = true; return true; }
132
+ this._respondToTerminalQueries(data, shell);
133
+ const cleanOutput = this.stripAnsi(output);
134
+ const isUpdateScreen = /u?pdate available/i.test(cleanOutput) && /[\d.]+\s*->\s*[\d.]+/.test(cleanOutput);
135
+ if (!state.continuationHandled && !isUpdateScreen && output.includes('Press enter to continue')) {
136
+ logger.agent(this.name, 'Detected continuation prompt');
137
+ state.continuationHandled = true;
138
+ shell.write('\r');
139
+ }
140
+ return false;
141
+ }
142
+
143
+ async spawnProcess() {
144
+ return new Promise((resolve, reject) => {
145
+ const pty = require('node-pty');
146
+ let spawnOutput = '';
147
+ const state = { trustHandled: false, continuationHandled: false, updateHandled: false };
148
+
149
+ logger.agent(this.name, 'Spawning persistent process:', logger.dim(`${this.command} ${this.args.join(' ')}`));
150
+
151
+ try {
152
+ this.shell = pty.spawn(this.command, this.args, {
153
+ name: 'xterm-256color',
154
+ cols: 120,
155
+ rows: 40,
156
+ cwd: '/tmp',
157
+ env: { ...process.env, TERM: 'xterm-256color' },
158
+ });
159
+ } catch (spawnErr) {
160
+ logger.error(`[${this.name}] Failed to spawn:`, spawnErr.message);
161
+ reject(new Error(`Failed to spawn ${this.command}: ${spawnErr.message}`));
162
+ return;
163
+ }
164
+
165
+ const timer = setTimeout(() => {
166
+ logger.error(`[${this.name}] Spawn timeout after ${this.getTimeout()}ms`);
167
+ this.killProcess();
168
+ reject(new Error('Timeout waiting for process to become ready'));
169
+ }, this.getTimeout());
170
+
171
+ let postPromptOutput = '';
172
+ const spawnDataHandler = this.shell.onData((data) => {
173
+ spawnOutput += data;
174
+ if (this.handleInteractivePrompts(this.shell, data, spawnOutput, state)) {
175
+ postPromptOutput = '';
176
+ return;
177
+ }
178
+ postPromptOutput += data;
179
+ const checkOutput = (state.updateHandled || state.trustHandled) ? postPromptOutput : spawnOutput;
180
+ if (this.isReadyForStatus(checkOutput)) {
181
+ logger.agent(this.name, 'Process ready for commands');
182
+ const versionFromSpawn = this.parseVersionInfo(spawnOutput);
183
+ if (versionFromSpawn) {
184
+ this._spawnVersionInfo = versionFromSpawn;
185
+ logger.agent(this.name, 'Version info from spawn:', logger.json(versionFromSpawn));
186
+ }
187
+ clearTimeout(timer);
188
+ spawnDataHandler.dispose();
189
+ this.processReady = true;
190
+ this._setupPersistentDataHandler();
191
+ this._setupPersistentExitHandler();
192
+ resolve();
193
+ }
194
+ });
195
+
196
+ const spawnExitHandler = this.shell.onExit(({ exitCode }) => {
197
+ clearTimeout(timer);
198
+ spawnDataHandler.dispose();
199
+ spawnExitHandler.dispose();
200
+ this.shell = null;
201
+ this.processReady = false;
202
+ reject(new Error(`Process exited during spawn with code ${exitCode}`));
203
+ });
204
+ });
205
+ }
206
+
207
+ async sendCommandAndWait() {
208
+ return new Promise((resolve, reject) => {
209
+ this.output = '';
210
+ this._commandInFlight = true;
211
+ let retryTimer = null;
212
+ let settleTimer = null;
213
+
214
+ const finish = (result) => {
215
+ this._commandInFlight = false;
216
+ this._onDataCallback = null;
217
+ clearTimeout(timer);
218
+ if (retryTimer) clearInterval(retryTimer);
219
+ if (settleTimer) clearTimeout(settleTimer);
220
+ resolve(result);
221
+ };
222
+
223
+ const timer = setTimeout(() => {
224
+ if (retryTimer) clearInterval(retryTimer);
225
+ if (settleTimer) clearTimeout(settleTimer);
226
+ logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
227
+ if (this.output.length > 0) {
228
+ logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
229
+ require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, this.output);
230
+ }
231
+ if (this.output.length > 100) {
232
+ finish(this.output);
233
+ } else {
234
+ this._commandInFlight = false;
235
+ this._onDataCallback = null;
236
+ reject(new Error('Timeout waiting for usage data'));
237
+ }
238
+ }, this.getTimeout());
239
+
240
+ retryTimer = setInterval(() => {
241
+ if (!this.hasCompleteOutput(this.output) && this.shell) {
242
+ logger.agent(this.name, 'Retrying /status...');
243
+ this.output = '';
244
+ this.shell.write('/status');
245
+ setTimeout(() => { if (this.shell) this.shell.write('\r'); }, 200);
246
+ }
247
+ }, 5000);
248
+
249
+ this._onDataCallback = () => {
250
+ if (this.hasCompleteOutput(this.output)) {
251
+ if (!settleTimer) {
252
+ logger.agent(this.name, 'Complete output detected, waiting to settle...');
253
+ settleTimer = setTimeout(() => finish(this.output), 200);
254
+ }
255
+ }
256
+ };
257
+
258
+ logger.agent(this.name, 'Sending /status to persistent process...');
259
+ setTimeout(() => { if (this.shell) this.shell.write('/status\r'); }, 100);
260
+ });
261
+ }
262
+
263
+ hasCompleteOutput(output) {
264
+ if (output.includes('5h limit') && output.includes('Weekly limit')) return true;
265
+ return false;
266
+ }
267
+
268
+ parseResetTime(resetStr) {
269
+ return parseResetTime(resetStr);
270
+ }
271
+
272
+ parseLimitEntry(match, label, cycleType) {
273
+ return parseLimitEntry(match, label, cycleType);
274
+ }
275
+
276
+ parseOutput(output) {
277
+ // Check if this is an RPC response marker
278
+ if (output === '__RPC_RESPONSE__' && this._rpcRateLimits) {
279
+ const context = {
280
+ versionInfo: this._spawnVersionInfo || null,
281
+ parseResetTime: this.parseResetTime.bind(this),
282
+ };
283
+ const { usage, metadataUpdates } = parseRpcRateLimits(this._rpcRateLimits, context);
284
+ this._rpcRateLimits = null;
285
+
286
+ // Apply metadata updates
287
+ if (!this.metadata) this.metadata = {};
288
+ Object.assign(this.metadata, metadataUpdates);
289
+
290
+ return usage;
291
+ }
292
+
293
+ const clean = this.stripAnsi(output);
294
+ const usage = {
295
+ fiveHour: null,
296
+ weekly: null,
297
+ version: (() => {
298
+ const v = { ...this._spawnVersionInfo, ...this.parseVersionInfo(output) };
299
+ return Object.keys(v).length > 0 ? v : null;
300
+ })(),
301
+ };
302
+
303
+ const fiveHourMatch = clean.match(/5h limit:\s*\[.*?\]\s*(\d+)%\s*left\s*\(resets\s*([^)]+)\)/i);
304
+ if (fiveHourMatch) {
305
+ usage.fiveHour = this.parseLimitEntry(fiveHourMatch, '5h limit', 'fiveHour');
306
+ }
307
+
308
+ const weeklyMatch = clean.match(/Weekly limit:\s*\[.*?\]\s*(\d+)%\s*left\s*\(resets\s*([^)]+)\)/i);
309
+ if (weeklyMatch) {
310
+ usage.weekly = this.parseLimitEntry(weeklyMatch, 'Weekly limit', 'weekly');
311
+ }
312
+
313
+ const modelLimits = parseModelLimits(clean);
314
+ if (modelLimits.length > 0) {
315
+ usage.modelLimits = modelLimits;
316
+ }
317
+
318
+ const modelMatch = clean.match(/Model:\s*([\w.-]+)/i);
319
+ if (modelMatch) {
320
+ usage.model = this.stripBoxChars(modelMatch[1]);
321
+ }
322
+
323
+ const accountMatch = clean.match(/Account:\s*(\S+@\S+)/i);
324
+ if (accountMatch) {
325
+ usage.account = this.stripBoxChars(accountMatch[1]);
326
+ }
327
+
328
+ this._updateMetadataFromOutput(clean);
329
+
330
+ return usage;
331
+ }
332
+
333
+ _updateMetadataFromOutput(clean) {
334
+ if (!this.metadata) this.metadata = {};
335
+ const extracted = extractMetadataFromOutput(clean, this.stripBoxChars.bind(this));
336
+ Object.assign(this.metadata, extracted);
337
+ }
338
+
339
+ get usingJsonRpc() {
340
+ return this._rpcSupported;
341
+ }
342
+
343
+ setRpcMode(useRpc) {
344
+ this._rpcSupported = useRpc;
345
+ }
346
+
347
+ killProcess() {
348
+ if (this._rpcClient) {
349
+ this._rpcClient.stop();
350
+ this._rpcClient = null;
351
+ }
352
+ super.killProcess();
353
+ }
354
+
355
+ /** Lightweight keepalive to prevent session expiration. @returns {Promise<boolean>} True if keepalive succeeded */
356
+ async keepalive() {
357
+ if (this._rpcSupported === true) { console.log(`[${this.name}] Keepalive skipped (JSON-RPC mode)`); return true; }
358
+ if (this.freshProcess) { console.log(`[${this.name}] Keepalive skipped (fresh process mode)`); return true; }
359
+ if (!this.shell || !this.processReady) { console.log(`[${this.name}] Keepalive: spawning process...`); await this.spawnProcess(); }
360
+ if (this.shell) { console.log(`[${this.name}] Keepalive: sending ping...`); this.shell.write('\x1b'); return true; }
361
+ return false;
362
+ }
363
+
364
+ /** Spawns fresh CLI, sends /status to refresh session, then tears down cleanly. @returns {Promise<boolean>} */
365
+ async pingKeepalive() {
366
+ if (this._rpcSupported === true) { console.log(`[${this.name}] pingKeepalive: skipped (JSON-RPC mode)`); return true; }
367
+ const state = { trustHandled: false, continuationHandled: false, updateHandled: false };
368
+ return pingKeepalive({
369
+ name: this.name,
370
+ command: this.command,
371
+ args: this.args,
372
+ env: { ...process.env, TERM: 'xterm-256color' },
373
+ termName: 'xterm-256color',
374
+ isReady: (output) => this.isReadyForStatus(output),
375
+ sendCommand: (shell) => setTimeout(() => shell.write('/status\r'), 100),
376
+ isComplete: (output) => this.hasCompleteOutput(output),
377
+ handlePrompts: (shell, data, output) => this.handleInteractivePrompts(shell, data, output, state),
378
+ respondToTerminalQueries: (data, shell) => this._respondToTerminalQueries(data, shell),
379
+ });
380
+ }
381
+ }
382
+
383
+ module.exports = { CodexAgent };