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 +61 -3
- package/bin/agent-tank.js +57 -4
- package/package.json +1 -1
- package/src/agents/agy.js +65 -10
- package/src/agents/base.js +80 -18
- package/src/agents/codex.js +3 -3
- package/src/cli-background.js +198 -0
- package/src/index.js +5 -1
- package/src/process-utils.js +215 -0
- package/src/shutdown-handler.js +15 -4
- package/src/status-page.js +48 -38
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 {
|
|
6
|
-
|
|
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({
|
|
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
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 (
|
|
132
|
-
return /(?:Gemini|Claude|GPT)[\s\S]*\d+(?:\.\d+)
|
|
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
|
-
|
|
175
|
-
|
|
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 (
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
}
|
package/src/agents/base.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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) {
|
package/src/agents/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/shutdown-handler.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|
package/src/status-page.js
CHANGED
|
@@ -26,10 +26,7 @@ function escapeHtml(value) {
|
|
|
26
26
|
.replace(/'/g, ''');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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>
|