agent-tank 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +675 -0
- package/bin/agent-tank.js +301 -0
- package/media/www-preview.png +0 -0
- package/package.json +65 -0
- package/src/activity-monitor.js +312 -0
- package/src/agents/api-response-parser.js +255 -0
- package/src/agents/base.js +396 -0
- package/src/agents/claude.js +388 -0
- package/src/agents/codex-pty-helpers.js +226 -0
- package/src/agents/codex-rpc-helpers.js +167 -0
- package/src/agents/codex.js +383 -0
- package/src/agents/gemini.js +302 -0
- package/src/agents/keepalive-helper.js +115 -0
- package/src/agents/oauth-helper.js +210 -0
- package/src/agents/pty-output-parser.js +301 -0
- package/src/auto-refresh-manager.js +246 -0
- package/src/client-auto-refresh-helpers.js +143 -0
- package/src/client-auto-refresh.js +321 -0
- package/src/client-favicon.js +198 -0
- package/src/client-monitor.js +242 -0
- package/src/client-script.js +147 -0
- package/src/discovery.js +128 -0
- package/src/history-store.js +273 -0
- package/src/http-handler.js +182 -0
- package/src/icons.js +67 -0
- package/src/index.js +376 -0
- package/src/json-rpc-client.js +285 -0
- package/src/keepalive-manager.js +163 -0
- package/src/logger.js +213 -0
- package/src/pace-attachment.js +104 -0
- package/src/pace-evaluator.js +269 -0
- package/src/public-status.js +276 -0
- package/src/server.js +144 -0
- package/src/snapshot-metrics.js +79 -0
- package/src/status-page.css +1149 -0
- package/src/status-page.js +128 -0
- package/src/usage-formatters.js +259 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable complexity */
|
|
3
|
+
|
|
4
|
+
const { parseArgs } = require('node:util');
|
|
5
|
+
const { AgentTank } = require('../src/index.js');
|
|
6
|
+
|
|
7
|
+
const options = {
|
|
8
|
+
claude: { type: 'boolean', default: false },
|
|
9
|
+
gemini: { type: 'boolean', default: false },
|
|
10
|
+
codex: { type: 'boolean', default: false },
|
|
11
|
+
'claude-api': { type: 'boolean', default: false },
|
|
12
|
+
port: { type: 'string', default: '3456' },
|
|
13
|
+
host: { type: 'string' },
|
|
14
|
+
'auth-user': { type: 'string' },
|
|
15
|
+
'auth-pass': { type: 'string' },
|
|
16
|
+
'auth-token': { type: 'string' },
|
|
17
|
+
'fresh-process': { type: 'boolean', default: false },
|
|
18
|
+
config: { type: 'string', short: 'c' },
|
|
19
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
20
|
+
'auto-discover': { type: 'boolean', default: true },
|
|
21
|
+
'auto-refresh': { type: 'boolean', default: true },
|
|
22
|
+
'auto-refresh-interval': { type: 'string', default: '60' },
|
|
23
|
+
'auto-refresh-mode': { type: 'string', default: 'activity' },
|
|
24
|
+
'activity-debounce': { type: 'string', default: '5000' },
|
|
25
|
+
'history-retention-days': { type: 'string', default: '14' },
|
|
26
|
+
'keepalive': { type: 'boolean', default: true },
|
|
27
|
+
'keepalive-interval': { type: 'string', default: '300' },
|
|
28
|
+
once: { type: 'boolean', default: false },
|
|
29
|
+
json: { type: 'boolean', default: false },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const { values } = parseArgs({ options, allowPositionals: false, allowNegative: true });
|
|
33
|
+
|
|
34
|
+
function exitWithCode(code, message, stream = process.stderr) {
|
|
35
|
+
if (message) {
|
|
36
|
+
stream.write(`${message}\n`);
|
|
37
|
+
}
|
|
38
|
+
process.exitCode = code;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function printHelp() {
|
|
42
|
+
process.stdout.write(`
|
|
43
|
+
agent-tank - Monitor LLM CLI usage limits
|
|
44
|
+
|
|
45
|
+
Usage: agent-tank [options]
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--claude Enable Claude monitoring
|
|
49
|
+
--gemini Enable Gemini monitoring
|
|
50
|
+
--codex Enable Codex monitoring
|
|
51
|
+
--claude-api Use direct Anthropic API for Claude usage (faster, 60s refresh)
|
|
52
|
+
--port <port> HTTP server port (default: 3456)
|
|
53
|
+
--host <host> Bind address (default: 127.0.0.1)
|
|
54
|
+
--auth-user <user> HTTP Basic Auth username
|
|
55
|
+
--auth-pass <pass> HTTP Basic Auth password
|
|
56
|
+
--auth-token <token> API key for Bearer token auth
|
|
57
|
+
--fresh-process Spawn a new process per refresh (default: false)
|
|
58
|
+
--config, -c Path to config file (JSON)
|
|
59
|
+
--auto-discover Auto-discover available agents (default: true)
|
|
60
|
+
--auto-refresh Enable/disable background auto-refresh (default: true)
|
|
61
|
+
--auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
|
|
62
|
+
--auto-refresh-interval <seconds> Auto-refresh interval in seconds (default: 60, 0 = disabled)
|
|
63
|
+
--activity-debounce <ms> Activity debounce interval in milliseconds (default: 5000)
|
|
64
|
+
--keepalive Enable/disable session keepalive (default: true)
|
|
65
|
+
--keepalive-interval <seconds> Session keepalive interval in seconds (default: 300, 0 = disabled)
|
|
66
|
+
--history-retention-days <days> Days to retain usage history (default: 14)
|
|
67
|
+
--once Fetch usage once and exit (no HTTP server)
|
|
68
|
+
--json Output pure JSON (suppress logging, use with --once)
|
|
69
|
+
--help, -h Show this help message
|
|
70
|
+
|
|
71
|
+
Auto-Refresh Modes:
|
|
72
|
+
none - No automatic refresh (manual refresh only via POST /refresh)
|
|
73
|
+
interval - Traditional interval-based polling (refreshes at fixed intervals)
|
|
74
|
+
activity - Activity-based polling (default) - monitors log directories and
|
|
75
|
+
refreshes when CLI activity is detected, saving resources during idle
|
|
76
|
+
|
|
77
|
+
Environment variables:
|
|
78
|
+
AGENT_TANK_USER Basic auth username (overrides --auth-user)
|
|
79
|
+
AGENT_TANK_PASS Basic auth password (overrides --auth-pass)
|
|
80
|
+
AGENT_TANK_TOKEN API key (overrides --auth-token)
|
|
81
|
+
AGENT_TANK_HOST Bind address (overrides --host)
|
|
82
|
+
AGENT_TANK_FRESH_PROCESS Use fresh process per refresh ("1" or "true")
|
|
83
|
+
AGENT_TANK_CLAUDE_API Use direct Anthropic API for Claude usage ("1" or "true")
|
|
84
|
+
AGENT_TANK_AUTO_REFRESH Enable/disable background auto-refresh ("1" or "true" / "0" or "false")
|
|
85
|
+
AGENT_TANK_AUTO_REFRESH_MODE Refresh mode: none, interval, activity
|
|
86
|
+
AGENT_TANK_AUTO_REFRESH_INTERVAL Auto-refresh interval in seconds
|
|
87
|
+
AGENT_TANK_ACTIVITY_DEBOUNCE Activity debounce interval in milliseconds
|
|
88
|
+
AGENT_TANK_KEEPALIVE Enable/disable session keepalive ("1" or "true" / "0" or "false")
|
|
89
|
+
AGENT_TANK_KEEPALIVE_INTERVAL Session keepalive interval in seconds
|
|
90
|
+
AGENT_TANK_HISTORY_RETENTION_DAYS Days to retain usage history
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
agent-tank # Auto-discover and monitor all available
|
|
94
|
+
agent-tank --claude --gemini # Monitor specific agents
|
|
95
|
+
agent-tank --port 8080 # Use custom port
|
|
96
|
+
agent-tank --host 0.0.0.0 # Expose on all interfaces
|
|
97
|
+
agent-tank --auth-user admin --auth-pass secret # Enable basic auth
|
|
98
|
+
agent-tank --auth-token mykey # Enable API key auth
|
|
99
|
+
agent-tank -c ./config.json # Use config file
|
|
100
|
+
agent-tank --auto-refresh-mode interval # Use traditional interval polling
|
|
101
|
+
agent-tank --auto-refresh-mode activity # Use activity-based polling (default)
|
|
102
|
+
agent-tank --activity-debounce 10000 # Wait 10 seconds after activity before refresh
|
|
103
|
+
agent-tank --auto-refresh-interval 30 # Refresh every 30 seconds
|
|
104
|
+
agent-tank --no-auto-refresh # Disable background auto-refresh
|
|
105
|
+
agent-tank --keepalive-interval 600 # Send keepalive every 10 minutes
|
|
106
|
+
agent-tank --no-keepalive # Disable session keepalive
|
|
107
|
+
agent-tank --history-retention-days 7 # Keep only 7 days of history
|
|
108
|
+
agent-tank --once # Fetch usage once and exit
|
|
109
|
+
agent-tank --once --json # Output pure JSON for scripting
|
|
110
|
+
|
|
111
|
+
HTTP Endpoints:
|
|
112
|
+
GET / Status page (HTML)
|
|
113
|
+
GET /status All agent statuses (JSON)
|
|
114
|
+
GET /status/:agent Status for specific agent (JSON)
|
|
115
|
+
GET /config Auto-refresh and history configuration (JSON)
|
|
116
|
+
GET /history Usage history statistics (JSON)
|
|
117
|
+
GET /history/:agent Usage history for specific agent (JSON)
|
|
118
|
+
POST /refresh Trigger refresh for all agents
|
|
119
|
+
POST /refresh/:agent Trigger refresh for specific agent
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function main() {
|
|
124
|
+
if (values.help) {
|
|
125
|
+
printHelp();
|
|
126
|
+
process.exitCode = 0;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Load config file if specified
|
|
131
|
+
let config = {};
|
|
132
|
+
if (values.config) {
|
|
133
|
+
try {
|
|
134
|
+
config = require(require('path').resolve(values.config));
|
|
135
|
+
} catch (err) {
|
|
136
|
+
exitWithCode(1, `Failed to load config file: ${err.message}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Merge CLI options with config (env vars > CLI flags > config file)
|
|
142
|
+
const agents = [];
|
|
143
|
+
if (values.claude || config.claude) agents.push('claude');
|
|
144
|
+
if (values.gemini || config.gemini) agents.push('gemini');
|
|
145
|
+
if (values.codex || config.codex) agents.push('codex');
|
|
146
|
+
|
|
147
|
+
const auth = {
|
|
148
|
+
user: process.env.AGENT_TANK_USER || values['auth-user'] || config.auth?.user,
|
|
149
|
+
pass: process.env.AGENT_TANK_PASS || values['auth-pass'] || config.auth?.pass,
|
|
150
|
+
token: process.env.AGENT_TANK_TOKEN || values['auth-token'] || config.auth?.token,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const host = process.env.AGENT_TANK_HOST || values.host || config.host;
|
|
154
|
+
|
|
155
|
+
const freshProcessEnv = process.env.AGENT_TANK_FRESH_PROCESS;
|
|
156
|
+
const freshProcess = values['fresh-process'] ||
|
|
157
|
+
config.freshProcess ||
|
|
158
|
+
freshProcessEnv === '1' || freshProcessEnv === 'true';
|
|
159
|
+
|
|
160
|
+
// Claude API configuration (env > CLI > config file)
|
|
161
|
+
const claudeApiEnv = process.env.AGENT_TANK_CLAUDE_API;
|
|
162
|
+
const claudeApi = values['claude-api'] ||
|
|
163
|
+
config.claudeApi ||
|
|
164
|
+
claudeApiEnv === '1' || claudeApiEnv === 'true';
|
|
165
|
+
|
|
166
|
+
// Auto-refresh configuration (env > CLI > config file)
|
|
167
|
+
const autoRefreshEnv = process.env.AGENT_TANK_AUTO_REFRESH;
|
|
168
|
+
let autoRefreshEnabled = values['auto-refresh'];
|
|
169
|
+
if (autoRefreshEnv !== undefined) {
|
|
170
|
+
autoRefreshEnabled = autoRefreshEnv === '1' || autoRefreshEnv === 'true';
|
|
171
|
+
} else if (config.autoRefresh?.enabled !== undefined) {
|
|
172
|
+
autoRefreshEnabled = config.autoRefresh.enabled;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const autoRefreshIntervalEnv = process.env.AGENT_TANK_AUTO_REFRESH_INTERVAL;
|
|
176
|
+
let autoRefreshInterval = parseInt(values['auto-refresh-interval'], 10);
|
|
177
|
+
if (autoRefreshIntervalEnv !== undefined) {
|
|
178
|
+
autoRefreshInterval = parseInt(autoRefreshIntervalEnv, 10);
|
|
179
|
+
} else if (config.autoRefresh?.interval !== undefined) {
|
|
180
|
+
autoRefreshInterval = config.autoRefresh.interval;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Auto-refresh mode configuration (env > CLI > config file)
|
|
184
|
+
const autoRefreshModeEnv = process.env.AGENT_TANK_AUTO_REFRESH_MODE;
|
|
185
|
+
let autoRefreshMode = values['auto-refresh-mode'];
|
|
186
|
+
if (autoRefreshModeEnv !== undefined) {
|
|
187
|
+
autoRefreshMode = autoRefreshModeEnv;
|
|
188
|
+
} else if (config.autoRefresh?.mode !== undefined) {
|
|
189
|
+
autoRefreshMode = config.autoRefresh.mode;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Activity debounce configuration (env > CLI > config file)
|
|
193
|
+
const activityDebounceEnv = process.env.AGENT_TANK_ACTIVITY_DEBOUNCE;
|
|
194
|
+
let activityDebounce = parseInt(values['activity-debounce'], 10);
|
|
195
|
+
if (activityDebounceEnv !== undefined) {
|
|
196
|
+
activityDebounce = parseInt(activityDebounceEnv, 10);
|
|
197
|
+
} else if (config.autoRefresh?.activityDebounce !== undefined) {
|
|
198
|
+
activityDebounce = config.autoRefresh.activityDebounce;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// History retention configuration (env > CLI > config file > default)
|
|
202
|
+
const historyRetentionEnv = process.env.AGENT_TANK_HISTORY_RETENTION_DAYS;
|
|
203
|
+
let historyRetentionDays = parseInt(values['history-retention-days'], 10);
|
|
204
|
+
if (historyRetentionEnv !== undefined) {
|
|
205
|
+
historyRetentionDays = parseInt(historyRetentionEnv, 10);
|
|
206
|
+
} else if (config.history?.retentionDays !== undefined) {
|
|
207
|
+
historyRetentionDays = config.history.retentionDays;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Keepalive configuration (env > CLI > config file)
|
|
211
|
+
const keepaliveEnv = process.env.AGENT_TANK_KEEPALIVE;
|
|
212
|
+
let keepaliveEnabled = values['keepalive'];
|
|
213
|
+
if (keepaliveEnv !== undefined) {
|
|
214
|
+
keepaliveEnabled = keepaliveEnv === '1' || keepaliveEnv === 'true';
|
|
215
|
+
} else if (config.keepalive?.enabled !== undefined) {
|
|
216
|
+
keepaliveEnabled = config.keepalive.enabled;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const keepaliveIntervalEnv = process.env.AGENT_TANK_KEEPALIVE_INTERVAL;
|
|
220
|
+
let keepaliveInterval = parseInt(values['keepalive-interval'], 10);
|
|
221
|
+
if (keepaliveIntervalEnv !== undefined) {
|
|
222
|
+
keepaliveInterval = parseInt(keepaliveIntervalEnv, 10);
|
|
223
|
+
} else if (config.keepalive?.interval !== undefined) {
|
|
224
|
+
keepaliveInterval = config.keepalive.interval;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// One-shot and JSON mode flags
|
|
228
|
+
const onceMode = values.once;
|
|
229
|
+
const jsonMode = values.json;
|
|
230
|
+
|
|
231
|
+
// Suppress console output in JSON mode
|
|
232
|
+
const originalLog = console.log;
|
|
233
|
+
if (jsonMode) {
|
|
234
|
+
console.log = () => {};
|
|
235
|
+
console.error = () => {};
|
|
236
|
+
console.warn = () => {};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const watcher = new AgentTank({
|
|
240
|
+
agents: agents.length > 0 ? agents : null, // null = auto-discover
|
|
241
|
+
autoDiscover: values['auto-discover'] && agents.length === 0,
|
|
242
|
+
port: parseInt(values.port || config.port || '3456', 10),
|
|
243
|
+
host,
|
|
244
|
+
auth,
|
|
245
|
+
freshProcess,
|
|
246
|
+
claudeApi,
|
|
247
|
+
autoRefreshEnabled: onceMode ? false : autoRefreshEnabled, // Disable auto-refresh in one-shot mode
|
|
248
|
+
autoRefreshInterval,
|
|
249
|
+
autoRefreshMode: onceMode ? 'none' : autoRefreshMode, // Disable auto-refresh in one-shot mode
|
|
250
|
+
activityDebounce,
|
|
251
|
+
keepaliveEnabled: onceMode ? false : keepaliveEnabled, // Disable keepalive in one-shot mode
|
|
252
|
+
keepaliveInterval,
|
|
253
|
+
historyRetentionDays,
|
|
254
|
+
skipServer: onceMode, // Don't start HTTP server in one-shot mode
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Graceful shutdown
|
|
258
|
+
const shutdown = () => {
|
|
259
|
+
if (!jsonMode) {
|
|
260
|
+
originalLog('\nShutting down...');
|
|
261
|
+
}
|
|
262
|
+
watcher.stop();
|
|
263
|
+
process.exitCode = 0;
|
|
264
|
+
};
|
|
265
|
+
process.on('SIGINT', shutdown);
|
|
266
|
+
process.on('SIGTERM', shutdown);
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
if (onceMode) {
|
|
270
|
+
await watcher.start();
|
|
271
|
+
const status = watcher.getStatus();
|
|
272
|
+
if (jsonMode) {
|
|
273
|
+
// Restore console.log for JSON output
|
|
274
|
+
console.log = originalLog;
|
|
275
|
+
console.log(JSON.stringify(status, null, 2));
|
|
276
|
+
// Suppress console.log again before stopping to avoid extra shutdown output
|
|
277
|
+
console.log = () => {};
|
|
278
|
+
} else {
|
|
279
|
+
for (const [name, agentStatus] of Object.entries(status)) {
|
|
280
|
+
originalLog(`\n=== ${name.toUpperCase()} ===`);
|
|
281
|
+
originalLog(JSON.stringify(agentStatus, null, 2));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
watcher.stop();
|
|
285
|
+
process.exitCode = 0;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await watcher.start();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
if (jsonMode) {
|
|
292
|
+
console.log = originalLog;
|
|
293
|
+
console.log(JSON.stringify({ error: err.message }, null, 2));
|
|
294
|
+
} else {
|
|
295
|
+
exitWithCode(1, `Failed to start: ${err.message}`);
|
|
296
|
+
}
|
|
297
|
+
process.exitCode = 1;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
main();
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-tank",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Monitor and query usage limits for LLM CLI tools (Claude, Gemini, Codex) via HTTP API",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-tank": "bin/agent-tank.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/agent-tank.js",
|
|
11
|
+
"dev": "node bin/agent-tank.js",
|
|
12
|
+
"lint": "eslint src/ bin/",
|
|
13
|
+
"test": "jest",
|
|
14
|
+
"test:unit": "jest --testPathPattern=test/unit",
|
|
15
|
+
"test:e2e": "jest --testPathPattern=test/e2e",
|
|
16
|
+
"install": "node scripts/install.js || true",
|
|
17
|
+
"rebuild": "PYTHON=$(which python3.11 || which python3.10 || which python3.9 || which python3.8 || which python3) npm rebuild node-pty"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"llm",
|
|
21
|
+
"claude",
|
|
22
|
+
"gemini",
|
|
23
|
+
"codex",
|
|
24
|
+
"openai",
|
|
25
|
+
"anthropic",
|
|
26
|
+
"google",
|
|
27
|
+
"usage",
|
|
28
|
+
"limits",
|
|
29
|
+
"rate-limit",
|
|
30
|
+
"monitor",
|
|
31
|
+
"cli",
|
|
32
|
+
"api"
|
|
33
|
+
],
|
|
34
|
+
"author": "Rinalds Uzkalns (https://propr.dev)",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/integry/agent-tank.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/integry/agent-tank/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://agenttank.io",
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"node-pty": "^1.0.0",
|
|
46
|
+
"source-map-support": "^0.5.21"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint/js": "^9.39.4",
|
|
50
|
+
"eslint": "^9.39.4",
|
|
51
|
+
"globals": "^15.15.0",
|
|
52
|
+
"jest": "^29.7.0",
|
|
53
|
+
"supertest": "^7.2.2"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18.0.0"
|
|
57
|
+
},
|
|
58
|
+
"files": [
|
|
59
|
+
"bin/",
|
|
60
|
+
"src/",
|
|
61
|
+
"media/",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
"README.md"
|
|
64
|
+
]
|
|
65
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityMonitor - Monitors local log directories for LLM CLI activity
|
|
3
|
+
*
|
|
4
|
+
* Watches known log directories for Claude, Codex, and Gemini using fs.watch.
|
|
5
|
+
* When activity is detected (new log entries), it triggers usage refresh callbacks
|
|
6
|
+
* with configurable debouncing to prevent excessive polling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default log directories for each agent.
|
|
15
|
+
* These are the typical locations where CLI tools store their logs/session data.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_LOG_DIRECTORIES = {
|
|
18
|
+
claude: [
|
|
19
|
+
path.join(os.homedir(), '.config', 'claude', 'projects'),
|
|
20
|
+
path.join(os.homedir(), '.claude'),
|
|
21
|
+
],
|
|
22
|
+
codex: [
|
|
23
|
+
path.join(os.homedir(), '.codex', 'sessions'),
|
|
24
|
+
path.join(os.homedir(), '.codex'),
|
|
25
|
+
],
|
|
26
|
+
gemini: [
|
|
27
|
+
path.join(os.homedir(), '.config', 'gemini'),
|
|
28
|
+
path.join(os.homedir(), '.gemini'),
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function normalizeFilename(filename) {
|
|
33
|
+
return String(filename || '').replace(/\\/g, '/');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isIgnoredActivityFile(filename) {
|
|
37
|
+
const normalized = normalizeFilename(filename);
|
|
38
|
+
if (!normalized) return false;
|
|
39
|
+
|
|
40
|
+
return normalized.endsWith('.lock') ||
|
|
41
|
+
normalized.includes('/.git/') ||
|
|
42
|
+
normalized.startsWith('.git/') ||
|
|
43
|
+
normalized.startsWith('plugins/marketplaces/') ||
|
|
44
|
+
normalized.includes('/plugins/marketplaces/') ||
|
|
45
|
+
normalized === 'plugins/known_marketplaces.json' ||
|
|
46
|
+
normalized.endsWith('/plugins/known_marketplaces.json');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class ActivityMonitor {
|
|
50
|
+
/**
|
|
51
|
+
* Create an ActivityMonitor instance.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} options - Configuration options
|
|
54
|
+
* @param {number} [options.debounceInterval=5000] - Debounce interval in milliseconds (default: 5 seconds)
|
|
55
|
+
* @param {Function} [options.onActivity] - Callback function when activity is detected
|
|
56
|
+
* @param {Object} [options.logDirectories] - Custom log directories per agent (overrides defaults)
|
|
57
|
+
* @param {string[]} [options.agents] - List of agents to monitor (default: all known agents)
|
|
58
|
+
*/
|
|
59
|
+
constructor(options = {}) {
|
|
60
|
+
this.debounceInterval = options.debounceInterval ?? 5000; // Default: 5 seconds
|
|
61
|
+
this.onActivity = options.onActivity || (() => {});
|
|
62
|
+
this.logDirectories = options.logDirectories || DEFAULT_LOG_DIRECTORIES;
|
|
63
|
+
this.agents = options.agents || Object.keys(DEFAULT_LOG_DIRECTORIES);
|
|
64
|
+
|
|
65
|
+
this.watchers = new Map(); // Map<agentName, FSWatcher[]>
|
|
66
|
+
this.debounceTimers = new Map(); // Map<agentName, timeoutId>
|
|
67
|
+
this.lastActivityAt = new Map(); // Map<agentName, ISO timestamp>
|
|
68
|
+
this.suppressedUntil = new Map(); // Map<agentName, unix timestamp in ms>
|
|
69
|
+
this.isMonitoring = false;
|
|
70
|
+
this.activityCount = 0; // Total activity events detected
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Start monitoring log directories for activity.
|
|
75
|
+
* Sets up fs.watch on each agent's log directories.
|
|
76
|
+
*
|
|
77
|
+
* @returns {Object} Status object with monitoring details
|
|
78
|
+
*/
|
|
79
|
+
start() {
|
|
80
|
+
if (this.isMonitoring) {
|
|
81
|
+
console.log('[ActivityMonitor] Already monitoring');
|
|
82
|
+
return this.getStatus();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const monitoredDirs = [];
|
|
86
|
+
const missingDirs = [];
|
|
87
|
+
|
|
88
|
+
for (const agent of this.agents) {
|
|
89
|
+
const dirs = this.logDirectories[agent] || DEFAULT_LOG_DIRECTORIES[agent] || [];
|
|
90
|
+
const agentWatchers = [];
|
|
91
|
+
|
|
92
|
+
for (const dir of dirs) {
|
|
93
|
+
if (fs.existsSync(dir)) {
|
|
94
|
+
try {
|
|
95
|
+
const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
96
|
+
this._handleFileChange(agent, eventType, filename);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
watcher.on('error', (err) => {
|
|
100
|
+
console.error(`[ActivityMonitor] Watcher error for ${agent} (${dir}): ${err.message}`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
agentWatchers.push(watcher);
|
|
104
|
+
monitoredDirs.push({ agent, dir });
|
|
105
|
+
console.log(`[ActivityMonitor] Watching ${agent}: ${dir}`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`[ActivityMonitor] Failed to watch ${dir}: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
missingDirs.push({ agent, dir });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (agentWatchers.length > 0) {
|
|
115
|
+
this.watchers.set(agent, agentWatchers);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.isMonitoring = true;
|
|
120
|
+
|
|
121
|
+
if (missingDirs.length > 0) {
|
|
122
|
+
console.log(`[ActivityMonitor] Some directories not found: ${missingDirs.map(d => d.dir).join(', ')}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log(`[ActivityMonitor] Started monitoring ${monitoredDirs.length} directories for ${this.watchers.size} agent(s)`);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
isMonitoring: this.isMonitoring,
|
|
129
|
+
monitoredDirs,
|
|
130
|
+
missingDirs,
|
|
131
|
+
agentCount: this.watchers.size,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Stop monitoring all directories.
|
|
137
|
+
*/
|
|
138
|
+
stop() {
|
|
139
|
+
for (const [agent, agentWatchers] of this.watchers) {
|
|
140
|
+
for (const watcher of agentWatchers) {
|
|
141
|
+
try {
|
|
142
|
+
watcher.close();
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(`[ActivityMonitor] Error closing watcher for ${agent}: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
this.watchers.clear();
|
|
149
|
+
|
|
150
|
+
// Clear any pending debounce timers
|
|
151
|
+
for (const timer of this.debounceTimers.values()) {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
this.debounceTimers.clear();
|
|
155
|
+
|
|
156
|
+
this.isMonitoring = false;
|
|
157
|
+
console.log('[ActivityMonitor] Stopped');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Handle a file change event from fs.watch.
|
|
162
|
+
* Debounces activity detection per agent.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} agent - Agent name
|
|
165
|
+
* @param {string} eventType - Event type ('rename' or 'change')
|
|
166
|
+
* @param {string} filename - Changed filename
|
|
167
|
+
* @private
|
|
168
|
+
*/
|
|
169
|
+
_handleFileChange(agent, eventType, filename) {
|
|
170
|
+
if (isIgnoredActivityFile(filename) || this._isSuppressed(agent)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Clear existing debounce timer for this agent
|
|
175
|
+
if (this.debounceTimers.has(agent)) {
|
|
176
|
+
clearTimeout(this.debounceTimers.get(agent));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Set up new debounce timer
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
this._triggerActivity(agent, eventType, filename);
|
|
182
|
+
this.debounceTimers.delete(agent);
|
|
183
|
+
}, this.debounceInterval);
|
|
184
|
+
|
|
185
|
+
this.debounceTimers.set(agent, timer);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
_isSuppressed(agent) {
|
|
189
|
+
const suppressedUntil = this.suppressedUntil.get(agent);
|
|
190
|
+
if (!suppressedUntil) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (Date.now() >= suppressedUntil) {
|
|
195
|
+
this.suppressedUntil.delete(agent);
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
suppressAgent(agent, durationMs) {
|
|
203
|
+
this.suppressedUntil.set(agent, Date.now() + durationMs);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
suppressAll(durationMs) {
|
|
207
|
+
for (const agent of this.agents) {
|
|
208
|
+
this.suppressAgent(agent, durationMs);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Trigger the activity callback after debounce period.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} agent - Agent name
|
|
216
|
+
* @param {string} eventType - Event type that triggered this
|
|
217
|
+
* @param {string} filename - Changed filename
|
|
218
|
+
* @private
|
|
219
|
+
*/
|
|
220
|
+
_triggerActivity(agent, eventType, filename) {
|
|
221
|
+
const now = new Date().toISOString();
|
|
222
|
+
this.lastActivityAt.set(agent, now);
|
|
223
|
+
this.activityCount++;
|
|
224
|
+
|
|
225
|
+
console.log(`[ActivityMonitor] Activity detected for ${agent}: ${eventType} ${filename || '(unknown file)'}`);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
this.onActivity({
|
|
229
|
+
agent,
|
|
230
|
+
eventType,
|
|
231
|
+
filename,
|
|
232
|
+
timestamp: now,
|
|
233
|
+
activityCount: this.activityCount,
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error(`[ActivityMonitor] Error in onActivity callback: ${err.message}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check if there is pending activity (debounce timer active).
|
|
242
|
+
*
|
|
243
|
+
* @param {string} [agent] - Optional agent name to check (checks all if not specified)
|
|
244
|
+
* @returns {boolean} True if there is pending activity
|
|
245
|
+
*/
|
|
246
|
+
hasPendingActivity(agent = null) {
|
|
247
|
+
if (agent) {
|
|
248
|
+
return this.debounceTimers.has(agent);
|
|
249
|
+
}
|
|
250
|
+
return this.debounceTimers.size > 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get the time since last activity for an agent.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} agent - Agent name
|
|
257
|
+
* @returns {number|null} Milliseconds since last activity, or null if never active
|
|
258
|
+
*/
|
|
259
|
+
getTimeSinceActivity(agent) {
|
|
260
|
+
const lastActivity = this.lastActivityAt.get(agent);
|
|
261
|
+
if (!lastActivity) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return Date.now() - new Date(lastActivity).getTime();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Set the debounce interval dynamically.
|
|
269
|
+
*
|
|
270
|
+
* @param {number} interval - Debounce interval in milliseconds
|
|
271
|
+
*/
|
|
272
|
+
setDebounceInterval(interval) {
|
|
273
|
+
this.debounceInterval = interval;
|
|
274
|
+
console.log(`[ActivityMonitor] Debounce interval set to ${interval}ms`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Get the current status of the activity monitor.
|
|
279
|
+
*
|
|
280
|
+
* @returns {Object} Status information
|
|
281
|
+
*/
|
|
282
|
+
getStatus() {
|
|
283
|
+
const monitoredAgents = Array.from(this.watchers.keys());
|
|
284
|
+
const lastActivityByAgent = {};
|
|
285
|
+
for (const [agent, timestamp] of this.lastActivityAt) {
|
|
286
|
+
lastActivityByAgent[agent] = timestamp;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
isMonitoring: this.isMonitoring,
|
|
291
|
+
debounceInterval: this.debounceInterval,
|
|
292
|
+
monitoredAgents,
|
|
293
|
+
agentCount: monitoredAgents.length,
|
|
294
|
+
activityCount: this.activityCount,
|
|
295
|
+
lastActivityAt: lastActivityByAgent,
|
|
296
|
+
hasPendingActivity: this.hasPendingActivity(),
|
|
297
|
+
pendingAgents: Array.from(this.debounceTimers.keys()),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Manually trigger an activity check for an agent.
|
|
303
|
+
* Useful for testing or forcing a refresh.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} agent - Agent name
|
|
306
|
+
*/
|
|
307
|
+
triggerManual(agent) {
|
|
308
|
+
this._triggerActivity(agent, 'manual', 'manual-trigger');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = { ActivityMonitor, DEFAULT_LOG_DIRECTORIES };
|