agent-tank 0.9.7 → 0.9.9

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/README.md CHANGED
@@ -62,6 +62,47 @@ Or run it directly:
62
62
  npx agent-tank
63
63
  ```
64
64
 
65
+ ### Run from Source
66
+
67
+ If you want to run the latest code from a checkout instead of the published package:
68
+
69
+ ```bash
70
+ # 1. Clone the repo
71
+ git clone https://github.com/integry/agent-tank.git
72
+ cd agent-tank
73
+
74
+ # 2. Install dependencies (compiles node-pty natively)
75
+ npm install
76
+
77
+ # 3. Run it
78
+ npm start # = node bin/agent-tank.js
79
+ ```
80
+
81
+ By default it auto-discovers installed CLIs and serves the dashboard + API at `http://127.0.0.1:3456`.
82
+
83
+ To pass flags, call the entry point directly (`npm start` does not forward arguments cleanly):
84
+
85
+ ```bash
86
+ node bin/agent-tank.js --claude --codex --port 8080
87
+ node bin/agent-tank.js --once --json # one-shot, prints JSON, no server
88
+ node bin/agent-tank.js --no-docker # localhost only
89
+ node bin/agent-tank.js --help # full option list
90
+ ```
91
+
92
+ Prerequisites:
93
+
94
+ - **Node.js 18+**
95
+ - `node-pty` is a native module, so `npm install` needs **Python 3.8+** and **C/C++ build tools**. See [Installation Notes](#installation-notes) if the build fails.
96
+ - At least one supported CLI installed and authenticated on your `PATH` (`claude`, `agy`, or `codex`).
97
+
98
+ Other useful scripts:
99
+
100
+ ```bash
101
+ npm test # run all tests
102
+ npm run lint # check code style
103
+ npm run rebuild # rebuild node-pty against a detected Python
104
+ ```
105
+
65
106
  ### First Run
66
107
 
67
108
  ```bash
@@ -92,6 +133,9 @@ agent-tank --claude --agy
92
133
  # Use a custom port
93
134
  agent-tank --port 8080
94
135
 
136
+ # Keep Agent Tank running after closing this terminal
137
+ agent-tank --background
138
+
95
139
  # Fetch once and print JSON
96
140
  agent-tank --once --json
97
141
 
@@ -150,6 +194,14 @@ agent-tank --claude --codex
150
194
  agent-tank --once --json
151
195
  ```
152
196
 
197
+ ### Background Server Mode
198
+
199
+ ```bash
200
+ agent-tank --background
201
+ ```
202
+
203
+ This starts Agent Tank as a detached process, prints the background PID, and exits the parent command.
204
+
153
205
  ### Bind to a Different Host or Port
154
206
 
155
207
  ```bash
@@ -198,14 +250,15 @@ Options:
198
250
  --auto-discover Auto-discover available agents (default: true)
199
251
  --auto-refresh Enable/disable background auto-refresh (default: true)
200
252
  --auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
201
- --auto-refresh-interval <seconds> Auto-refresh interval in seconds (default: 60)
253
+ --auto-refresh-interval <seconds> Auto-refresh interval in seconds (default: 60, 0 = disabled)
202
254
  --refresh-cooldown <seconds> Minimum time between refreshes per agent (default: 30, 0 = disabled)
203
255
  --activity-debounce <ms> Activity debounce interval in milliseconds (default: 5000)
204
256
  --keepalive Enable/disable session keepalive (default: true)
205
- --keepalive-interval <seconds> Session keepalive interval in seconds (default: 300)
257
+ --keepalive-interval <seconds> Session keepalive interval in seconds (default: 300, 0 = disabled)
206
258
  --history-retention-days <days> Days to retain usage history (default: 14)
207
259
  --once Fetch usage once and exit (no HTTP server)
208
260
  --json Output pure JSON (suppress logging, use with --once)
261
+ --background Start Agent Tank as a detached background process
209
262
  --help, -h Show this help message
210
263
  ```
211
264
 
@@ -213,7 +266,9 @@ Options:
213
266
 
214
267
  ### Environment Variables
215
268
 
216
- Environment variables override CLI flags and config file values.
269
+ Environment variables override CLI flags and config file values. Background mode has
270
+ three exceptions: `--no-background`, `--once`, and `--json` suppress
271
+ `AGENT_TANK_BACKGROUND=1` for that run.
217
272
 
218
273
  | Variable | Description |
219
274
  |---|---|
@@ -232,6 +287,9 @@ Environment variables override CLI flags and config file values.
232
287
  | `AGENT_TANK_KEEPALIVE` | Enable/disable keepalive |
233
288
  | `AGENT_TANK_KEEPALIVE_INTERVAL` | Keepalive interval in seconds |
234
289
  | `AGENT_TANK_HISTORY_RETENTION_DAYS` | History retention window |
290
+ | `AGENT_TANK_BACKGROUND` | Start as a detached background process (`1`/`true`) |
291
+ | `AGENT_TANK_BACKGROUND_LOG` | Background child stdout/stderr log path (defaults to a temp file) |
292
+ | `AGENT_TANK_BACKGROUND_GRACE_MS` | Parent startup grace period before reporting background success; success means the child survived this period |
235
293
 
236
294
  ### Config File
237
295
 
package/bin/agent-tank.js CHANGED
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env node
2
- /* eslint-disable complexity */
2
+ /* eslint-disable complexity -- CLI option merging is centralized here to preserve precedence behavior. */
3
+ /* eslint-disable max-lines -- Tracked in issue #90; CLI help text and option precedence stay together for now. */
3
4
 
4
5
  const { parseArgs } = require('node:util');
5
- const { AgentTank } = require('../src/index.js');
6
- const { installShutdownHandlers } = require('../src/shutdown-handler.js');
6
+ const {
7
+ isTruthyEnv,
8
+ } = require('../src/process-utils.js');
9
+ const {
10
+ spawnBackgroundProcess,
11
+ warnAboutRunningProcesses,
12
+ } = require('../src/cli-background.js');
7
13
  const pkg = require('../package.json');
8
14
 
9
15
  const options = {
@@ -32,9 +38,15 @@ const options = {
32
38
  'keepalive-interval': { type: 'string', default: '300' },
33
39
  once: { type: 'boolean', default: false },
34
40
  json: { type: 'boolean', default: false },
41
+ background: { type: 'boolean', default: false },
35
42
  };
36
43
 
37
- const { values } = parseArgs({ options, allowPositionals: false, allowNegative: true });
44
+ const { values, tokens } = parseArgs({
45
+ options,
46
+ allowPositionals: false,
47
+ allowNegative: true,
48
+ tokens: true,
49
+ });
38
50
 
39
51
  function exitWithCode(code, message, stream = process.stderr) {
40
52
  if (message) {
@@ -85,6 +97,7 @@ Options:
85
97
  --history-retention-days <days> Days to retain usage history (default: 14)
86
98
  --once Fetch usage once and exit (no HTTP server)
87
99
  --json Output pure JSON (suppress logging, use with --once)
100
+ --background Start Agent Tank as a detached background process
88
101
  --help, -h Show this help message
89
102
 
90
103
  Auto-Refresh Modes:
@@ -109,9 +122,14 @@ Environment variables:
109
122
  AGENT_TANK_KEEPALIVE Enable/disable session keepalive ("1" or "true" / "0" or "false")
110
123
  AGENT_TANK_KEEPALIVE_INTERVAL Session keepalive interval in seconds
111
124
  AGENT_TANK_HISTORY_RETENTION_DAYS Days to retain usage history
125
+ AGENT_TANK_BACKGROUND Start as a detached background process ("1" or "true")
126
+ AGENT_TANK_BACKGROUND_LOG Log file for background child stdout/stderr
127
+ AGENT_TANK_BACKGROUND_GRACE_MS Parent startup grace period before reporting background success
128
+ (success means the child survived this period)
112
129
 
113
130
  Examples:
114
131
  agent-tank # Auto-discover and monitor all available
132
+ agent-tank --background # Start in the background and print the PID
115
133
  agent-tank --claude --agy # Monitor specific agents
116
134
  agent-tank --port 8080 # Use custom port
117
135
  agent-tank --host 0.0.0.0 # Expose on all interfaces
@@ -156,6 +174,37 @@ async function main() {
156
174
  return;
157
175
  }
158
176
 
177
+ const explicitNoBackground = tokens.some(token =>
178
+ token.kind === 'option' &&
179
+ token.name === 'background' &&
180
+ token.rawName === '--no-background');
181
+ const explicitBackground = tokens.some(token =>
182
+ token.kind === 'option' &&
183
+ token.name === 'background' &&
184
+ token.rawName === '--background');
185
+ const envBackgroundRequested = isTruthyEnv(process.env.AGENT_TANK_BACKGROUND) &&
186
+ !explicitNoBackground &&
187
+ !values.once &&
188
+ !values.json;
189
+ const backgroundRequested = explicitBackground || envBackgroundRequested;
190
+ const backgroundChild = isTruthyEnv(process.env.AGENT_TANK_BACKGROUND_CHILD);
191
+
192
+ if (backgroundRequested && !backgroundChild) {
193
+ for (const incompatibleFlag of ['once', 'json']) {
194
+ if (values[incompatibleFlag]) {
195
+ exitWithCode(1, `Error: --background cannot be combined with --${incompatibleFlag}`);
196
+ return;
197
+ }
198
+ }
199
+
200
+ const started = await spawnBackgroundProcess();
201
+ process.exitCode = started ? 0 : 1;
202
+ return;
203
+ }
204
+
205
+ const { AgentTank } = require('../src/index.js');
206
+ const { installShutdownHandlers } = require('../src/shutdown-handler.js');
207
+
159
208
  // Load config file if specified
160
209
  let config = {};
161
210
  if (values.config) {
@@ -346,7 +395,11 @@ async function main() {
346
395
  }
347
396
  }
348
397
 
398
+ const runningProcessWarning = (!jsonMode && !backgroundChild) ?
399
+ warnAboutRunningProcesses() :
400
+ Promise.resolve();
349
401
  await watcher.start();
402
+ await runningProcessWarning;
350
403
  } catch (err) {
351
404
  cleanupShutdownHandlers();
352
405
  if (jsonMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Monitor and query usage limits for LLM CLI tools (Claude, Antigravity, Codex) via HTTP API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/agents/agy.js CHANGED
@@ -4,6 +4,10 @@ const { CYCLE_DURATIONS } = require('../usage-formatters.js');
4
4
  const logger = require('../logger.js');
5
5
 
6
6
  class AgyAgent extends BaseAgent {
7
+ // Matches both the older "Model Quota" screen and the newer grouped
8
+ // "Models & Quota" screen reported by recent Antigravity CLI builds.
9
+ static QUOTA_HEADER = /Models?\s*&?\s*Quota/i;
10
+
7
11
  constructor() {
8
12
  super('agy', 'agy', ['--dangerously-skip-permissions']);
9
13
  this._aboutSent = false;
@@ -128,8 +132,8 @@ class AgyAgent extends BaseAgent {
128
132
  hasCompleteOutput(output) {
129
133
  const clean = this.stripAnsi(output);
130
134
  if (this.detectAuthenticationState(clean)) return true;
131
- if (/Model\s+Quota/i.test(clean)) {
132
- return /(?:Gemini|Claude|GPT)[\s\S]*\d+(?:\.\d+)?%/i.test(clean);
135
+ if (AgyAgent.QUOTA_HEADER.test(clean)) {
136
+ return /(?:Gemini|Claude|GPT|Weekly\s+Limit|Five[\s-]?Hour\s+Limit)[\s\S]*\d+(?:\.\d+)?\s*%/i.test(clean);
133
137
  }
134
138
  const hasModel = /(?:Gemini|Claude|GPT)[^\n]+/i.test(clean);
135
139
  const hasPercent = /\d+(?:\.\d+)?%/i.test(clean);
@@ -171,8 +175,32 @@ class AgyAgent extends BaseAgent {
171
175
  return;
172
176
  }
173
177
  logger.agent(this.name, 'Sending /usage command...');
174
- setTimeout(() => shell.write('/usage'), 200);
175
- setTimeout(() => shell.write('\r'), 600);
178
+ // A prior /usage leaves Antigravity's usage modal open. On a reused
179
+ // persistent process that modal swallows the next /usage (0 bytes back ->
180
+ // 35s timeout -> kill/respawn, which is why refreshes alternated
181
+ // success/timeout). Press Escape first to return to the prompt; it is a
182
+ // no-op at a clean prompt and safe because auth flows are guarded above.
183
+ setTimeout(() => shell.write('\x1b'), 200);
184
+ setTimeout(() => shell.write('/usage'), 600);
185
+ setTimeout(() => shell.write('\r'), 1000);
186
+ }
187
+
188
+ // Convert an ALL-CAPS Antigravity group header (e.g. "CLAUDE AND GPT") into a
189
+ // readable label ("Claude and GPT"), preserving known acronyms and lowercasing
190
+ // connective words.
191
+ _formatGroupName(raw) {
192
+ const acronyms = new Set(['GPT', 'GPU', 'AI', 'CLI', 'API', 'OSS']);
193
+ const connectives = new Set(['and', 'or', 'of', 'the', 'for', 'with']);
194
+ return raw
195
+ .split(/\s+/)
196
+ .map((word, idx) => {
197
+ const upper = word.toUpperCase();
198
+ if (acronyms.has(upper)) return upper;
199
+ const lower = word.toLowerCase();
200
+ if (idx > 0 && connectives.has(lower)) return lower;
201
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
202
+ })
203
+ .join(' ');
176
204
  }
177
205
 
178
206
  // Calculate pace data for a model entry when reset timing is available.
@@ -201,21 +229,40 @@ class AgyAgent extends BaseAgent {
201
229
  }
202
230
 
203
231
  _parseAgyUsage(clean, usage) {
204
- if (!/Model\s+Quota/i.test(clean)) return;
232
+ if (!AgyAgent.QUOTA_HEADER.test(clean)) return;
205
233
 
206
234
  const lines = clean.split('\n').map(line => line.trim()).filter(Boolean);
207
235
  const knownMarkers = new Set([
208
236
  'Model Quota',
237
+ 'Models & Quota',
209
238
  'Quota available',
210
239
  '? for shortcuts',
211
240
  ]);
212
241
 
242
+ // Newer Antigravity builds group quotas under section headers such as
243
+ // "GEMINI MODELS" / "CLAUDE AND GPT MODELS", with each group reporting its
244
+ // own "Weekly Limit" and "Five Hour Limit". Track the active group so those
245
+ // generic labels can be qualified and not collapsed into a single entry.
246
+ let currentGroup = null;
247
+
213
248
  for (let i = 0; i < lines.length; i++) {
214
- const modelLine = lines[i];
215
- if (knownMarkers.has(modelLine) || /^(?:[>└]|[-─↑/↓]|esc\s+to\s+cancel)/i.test(modelLine)) {
249
+ const line = lines[i];
250
+
251
+ const groupMatch = line.match(/^([A-Z][A-Z0-9 &/-]*?)\s+MODELS$/);
252
+ if (groupMatch) {
253
+ currentGroup = this._formatGroupName(groupMatch[1].trim());
216
254
  continue;
217
255
  }
218
- if (!/[A-Za-z]/.test(modelLine) || /\d+\s*%/.test(modelLine)) {
256
+
257
+ if (knownMarkers.has(line) || /^(?:[>└│]|[-─↑/↓]|esc\s+to\s+cancel)/i.test(line)) {
258
+ continue;
259
+ }
260
+ // Descriptive lines that are not models (e.g. "Account: ...",
261
+ // "Models within this group: ...").
262
+ if (/^(?:Account|Models within this group)\s*:/i.test(line)) {
263
+ continue;
264
+ }
265
+ if (!/[A-Za-z]/.test(line) || /\d+\s*%/.test(line)) {
219
266
  continue;
220
267
  }
221
268
 
@@ -232,8 +279,16 @@ class AgyAgent extends BaseAgent {
232
279
  const resetsIn = resetMatch?.[1]?.trim() || null;
233
280
  const resetsInSeconds = resetsIn ? this.parseDurationToSeconds(resetsIn) : null;
234
281
 
235
- if (!usage.models.find(m => m.model === modelLine)) {
236
- const modelEntry = { model: modelLine, usageLeft, percentUsed, resetsIn, resetsInSeconds };
282
+ // Qualify shared limit labels with their group so entries from different
283
+ // groups stay distinct; per-model labels are already unique on their own.
284
+ const isSharedLimit = /^(?:weekly|five[\s-]?hour|5[\s-]?hour|daily|hourly)\b/i.test(line);
285
+ const modelName = (currentGroup && isSharedLimit)
286
+ ? `${currentGroup} · ${line}`
287
+ : line;
288
+
289
+ if (!usage.models.find(m => m.model === modelName)) {
290
+ const modelEntry = { model: modelName, usageLeft, percentUsed, resetsIn, resetsInSeconds };
291
+ if (currentGroup) modelEntry.group = currentGroup;
237
292
  this._addPaceData(modelEntry, resetsInSeconds);
238
293
  usage.models.push(modelEntry);
239
294
  }
@@ -1,8 +1,14 @@
1
+ /* eslint-disable max-lines -- Tracked in issue #90; keep this PR scoped to background startup behavior. */
2
+
1
3
  const pty = require('node-pty');
2
4
  const process = require('node:process');
3
5
  const logger = require('../logger.js');
4
6
 
5
7
  class BaseAgent {
8
+ // Grace period before escalating a runtime SIGTERM to SIGKILL for CLIs that
9
+ // ignore graceful termination. Overridable in tests.
10
+ static FORCE_KILL_GRACE_MS = 2000;
11
+
6
12
  constructor(name, command, args = []) {
7
13
  this.name = name;
8
14
  this.command = command;
@@ -32,6 +38,25 @@ class BaseAgent {
32
38
  return new Error('Agent stopping');
33
39
  }
34
40
 
41
+ /**
42
+ * Best-effort dump of raw output to a temp file for debugging.
43
+ * Never throws: the target may be unwritable (e.g. a stale file in
44
+ * /tmp owned by another user), and these dumps run inside timer
45
+ * callbacks where an exception would crash the whole process.
46
+ * @param {string} output Raw output to dump
47
+ * @returns {string|null} Path written to, or null on failure
48
+ */
49
+ writeDebugOutput(output) {
50
+ const debugPath = require('node:path').join(require('node:os').tmpdir(), `${this.name}-output.txt`);
51
+ try {
52
+ require('fs').writeFileSync(debugPath, output);
53
+ return debugPath;
54
+ } catch (err) {
55
+ logger.agent(this.name, 'Could not write debug output:', logger.dim(err.message));
56
+ return null;
57
+ }
58
+ }
59
+
35
60
  isStopping() {
36
61
  return this._stopRequested;
37
62
  }
@@ -308,7 +333,7 @@ class BaseAgent {
308
333
  logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
309
334
  if (this.output.length > 0) {
310
335
  logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
311
- require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, this.output);
336
+ this.writeDebugOutput(this.output);
312
337
  }
313
338
  // Kill the stuck process so it respawns fresh on the next refresh
314
339
  logger.agent(this.name, 'Killing stuck process to force respawn');
@@ -388,22 +413,57 @@ class BaseAgent {
388
413
  }
389
414
 
390
415
  _handleAdditionalPrompts(_s, _d, _o) { } // Hook for subclasses; killProcess: see below
391
- killProcess() { // Terminates the persistent PTY process
392
- if (this.shell) {
393
- const pidText = typeof this.shell.pid === 'number' ? ` (pid ${this.shell.pid})` : '';
394
- logger.agent(this.name, `Killing persistent process${pidText}`);
395
- const shell = this.shell;
396
- for (const d of this._disposables) { d.dispose(); }
397
- this._disposables = []; this._onDataCallback = null; this.processReady = false;
398
- try {
399
- // PTY-backed CLIs may spawn descendants; kill the whole process group when available.
400
- if (typeof shell.pid === 'number' && shell.pid > 0) {
401
- try { process.kill(-shell.pid, 'SIGTERM'); } catch (_e) { /* Group may not exist */ }
402
- }
403
- shell.kill();
404
- } catch (_e) { /* Process may already be dead */ }
405
- this.shell = null;
416
+ // Terminates the persistent PTY process.
417
+ //
418
+ // Some CLIs (notably `agy` and `codex`) ignore SIGTERM/SIGHUP and would
419
+ // otherwise survive as orphaned processes (reparented to init) every time we
420
+ // respawn them or the server exits. We always attempt a graceful SIGTERM
421
+ // first, then escalate to SIGKILL:
422
+ // - runtime (default): asynchronously, after a short grace period, if the
423
+ // process is still alive (the event loop is running, so the timer fires);
424
+ // - immediate (shutdown): synchronously, because the process is about to
425
+ // call process.exit() and pending timers would never run.
426
+ killProcess({ immediate = false } = {}) {
427
+ if (!this.shell) return;
428
+ const shell = this.shell;
429
+ const pid = (typeof shell.pid === 'number' && shell.pid > 0) ? shell.pid : null;
430
+ const pidText = pid != null ? ` (pid ${pid})` : '';
431
+ logger.agent(this.name, `Killing persistent process${pidText}`);
432
+ for (const d of this._disposables) { d.dispose(); }
433
+ this._disposables = []; this._onDataCallback = null; this.processReady = false;
434
+
435
+ this._signalProcess(pid, shell, 'SIGTERM');
436
+
437
+ if (immediate) {
438
+ this._signalProcess(pid, shell, 'SIGKILL');
439
+ } else if (pid != null) {
440
+ const timer = setTimeout(() => {
441
+ if (!this._isProcessAlive(pid)) return;
442
+ logger.agent(this.name, `Process ${pid} ignored SIGTERM; sending SIGKILL`);
443
+ this._signalProcess(pid, shell, 'SIGKILL');
444
+ }, BaseAgent.FORCE_KILL_GRACE_MS);
445
+ // Never let the escalation timer keep the process alive on its own.
446
+ if (typeof timer.unref === 'function') timer.unref();
406
447
  }
448
+
449
+ this.shell = null;
450
+ }
451
+
452
+ // Best-effort signal to a PTY process and its process group. PTY-backed CLIs
453
+ // may spawn descendants, so we signal the whole group when we have a pid.
454
+ // Never throws: the process or group may already be gone.
455
+ _signalProcess(pid, shell, signal) {
456
+ try {
457
+ if (pid != null) {
458
+ try { process.kill(-pid, signal); } catch (_e) { /* Group may not exist */ }
459
+ }
460
+ shell.kill(signal);
461
+ } catch (_e) { /* Process may already be dead */ }
462
+ }
463
+
464
+ // Returns true if the process is still alive (signal 0 probes without killing).
465
+ _isProcessAlive(pid) {
466
+ try { process.kill(pid, 0); return true; } catch (_e) { return false; }
407
467
  }
408
468
 
409
469
  async _runCommandFresh() {
@@ -448,8 +508,10 @@ class BaseAgent {
448
508
  logger.agent(this.name, 'Timeout after', logger.dim(`${timeout}ms`), ', output length:', logger.dim(`${output.length}`));
449
509
  if (output.length > 0) {
450
510
  logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(output).substring(0, 500)));
451
- require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, output);
452
- logger.agent(this.name, 'Full output written to', logger.dim(`/tmp/${this.name}-output.txt`));
511
+ const debugPath = this.writeDebugOutput(output);
512
+ if (debugPath) {
513
+ logger.agent(this.name, 'Full output written to', logger.dim(debugPath));
514
+ }
453
515
  }
454
516
  shell.kill();
455
517
  if (output.length > 100) {
@@ -229,7 +229,7 @@ class CodexAgent extends BaseAgent {
229
229
  logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
230
230
  if (this.output.length > 0) {
231
231
  logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
232
- require('fs').writeFileSync(`/tmp/${this.name}-output.txt`, this.output);
232
+ this.writeDebugOutput(this.output);
233
233
  }
234
234
  if (this.output.length > 100) {
235
235
  finish(this.output);
@@ -347,12 +347,12 @@ class CodexAgent extends BaseAgent {
347
347
  this._rpcSupported = useRpc;
348
348
  }
349
349
 
350
- killProcess() {
350
+ killProcess(options = {}) {
351
351
  if (this._rpcClient) {
352
352
  this._rpcClient.stop();
353
353
  this._rpcClient = null;
354
354
  }
355
- super.killProcess();
355
+ super.killProcess(options);
356
356
  }
357
357
 
358
358
  /** Lightweight keepalive to prevent session expiration. @returns {Promise<boolean>} True if keepalive succeeded */
@@ -0,0 +1,198 @@
1
+ const fs = require('node:fs');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+ const { spawn } = require('node:child_process');
5
+ const {
6
+ filterBackgroundArgs,
7
+ findAgentTankProcessesAsync,
8
+ } = require('./process-utils.js');
9
+ const { ANSI } = require('./logger.js');
10
+
11
+ const BACKGROUND_STARTUP_GRACE_MS = 750;
12
+
13
+ function createBackgroundLogPath({
14
+ env = process.env,
15
+ tmpdir = os.tmpdir,
16
+ pid = process.pid,
17
+ now = Date.now,
18
+ } = {}) {
19
+ return env.AGENT_TANK_BACKGROUND_LOG ||
20
+ path.join(tmpdir(), `agent-tank-${pid}-${now()}.log`);
21
+ }
22
+
23
+ function writeBackgroundStartMessage({ pid, logPath, stdout = process.stdout }) {
24
+ stdout.write(`Agent Tank started in the background with PID ${pid}\n`);
25
+ stdout.write(`Background log: ${logPath}\n`);
26
+ }
27
+
28
+ function writeBackgroundStartFailure({ message, logPath, stderr = process.stderr }) {
29
+ stderr.write(`Failed to start Agent Tank in the background: ${message}\n`);
30
+ stderr.write(`Background log: ${logPath}\n`);
31
+ }
32
+
33
+ function ignoreLateChildError() {}
34
+
35
+ function openBackgroundLog(logPath, openSync = fs.openSync) {
36
+ return openSync(logPath, 'a');
37
+ }
38
+
39
+ function closeBackgroundLog(logFd, closeSync = fs.closeSync) {
40
+ try {
41
+ closeSync(logFd);
42
+ } catch (_err) {
43
+ // The child owns the duplicated descriptor after spawn; parent close failures are non-fatal.
44
+ }
45
+ }
46
+
47
+ function getBackgroundStartupGraceMs(env = process.env) {
48
+ const parsed = Number.parseInt(env.AGENT_TANK_BACKGROUND_GRACE_MS, 10);
49
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : BACKGROUND_STARTUP_GRACE_MS;
50
+ }
51
+
52
+ function waitForBackgroundStartup(child, graceMs, setTimer = setTimeout, clearTimer = clearTimeout) {
53
+ return new Promise((resolve, reject) => {
54
+ let settled = false;
55
+
56
+ const finish = (callback, value) => {
57
+ if (settled) {
58
+ return;
59
+ }
60
+ settled = true;
61
+ clearTimer(timer);
62
+ child.off('error', onError);
63
+ child.off('exit', onExit);
64
+ callback(value);
65
+ };
66
+
67
+ const onError = (err) => finish(reject, err);
68
+ const onExit = (code, signal) => {
69
+ const detail = signal ? `signal ${signal}` : `exit code ${code}`;
70
+ finish(reject, new Error(`child exited during startup (${detail})`));
71
+ };
72
+ const timer = setTimer(() => finish(resolve), graceMs);
73
+
74
+ child.once('error', onError);
75
+ child.once('exit', onExit);
76
+ });
77
+ }
78
+
79
+ async function spawnBackgroundProcess({
80
+ argv = process.argv,
81
+ env = process.env,
82
+ execPath = process.execPath,
83
+ spawnFn = spawn,
84
+ stdout = process.stdout,
85
+ stderr = process.stderr,
86
+ openSync = fs.openSync,
87
+ closeSync = fs.closeSync,
88
+ startupGraceMs,
89
+ } = {}) {
90
+ const resolvedStartupGraceMs = startupGraceMs ?? getBackgroundStartupGraceMs(env);
91
+ const logPath = createBackgroundLogPath({ env });
92
+ let logFd;
93
+
94
+ try {
95
+ logFd = openBackgroundLog(logPath, openSync);
96
+ } catch (err) {
97
+ writeBackgroundStartFailure({ message: err.message, logPath, stderr });
98
+ return false;
99
+ }
100
+
101
+ const childArgs = [
102
+ argv[1],
103
+ ...filterBackgroundArgs(argv.slice(2)),
104
+ ];
105
+ const childEnv = {
106
+ ...env,
107
+ AGENT_TANK_BACKGROUND_CHILD: '1',
108
+ };
109
+ delete childEnv.AGENT_TANK_BACKGROUND;
110
+ delete childEnv.AGENT_TANK_BACKGROUND_LOG;
111
+ delete childEnv.AGENT_TANK_BACKGROUND_GRACE_MS;
112
+
113
+ let child;
114
+ try {
115
+ child = spawnFn(execPath, childArgs, {
116
+ detached: true,
117
+ stdio: ['ignore', logFd, logFd],
118
+ env: childEnv,
119
+ windowsHide: true,
120
+ });
121
+ } catch (err) {
122
+ closeBackgroundLog(logFd, closeSync);
123
+ writeBackgroundStartFailure({ message: err.message, logPath, stderr });
124
+ return false;
125
+ }
126
+
127
+ closeBackgroundLog(logFd, closeSync);
128
+ child.on('error', ignoreLateChildError);
129
+
130
+ try {
131
+ await waitForBackgroundStartup(child, resolvedStartupGraceMs);
132
+ } catch (err) {
133
+ writeBackgroundStartFailure({ message: err.message, logPath, stderr });
134
+ return false;
135
+ }
136
+
137
+ if (!child.pid) {
138
+ // Extremely defensive: normal spawn failures are reported via the error event above.
139
+ writeBackgroundStartFailure({ message: 'child process PID was not assigned', logPath, stderr });
140
+ return false;
141
+ }
142
+
143
+ child.unref();
144
+ writeBackgroundStartMessage({ pid: child.pid, logPath, stdout });
145
+ return true;
146
+ }
147
+
148
+ function redactProcessCommand(command) {
149
+ return command
150
+ .replace(/(--auth-pass(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/g, '$1[redacted]')
151
+ .replace(/(--auth-token(?:=|\s+))(?:"[^"]*"|'[^']*'|\S+)/g, '$1[redacted]');
152
+ }
153
+
154
+ async function warnAboutRunningProcesses({
155
+ findProcesses = findAgentTankProcessesAsync,
156
+ stderr = process.stderr,
157
+ } = {}) {
158
+ let running;
159
+ try {
160
+ running = await findProcesses();
161
+ } catch (_err) {
162
+ return;
163
+ }
164
+
165
+ if (running.length === 0) {
166
+ return;
167
+ }
168
+
169
+ const lines = running
170
+ .map(entry => ` PID ${entry.pid}: ${redactProcessCommand(entry.command)}`)
171
+ .join('\n');
172
+
173
+ // Only emit color when writing to an interactive terminal, so redirected
174
+ // logs stay free of ANSI escape sequences.
175
+ const header = 'Warning: other Agent Tank process(es) already running:';
176
+ const coloredHeader = stderr.isTTY
177
+ ? `${ANSI.bold}${ANSI.red}${header}${ANSI.reset}`
178
+ : header;
179
+
180
+ const pids = running.map(entry => entry.pid).join(' ');
181
+
182
+ stderr.write(`${coloredHeader}\n`);
183
+ stderr.write(`${lines}\n`);
184
+ stderr.write('If this instance uses the same port, startup will fail; choose a different --port or stop the other process.\n');
185
+ stderr.write(`To stop the process(es) above, run: kill ${pids}\n`);
186
+ stderr.write(`If a process does not exit, force it with: kill -9 ${pids}\n`);
187
+ stderr.write('Tip: start Agent Tank in the background with agent-tank --background\n');
188
+ }
189
+
190
+ module.exports = {
191
+ BACKGROUND_STARTUP_GRACE_MS,
192
+ createBackgroundLogPath,
193
+ getBackgroundStartupGraceMs,
194
+ redactProcessCommand,
195
+ spawnBackgroundProcess,
196
+ waitForBackgroundStartup,
197
+ warnAboutRunningProcesses,
198
+ };
package/src/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ /* eslint-disable max-lines -- Tracked in issue #90; keep this PR scoped to background startup behavior. */
2
+
1
3
  const path = require('node:path');
2
4
  const os = require('node:os');
3
5
  const { execFileSync } = require('node:child_process');
@@ -569,7 +571,9 @@ class AgentTank {
569
571
  if (typeof agent.requestStop === 'function') {
570
572
  agent.requestStop();
571
573
  }
572
- agent.killProcess();
574
+ // Shutdown is followed by process.exit(), so escalate to SIGKILL
575
+ // synchronously — a deferred timer would never run.
576
+ agent.killProcess({ immediate: true });
573
577
  }
574
578
  if (this.servers.length > 0) {
575
579
  console.log('[Shutdown] Closing HTTP server');
@@ -0,0 +1,215 @@
1
+ const { execFile } = require('node:child_process');
2
+ const path = require('node:path');
3
+
4
+ const BACKGROUND_ARG_RE = /^--(?:no-)?background$/;
5
+ const AGENT_TANK_BIN_RE = /(^|[\\/])bin[\\/]agent-tank\.js$/;
6
+ const PROCESS_SCAN_TIMEOUT_MS = 2000;
7
+ const WINDOWS_PROCESS_SCAN_TIMEOUT_MS = 8000;
8
+ const PROCESS_SCAN_MAX_BUFFER = 8 * 1024 * 1024;
9
+ const WINDOWS_PROCESS_SCAN_MAX_BUFFER = 32 * 1024 * 1024;
10
+ // This covers common Node flags that take a separate value; unknown flags may
11
+ // still be heuristic misses, but process warnings are best-effort only.
12
+ const NODE_OPTIONS_WITH_VALUE = new Set([
13
+ '-C',
14
+ '--conditions',
15
+ '-e',
16
+ '--eval',
17
+ '--experimental-loader',
18
+ '--import',
19
+ '--loader',
20
+ '-r',
21
+ '--require',
22
+ ]);
23
+
24
+ function isTruthyEnv(value) {
25
+ return value === '1' || value === 'true';
26
+ }
27
+
28
+ function filterBackgroundArgs(args) {
29
+ return args.filter(arg => !BACKGROUND_ARG_RE.test(arg));
30
+ }
31
+
32
+ function parseProcessList(output) {
33
+ return output
34
+ .split('\n')
35
+ .map(line => line.trim())
36
+ .filter(Boolean)
37
+ .map(line => {
38
+ const match = line.match(/^(\d+)\s+(.+)$/);
39
+ if (!match) {
40
+ return null;
41
+ }
42
+ return {
43
+ pid: Number(match[1]),
44
+ command: match[2],
45
+ };
46
+ })
47
+ .filter(Boolean);
48
+ }
49
+
50
+ function parseWindowsProcessList(output) {
51
+ const parsed = JSON.parse(output || '[]');
52
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
53
+
54
+ return entries
55
+ .map(entry => ({
56
+ pid: Number(entry.ProcessId),
57
+ command: entry.CommandLine || '',
58
+ }))
59
+ .filter(entry => Number.isFinite(entry.pid) && entry.command);
60
+ }
61
+
62
+ function commandTokens(command) {
63
+ const tokens = [];
64
+ const tokenRe = /"([^"]*)"|'([^']*)'|[^\s]+/g;
65
+ let match;
66
+
67
+ while ((match = tokenRe.exec(command)) !== null) {
68
+ tokens.push(match[1] ?? match[2] ?? match[0]);
69
+ }
70
+
71
+ return tokens;
72
+ }
73
+
74
+ function commandBasename(token) {
75
+ return path.basename(token.replace(/^"|"$/g, '').replace(/\\/g, '/')).toLowerCase();
76
+ }
77
+
78
+ function isAgentTankExecutable(token) {
79
+ const executable = commandBasename(token);
80
+ return executable === 'agent-tank' ||
81
+ executable === 'agent-tank.cmd' ||
82
+ executable === 'agent-tank.ps1';
83
+ }
84
+
85
+ function isAgentTankNodeScript(token) {
86
+ const scriptPath = token.replace(/^"|"$/g, '');
87
+ return AGENT_TANK_BIN_RE.test(scriptPath) || isAgentTankExecutable(scriptPath);
88
+ }
89
+
90
+ function isNodeInterpreter(token) {
91
+ return /^(?:node|nodejs|node\d*)(?:\.exe)?$/.test(commandBasename(token));
92
+ }
93
+
94
+ function findNodeScriptToken(tokens) {
95
+ for (let index = 1; index < tokens.length; index += 1) {
96
+ const token = tokens[index];
97
+
98
+ if (NODE_OPTIONS_WITH_VALUE.has(token)) {
99
+ index += 1;
100
+ continue;
101
+ }
102
+
103
+ if (token.startsWith('--') && NODE_OPTIONS_WITH_VALUE.has(token.split('=')[0])) {
104
+ continue;
105
+ }
106
+
107
+ if (token.startsWith('-')) {
108
+ continue;
109
+ }
110
+
111
+ return token;
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ function isAgentTankCommand(command) {
118
+ const tokens = commandTokens(command);
119
+ if (tokens.length === 0) {
120
+ return false;
121
+ }
122
+
123
+ if (isAgentTankExecutable(tokens[0])) {
124
+ return true;
125
+ }
126
+
127
+ if (!isNodeInterpreter(tokens[0])) {
128
+ return false;
129
+ }
130
+
131
+ const scriptToken = findNodeScriptToken(tokens);
132
+ return Boolean(scriptToken && isAgentTankNodeScript(scriptToken));
133
+ }
134
+
135
+ function getProcessScanCommand(platform, scanTimeoutMs) {
136
+ const timeout = scanTimeoutMs ?? (
137
+ platform === 'win32' ? WINDOWS_PROCESS_SCAN_TIMEOUT_MS : PROCESS_SCAN_TIMEOUT_MS
138
+ );
139
+
140
+ if (platform === 'win32') {
141
+ return {
142
+ file: 'powershell.exe',
143
+ args: [
144
+ '-NoProfile',
145
+ '-Command',
146
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress',
147
+ ],
148
+ options: {
149
+ encoding: 'utf8',
150
+ maxBuffer: WINDOWS_PROCESS_SCAN_MAX_BUFFER,
151
+ timeout,
152
+ },
153
+ parse: parseWindowsProcessList,
154
+ };
155
+ }
156
+
157
+ return {
158
+ file: 'ps',
159
+ args: ['-eo', 'pid=,args='],
160
+ options: {
161
+ encoding: 'utf8',
162
+ maxBuffer: PROCESS_SCAN_MAX_BUFFER,
163
+ timeout,
164
+ },
165
+ parse: parseProcessList,
166
+ };
167
+ }
168
+
169
+ function filterAgentTankProcesses(processes, currentPid) {
170
+ return processes
171
+ .filter(entry => entry.pid !== currentPid)
172
+ .filter(entry => isAgentTankCommand(entry.command));
173
+ }
174
+
175
+ function execFilePromise(execFileFn, file, args, options) {
176
+ return new Promise((resolve, reject) => {
177
+ execFileFn(file, args, options, (err, stdout) => {
178
+ if (err) {
179
+ reject(err);
180
+ return;
181
+ }
182
+
183
+ resolve(stdout);
184
+ });
185
+ });
186
+ }
187
+
188
+ async function findAgentTankProcessesAsync({
189
+ currentPid = process.pid,
190
+ execFileFn = execFile,
191
+ platform = process.platform,
192
+ scanTimeoutMs,
193
+ } = {}) {
194
+ const command = getProcessScanCommand(platform, scanTimeoutMs);
195
+
196
+ try {
197
+ const output = await execFilePromise(execFileFn, command.file, command.args, command.options);
198
+ return filterAgentTankProcesses(command.parse(output), currentPid);
199
+ } catch (_err) {
200
+ return [];
201
+ }
202
+ }
203
+
204
+ module.exports = {
205
+ filterBackgroundArgs,
206
+ findAgentTankProcessesAsync,
207
+ isAgentTankCommand,
208
+ isTruthyEnv,
209
+ parseProcessList,
210
+ parseWindowsProcessList,
211
+ PROCESS_SCAN_MAX_BUFFER,
212
+ PROCESS_SCAN_TIMEOUT_MS,
213
+ WINDOWS_PROCESS_SCAN_MAX_BUFFER,
214
+ WINDOWS_PROCESS_SCAN_TIMEOUT_MS,
215
+ };
@@ -1,5 +1,16 @@
1
+ // Signals that should trigger a graceful shutdown so agent PTY processes are
2
+ // cleaned up instead of leaking as orphans. SIGINT and SIGHUP matter for
3
+ // detached/background runs that have no TTY (where the stdin Ctrl+C watcher
4
+ // below does not apply). SIGKILL is intentionally absent — it cannot be caught.
5
+ const SHUTDOWN_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
6
+
1
7
  function installShutdownHandlers({ shutdown, processObj = process, stdin = process.stdin }) {
2
- const onSigterm = () => shutdown('SIGTERM');
8
+ const signalHandlers = SHUTDOWN_SIGNALS.map(signal => {
9
+ const handler = () => shutdown(signal);
10
+ processObj.on(signal, handler);
11
+ return [signal, handler];
12
+ });
13
+
3
14
  const onStdinData = (chunk) => {
4
15
  const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ''));
5
16
  if (data.includes(0x03)) {
@@ -7,8 +18,6 @@ function installShutdownHandlers({ shutdown, processObj = process, stdin = proce
7
18
  }
8
19
  };
9
20
 
10
- processObj.on('SIGTERM', onSigterm);
11
-
12
21
  const shouldWatchStdin = stdin && stdin.isTTY && typeof stdin.on === 'function';
13
22
  if (shouldWatchStdin) {
14
23
  stdin.on('data', onStdinData);
@@ -18,7 +27,9 @@ function installShutdownHandlers({ shutdown, processObj = process, stdin = proce
18
27
  }
19
28
 
20
29
  return () => {
21
- processObj.off('SIGTERM', onSigterm);
30
+ for (const [signal, handler] of signalHandlers) {
31
+ processObj.off(signal, handler);
32
+ }
22
33
 
23
34
  if (shouldWatchStdin) {
24
35
  stdin.off('data', onStdinData);
@@ -26,10 +26,7 @@ function escapeHtml(value) {
26
26
  .replace(/'/g, '&#39;');
27
27
  }
28
28
 
29
- function statusPage(status) {
30
- const agents = Object.entries(status);
31
-
32
- // Calculate the maximum lastUpdated timestamp across all agents for global display
29
+ function getGlobalLastChecked(agents) {
33
30
  const maxLastUpdated = agents.reduce((max, [, data]) => {
34
31
  if (data.lastUpdated) {
35
32
  const ts = new Date(data.lastUpdated).getTime();
@@ -38,42 +35,50 @@ function statusPage(status) {
38
35
  return max;
39
36
  }, 0);
40
37
 
41
- const globalLastChecked = maxLastUpdated > 0
38
+ return maxLastUpdated > 0
42
39
  ? new Date(maxLastUpdated).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
43
40
  : null;
41
+ }
42
+
43
+ function renderPublicStatusBadge(publicStatus) {
44
+ const badgeClass = publicStatus ? getStatusBadgeClass(publicStatus.status) : 'status-badge-grey';
45
+ const badgeText = publicStatus ? getStatusText(publicStatus.status) : 'Unknown';
46
+ const badgeTitle = escapeHtml(publicStatus?.description || 'Unable to fetch status');
44
47
 
45
- const agentCards = agents.map(([name, data]) => {
46
- const usageHtml = formatUsage(name, data.usage);
47
- const statusClass = data.error ? 'error' : data.isRefreshing ? 'refreshing' : 'ok';
48
-
49
- const icon = agentIcons[name] || '';
50
- const displayName = getAgentDisplayName(name);
51
-
52
- // Public API status badge
53
- const publicStatus = data.publicStatus;
54
- const badgeClass = publicStatus ? getStatusBadgeClass(publicStatus.status) : 'status-badge-grey';
55
- const badgeText = publicStatus ? getStatusText(publicStatus.status) : 'Unknown';
56
- const badgeTitle = escapeHtml(publicStatus?.description || 'Unable to fetch status');
57
- const statusBadgeHtml = `<span class="status-badge ${badgeClass}" title="${badgeTitle}">${badgeText}</span>`;
58
-
59
- // Version update notice - check usage.version or metadata.updateAvailable.
60
- const version = data.usage?.version || data.metadata?.updateAvailable;
61
- const updateHtml = (version && version.current && version.latest && version.current !== version.latest)
62
- ? `<div class="version-update-notice">Update available: v${version.current} → v${version.latest}</div>`
63
- : '';
64
-
65
- const authHtml = data.auth
66
- ? `<div class="auth-notice">${escapeHtml(data.auth.action || 'Please log in via the CLI.')}</div>`
67
- : '';
68
- const errorHtml = data.error && !data.auth ? `<span class="error-msg">${escapeHtml(data.error)}</span>` : '';
69
-
70
- // Only show card-footer if there's an error or update notice
71
- const hasFooterContent = errorHtml || authHtml || updateHtml;
72
- const footerHtml = hasFooterContent
73
- ? `<div class="card-footer">${errorHtml}${authHtml}${updateHtml}</div>`
74
- : '';
75
-
76
- return `
48
+ return `<span class="status-badge ${badgeClass}" title="${badgeTitle}">${badgeText}</span>`;
49
+ }
50
+
51
+ function renderVersionUpdate(data) {
52
+ const version = data.usage?.version || data.metadata?.updateAvailable;
53
+
54
+ if (!version || !version.current || !version.latest || version.current === version.latest) {
55
+ return '';
56
+ }
57
+
58
+ return `<div class="version-update-notice">Update available: v${version.current} → v${version.latest}</div>`;
59
+ }
60
+
61
+ function renderCardFooter(data) {
62
+ const authHtml = data.auth
63
+ ? `<div class="auth-notice">${escapeHtml(data.auth.action || 'Please log in via the CLI.')}</div>`
64
+ : '';
65
+ const errorHtml = data.error && !data.auth ? `<span class="error-msg">${escapeHtml(data.error)}</span>` : '';
66
+ const updateHtml = renderVersionUpdate(data);
67
+
68
+ return errorHtml || authHtml || updateHtml
69
+ ? `<div class="card-footer">${errorHtml}${authHtml}${updateHtml}</div>`
70
+ : '';
71
+ }
72
+
73
+ function renderAgentCard([name, data]) {
74
+ const usageHtml = formatUsage(name, data.usage);
75
+ const statusClass = data.error ? 'error' : data.isRefreshing ? 'refreshing' : 'ok';
76
+ const icon = agentIcons[name] || '';
77
+ const displayName = getAgentDisplayName(name);
78
+ const statusBadgeHtml = renderPublicStatusBadge(data.publicStatus);
79
+ const footerHtml = renderCardFooter(data);
80
+
81
+ return `
77
82
  <div class="agent-card ${statusClass} agent-${name}">
78
83
  <h2 class="agent-heading">
79
84
  ${icon}
@@ -87,7 +92,12 @@ function statusPage(status) {
87
92
  ${footerHtml}
88
93
  </div>
89
94
  `;
90
- }).join('');
95
+ }
96
+
97
+ function statusPage(status) {
98
+ const agents = Object.entries(status);
99
+ const globalLastChecked = getGlobalLastChecked(agents);
100
+ const agentCards = agents.map(renderAgentCard).join('');
91
101
 
92
102
  return `<!DOCTYPE html>
93
103
  <html>