agent-tank 0.9.1 → 0.9.3
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/bin/agent-tank.js +21 -5
- package/package.json +1 -1
- package/src/agents/base.js +144 -2
- package/src/agents/codex.js +3 -0
- package/src/agents/gemini.js +116 -8
- package/src/index.js +50 -4
- package/src/json-rpc-client.js +3 -0
- package/src/shutdown-handler.js +32 -0
- package/src/status-page.css +21 -0
- package/src/status-page.js +17 -3
package/bin/agent-tank.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
const { parseArgs } = require('node:util');
|
|
5
5
|
const { AgentTank } = require('../src/index.js');
|
|
6
|
+
const { installShutdownHandlers } = require('../src/shutdown-handler.js');
|
|
7
|
+
const pkg = require('../package.json');
|
|
6
8
|
|
|
7
9
|
const options = {
|
|
8
10
|
claude: { type: 'boolean', default: false },
|
|
@@ -17,6 +19,7 @@ const options = {
|
|
|
17
19
|
'fresh-process': { type: 'boolean', default: false },
|
|
18
20
|
config: { type: 'string', short: 'c' },
|
|
19
21
|
help: { type: 'boolean', short: 'h', default: false },
|
|
22
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
20
23
|
'auto-discover': { type: 'boolean', default: true },
|
|
21
24
|
'auto-refresh': { type: 'boolean', default: true },
|
|
22
25
|
'auto-refresh-interval': { type: 'string', default: '60' },
|
|
@@ -56,6 +59,7 @@ Options:
|
|
|
56
59
|
--auth-token <token> API key for Bearer token auth
|
|
57
60
|
--fresh-process Spawn a new process per refresh (default: false)
|
|
58
61
|
--config, -c Path to config file (JSON)
|
|
62
|
+
--version, -v Show version
|
|
59
63
|
--auto-discover Auto-discover available agents (default: true)
|
|
60
64
|
--auto-refresh Enable/disable background auto-refresh (default: true)
|
|
61
65
|
--auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
|
|
@@ -127,6 +131,12 @@ async function main() {
|
|
|
127
131
|
return;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
if (values.version) {
|
|
135
|
+
process.stdout.write(`${pkg.version}\n`);
|
|
136
|
+
process.exitCode = 0;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
130
140
|
// Load config file if specified
|
|
131
141
|
let config = {};
|
|
132
142
|
if (values.config) {
|
|
@@ -255,15 +265,19 @@ async function main() {
|
|
|
255
265
|
});
|
|
256
266
|
|
|
257
267
|
// Graceful shutdown
|
|
258
|
-
|
|
268
|
+
let shuttingDown = false;
|
|
269
|
+
let cleanupShutdownHandlers = () => {};
|
|
270
|
+
const shutdown = (signal) => {
|
|
271
|
+
if (shuttingDown) return;
|
|
272
|
+
shuttingDown = true;
|
|
273
|
+
cleanupShutdownHandlers();
|
|
259
274
|
if (!jsonMode) {
|
|
260
|
-
originalLog(
|
|
275
|
+
originalLog(`\nShutting down${signal ? ` (${signal})` : ''}...`);
|
|
261
276
|
}
|
|
262
277
|
watcher.stop();
|
|
263
|
-
process.
|
|
278
|
+
process.exit(0);
|
|
264
279
|
};
|
|
265
|
-
|
|
266
|
-
process.on('SIGTERM', shutdown);
|
|
280
|
+
cleanupShutdownHandlers = installShutdownHandlers({ shutdown });
|
|
267
281
|
|
|
268
282
|
try {
|
|
269
283
|
if (onceMode) {
|
|
@@ -281,6 +295,7 @@ async function main() {
|
|
|
281
295
|
originalLog(JSON.stringify(agentStatus, null, 2));
|
|
282
296
|
}
|
|
283
297
|
}
|
|
298
|
+
cleanupShutdownHandlers();
|
|
284
299
|
watcher.stop();
|
|
285
300
|
process.exitCode = 0;
|
|
286
301
|
return;
|
|
@@ -288,6 +303,7 @@ async function main() {
|
|
|
288
303
|
|
|
289
304
|
await watcher.start();
|
|
290
305
|
} catch (err) {
|
|
306
|
+
cleanupShutdownHandlers();
|
|
291
307
|
if (jsonMode) {
|
|
292
308
|
console.log = originalLog;
|
|
293
309
|
console.log(JSON.stringify({ error: err.message }, null, 2));
|
package/package.json
CHANGED
package/src/agents/base.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const pty = require('node-pty');
|
|
2
|
+
const process = require('node:process');
|
|
2
3
|
const logger = require('../logger.js');
|
|
3
4
|
|
|
4
5
|
class BaseAgent {
|
|
@@ -11,6 +12,7 @@ class BaseAgent {
|
|
|
11
12
|
this._metadataFetched = false;
|
|
12
13
|
this.lastUpdated = null;
|
|
13
14
|
this.error = null;
|
|
15
|
+
this.auth = null;
|
|
14
16
|
this.isRefreshing = false;
|
|
15
17
|
this.refreshInterval = null; // Per-agent override (seconds), null = use global
|
|
16
18
|
this.minRefreshInterval = null; // Per-agent minimum (seconds), null = no minimum
|
|
@@ -23,6 +25,22 @@ class BaseAgent {
|
|
|
23
25
|
this._onDataCallback = null;
|
|
24
26
|
this._commandInFlight = false;
|
|
25
27
|
this._disposables = [];
|
|
28
|
+
this._stopRequested = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_createStopError() {
|
|
32
|
+
return new Error('Agent stopping');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isStopping() {
|
|
36
|
+
return this._stopRequested;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
requestStop() {
|
|
40
|
+
this._stopRequested = true;
|
|
41
|
+
this.isRefreshing = false;
|
|
42
|
+
this._commandInFlight = false;
|
|
43
|
+
this._onDataCallback = null;
|
|
26
44
|
}
|
|
27
45
|
|
|
28
46
|
getStatus() {
|
|
@@ -32,11 +50,17 @@ class BaseAgent {
|
|
|
32
50
|
metadata: this.metadata,
|
|
33
51
|
lastUpdated: this.lastUpdated,
|
|
34
52
|
error: this.error,
|
|
53
|
+
auth: this.auth,
|
|
35
54
|
isRefreshing: this.isRefreshing,
|
|
36
55
|
};
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
async refresh() {
|
|
59
|
+
if (this.isStopping()) {
|
|
60
|
+
logger.agent(this.name, 'Skipping refresh while stopping');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
if (this.isRefreshing) {
|
|
41
65
|
logger.agent(this.name, 'Already refreshing, skipping...');
|
|
42
66
|
return;
|
|
@@ -45,6 +69,7 @@ class BaseAgent {
|
|
|
45
69
|
logger.agent(this.name, 'Starting refresh...');
|
|
46
70
|
this.isRefreshing = true;
|
|
47
71
|
this.error = null;
|
|
72
|
+
this.auth = null;
|
|
48
73
|
|
|
49
74
|
try {
|
|
50
75
|
// Fetch metadata once on first refresh (if agent supports it)
|
|
@@ -65,12 +90,25 @@ class BaseAgent {
|
|
|
65
90
|
}
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
if (this.isStopping()) {
|
|
94
|
+
throw this._createStopError();
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
const output = await this.runCommand();
|
|
69
98
|
logger.agent(this.name, 'Got output, length:', logger.dim(`${output.length} chars`));
|
|
70
99
|
|
|
71
100
|
// Check for rate limit and session errors before parsing
|
|
72
101
|
// Match actual error messages, not incidental mentions like "rate limits and credits"
|
|
73
102
|
const cleanOutput = this.stripAnsi(output);
|
|
103
|
+
const authState = this.detectAuthenticationState ? this.detectAuthenticationState(cleanOutput) : null;
|
|
104
|
+
if (authState) {
|
|
105
|
+
logger.agent(this.name, 'Authentication issue detected:', logger.json(authState));
|
|
106
|
+
this.auth = authState;
|
|
107
|
+
this.error = this.usage ? `${authState.message} — using cached data` : authState.message;
|
|
108
|
+
this.lastUpdated = new Date().toISOString();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
74
112
|
if (/rate.?limited|rate_limit_error/i.test(cleanOutput)) {
|
|
75
113
|
logger.agent(this.name, 'Rate limited, preserving last known usage data');
|
|
76
114
|
this.error = 'Rate limited — using cached data';
|
|
@@ -96,6 +134,10 @@ class BaseAgent {
|
|
|
96
134
|
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
135
|
logger.agent(this.name, 'Parsed usage:', logger.json(this.usage));
|
|
98
136
|
} catch (err) {
|
|
137
|
+
if (this.isStopping() && err.message === 'Agent stopping') {
|
|
138
|
+
logger.agent(this.name, 'Refresh cancelled during shutdown');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
99
141
|
logger.error(`[${this.name}] Error during refresh:`, err.message);
|
|
100
142
|
this.error = err.message;
|
|
101
143
|
} finally {
|
|
@@ -105,6 +147,10 @@ class BaseAgent {
|
|
|
105
147
|
}
|
|
106
148
|
|
|
107
149
|
async runCommand() {
|
|
150
|
+
if (this.isStopping()) {
|
|
151
|
+
throw this._createStopError();
|
|
152
|
+
}
|
|
153
|
+
|
|
108
154
|
if (this.freshProcess) {
|
|
109
155
|
return this._runCommandFresh();
|
|
110
156
|
}
|
|
@@ -118,6 +164,10 @@ class BaseAgent {
|
|
|
118
164
|
}
|
|
119
165
|
|
|
120
166
|
async spawnProcess() {
|
|
167
|
+
if (this.isStopping()) {
|
|
168
|
+
throw this._createStopError();
|
|
169
|
+
}
|
|
170
|
+
|
|
121
171
|
return new Promise((resolve, reject) => {
|
|
122
172
|
let trustHandled = false;
|
|
123
173
|
let spawnOutput = '';
|
|
@@ -133,6 +183,9 @@ class BaseAgent {
|
|
|
133
183
|
cwd: '/tmp',
|
|
134
184
|
env,
|
|
135
185
|
});
|
|
186
|
+
if (typeof this.shell.pid === 'number') {
|
|
187
|
+
logger.agent(this.name, 'Persistent process pid:', logger.dim(String(this.shell.pid)));
|
|
188
|
+
}
|
|
136
189
|
} catch (spawnErr) {
|
|
137
190
|
logger.error(`[${this.name}] Failed to spawn:`, spawnErr.message);
|
|
138
191
|
reject(new Error(`Failed to spawn ${this.command}: ${spawnErr.message}`));
|
|
@@ -140,6 +193,10 @@ class BaseAgent {
|
|
|
140
193
|
}
|
|
141
194
|
|
|
142
195
|
const timer = setTimeout(() => {
|
|
196
|
+
if (this.isStopping()) {
|
|
197
|
+
reject(this._createStopError());
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
143
200
|
logger.error(`[${this.name}] Spawn timeout after ${this.getTimeout()}ms`);
|
|
144
201
|
this.killProcess();
|
|
145
202
|
reject(new Error('Timeout waiting for process to become ready'));
|
|
@@ -147,6 +204,15 @@ class BaseAgent {
|
|
|
147
204
|
|
|
148
205
|
let trustCooldownUntil = 0;
|
|
149
206
|
const spawnDataHandler = this.shell.onData((data) => {
|
|
207
|
+
if (this.isStopping()) {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
spawnDataHandler.dispose();
|
|
210
|
+
spawnExitHandler.dispose();
|
|
211
|
+
this.killProcess();
|
|
212
|
+
reject(this._createStopError());
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
150
216
|
spawnOutput += data;
|
|
151
217
|
|
|
152
218
|
// Handle trust/update prompts during spawn (only once each)
|
|
@@ -159,6 +225,20 @@ class BaseAgent {
|
|
|
159
225
|
this._handleAdditionalPrompts(this.shell, data, spawnOutput);
|
|
160
226
|
this._respondToTerminalQueries(data);
|
|
161
227
|
|
|
228
|
+
const authState = this.detectAuthenticationState ? this.detectAuthenticationState(spawnOutput) : null;
|
|
229
|
+
if (authState) {
|
|
230
|
+
logger.agent(this.name, 'Authentication issue detected during spawn');
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
spawnDataHandler.dispose();
|
|
233
|
+
spawnExitHandler.dispose();
|
|
234
|
+
this.processReady = true;
|
|
235
|
+
this.output = spawnOutput;
|
|
236
|
+
this._setupPersistentDataHandler();
|
|
237
|
+
this._setupPersistentExitHandler();
|
|
238
|
+
resolve();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
162
242
|
if (Date.now() >= trustCooldownUntil && this.isReadyForCommands(spawnOutput)) {
|
|
163
243
|
logger.agent(this.name, 'Process ready for commands');
|
|
164
244
|
clearTimeout(timer);
|
|
@@ -176,13 +256,29 @@ class BaseAgent {
|
|
|
176
256
|
spawnExitHandler.dispose();
|
|
177
257
|
this.shell = null;
|
|
178
258
|
this.processReady = false;
|
|
259
|
+
if (this.isStopping()) {
|
|
260
|
+
reject(this._createStopError());
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
179
263
|
reject(new Error(`Process exited during spawn with code ${exitCode}`));
|
|
180
264
|
});
|
|
181
265
|
});
|
|
182
266
|
}
|
|
183
267
|
|
|
184
268
|
async sendCommandAndWait() {
|
|
269
|
+
if (this.isStopping()) {
|
|
270
|
+
throw this._createStopError();
|
|
271
|
+
}
|
|
272
|
+
|
|
185
273
|
return new Promise((resolve, reject) => {
|
|
274
|
+
const existingOutput = this.output;
|
|
275
|
+
const existingAuthState = this.detectAuthenticationState ? this.detectAuthenticationState(existingOutput) : null;
|
|
276
|
+
if (existingAuthState) {
|
|
277
|
+
logger.agent(this.name, 'Using existing authentication output without sending commands');
|
|
278
|
+
resolve(existingOutput);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
186
282
|
this.output = '';
|
|
187
283
|
this._commandInFlight = true;
|
|
188
284
|
|
|
@@ -194,6 +290,12 @@ class BaseAgent {
|
|
|
194
290
|
};
|
|
195
291
|
|
|
196
292
|
const timer = setTimeout(() => {
|
|
293
|
+
if (this.isStopping()) {
|
|
294
|
+
this._commandInFlight = false;
|
|
295
|
+
this._onDataCallback = null;
|
|
296
|
+
reject(this._createStopError());
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
197
299
|
logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
|
|
198
300
|
if (this.output.length > 0) {
|
|
199
301
|
logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
|
|
@@ -211,6 +313,13 @@ class BaseAgent {
|
|
|
211
313
|
}, this.getTimeout());
|
|
212
314
|
|
|
213
315
|
this._onDataCallback = () => {
|
|
316
|
+
if (this.isStopping()) {
|
|
317
|
+
this._commandInFlight = false;
|
|
318
|
+
this._onDataCallback = null;
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
reject(this._createStopError());
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
214
323
|
if (this.hasCompleteOutput(this.output)) {
|
|
215
324
|
logger.agent(this.name, 'Complete output detected');
|
|
216
325
|
// Small delay to capture any remaining output
|
|
@@ -264,15 +373,27 @@ class BaseAgent {
|
|
|
264
373
|
_handleAdditionalPrompts(_s, _d, _o) { } // Hook for subclasses; killProcess: see below
|
|
265
374
|
killProcess() { // Terminates the persistent PTY process
|
|
266
375
|
if (this.shell) {
|
|
267
|
-
|
|
376
|
+
const pidText = typeof this.shell.pid === 'number' ? ` (pid ${this.shell.pid})` : '';
|
|
377
|
+
logger.agent(this.name, `Killing persistent process${pidText}`);
|
|
378
|
+
const shell = this.shell;
|
|
268
379
|
for (const d of this._disposables) { d.dispose(); }
|
|
269
380
|
this._disposables = []; this._onDataCallback = null; this.processReady = false;
|
|
270
|
-
try {
|
|
381
|
+
try {
|
|
382
|
+
// PTY-backed CLIs may spawn descendants; kill the whole process group when available.
|
|
383
|
+
if (typeof shell.pid === 'number' && shell.pid > 0) {
|
|
384
|
+
try { process.kill(-shell.pid, 'SIGTERM'); } catch (_e) { /* Group may not exist */ }
|
|
385
|
+
}
|
|
386
|
+
shell.kill();
|
|
387
|
+
} catch (_e) { /* Process may already be dead */ }
|
|
271
388
|
this.shell = null;
|
|
272
389
|
}
|
|
273
390
|
}
|
|
274
391
|
|
|
275
392
|
async _runCommandFresh() {
|
|
393
|
+
if (this.isStopping()) {
|
|
394
|
+
throw this._createStopError();
|
|
395
|
+
}
|
|
396
|
+
|
|
276
397
|
return new Promise((resolve, reject) => {
|
|
277
398
|
const timeout = this.getTimeout();
|
|
278
399
|
let output = '';
|
|
@@ -300,6 +421,12 @@ class BaseAgent {
|
|
|
300
421
|
|
|
301
422
|
const timer = setTimeout(() => {
|
|
302
423
|
if (!completed) {
|
|
424
|
+
if (this.isStopping()) {
|
|
425
|
+
completed = true;
|
|
426
|
+
shell.kill();
|
|
427
|
+
reject(this._createStopError());
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
303
430
|
completed = true;
|
|
304
431
|
logger.agent(this.name, 'Timeout after', logger.dim(`${timeout}ms`), ', output length:', logger.dim(`${output.length}`));
|
|
305
432
|
if (output.length > 0) {
|
|
@@ -318,6 +445,16 @@ class BaseAgent {
|
|
|
318
445
|
|
|
319
446
|
let trustCooldownUntil = 0;
|
|
320
447
|
shell.onData((data) => {
|
|
448
|
+
if (this.isStopping()) {
|
|
449
|
+
if (!completed) {
|
|
450
|
+
completed = true;
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
shell.kill();
|
|
453
|
+
reject(this._createStopError());
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
321
458
|
output += data;
|
|
322
459
|
|
|
323
460
|
if (output.length <= data.length) {
|
|
@@ -361,6 +498,10 @@ class BaseAgent {
|
|
|
361
498
|
if (!completed) {
|
|
362
499
|
completed = true;
|
|
363
500
|
clearTimeout(timer);
|
|
501
|
+
if (this.isStopping()) {
|
|
502
|
+
reject(this._createStopError());
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
364
505
|
logger.agent(this.name, 'Process exited with code', logger.dim(exitCode), ', output length:', logger.dim(`${output.length}`));
|
|
365
506
|
if (output) {
|
|
366
507
|
resolve(output);
|
|
@@ -380,6 +521,7 @@ class BaseAgent {
|
|
|
380
521
|
|
|
381
522
|
/** Lightweight keepalive method to prevent session expiration. Subclasses can override. @returns {Promise<boolean>} True if keepalive succeeded */
|
|
382
523
|
async keepalive() {
|
|
524
|
+
if (this.isStopping()) { return false; }
|
|
383
525
|
if (this.freshProcess) { console.log(`[${this.name}] Keepalive skipped (fresh process mode)`); return true; }
|
|
384
526
|
if (!this.shell || !this.processReady) { console.log(`[${this.name}] Keepalive: spawning process...`); await this.spawnProcess(); }
|
|
385
527
|
if (this.shell) { console.log(`[${this.name}] Keepalive: sending ping...`); this.shell.write('\x1b'); return true; }
|
package/src/agents/codex.js
CHANGED
|
@@ -156,6 +156,9 @@ class CodexAgent extends BaseAgent {
|
|
|
156
156
|
cwd: '/tmp',
|
|
157
157
|
env: { ...process.env, TERM: 'xterm-256color' },
|
|
158
158
|
});
|
|
159
|
+
if (typeof this.shell.pid === 'number') {
|
|
160
|
+
logger.agent(this.name, 'Persistent process pid:', logger.dim(String(this.shell.pid)));
|
|
161
|
+
}
|
|
159
162
|
} catch (spawnErr) {
|
|
160
163
|
logger.error(`[${this.name}] Failed to spawn:`, spawnErr.message);
|
|
161
164
|
reject(new Error(`Failed to spawn ${this.command}: ${spawnErr.message}`));
|
package/src/agents/gemini.js
CHANGED
|
@@ -10,10 +10,24 @@ class GeminiAgent extends BaseAgent {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
getTimeout() {
|
|
13
|
-
return
|
|
13
|
+
return 35000; // 35 seconds - Gemini can take 10+ seconds to start
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getEnv() {
|
|
17
|
+
// Use xterm-256color for proper terminal support
|
|
18
|
+
// Gemini CLI needs good terminal emulation for its TUI
|
|
19
|
+
return {
|
|
20
|
+
...process.env,
|
|
21
|
+
TERM: 'xterm-256color',
|
|
22
|
+
COLORTERM: 'truecolor',
|
|
23
|
+
};
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
isReadyForCommands(output) {
|
|
27
|
+
// Don't consider ready if authentication is in progress
|
|
28
|
+
if (this._isAuthenticating(output)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
17
31
|
// v0.24: "Type your message" prompt
|
|
18
32
|
// v0.35+: "Ready" in terminal title + visible prompt area (no "Type your message")
|
|
19
33
|
// Also check for terminal title sequence containing "Ready"
|
|
@@ -22,6 +36,52 @@ class GeminiAgent extends BaseAgent {
|
|
|
22
36
|
|| /Ready\s*\(/.test(output);
|
|
23
37
|
}
|
|
24
38
|
|
|
39
|
+
_isAuthenticating(output) {
|
|
40
|
+
// Detect OAuth/authentication screens where Escape would cancel the flow
|
|
41
|
+
const clean = this.stripAnsi(output);
|
|
42
|
+
|
|
43
|
+
// If we see the ready prompt, auth is complete (even if auth text still in buffer)
|
|
44
|
+
if (clean.includes('Type your message') || clean.includes('? for shortcuts')) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Only consider authenticating if we see auth-specific text
|
|
49
|
+
return /waiting for authentication/i.test(clean)
|
|
50
|
+
|| /press esc.*to cancel/i.test(clean)
|
|
51
|
+
|| /authenticating\.\.\./i.test(clean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
detectAuthenticationState(output) {
|
|
55
|
+
const clean = this.stripAnsi(output);
|
|
56
|
+
if (!clean) return null;
|
|
57
|
+
|
|
58
|
+
const hasAuthMenu = /how would you like to authenticate for this project\?/i.test(clean)
|
|
59
|
+
|| /get started/i.test(clean);
|
|
60
|
+
const hasAuthFailure = /failed to sign in\.\s*message:\s*([^\n]+)/i.test(clean)
|
|
61
|
+
|| /please run gemini cli in an interactive terminal to authenticate/i.test(clean)
|
|
62
|
+
|| /authentication consent could not be obtained/i.test(clean);
|
|
63
|
+
|
|
64
|
+
if (!hasAuthMenu && !hasAuthFailure) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const failureMatch = clean.match(/failed to sign in\.\s*message:\s*([^\n]+)/i);
|
|
69
|
+
const detail = failureMatch
|
|
70
|
+
? `Failed to sign in: ${failureMatch[1].trim()}`
|
|
71
|
+
: 'Gemini CLI is waiting for authentication.';
|
|
72
|
+
const action = /NO_BROWSER=true/i.test(clean)
|
|
73
|
+
? 'Run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.'
|
|
74
|
+
: 'Run Gemini CLI in an interactive terminal to authenticate.';
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
authenticated: false,
|
|
78
|
+
status: 'unauthenticated',
|
|
79
|
+
message: 'Authentication required',
|
|
80
|
+
detail,
|
|
81
|
+
action,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
25
85
|
handleTrustPrompt(shell, output) {
|
|
26
86
|
// Check for trust prompt with various wordings
|
|
27
87
|
const trustPatterns = [
|
|
@@ -42,6 +102,7 @@ class GeminiAgent extends BaseAgent {
|
|
|
42
102
|
// v0.35+ does an internal restart after trust — send Escape after a delay
|
|
43
103
|
// to dismiss any transitional UI before the process becomes ready again
|
|
44
104
|
setTimeout(() => {
|
|
105
|
+
if (!shell) return;
|
|
45
106
|
logger.agent(this.name, 'Post-trust: sending Escape to clear restart UI...');
|
|
46
107
|
shell.write('\x1b');
|
|
47
108
|
}, 3000);
|
|
@@ -55,6 +116,7 @@ class GeminiAgent extends BaseAgent {
|
|
|
55
116
|
// Gemini shows "Usage limits" after the model usage table (v0.24.x)
|
|
56
117
|
if (output.includes('Usage limits span all sessions')) return true;
|
|
57
118
|
const clean = this.stripAnsi(output);
|
|
119
|
+
if (this.detectAuthenticationState(clean)) return true;
|
|
58
120
|
const hasModel = /gemini-[\w.-]+/i.test(clean);
|
|
59
121
|
const hasPercent = /\d+(?:\.\d+)?%/i.test(clean);
|
|
60
122
|
// v0.24: "Resets in 3h 26m", v0.35+: "(23h 21m)" after time
|
|
@@ -89,10 +151,16 @@ class GeminiAgent extends BaseAgent {
|
|
|
89
151
|
return totalSeconds > 0 ? totalSeconds : null;
|
|
90
152
|
}
|
|
91
153
|
|
|
92
|
-
sendCommands(shell,
|
|
154
|
+
sendCommands(shell, output) {
|
|
155
|
+
// Check if authentication is still in progress - don't send commands that could interfere
|
|
156
|
+
if (this._isAuthenticating(output) || this.detectAuthenticationState(output)) {
|
|
157
|
+
logger.agent(this.name, 'Authentication required or in progress, cannot send /stats command');
|
|
158
|
+
// Don't retry - let the timeout handle it. Sending Escape would cancel auth.
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
93
161
|
logger.agent(this.name, 'Sending /stats command...');
|
|
94
162
|
// v0.35+: typing /stats triggers autocomplete dropdown.
|
|
95
|
-
// Escape
|
|
163
|
+
// Escape clears any pending state, then send command, Escape again to dismiss autocomplete
|
|
96
164
|
setTimeout(() => shell.write('\x1b'), 50);
|
|
97
165
|
setTimeout(() => shell.write('/stats'), 500);
|
|
98
166
|
setTimeout(() => shell.write('\x1b'), 1000);
|
|
@@ -161,6 +229,10 @@ class GeminiAgent extends BaseAgent {
|
|
|
161
229
|
await this.spawnProcess();
|
|
162
230
|
}
|
|
163
231
|
|
|
232
|
+
if (this.detectAuthenticationState(this.output)) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
164
236
|
return new Promise((resolve, _reject) => {
|
|
165
237
|
let aboutOutput = '';
|
|
166
238
|
let completed = false;
|
|
@@ -181,8 +253,16 @@ class GeminiAgent extends BaseAgent {
|
|
|
181
253
|
}, 10000); // 10 second timeout for /about
|
|
182
254
|
|
|
183
255
|
this._commandInFlight = true;
|
|
256
|
+
let trustHandled = false;
|
|
184
257
|
this._onDataCallback = () => {
|
|
185
258
|
aboutOutput = this.output;
|
|
259
|
+
// Handle trust prompts that appear during command execution
|
|
260
|
+
if (!trustHandled && this.handleTrustPrompt(this.shell, aboutOutput)) {
|
|
261
|
+
trustHandled = true;
|
|
262
|
+
aboutOutput = '';
|
|
263
|
+
this.output = '';
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
186
266
|
// Check if we have complete /about output
|
|
187
267
|
if (this._hasCompleteAboutOutput(aboutOutput)) {
|
|
188
268
|
logger.agent(this.name, 'Complete /about output detected');
|
|
@@ -192,11 +272,39 @@ class GeminiAgent extends BaseAgent {
|
|
|
192
272
|
|
|
193
273
|
logger.agent(this.name, 'Sending /about command for metadata...');
|
|
194
274
|
this.output = '';
|
|
195
|
-
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
275
|
+
|
|
276
|
+
// Wait for authentication to complete before sending commands
|
|
277
|
+
// Auth can start after spawn if token refresh is needed
|
|
278
|
+
// Gemini can take 10+ seconds to fully start up
|
|
279
|
+
let authWaitCount = 0;
|
|
280
|
+
const maxAuthWait = 15; // Max 15 seconds waiting for auth
|
|
281
|
+
|
|
282
|
+
const waitForAuth = () => {
|
|
283
|
+
if (this.detectAuthenticationState(this.output)) {
|
|
284
|
+
logger.agent(this.name, 'Authentication required, skipping /about metadata fetch');
|
|
285
|
+
finish(null);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (this._isAuthenticating(this.output)) {
|
|
289
|
+
authWaitCount++;
|
|
290
|
+
if (authWaitCount >= maxAuthWait) {
|
|
291
|
+
logger.agent(this.name, 'Authentication timeout - Gemini may be stuck in auth flow');
|
|
292
|
+
// Don't send commands that would interfere with stuck auth
|
|
293
|
+
// Let the overall timeout handle this
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
logger.agent(this.name, 'Authentication in progress, waiting...');
|
|
297
|
+
setTimeout(waitForAuth, 1000);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// Auth complete (or not happening), send commands
|
|
301
|
+
// Guard against shell being nullified if process exits during setTimeout delays
|
|
302
|
+
// Don't send Escape first - it can cancel auth flows that started between checks
|
|
303
|
+
setTimeout(() => this.shell?.write('/about\r'), 500);
|
|
304
|
+
setTimeout(() => this.shell?.write('\r'), 1000);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
setTimeout(waitForAuth, 100);
|
|
200
308
|
});
|
|
201
309
|
}
|
|
202
310
|
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,24 @@ const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
|
23
23
|
*/
|
|
24
24
|
const AUTO_REFRESH_MODES = ['none', 'interval', 'activity'];
|
|
25
25
|
|
|
26
|
+
function describeAgentShutdown(agent) {
|
|
27
|
+
const parts = [];
|
|
28
|
+
|
|
29
|
+
if (agent.shell && typeof agent.shell.pid === 'number') {
|
|
30
|
+
parts.push(`pty pid ${agent.shell.pid}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (agent._rpcClient) {
|
|
34
|
+
parts.push('json-rpc client');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (parts.length === 0) {
|
|
38
|
+
parts.push('no active child process');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return parts.join(', ');
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
class AgentTank {
|
|
27
45
|
constructor(options = {}) {
|
|
28
46
|
this.port = options.port || 3456;
|
|
@@ -37,6 +55,7 @@ class AgentTank {
|
|
|
37
55
|
this.publicStatus = {}; // Public API status from upstream providers
|
|
38
56
|
this.skipServer = options.skipServer || false; // Skip HTTP server in one-shot mode
|
|
39
57
|
this.lastRefreshedAt = null;
|
|
58
|
+
this.stopping = false;
|
|
40
59
|
|
|
41
60
|
// Auto-refresh configuration (backend periodic refresh)
|
|
42
61
|
// Determine the mode: 'none', 'interval', or 'activity' (default: 'activity')
|
|
@@ -121,6 +140,10 @@ class AgentTank {
|
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
async start() {
|
|
143
|
+
if (this.stopping) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
124
147
|
// Display startup banner with website URL, version, and copyright
|
|
125
148
|
this.displayStartupBanner();
|
|
126
149
|
|
|
@@ -169,18 +192,24 @@ class AgentTank {
|
|
|
169
192
|
await Promise.all(
|
|
170
193
|
Array.from(this.agents.values()).map(agent =>
|
|
171
194
|
agent.spawnProcess().catch(err =>
|
|
172
|
-
|
|
195
|
+
this.stopping || err.message === 'Agent stopping'
|
|
196
|
+
? null
|
|
197
|
+
: logger.error(`Error spawning ${agent.name}:`, err.message)
|
|
173
198
|
)
|
|
174
199
|
)
|
|
175
200
|
);
|
|
176
201
|
}
|
|
177
202
|
|
|
203
|
+
if (this.stopping) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
178
207
|
// Initial fetch
|
|
179
208
|
logger.info('📊 Fetching initial usage data...');
|
|
180
209
|
await this.refreshAll();
|
|
181
210
|
|
|
182
211
|
// Start backend auto-refresh if enabled (skip in one-shot mode)
|
|
183
|
-
if (!this.skipServer) {
|
|
212
|
+
if (!this.skipServer && !this.stopping) {
|
|
184
213
|
this.startAutoRefresh();
|
|
185
214
|
this.startKeepalive();
|
|
186
215
|
}
|
|
@@ -231,6 +260,10 @@ class AgentTank {
|
|
|
231
260
|
}
|
|
232
261
|
|
|
233
262
|
async refreshAll() {
|
|
263
|
+
if (this.stopping) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
234
267
|
logger.info('🔄 Refreshing all agents...');
|
|
235
268
|
const agentNames = Array.from(this.agents.keys());
|
|
236
269
|
|
|
@@ -240,7 +273,9 @@ class AgentTank {
|
|
|
240
273
|
Promise.all(
|
|
241
274
|
Array.from(this.agents.values()).map(agent =>
|
|
242
275
|
agent.refresh().catch(err => {
|
|
243
|
-
|
|
276
|
+
if (!this.stopping && err.message !== 'Agent stopping') {
|
|
277
|
+
logger.error(`Error refreshing ${agent.name}:`, err.message);
|
|
278
|
+
}
|
|
244
279
|
})
|
|
245
280
|
)
|
|
246
281
|
),
|
|
@@ -276,6 +311,10 @@ class AgentTank {
|
|
|
276
311
|
}
|
|
277
312
|
|
|
278
313
|
async refreshAgent(name) {
|
|
314
|
+
if (this.stopping) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
279
318
|
const agent = this.agents.get(name);
|
|
280
319
|
if (!agent) {
|
|
281
320
|
throw new Error(`Agent not found: ${name}`);
|
|
@@ -345,12 +384,19 @@ class AgentTank {
|
|
|
345
384
|
}
|
|
346
385
|
|
|
347
386
|
stop() {
|
|
387
|
+
this.stopping = true;
|
|
388
|
+
console.log('[Shutdown] Stopping Agent Tank...');
|
|
348
389
|
this.stopAutoRefresh();
|
|
349
390
|
this.stopKeepalive();
|
|
350
|
-
for (const agent of this.agents
|
|
391
|
+
for (const [name, agent] of this.agents) {
|
|
392
|
+
console.log(`[Shutdown] ${name}: ${describeAgentShutdown(agent)}`);
|
|
393
|
+
if (typeof agent.requestStop === 'function') {
|
|
394
|
+
agent.requestStop();
|
|
395
|
+
}
|
|
351
396
|
agent.killProcess();
|
|
352
397
|
}
|
|
353
398
|
if (this.server) {
|
|
399
|
+
console.log('[Shutdown] Closing HTTP server');
|
|
354
400
|
this.server.close();
|
|
355
401
|
}
|
|
356
402
|
}
|
package/src/json-rpc-client.js
CHANGED
|
@@ -44,6 +44,9 @@ class JsonRpcClient extends EventEmitter {
|
|
|
44
44
|
env: this.options.env,
|
|
45
45
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
46
|
});
|
|
47
|
+
if (typeof this.process.pid === 'number') {
|
|
48
|
+
console.log(`[JsonRpcClient] Started ${this.command} (pid ${this.process.pid})`);
|
|
49
|
+
}
|
|
47
50
|
|
|
48
51
|
this.process.on('error', (err) => {
|
|
49
52
|
this.isConnected = false;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function installShutdownHandlers({ shutdown, processObj = process, stdin = process.stdin }) {
|
|
2
|
+
const onSigterm = () => shutdown('SIGTERM');
|
|
3
|
+
const onStdinData = (chunk) => {
|
|
4
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ''));
|
|
5
|
+
if (data.includes(0x03)) {
|
|
6
|
+
shutdown('CTRL+C');
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
processObj.on('SIGTERM', onSigterm);
|
|
11
|
+
|
|
12
|
+
const shouldWatchStdin = stdin && stdin.isTTY && typeof stdin.on === 'function';
|
|
13
|
+
if (shouldWatchStdin) {
|
|
14
|
+
stdin.on('data', onStdinData);
|
|
15
|
+
if (typeof stdin.resume === 'function') {
|
|
16
|
+
stdin.resume();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return () => {
|
|
21
|
+
processObj.off('SIGTERM', onSigterm);
|
|
22
|
+
|
|
23
|
+
if (shouldWatchStdin) {
|
|
24
|
+
stdin.off('data', onStdinData);
|
|
25
|
+
if (typeof stdin.pause === 'function') {
|
|
26
|
+
stdin.pause();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { installShutdownHandlers };
|
package/src/status-page.css
CHANGED
|
@@ -516,6 +516,21 @@ body.light-mode .progress-bar {
|
|
|
516
516
|
font-size: 13px;
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
.auth-notice {
|
|
520
|
+
font-size: 13px;
|
|
521
|
+
color: #f6ad55;
|
|
522
|
+
padding: 8px 10px;
|
|
523
|
+
margin-bottom: 10px;
|
|
524
|
+
background: rgba(246, 173, 85, 0.08);
|
|
525
|
+
border: 1px solid rgba(246, 173, 85, 0.2);
|
|
526
|
+
border-radius: 4px;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.auth-detail {
|
|
530
|
+
margin-top: 4px;
|
|
531
|
+
color: var(--text-secondary);
|
|
532
|
+
}
|
|
533
|
+
|
|
519
534
|
/* Version update notice below agent cards */
|
|
520
535
|
.version-update-notice {
|
|
521
536
|
font-family: var(--font-mono);
|
|
@@ -533,6 +548,12 @@ body.light-mode .version-update-notice {
|
|
|
533
548
|
border-color: rgba(183, 121, 31, 0.2);
|
|
534
549
|
}
|
|
535
550
|
|
|
551
|
+
body.light-mode .auth-notice {
|
|
552
|
+
color: #b7791f;
|
|
553
|
+
background: rgba(246, 173, 85, 0.12);
|
|
554
|
+
border-color: rgba(183, 121, 31, 0.2);
|
|
555
|
+
}
|
|
556
|
+
|
|
536
557
|
button {
|
|
537
558
|
width: 100%;
|
|
538
559
|
padding: 10px;
|
package/src/status-page.js
CHANGED
|
@@ -9,6 +9,15 @@ const { getStatusBadgeClass, getStatusText } = require('./public-status');
|
|
|
9
9
|
|
|
10
10
|
const styles = fs.readFileSync(path.join(__dirname, 'status-page.css'), 'utf8');
|
|
11
11
|
|
|
12
|
+
function escapeHtml(value) {
|
|
13
|
+
return String(value)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
function statusPage(status) {
|
|
13
22
|
const agents = Object.entries(status);
|
|
14
23
|
|
|
@@ -36,7 +45,7 @@ function statusPage(status) {
|
|
|
36
45
|
const publicStatus = data.publicStatus;
|
|
37
46
|
const badgeClass = publicStatus ? getStatusBadgeClass(publicStatus.status) : 'status-badge-grey';
|
|
38
47
|
const badgeText = publicStatus ? getStatusText(publicStatus.status) : 'Unknown';
|
|
39
|
-
const badgeTitle = publicStatus?.description || 'Unable to fetch status';
|
|
48
|
+
const badgeTitle = escapeHtml(publicStatus?.description || 'Unable to fetch status');
|
|
40
49
|
const statusBadgeHtml = `<span class="status-badge ${badgeClass}" title="${badgeTitle}">${badgeText}</span>`;
|
|
41
50
|
|
|
42
51
|
// Version update notice - check usage.version (Codex) or metadata.updateAvailable (Gemini)
|
|
@@ -45,10 +54,15 @@ function statusPage(status) {
|
|
|
45
54
|
? `<div class="version-update-notice">Update available: v${version.current} → v${version.latest}</div>`
|
|
46
55
|
: '';
|
|
47
56
|
|
|
57
|
+
const authHtml = data.auth
|
|
58
|
+
? `<div class="auth-notice">${escapeHtml(data.auth.action || 'Please log in via the CLI.')}</div>`
|
|
59
|
+
: '';
|
|
60
|
+
const errorHtml = data.error && !data.auth ? `<span class="error-msg">${escapeHtml(data.error)}</span>` : '';
|
|
61
|
+
|
|
48
62
|
// Only show card-footer if there's an error or update notice
|
|
49
|
-
const hasFooterContent =
|
|
63
|
+
const hasFooterContent = errorHtml || authHtml || updateHtml;
|
|
50
64
|
const footerHtml = hasFooterContent
|
|
51
|
-
? `<div class="card-footer">${
|
|
65
|
+
? `<div class="card-footer">${errorHtml}${authHtml}${updateHtml}</div>`
|
|
52
66
|
: '';
|
|
53
67
|
|
|
54
68
|
return `
|