agent-tank 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 };